diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 98d5b9093..eff82fa99 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,7 +3,8 @@ name: checks
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
-# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. A fork's pull
+# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
+# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
on:
@@ -72,8 +73,37 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
- parity:
+ # Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
+ # (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
+ # a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
+ parity-scope:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ outputs:
+ run: ${{ steps.scope.outputs.run }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ - id: scope
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
+ run: |
+ changed=$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)
+ harness=$(grep -E '^(tools/test_[a-z0-9_]+_parity\.py|tools/(parity[a-z_]*|lab)\.py|tools/parity_hosts\.json|mods/horizon/src/test/|crates/[^/]+/examples/|crates/horizon-client/src/game/[a-z_]*(fixture|harness)\.rs|\.github/workflows/parity[a-z-]*\.yml)' <<< "$changed" || true)
+ if [ -n "$harness" ] || [[ " $LABELS " == *" run-parity "* ]]; then
+ echo "run=true" >> "$GITHUB_OUTPUT"
+ echo "The pull request runs its harnesses: ${harness:-label run-parity}"
+ else
+ echo "run=false" >> "$GITHUB_OUTPUT"
+ echo "No harness, oracle, probe or runner change: parity-main checks this on main"
+ fi
+ parity:
+ needs: parity-scope
+ if: needs.parity-scope.outputs.run == 'true'
uses: ./.github/workflows/parity.yml
with:
base: ${{ github.event.pull_request.base.sha }}
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 51f9e89cd..60bbbf057 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -29,7 +29,8 @@ env:
jobs:
audit:
- runs-on: [self-hosted, horizon-game]
+ # The Mac mini's runners only: the audit compares with the previous one, so it stays on one platform.
+ runs-on: [self-hosted, horizon-game, macOS]
timeout-minutes: 150
steps:
- name: 03:00 in Paris
diff --git a/.github/workflows/parity-main.yml b/.github/workflows/parity-main.yml
new file mode 100644
index 000000000..c47005ab0
--- /dev/null
+++ b/.github/workflows/parity-main.yml
@@ -0,0 +1,74 @@
+name: parity-main
+# The vanilla harnesses on main, a batch of merges at a time (docs/release-flow.md#parity-on-main), on the Mac mini's
+# runner. One run at a time: merges that land while a run goes on wait in one pending run (GitHub keeps only the
+# newest), which checks every merge since main's last green parity-main (tools/parity_main.py base). On a failure it
+# bisects the failing harnesses over those merges and comments on the pull request that broke them. Releases ship
+# only a commit whose parity-main passed (tools/release_train.py), so nothing a harness catches reaches players.
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+permissions:
+ contents: read
+ # The comment on the merge that broke a harness.
+ pull-requests: write
+ # tools/parity_main.py base reads earlier runs.
+ actions: read
+concurrency:
+ group: parity-main
+ cancel-in-progress: false
+env:
+ # Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
+ HORIZON_TELEMETRY_URL: "off"
+
+jobs:
+ parity-main:
+ # A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
+ # this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
+ # behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
+ # because the maintainer came back could time out, and a failure here bisects and comments on merges.
+ runs-on: [self-hosted, macOS, horizon-parity-main]
+ timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+ # Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
+ clean: false
+ - uses: dtolnay/rust-toolchain@stable
+ - name: Tools from the runner
+ run: bash tools/ci/runner-env.sh
+ - name: Vanilla data, and resources prepared by this preparer
+ run: bash tools/ci/vanilla-data.sh
+ - name: Main's last green parity-main
+ id: base
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
+ - name: The harnesses the merges since then can affect
+ env:
+ PARITY_HOSTS: ''
+ BASE_SHA: ${{ steps.base.outputs.sha }}
+ run: |
+ echo "Merges since ${BASE_SHA}:"
+ git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
+ python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
+ - name: The merge that broke them
+ if: failure()
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PARITY_HOSTS: ''
+ BASE_SHA: ${{ steps.base.outputs.sha }}
+ run: |
+ rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
+ python3 tools/parity_main.py bisect "$BASE_SHA" --comment
+ - name: Logs of the failed harnesses
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: parity-main-logs
+ path: .local/parity-main-logs/
+ if-no-files-found: ignore
+ retention-days: 7
diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml
index a18fc5019..1fc31d8cb 100644
--- a/.github/workflows/parity.yml
+++ b/.github/workflows/parity.yml
@@ -36,14 +36,76 @@ jobs:
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
- run: bash tools/ci/vanilla-data.sh
+ env:
+ BASE_SHA: ${{ inputs.base }}
+ run: |
+ # main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
+ # the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
+ set -euo pipefail
+ if git rev-parse --verify -q origin/main >/dev/null \
+ && git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
+ git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
+ bash "$RUNNER_TEMP/vanilla-data.sh"
+ else
+ bash tools/ci/vanilla-data.sh
+ fi
+ - name: The prepared output is the one the preparer's OUTPUT_VERSION says
+ env:
+ BASE_SHA: ${{ inputs.base }}
+ run: |
+ # The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
+ # the tool nor the number, so there is nothing to check.
+ if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
+ echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
+ exit 0
+ fi
+ python3 tools/prepare_output.py check --cache --base "$BASE_SHA"
- name: The version constants are the pinned jar's
run: python3 tools/gen_version_consts.py --check
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ inputs.base }}
- run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
+ run: |
+ # main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
+ # branch made before a better selection landed gets it too; the head's own runner then runs those
+ # harnesses. A branch that changes the selection itself selects with its own code.
+ set -euo pipefail
+ own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
+ fork_point=$(git merge-base "$BASE_SHA" HEAD)
+ if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
+ own
+ exit
+ fi
+ had_reach=$(git ls-files tools/parity_reach.py)
+ git show origin/main:tools/parity.py > tools/parity.py
+ git show origin/main:tools/parity_reach.py > tools/parity_reach.py
+ status=0
+ python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
+ git checkout -- tools/parity.py
+ if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
+ cat .local/parity-selection.txt
+ if [ "$status" != 0 ]; then
+ echo "main's selection failed: the head's own selection runs"
+ own
+ exit
+ fi
+ names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
+ # The summary line says how many would run: a count that doesn't match the names read (a format change)
+ # must not let the job pass untested.
+ expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
+ count=$(printf '%s' "$names" | grep -c . || true)
+ if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
+ echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
+ own
+ exit
+ fi
+ if [ "$count" = 0 ]; then
+ echo "Nothing to run."
+ exit 0
+ fi
+ # shellcheck disable=SC2086
+ python3 tools/parity.py $names --timeout 2400
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4cba851fa..d879f0d61 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,6 +1,7 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
-# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
+# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides,
+# for a commit whose checks and parity-main (the vanilla harnesses on main) both passed:
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
@@ -18,7 +19,8 @@ name: release
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
workflow_run:
- workflows: [checks]
+ # A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
+ workflows: [checks, parity-main]
types: [completed]
branches: [main]
schedule:
@@ -64,8 +66,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RELEASES_PAUSED: ${{ vars.RELEASES_PAUSED }}
- # The commit whose checks just passed; a dry run builds the branch it was started on; otherwise
- # main's newest commit with green checks.
+ # The commit whose checks or parity-main just passed; a dry run builds the branch it was started on;
+ # otherwise main's newest commit with green checks and parity-main.
HEAD_SHA: ${{ github.event.workflow_run.head_sha || (steps.mode.outputs.dry_run == 'true' && github.sha) || '' }}
FORCE: ${{ inputs.force && 'yes' || '' }}
run: |
@@ -77,9 +79,50 @@ jobs:
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
- build:
+ exporters:
needs: decide
if: needs.decide.outputs.release == 'true'
+ # This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
+ runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
+ # Three full preparations (two isolated, one batched) besides the compile.
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.decide.outputs.head }}
+ persist-credentials: false
+ clean: true
+ - uses: dtolnay/rust-toolchain@stable
+ - run: bash tools/ci/runner-env.sh
+ - run: bash tools/ci/vanilla-data.sh
+ - name: Compile and validate original exporters against the pinned jars
+ shell: bash
+ run: |
+ set -euo pipefail
+ # target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
+ # prepares afresh.
+ cargo build --release --locked -p horizon-prepare
+ # Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
+ # write those (vanilla-data.sh links .local/exporters to them).
+ rm -rf .local/exporters
+ bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
+ mkdir -p exporters
+ cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
+ # Only our classes and their manifest leave this runner.
+ stray=$(find exporters -type f ! -path exporters/manifest.json \
+ ! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
+ [ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
+ python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
+ - uses: actions/upload-artifact@v4
+ with:
+ name: original-exporters
+ path: exporters/
+ if-no-files-found: error
+ retention-days: 3
+
+ build:
+ needs: [decide, exporters]
+ if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
@@ -218,6 +261,10 @@ jobs:
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
+ - uses: actions/download-artifact@v4
+ with:
+ name: original-exporters
+ path: exporters
- name: Package
run: |
set -euo pipefail
diff --git a/.gitignore b/.gitignore
index 7fffd6a99..6bdae649a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,6 @@
/.horizon-cache/
/crash-reports/
/debug/
+
+# Release-only compiled original exporter bundle.
+/exporters/
diff --git a/AGENTS.md b/AGENTS.md
index 0656e40fc..549fa5c7f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
+| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
@@ -81,7 +82,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
- `horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
+ `horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude predator-missile --exclude horizon-mods-test-guest --exclude horizon-mods-test-ui-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e6e959186..21951c083 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,6 +7,11 @@ welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
+**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
+([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
+suggest something. A pull request for a roadmap item carries its `roadmap:` label; ideas go to the suggestion
+queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
+
## Build it
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
diff --git a/Cargo.lock b/Cargo.lock
index 0b8adf3ea..d9c91b576 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -20,9 +20,9 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "addr2line"
-version = "0.25.1"
+version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
dependencies = [
"gimli",
]
@@ -187,6 +187,17 @@ dependencies = [
"libloading",
]
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "atomic-waker"
version = "1.1.2"
@@ -531,7 +542,7 @@ dependencies = [
"jni 0.21.1",
"js-sys",
"libc",
- "mach2",
+ "mach2 0.4.3",
"ndk",
"ndk-context",
"num-derive",
@@ -545,6 +556,15 @@ dependencies = [
"windows 0.54.0",
]
+[[package]]
+name = "cpp_demangle"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -556,46 +576,48 @@ dependencies = [
[[package]]
name = "cranelift-assembler-x64"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e92993516bcc43fd4e511b31d2e0c0c24f6b7ac18bb39e32cac2bfc05ea378e"
+checksum = "e6071a4f1df4837aa03d1e4d53067784acafe270ffc7dad66a7f39832204da3f"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97eeeb61b08ec484f274119faf3a8a1b80dd300b0208c0247d16d05982fc89fe"
+checksum = "5e3b92a9cc90388fe70e63640af30b734387449445aef0176ab2028e9892d70e"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "782eb44158681a105d157cb0ce63e3dc83f78ef5674266c12234f49da3f5c6e8"
+checksum = "f45c85646c4425cc85b868effaf20c302b437a3fb79bb27a64513dd522f07411"
dependencies = [
"cranelift-entity",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-bitset"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edd59af193204c4f025fb2c2d9ee0aeb1f60929edf000b0dfa3d45543a62f264"
+checksum = "94f56b5c7d564cfa03aa269a6b98c916cab4d545196a391d31403a23ef39333a"
dependencies = [
"serde",
"serde_derive",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1c29bd985057d790db25f6bc60b369b311aa2d75c2f496a7e9993585bfc7d60"
+checksum = "2c073641ce6e382814a8220331cc1ea2c782a9ea5d5da6d0f7f7c0541192693e"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
@@ -607,22 +629,26 @@ dependencies = [
"cranelift-entity",
"cranelift-isle",
"gimli",
- "hashbrown 0.15.5",
+ "hashbrown 0.17.1",
+ "libm",
"log",
+ "postcard",
"pulley-interpreter",
"regalloc2",
"rustc-hash 2.1.3",
"serde",
+ "serde_derive",
+ "sha2",
"smallvec",
"target-lexicon",
- "wasmtime-internal-math",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-codegen-meta"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9ef8a8495f19212f92db8425f768ffc67000c5cb417a0664475a8d09596700f"
+checksum = "db226fafea5194921de10dccf35ac2863500313990f770cbe8b26c51eebec4d4"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
@@ -633,37 +659,39 @@ dependencies = [
[[package]]
name = "cranelift-codegen-shared"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3aa5099097666baf45bd68ef036327c8f29d8785fffaaa7db3b3b6b7e94bcc6"
+checksum = "cf8ac46b2c8e328ca979810426ea635ef90ecc8c54fd3296bde31ab470a48a1d"
[[package]]
name = "cranelift-control"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26f67f4f4e92c2934f40c2ff4574400bd27326932aad937a438b7fb459f60a63"
+checksum = "6754e0ab97915209ea6ce6549f6443b018e1338142ada4c856cec213ee82e4b8"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8393d09f0abaa504896069e79f4eb6f15eb1853e5645732b082dac1ed292aaa8"
+checksum = "774d25e36df18696f63a284e9c4ea43455e2ebcca6308e0e02301f18c8e777e2"
dependencies = [
"cranelift-bitset",
"serde",
"serde_derive",
+ "wasmtime-internal-core",
]
[[package]]
name = "cranelift-frontend"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "580cfaecbd11476dd82f6a393123dcbd9dfe583467df8d0c8441c641444d4985"
+checksum = "69cfcb31405e2de2a48e5a6de8e07a3d8b77f35caa3e944d627780734b36c138"
dependencies = [
"cranelift-codegen",
+ "hashbrown 0.17.1",
"log",
"smallvec",
"target-lexicon",
@@ -671,15 +699,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c044843adb7d94b1facebaad846c0a762f79a77953417e3f0182093bcbbdc38"
+checksum = "2f5a26df22db546ab92be6feaf21835ae91082af0a32bbb398b277df3765412f"
[[package]]
name = "cranelift-native"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d4c1707214e53f2040e4d13430069ea22623287b2750ed6d62e40eba88d91a"
+checksum = "19f258c08e590f90dcaf9904d25ba142ee2980c3aaeac4bb45f1452b1a407c2a"
dependencies = [
"cranelift-codegen",
"libc",
@@ -688,9 +716,9 @@ dependencies = [
[[package]]
name = "cranelift-srcgen"
-version = "0.123.18"
+version = "0.135.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d355a35edc49f628db7b793f3527eed31b560e77c3e8ed0e5258aad8df8a5249"
+checksum = "67055b4f315e8f6af3e8eccec74ad38f7407f0fd854adbd49b4b3b27bdb2e3be"
[[package]]
name = "crc32fast"
@@ -895,12 +923,6 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
-[[package]]
-name = "fallible-iterator"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
-
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -993,12 +1015,55 @@ dependencies = [
"percent-encoding",
]
+[[package]]
+name = "fps-overlay"
+version = "1.0.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
[[package]]
name = "futures-task"
version = "0.3.34"
@@ -1011,8 +1076,12 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
+ "futures-channel",
"futures-core",
+ "futures-io",
+ "futures-sink",
"futures-task",
+ "memchr",
"pin-project-lite",
"slab",
]
@@ -1107,21 +1176,54 @@ dependencies = [
[[package]]
name = "gimli"
-version = "0.32.3"
+version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
+checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
dependencies = [
- "fallible-iterator",
+ "fnv",
+ "hashbrown 0.16.1",
"indexmap",
"stable_deref_trait",
]
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
[[package]]
name = "glam"
version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
[[package]]
name = "gpu-allocator"
version = "0.28.0"
@@ -1155,7 +1257,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash 0.1.5",
- "serde",
]
[[package]]
@@ -1176,6 +1277,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
+ "serde",
+ "serde_core",
]
[[package]]
@@ -1268,6 +1371,7 @@ dependencies = [
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
+ "horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
@@ -1326,7 +1430,7 @@ dependencies = [
name = "horizon-mod-sdk"
version = "0.1.0"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.60.0",
]
[[package]]
@@ -1340,8 +1444,8 @@ dependencies = [
"sha2",
"toml",
"wasmtime",
- "wit-component 0.239.0",
- "wit-parser 0.239.0",
+ "wit-component",
+ "wit-parser",
"zip",
]
@@ -1352,6 +1456,13 @@ dependencies = [
"horizon-mod-sdk",
]
+[[package]]
+name = "horizon-mods-test-ui-guest"
+version = "0.1.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1369,6 +1480,8 @@ dependencies = [
name = "horizon-prepare"
version = "0.1.0"
dependencies = [
+ "crc32fast",
+ "libc",
"regex",
"sha1",
"sha2",
@@ -1437,6 +1550,19 @@ dependencies = [
"ureq",
]
+[[package]]
+name = "horizon-shader-api"
+version = "0.1.0"
+dependencies = [
+ "bytemuck",
+ "naga",
+ "serde",
+ "serde_json",
+ "sha2",
+ "toml",
+ "zip",
+]
+
[[package]]
name = "horizon-shaders"
version = "0.1.0"
@@ -1792,6 +1918,30 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keystrokes"
+version = "1.0.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -1902,6 +2052,12 @@ dependencies = [
"libc",
]
+[[package]]
+name = "mach2"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -1926,6 +2082,13 @@ dependencies = [
"libc",
]
+[[package]]
+name = "minimap"
+version = "1.0.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
[[package]]
name = "minisign-verify"
version = "0.3.0"
@@ -2437,12 +2600,12 @@ dependencies = [
[[package]]
name = "object"
-version = "0.37.3"
+version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
+checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"crc32fast",
- "hashbrown 0.15.5",
+ "hashbrown 0.17.1",
"indexmap",
"memchr",
]
@@ -2664,6 +2827,13 @@ dependencies = [
"unicode-xid",
]
+[[package]]
+name = "predator-missile"
+version = "0.1.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
[[package]]
name = "presser"
version = "0.3.1"
@@ -2706,21 +2876,21 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "pulley-interpreter"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "334ae8982dcf354e5f28e79fc6f935601e028b729cc174d3937eeeaa859057b8"
+checksum = "2b8187c8e7db87d085865fc106502e7b34d5c25f8ce4c272945b21503371f6a0"
dependencies = [
"cranelift-bitset",
"log",
"pulley-macros",
- "wasmtime-internal-math",
+ "wasmtime-internal-core",
]
[[package]]
name = "pulley-macros"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9cd207bca4bc5e46cb4fcea87196dcbd78befc628c64c67d11dc99fce5cd5369"
+checksum = "7f98ce0e381d9e45e63ea1d20bba3cf131797086077f43af9203f1fb7de0ed6b"
dependencies = [
"proc-macro2",
"quote",
@@ -2819,15 +2989,16 @@ dependencies = [
[[package]]
name = "regalloc2"
-version = "0.12.2"
+version = "0.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734"
+checksum = "757712e8e61590d6d4f5d563483755538b5aa13467837a3b41cd9832509a7f85"
dependencies = [
"allocator-api2",
"bumpalo",
- "hashbrown 0.15.5",
+ "hashbrown 0.17.1",
"log",
"rustc-hash 2.1.3",
+ "serde",
"smallvec",
]
@@ -2880,6 +3051,12 @@ dependencies = [
"windows-sys 0.52.0",
]
+[[package]]
+name = "rustc-demangle"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
+
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -3129,6 +3306,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
[[package]]
name = "smallvec"
version = "1.16.2"
@@ -3654,7 +3840,7 @@ version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
- "wit-bindgen",
+ "wit-bindgen 0.57.1",
]
[[package]]
@@ -3715,124 +3901,66 @@ dependencies = [
[[package]]
name = "wasm-encoder"
-version = "0.236.1"
+version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7"
+checksum = "ebaca0b540bdb15d195ff34fb23074255948708b8916a11a339ba4513dc61906"
dependencies = [
"leb128fmt",
- "wasmparser 0.236.1",
-]
-
-[[package]]
-name = "wasm-encoder"
-version = "0.239.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c"
-dependencies = [
- "leb128fmt",
- "wasmparser 0.239.0",
-]
-
-[[package]]
-name = "wasm-encoder"
-version = "0.247.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204"
-dependencies = [
- "leb128fmt",
- "wasmparser 0.247.0",
+ "wasmparser",
]
[[package]]
name = "wasm-metadata"
-version = "0.239.0"
+version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2"
+checksum = "5d1880dd9a848aa68f387966256c34ad49abb4607e3cfa35720d2723d0591cd3"
dependencies = [
"anyhow",
"indexmap",
- "wasm-encoder 0.239.0",
- "wasmparser 0.239.0",
-]
-
-[[package]]
-name = "wasm-metadata"
-version = "0.247.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10"
-dependencies = [
- "anyhow",
- "indexmap",
- "wasm-encoder 0.247.0",
- "wasmparser 0.247.0",
+ "wasm-encoder",
+ "wasmparser",
]
[[package]]
name = "wasmparser"
-version = "0.236.1"
+version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7"
+checksum = "c5ff8685b587722894b6f25958fc608d315b93b8d23647bfb567664e7006d757"
dependencies = [
"bitflags 2.13.2",
- "hashbrown 0.15.5",
+ "hashbrown 0.17.1",
"indexmap",
"semver",
"serde",
]
-[[package]]
-name = "wasmparser"
-version = "0.239.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0"
-dependencies = [
- "bitflags 2.13.2",
- "hashbrown 0.15.5",
- "indexmap",
- "semver",
-]
-
-[[package]]
-name = "wasmparser"
-version = "0.247.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80"
-dependencies = [
- "bitflags 2.13.2",
- "hashbrown 0.17.1",
- "indexmap",
- "semver",
-]
-
[[package]]
name = "wasmprinter"
-version = "0.236.1"
+version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1"
+checksum = "8b03c8f270f3f384f70f42869b5ff7f6253fe4f0aa8590f7edd332c1943e5f51"
dependencies = [
"anyhow",
"termcolor",
- "wasmparser 0.236.1",
+ "wasmparser",
]
[[package]]
name = "wasmtime"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70095b9fe63cc2a87ed206729e11544610decc184c62898c5d201ff1d666d82e"
+checksum = "12300d040e8bea7e7ccde75b9919d27ebba47a217a64b09ba058ae8d3b4290b6"
dependencies = [
"addr2line",
- "anyhow",
+ "async-trait",
"bitflags 2.13.2",
"bumpalo",
"cc",
- "cfg-if",
"encoding_rs",
- "hashbrown 0.15.5",
- "indexmap",
+ "futures",
"libc",
"log",
- "mach2",
+ "mach2 0.6.0",
"memfd",
"object",
"once_cell",
@@ -3844,62 +3972,56 @@ dependencies = [
"serde_derive",
"smallvec",
"target-lexicon",
- "wasmparser 0.236.1",
+ "wasmparser",
"wasmtime-environ",
- "wasmtime-internal-asm-macros",
"wasmtime-internal-component-macro",
"wasmtime-internal-component-util",
+ "wasmtime-internal-core",
"wasmtime-internal-cranelift",
"wasmtime-internal-fiber",
"wasmtime-internal-jit-debug",
"wasmtime-internal-jit-icache-coherence",
- "wasmtime-internal-math",
- "wasmtime-internal-slab",
"wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
- "wasmtime-internal-winch",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-environ"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a67ea8904b6e369e2218780bd1f9186f1980cb616445eb2c1f3c4e45c8397865"
+checksum = "805d6554eb6dd6e62891061a31383a89d69fd891f785e5ae68f0835a554b7a2a"
dependencies = [
"anyhow",
+ "cpp_demangle",
+ "cranelift-bforest",
"cranelift-bitset",
"cranelift-entity",
"gimli",
+ "hashbrown 0.17.1",
"indexmap",
"log",
"object",
"postcard",
+ "rustc-demangle",
"semver",
"serde",
"serde_derive",
+ "sha2",
"smallvec",
"target-lexicon",
- "wasm-encoder 0.236.1",
- "wasmparser 0.236.1",
+ "wasm-encoder",
+ "wasmparser",
"wasmprinter",
"wasmtime-internal-component-util",
-]
-
-[[package]]
-name = "wasmtime-internal-asm-macros"
-version = "36.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0488a52ae93e654baecb08ce04d15d478756eb2168c2bdab7915710109161ce"
-dependencies = [
- "cfg-if",
+ "wasmtime-internal-core",
]
[[package]]
name = "wasmtime-internal-component-macro"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "377e406c3f8e7bea64db3cb3ee0d406c331e8fc09d8cb2e7bf9ad74cb5d3b31a"
+checksum = "b832b80853365d9d3a741681044554be291a4f977aae074b318b55c7a4835f83"
dependencies = [
"anyhow",
"proc-macro2",
@@ -3907,23 +4029,32 @@ dependencies = [
"syn 2.0.119",
"wasmtime-internal-component-util",
"wasmtime-internal-wit-bindgen",
- "wit-parser 0.236.1",
+ "wit-parser",
]
[[package]]
name = "wasmtime-internal-component-util"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97c24503bb712afe2c797dc291b96e57556ee62c35fd951f87e96088324de37c"
+checksum = "4c8a11aaf68d8945991429d45950f32490a30ddcdcdb56629816cb41a255d8fe"
+
+[[package]]
+name = "wasmtime-internal-core"
+version = "48.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea266d6cfb31c324c075c8432386654bcbb1559aa8d29be9d47eacf7dcc41012"
+dependencies = [
+ "hashbrown 0.17.1",
+ "libm",
+ "serde",
+]
[[package]]
name = "wasmtime-internal-cranelift"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63ea7ee133de9109a4a1fc63dae3afc9ea32e92b8dada824fa4595dbf175f80"
+checksum = "d427759553b07c296c8ed7bff8ac930a94b9ba99e6a79243ea79bcc51aa58d96"
dependencies = [
- "anyhow",
- "cfg-if",
"cranelift-codegen",
"cranelift-control",
"cranelift-entity",
@@ -3937,33 +4068,32 @@ dependencies = [
"smallvec",
"target-lexicon",
"thiserror 2.0.21",
- "wasmparser 0.236.1",
+ "wasmparser",
"wasmtime-environ",
- "wasmtime-internal-math",
+ "wasmtime-internal-core",
+ "wasmtime-internal-unwinder",
"wasmtime-internal-versioned-export-macros",
]
[[package]]
name = "wasmtime-internal-fiber"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69662a0a4dbe9784d1e8add9e3c5d2db0ca348085c624c27a70992dbe2eff4a3"
+checksum = "b54c8c5b02ea985149a90f917e1761af99734c6c27e6d7fdd5b6f69fc1db3950"
dependencies = [
- "anyhow",
"cc",
- "cfg-if",
"libc",
"rustix 1.1.5",
- "wasmtime-internal-asm-macros",
+ "wasmtime-environ",
"wasmtime-internal-versioned-export-macros",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
name = "wasmtime-internal-jit-debug"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00cb07d23903c0be359d6b920d94774ea322759c3f4d6721b61afa03602e4804"
+checksum = "72bb10c94724f072b6eca515085033e61da9efae65d054545126df37a15530c3"
dependencies = [
"cc",
"wasmtime-internal-versioned-export-macros",
@@ -3971,83 +4101,50 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa2b47c37a6d026e2755fe2cf7ec5c1f4b0fba62e448ea910cdf6a07a4982c6a"
+checksum = "1b781a27e8958fd07fd3135c0be216f77301efecd15cbf4f14ff210cdad5f169"
dependencies = [
- "anyhow",
- "cfg-if",
"libc",
- "windows-sys 0.60.2",
+ "wasmtime-internal-core",
+ "windows-sys 0.61.2",
]
-[[package]]
-name = "wasmtime-internal-math"
-version = "36.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b269f0ba44102c57fd5f94f1bf4395ccd4fc2b84698fc78f627a6f2a47131a62"
-dependencies = [
- "libm",
-]
-
-[[package]]
-name = "wasmtime-internal-slab"
-version = "36.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e35ac9d503d503703cd24d532e53e2e93c86109b451df40a9801500045aee25"
-
[[package]]
name = "wasmtime-internal-unwinder"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "83ce1d0930da44ed375701e6b4f187b46249f7efe2b1d8c1c77229a4adc82de9"
+checksum = "121188de1b2741b8e35530a9b0c8adf525b3aa5913a2ca3d4522509cfbc0494a"
dependencies = [
- "anyhow",
- "cfg-if",
"cranelift-codegen",
"log",
"object",
+ "wasmtime-environ",
]
[[package]]
name = "wasmtime-internal-versioned-export-macros"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b6633be262400bd7abab4ff767a16e8b6a41ff117be54bf76dabecea9f96225"
+checksum = "933f1990e4e5177547a4abd51dcfa5cb7616fe42b9fb6f0f2b7423627758223c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
-[[package]]
-name = "wasmtime-internal-winch"
-version = "36.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "509d1e1cf8e3b3306f291407c92bca8381d9294f8c54ad74b692538f1f5b23c7"
-dependencies = [
- "anyhow",
- "cranelift-codegen",
- "gimli",
- "object",
- "target-lexicon",
- "wasmparser 0.236.1",
- "wasmtime-environ",
- "wasmtime-internal-cranelift",
- "winch-codegen",
-]
-
[[package]]
name = "wasmtime-internal-wit-bindgen"
-version = "36.0.18"
+version = "48.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6d9a5b51b3400b4df1055724c4536ad5aa58fafe83cea5ca94f88cc9eb97563"
+checksum = "417ebd61d2581d86d65413517f3a41294a9ad105da26f0dc7039912a0d97e1ed"
dependencies = [
"anyhow",
"bitflags 2.13.2",
"heck",
"indexmap",
- "wit-parser 0.236.1",
+ "wit-component",
+ "wit-parser",
]
[[package]]
@@ -4261,6 +4358,7 @@ dependencies = [
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
"wgpu-core-deps-windows-linux-android",
"wgpu-hal",
"wgpu-naga-bridge",
@@ -4276,6 +4374,15 @@ dependencies = [
"wgpu-hal",
]
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
+dependencies = [
+ "wgpu-hal",
+]
+
[[package]]
name = "wgpu-core-deps-windows-linux-android"
version = "30.0.1"
@@ -4298,13 +4405,18 @@ dependencies = [
"bytemuck",
"cfg-if",
"cfg_aliases",
+ "glow",
+ "glutin_wgl_sys",
"gpu-allocator",
"hashbrown 0.17.1",
+ "js-sys",
+ "khronos-egl",
"libc",
"libloading",
"log",
"naga",
"naga-types",
+ "ndk-sys",
"objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
@@ -4324,10 +4436,14 @@ dependencies = [
"smallvec",
"static_assertions",
"thiserror 2.0.21",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
"wgpu-naga-bridge",
"wgpu-types",
"windows 0.62.2",
"windows-core 0.62.2",
+ "windows-result 0.4.1",
]
[[package]]
@@ -4371,26 +4487,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "winch-codegen"
-version = "36.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e470d7e674c41ad32dddc02d8d8a94983e0a4c516028bda60747e6dd862a567"
-dependencies = [
- "anyhow",
- "cranelift-assembler-x64",
- "cranelift-codegen",
- "gimli",
- "regalloc2",
- "smallvec",
- "target-lexicon",
- "thiserror 2.0.21",
- "wasmparser 0.236.1",
- "wasmtime-environ",
- "wasmtime-internal-cranelift",
- "wasmtime-internal-math",
-]
-
[[package]]
name = "windows"
version = "0.54.0"
@@ -4848,42 +4944,48 @@ name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
-version = "0.57.1"
+version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c"
+checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f"
dependencies = [
"anyhow",
"heck",
- "wit-parser 0.247.0",
+ "wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
-version = "0.57.1"
+version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484"
+checksum = "3df3fe9b9a0066f82fd0c2f07f79d0ffc0f85c53fa5f998516d8722712479042"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn 2.0.119",
- "wasm-metadata 0.247.0",
+ "wasm-metadata",
"wit-bindgen-core",
- "wit-component 0.247.0",
+ "wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
-version = "0.57.1"
+version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89"
+checksum = "d689c4bc9d6af067c651cfba444766343c9a4bdef15fad1e2efab95c0c277af0"
dependencies = [
"anyhow",
"prettyplease",
@@ -4896,9 +4998,9 @@ dependencies = [
[[package]]
name = "wit-component"
-version = "0.239.0"
+version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136"
+checksum = "1c96b3cea6838dbea55173baa56991fcb087896c321b0a770db201e5a0bd3506"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -4907,72 +5009,17 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
- "wasm-encoder 0.239.0",
- "wasm-metadata 0.239.0",
- "wasmparser 0.239.0",
- "wit-parser 0.239.0",
-]
-
-[[package]]
-name = "wit-component"
-version = "0.247.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8"
-dependencies = [
- "anyhow",
- "bitflags 2.13.2",
- "indexmap",
- "log",
- "serde",
- "serde_derive",
- "serde_json",
- "wasm-encoder 0.247.0",
- "wasm-metadata 0.247.0",
- "wasmparser 0.247.0",
- "wit-parser 0.247.0",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
]
[[package]]
name = "wit-parser"
-version = "0.236.1"
+version = "0.254.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15"
-dependencies = [
- "anyhow",
- "id-arena",
- "indexmap",
- "log",
- "semver",
- "serde",
- "serde_derive",
- "serde_json",
- "unicode-xid",
- "wasmparser 0.236.1",
-]
-
-[[package]]
-name = "wit-parser"
-version = "0.239.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d"
-dependencies = [
- "anyhow",
- "id-arena",
- "indexmap",
- "log",
- "semver",
- "serde",
- "serde_derive",
- "serde_json",
- "unicode-xid",
- "wasmparser 0.239.0",
-]
-
-[[package]]
-name = "wit-parser"
-version = "0.247.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc"
+checksum = "22672d61bfb9df46a16b46ce38522293aee1cc4749fc84a3464ce4083b3d5fd6"
dependencies = [
"anyhow",
"hashbrown 0.17.1",
@@ -4983,8 +5030,8 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
- "unicode-xid",
- "wasmparser 0.247.0",
+ "unicode-ident",
+ "wasmparser",
]
[[package]]
@@ -5068,6 +5115,12 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+[[package]]
+name = "xml-rs"
+version = "0.8.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
+
[[package]]
name = "xtask"
version = "0.1.0"
@@ -5213,6 +5266,13 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zoom"
+version = "1.0.0"
+dependencies = [
+ "horizon-mod-sdk",
+]
+
[[package]]
name = "zopfli"
version = "0.8.4"
diff --git a/Cargo.toml b/Cargo.toml
index 44ed89122..e4628f962 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
-members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/welcome", "xtask"]
+members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/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", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
resolver = "2"
[workspace.package]
diff --git a/README.md b/README.md
index 6dd192d5d..65078464e 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
Download ·
Documentation ·
Release notes ·
+ Roadmap ·
Project status ·
Contributing
@@ -30,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
-**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
+**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
+## Roadmap
+
+What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
+
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..a04eca2ce
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,966 @@
+# Horizon roadmap
+
+
+
+What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:` label of their item.
+
+## At a glance
+
+Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
+
+**Being done now**
+
+- **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.
+
+**Next**: Microsoft sign-in (coming soon); 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); Lua and other languages for mods; 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.
+
+**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.
+
+**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
+
+## Now
+
+### Performance
+
+#### 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.
+
+Technical
+
+Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/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/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/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)).
+
+
+
+#### Faster block lookups
+
+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.
+
+Technical
+
+`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/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)).
+
+
+
+### Modpacks
+
+#### 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.
+
+Technical
+
+`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/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
+
+
+
+### 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.
+
+Technical
+
+Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
+
+
+
+#### Server dialogs and login screens
+
+Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
+
+Technical
+
+26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
+
+
+
+#### 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.
+
+Technical
+
+Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/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'.
+
+
+
+### Mods and API
+
+#### 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.
+
+Technical
+
+Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
+
+More: [docs/mods.md](docs/mods.md)
+
+
+
+#### 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.
+
+Technical
+
+API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
+
+
+
+#### Screens, input and drawing for mods
+
+Mods will be able to open their own screens and settings pages and react to keys and item use, so bigger mods, such as the Orbital Laser, can run on the normal game.
+
+Technical
+
+Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
+
+More: [docs/mods.md](docs/mods.md)
+
+
+
+#### Tools for people who make mods
+
+Better tools for mod makers: a test runner that needs no game window, templates for each kind of mod, and clear promises about what keeps working from one version to the next.
+
+Technical
+
+`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
+
+More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
+
+
+
+### Gameplay parity
+
+#### 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.
+
+Technical
+
+Since [#1514](https://github.com/ludovic111/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/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
+
+
+
+#### 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.
+
+Technical
+
+Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
+
+
+
+### 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.
+
+Technical
+
+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/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/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/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
+
+More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
+
+
+
+#### 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.
+
+Technical
+
+`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/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/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'.
+
+
+
+### Horizon extras
+
+#### Discord Rich Presence
+
+Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
+
+Technical
+
+A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
+
+
+
+### 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.
+
+Technical
+
+A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
+
+
+
+## Next
+
+### Performance
+
+#### Fresh benchmarks against vanilla
+
+New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, with a history that shows what each release changed.
+
+Technical
+
+Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
+
+
+
+#### Faster world creation and entry on small CPUs
+
+Creating a world and joining it should be quick even on a four-thread laptop, where a fresh world at 32 chunks can make you wait minutes for the first terrain.
+
+Technical
+
+Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
+
+
+
+#### 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.
+
+Technical
+
+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.
+
+
+
+#### 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.
+
+Technical
+
+Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
+
+More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
+
+
+
+#### Native upscaling
+
+Draw the game at a lower resolution and sharpen it to your screen's size, in the way of FSR or DLSS, to gain frames on weaker graphics cards. An optional graphics setting.
+
+Technical
+
+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/ludovic111/horizon/issues/1403).
+
+
+
+#### 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.
+
+Technical
+
+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/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
+
+More: [docs/profiling.md](docs/profiling.md)
+
+
+
+### Modpacks
+
+#### 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.
+
+Technical
+
+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)).
+
+
+
+#### 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.
+
+Technical
+
+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)).
+
+
+
+#### 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.
+
+Technical
+
+333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/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/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
+
+
+
+#### 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.
+
+Technical
+
+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)).
+
+
+
+### 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.
+
+Technical
+
+Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/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)).
+
+
+
+#### 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.
+
+Technical
+
+`horizon-server console` (a text interface), `ban`, `pardon`, `ban-ip` and `mcp` over stdio. Admin API additions: the player list, kick, ban, op and whitelist, an SSE stream of logs and events, a text metrics endpoint, config GET and PATCH, backup delete and download. The panel's later features (bans, worlds, server.properties import, logs and crash reports, modpacks) and vanilla's management protocol (the JSON-RPC over WebSocket that the `management-server-*` keys configure), so vanilla's tools can manage a Horizon server. [docs/server.md](docs/server.md), [docs/api/server.md](docs/api/server.md).
+
+
+
+#### Hosting polish: Windows service, safe updates
+
+Smoother hosting: a real Windows service, a backup before every update, backups that keep the world and each player's state, and a changed view distance or message without a restart.
+
+Technical
+
+A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
+
+
+
+#### 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.
+
+Technical
+
+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)
+
+
+
+#### 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.
+
+Technical
+
+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)
+
+
+
+#### 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.
+
+Technical
+
+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).
+
+
+
+### 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.
+
+Technical
+
+After server mods: read blocks, states and biomes in loaded chunks and change them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory, held item, open container and item stacks with components; entity queries near a position, a safe subset of entity data and data-driven mod entity types. Snapshots and batched calls per tick, never per-block host calls in hot paths. [docs/api/mods.md](docs/api/mods.md) ('Not there yet').
+
+More: [docs/modpacks.md](docs/modpacks.md)
+
+
+
+#### 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.
+
+Technical
+
+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/ludovic111/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).
+
+
+
+#### Lua and other languages for mods
+
+Write mods as Lua scripts, in the style of KubeJS, with no compiler, and later in other languages that pass the tests. Every language runs in the same sandbox.
+
+Technical
+
+Lua first: Horizon ships a Lua interpreter that is itself a WebAssembly mod, and a script mod's manifest names it and the capabilities it uses, so a script can do exactly what a compiled mod with the same manifest can, with hot reload of `main.lua`. Then C, C++, Zig, Go (TinyGo), AssemblyScript and JavaScript or TypeScript are measured against Rust on the same tick-heavy example, and each is supported only with a template, an example and a CI test. [docs/api/mods.md](docs/api/mods.md) ('Ways in').
+
+
+
+### 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.
+
+Technical
+
+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/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
+
+
+
+#### 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.
+
+Technical
+
+`--in-frame` (also `--no-server-thread` and `HORIZON_SERVER_THREAD=0`) still ticks the world inside the window's `Game`, and most harnesses test that path. Delete it once `lan_e2e --server-thread` and the parity harnesses pass on the server thread, which also halves the bug surface. See also 'Single player as a server' and [docs/server-levels.md](docs/server-levels.md).
+
+
+
+#### 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.
+
+Technical
+
+Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/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)).
+
+
+
+#### Choose the audio output device
+
+Pick the speakers or headphones Horizon plays through, in Music & Sounds.
+
+Technical
+
+An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/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.
+
+
+
+### Rendering
+
+#### 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.
+
+Technical
+
+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)
+
+
+
+### Launcher
+
+#### Microsoft sign-in (coming soon)
+
+Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
+
+Technical
+
+Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
+
+More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
+
+
+
+#### A visible beta label
+
+A clear 'beta' label in the launcher and the game, so nobody is surprised that Horizon is still being built.
+
+Technical
+
+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)).
+
+
+
+### Platforms
+
+#### Horizon on Intel Macs
+
+Builds of the game and the launcher for Macs with an Intel processor.
+
+Technical
+
+x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/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/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
+
+
+
+#### 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.
+
+Technical
+
+Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
+
+
+
+### 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.
+
+Technical
+
+`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)
+
+
+
+#### 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.
+
+Technical
+
+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 ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
+
+
+
+### Community
+
+#### Horizon on X (coming soon)
+
+Coming soon: an official Horizon account on X, used to announce releases and the weekly summary.
+
+Technical
+
+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.
+
+
+
+### 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.
+
+Technical
+
+`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/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)).
+
+
+
+#### 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.
+
+Technical
+
+Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/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)).
+
+
+
+## Later
+
+### Performance
+
+#### Exact far terrain from real chunks
+
+Far terrain drawn from the world you really built and explored, caves and builds included, not only a preview from the seed.
+
+Technical
+
+Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
+
+
+
+### Modpacks
+
+#### Older Fabric and Forge mods
+
+Recognise mods built for versions before 26.1 in the mod browser, so they can be checked and carried over like newer ones.
+
+Technical
+
+A mapping table from intermediary and SRG names so pre-26.1 jars can be classified, and a rename for mod ids with dashes (`fabric-api`); the port step then treats them like newer jars ([docs/mod-browser.md](docs/mod-browser.md)).
+
+
+
+#### A modpack's own resource and shader packs
+
+Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
+
+Technical
+
+Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
+
+
+
+#### Whole modpacks, such as ATM10, rewritten
+
+Rewriting a complete big modpack for Horizon so it runs far better than the original. Not a priority now: the work above comes first.
+
+Technical
+
+The project's first goal for modpacks: support them ten times better than vanilla and port any of them. It was set aside in favour of compatibility through the browser. What exists: ATM10's world generates and 42 of its 496 mods are carried at least in part ([docs/modpacks.md](docs/modpacks.md)). It depends on 'Mods with code' and the native mod API.
+
+
+
+#### Running Java mods that aren't rewritten
+
+Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a converter. Hard and not started: the Convert button and native rewrites come first.
+
+Technical
+
+Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
+
+
+
+### Servers and multiplayer
+
+#### Ely.by and other sign-in providers
+
+Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible one, beside Microsoft.
+
+Technical
+
+Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
+
+
+
+#### Online-mode checks for Play with Friends
+
+Friends who join through an invite code could prove who they are with their Microsoft account, as on online-mode servers.
+
+Technical
+
+Play with Friends joins over an encrypted tunnel with an invite code ([docs/play-with-friends.md](docs/play-with-friends.md), 'Identity'). Online-mode checks against Mojang's session servers can be added on top of the same tunnel; they need Microsoft sign-in first, which is coming soon. Play with Friends stays self-hosted between players, with no relay run by the project.
+
+
+
+### Mods and API
+
+#### Orbital Laser on the normal game
+
+The community mod Orbital Laser, made for a fork of Horizon, running on the normal game and listed in the registry as the showcase of the mod API.
+
+Technical
+
+A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/Stink-O/orbital-laser) (MIT) once the input, ui, render and world capabilities exist (see 'Screens, input and drawing for mods'): today the mod needs them through its author's fork. Tested in the game, and listed in the registry.
+
+
+
+#### Native versions of popular client mods
+
+Built-in versions of the client mods players ask for: block info on hover, a recipe viewer like JEI, a world map and schematic overlays.
+
+Technical
+
+Done as extras: a mod list and menu, a minimap and a zoom ([docs/extras.md](docs/extras.md)). Left: block info tooltips, a recipe viewer (a contributor offered to port JEI if it becomes a native feature), a world map and a schematic overlay. They need the read-only interfaces `inventory-read`, `recipes`, `world-read` and `render` in the mod API, or they go into the game directly.
+
+More: [docs/mods.md](docs/mods.md)
+
+
+
+#### Create-style physics as a native mod
+
+Contraptions and physics like the Create and Aeronautics mods, as a mod for Horizon rather than part of the base game.
+
+Technical
+
+Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
+
+
+
+#### Proximity voice chat as a native mod
+
+Hear nearby players talk, as with the voice chat mods of other Minecraft versions, as a Horizon mod.
+
+Technical
+
+Needs server mods, mod networking and an audio interface in the mod API before anything can be built ([docs/api/mods.md](docs/api/mods.md)).
+
+
+
+### Gameplay parity
+
+#### Every Minecraft version, prepared automatically
+
+Every past and future Minecraft version prepared for Horizon automatically. When Mojang releases one, Horizon follows it, and every system is checked against it again.
+
+Technical
+
+The port pipeline ([docs/version-ports.md](docs/version-ports.md), `tools/version_port.py`) detects a release, prepares it, diffs its classes against the pinned 26.3, regenerates the data-driven tables, runs every parity harness on it and writes a port report that becomes issues. The goal is as little human work as possible for any version, past or future. Detection and the first slices are merged; the scheduled agent that runs it every day isn't installed yet. Two versions in one build are out of scope.
+
+
+
+#### Controllers: tested on real pads, and in the launcher
+
+Make controller support solid: tested with real PlayStation and Xbox pads and a Steam Deck, and usable in the launcher too.
+
+Technical
+
+The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read with gilrs, menus, rumble, glyphs, remapping and DualSense adaptive triggers, lightbar and haptics, but not yet confirmed on real pads or a Steam Deck (DualSense haptics stay behind a switch until then). Left: a test pass on real hardware, and the same focus navigation in the GPUI launcher.
+
+
+
+#### The full debug screen
+
+Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
+
+Technical
+
+Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
+
+
+
+### Rendering
+
+#### A light default shader pack
+
+A small built-in look (tone mapping, colour grade, a cheap vignette) that costs almost nothing, for players who want a nicer picture without a heavy pack.
+
+Technical
+
+An opt-in, composite-only pack on the native shader API, under about 0.3 ms at 1080p, shown on the Shader Packs page. Pack option sliders and profiles, and a Failed row with the full error, belong with it.
+
+More: [docs/shaders.md](docs/shaders.md)
+
+
+
+#### Optional ray tracing
+
+An optional ray-tracing mode for graphics cards that support it (RTX and recent AMD): shadows, water reflections and global lighting on top of the vanilla look.
+
+Technical
+
+A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
+
+
+
+#### HDR monitors
+
+Output for HDR monitors: brighter highlights and richer colour where your screen supports it.
+
+Technical
+
+Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
+
+
+
+#### MSAA, anisotropic filtering, exclusive fullscreen
+
+More graphics options: multisample anti-aliasing, anisotropic filtering and an exclusive fullscreen mode. Today Horizon has FXAA and borderless fullscreen.
+
+Technical
+
+Not implemented ([docs/graphics.md](docs/graphics.md)). Anisotropic filtering needs linear magnification in wgpu, which blurs close blocks, so close-up sharpness has to be kept first; MSAA would sit beside FXAA; exclusive fullscreen where the backend supports it.
+
+
+
+### Launcher
+
+#### Lean installs that use less disk
+
+Smaller installs: Horizon keeps one shared copy of the prepared game files and removes what only the preparation needed.
+
+Technical
+
+A shared resource store keyed by the preparer's digest, no prepare intermediates left behind, the current and the previous version kept, and an automatic clean-up. Not started.
+
+
+
+### Platforms
+
+#### Horizon on Linux ARM64
+
+Game and launcher builds for 64-bit ARM Linux computers.
+
+Technical
+
+Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
+
+
+
+#### Android, iOS and the browser
+
+Versions of Horizon for Android, iOS and web browsers. Nothing official yet; contributors have started the first pieces.
+
+Technical
+
+No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
+
+
+
+#### AppImage and a Nix flake
+
+Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
+
+Technical
+
+A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
+
+
+
+### Horizon extras
+
+#### A complete video studio inside the game
+
+Record, replay and edit videos without leaving the game: record in a keypress, replay a session, cut and finish the video. One of Horizon's original goals.
+
+Technical
+
+Three stages. Recording: capture gameplay in a keypress with an encoder thread and little frame-time cost. Replay: a replay mode for recorded sessions. Editing: cuts, camera paths, titles and export. None of it exists yet; the agent-control socket ([docs/agent-control.md](docs/agent-control.md)) already lets tools drive the game for scripted shots.
+
+
+
+#### Game menus in the launcher's style
+
+An optional look for the game's own menus and options that matches the Horizon launcher. Off by default: the vanilla menus stay as they are.
+
+Technical
+
+A player's idea (#horizon-ideas). A built-in resource pack or a Horizon setting that restyles the title, pause and options screens with the launcher's design tokens (crates/horizon-theme), following [docs/design-system.md](docs/design-system.md); off by default because it changes the vanilla look.
+
+
+
+### AI and agents
+
+#### An optional AI teammate
+
+An optional AI teammate you can talk to by voice. It plays beside you, helps or finishes the game with you, runs on your computer first, and exists only if you turn it on. The No AI switch hides it.
+
+Technical
+
+Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
+
+
+
+### Community
+
+#### A video series about Horizon
+
+A series of videos about how Horizon is made and what is new, with each upload announced on Discord.
+
+Technical
+
+Kira already shows new uploads in a read-only Discord channel (done). The series follows each stage of the project: a script, edits, titles and a thumbnail per video.
+
+
+
+## Help wanted
+
+### Performance
+
+#### Benchmark your computer
+
+Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
+
+Technical
+
+`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark ` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
+
+
+
+### Gameplay parity
+
+#### End city banners keep their pattern
+
+Banners in End cities show as plain magenta instead of with their pattern.
+
+Technical
+
+Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
+
+
+
+#### Enchanted loot in End cities
+
+Armour and tools in End city chests should come enchanted, as in vanilla.
+
+Technical
+
+`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
+
+
+
+#### Cooldowns drawn on items
+
+After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
+
+Technical
+
+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/ludovic111/horizon/issues/1763).
+
+
+
+#### Chat settings and team prefixes
+
+The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
+
+Technical
+
+Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
+
+
+
+#### A pointing hand over buttons
+
+The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
+
+Technical
+
+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/ludovic111/horizon/issues/1347).
+
+
+
+#### 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.
+
+Technical
+
+`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
+
+
+
+#### Chickens flap while they fall
+
+Chickens should flap their wings while they fall, not stand still in the air.
+
+Technical
+
+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/ludovic111/horizon/issues/1706).
+
+
+
+## Recently shipped
+
+- **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/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/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/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
+- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
+- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
+- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
+- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
+- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
+- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
+- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
+- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs
index 86ce9654d..8f0019a0b 100644
--- a/crates/cargo-horizon-mod/src/check.rs
+++ b/crates/cargo-horizon-mod/src/check.rs
@@ -22,6 +22,8 @@ pub struct Report {
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,
/// A mod's commands, each run once.
@@ -50,6 +52,7 @@ impl Report {
"sha256": self.sha256,
"size": self.size,
"signed": self.signed,
+ "code": self.code,
"imports": self.imports,
"commands": self.commands,
"output": self.output,
@@ -88,7 +91,9 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
- if self.kind == Kind::Mod {
+ if self.kind == Kind::Mod && !self.code {
+ 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 {
@@ -143,6 +148,7 @@ pub fn check(
sha256: index::sha256_file(path)?,
size,
signed,
+ code: false,
imports: Vec::new(),
commands: Vec::new(),
output: Vec::new(),
@@ -154,10 +160,14 @@ pub fn check(
}
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
-/// storage lands in a folder removed afterwards.
+/// 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 wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
+ let Some(wasm) = package.wasm.as_deref() else {
+ return Ok(());
+ };
+ report.code = true;
report.imports = component_imports(wasm)?;
package
.manifest
@@ -212,6 +222,9 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
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);
@@ -241,9 +254,19 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
Output::Feedback { text, error } | Output::Reply { text, error, .. } => {
format!("{}: {text}", if error { "error" } else { "feedback" })
}
+ Output::Command { line } => format!("command: /{line}"),
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
+ Output::OpenScreen { .. } => "opened a screen".to_string(),
+ Output::CloseScreen { .. } => "closed its screen".to_string(),
+ 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}")),
None => Ok(()),
diff --git a/crates/cargo-horizon-mod/src/main.rs b/crates/cargo-horizon-mod/src/main.rs
index 2117c849a..b82c6d07f 100644
--- a/crates/cargo-horizon-mod/src/main.rs
+++ b/crates/cargo-horizon-mod/src/main.rs
@@ -1,7 +1,9 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
-//! - `new [--name "Name"] [--sdk-path ]`: a mod crate from the template.
-//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
+//! - `new [--template code|content] [--name "Name"] [--sdk-path ]`:
+//! 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.
//! - `run [--game ] [-- ]`: build, then play with
//! the mod; run `build` again and the game reloads it.
//! - `test [--debug] [--hmod ] []`: build, then run the mod's
@@ -10,7 +12,7 @@
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
//! - `registry build|review|check|list`: the registry repository's CI.
use horizon_mods::{
- Manifest, Package,
+ Capability, Content, Manifest, Package,
manifest::{MANIFEST, component_imports, componentize},
testing::{Script, TestGame},
};
@@ -40,7 +42,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
- _ => Err("usage: cargo horizon-mod new | build [--debug] | run [--game ] [-- ] | test [--debug] [--hmod ] [] | check [--kind ] [--key --signature ] [--json] | registry build|review|check|list".into()),
+ _ => Err("usage: cargo horizon-mod new [--template code|content] | build [--debug] | run [--game ] [-- ] | test [--debug] [--hmod ] [] | check [--kind ] [--key --signature ] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -94,7 +96,7 @@ fn new(args: &[String]) -> Result<(), String> {
.replace("{name}", &name)
.replace("{sdk}", &sdk)
};
- let files = [
+ 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")),
@@ -104,13 +106,62 @@ fn new(args: &[String]) -> Result<(), String> {
include_str!("../template/tests/hello.toml"),
),
];
- for (file, text) in files {
+ // A block copying a vanilla one, with its model, name, loot, recipe and tag.
+ let content = [
+ (
+ MANIFEST,
+ include_str!("../template/content/horizon-mod.toml"),
+ ),
+ (
+ "data/{id}/horizon/block/glowing_amethyst.json",
+ include_str!("../template/content/block.json"),
+ ),
+ (
+ "assets/{id}/blockstates/glowing_amethyst.json",
+ include_str!("../template/content/blockstate.json"),
+ ),
+ (
+ "assets/{id}/models/block/glowing_amethyst.json",
+ include_str!("../template/content/model.json"),
+ ),
+ (
+ "assets/{id}/items/glowing_amethyst.json",
+ include_str!("../template/content/item.json"),
+ ),
+ (
+ "assets/{id}/lang/en_us.json",
+ include_str!("../template/content/lang.json"),
+ ),
+ (
+ "data/{id}/loot_table/blocks/glowing_amethyst.json",
+ include_str!("../template/content/loot.json"),
+ ),
+ (
+ "data/{id}/recipe/glowing_amethyst.json",
+ include_str!("../template/content/recipe.json"),
+ ),
+ (
+ "data/minecraft/tags/block/mineable/pickaxe.json",
+ include_str!("../template/content/pickaxe.json"),
+ ),
+ (".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 {
+ let file = fill(file);
+ let file = file.as_str();
let to = dir.join(file);
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
.map_err(|e| e.to_string())?;
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
}
- Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
+ let manifest =
+ Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
+ Content::read(dir, &manifest)?;
println!(
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
dir.display()
@@ -118,14 +169,53 @@ fn new(args: &[String]) -> Result<(), String> {
Ok(())
}
-/// Builds the crate in the current folder and packs `target/horizon-mod/.hmod`.
+/// Builds the mod in the current folder and packs `target/horizon-mod/.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 {
- let manifest_text = std::fs::read_to_string(MANIFEST)
+ build_in(Path::new("."), release)
+}
+
+fn build_in(root: &Path, release: bool) -> Result {
+ 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 {
+ return Err(format!(
+ "no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
+ ));
+ };
+ // 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()?;
+ let mut files = Vec::with_capacity(content.files.len());
+ for name in &content.files {
+ 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");
+ 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)?;
+ 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, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
- cmd.args([
+ cmd.current_dir(root).args([
"build",
"--lib",
"--target",
@@ -153,17 +243,8 @@ fn build(release: bool) -> Result {
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
- // The game refuses a component importing capabilities the manifest lacks.
- manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
- let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
- let dir = target_dir.join("horizon-mod");
- std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
- let path = dir.join(format!("{}.hmod", manifest.id));
- // Write then rename, so a running game never reads half a file.
- let partial = path.with_extension("hmod.partial");
- std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
- std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
- Ok(path)
+ let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
+ Ok((wasm, target.to_path_buf()))
}
/// `check `: the registry's checks of a submission, printed; fails
@@ -304,4 +385,49 @@ mod tests {
);
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()));
+ let path = dir.join("gems");
+ let new_args = [
+ path.display().to_string(),
+ "--template".into(),
+ "content".into(),
+ ];
+ new(&new_args).unwrap();
+ assert!(!path.join("Cargo.toml").exists());
+ let hmod = build_in(&path, true).unwrap();
+ assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
+ let package = Package::read(&hmod).unwrap();
+ assert!(package.wasm.is_none());
+ let content = Content::read(&hmod, &package.manifest).unwrap();
+ assert!(
+ content
+ .files
+ .contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
+ );
+ 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"));
+ assert!(
+ new(&[
+ dir.join("x").display().to_string(),
+ "--template".into(),
+ "lua".into()
+ ])
+ .is_err()
+ );
+ std::fs::remove_dir_all(&dir).unwrap();
+ }
}
diff --git a/crates/cargo-horizon-mod/template/content/block.json b/crates/cargo-horizon-mod/template/content/block.json
new file mode 100644
index 000000000..ea68a1a27
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/block.json
@@ -0,0 +1,4 @@
+{
+ "template": "minecraft:amethyst_block",
+ "properties": { "hardness": 2.0, "light": 7 }
+}
diff --git a/crates/cargo-horizon-mod/template/content/blockstate.json b/crates/cargo-horizon-mod/template/content/blockstate.json
new file mode 100644
index 000000000..ea472666e
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/blockstate.json
@@ -0,0 +1,5 @@
+{
+ "variants": {
+ "": { "model": "{id}:block/glowing_amethyst" }
+ }
+}
diff --git a/crates/cargo-horizon-mod/template/content/horizon-mod.toml b/crates/cargo-horizon-mod/template/content/horizon-mod.toml
new file mode 100644
index 000000000..4f133a905
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/horizon-mod.toml
@@ -0,0 +1,10 @@
+id = "{id}"
+name = "{name}"
+version = "0.1.0"
+description = ""
+authors = []
+# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
+api = "0.2"
+side = "both"
+# registries: "May add blocks and items to your worlds".
+capabilities = ["registries"]
diff --git a/crates/cargo-horizon-mod/template/content/item.json b/crates/cargo-horizon-mod/template/content/item.json
new file mode 100644
index 000000000..411bc4499
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/item.json
@@ -0,0 +1,3 @@
+{
+ "model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
+}
diff --git a/crates/cargo-horizon-mod/template/content/lang.json b/crates/cargo-horizon-mod/template/content/lang.json
new file mode 100644
index 000000000..74baddab8
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/lang.json
@@ -0,0 +1,3 @@
+{
+ "block.{id}.glowing_amethyst": "Glowing Amethyst"
+}
diff --git a/crates/cargo-horizon-mod/template/content/loot.json b/crates/cargo-horizon-mod/template/content/loot.json
new file mode 100644
index 000000000..898111f1b
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/loot.json
@@ -0,0 +1,11 @@
+{
+ "type": "minecraft:block",
+ "pools": [
+ {
+ "condition": { "type": "minecraft:survives_explosion" },
+ "entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
+ "rolls": 1
+ }
+ ],
+ "random_sequence": "{id}:blocks/glowing_amethyst"
+}
diff --git a/crates/cargo-horizon-mod/template/content/model.json b/crates/cargo-horizon-mod/template/content/model.json
new file mode 100644
index 000000000..5710fecb1
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/model.json
@@ -0,0 +1,4 @@
+{
+ "parent": "minecraft:block/cube_all",
+ "textures": { "all": "minecraft:block/amethyst_block" }
+}
diff --git a/crates/cargo-horizon-mod/template/content/pickaxe.json b/crates/cargo-horizon-mod/template/content/pickaxe.json
new file mode 100644
index 000000000..8725edebd
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/pickaxe.json
@@ -0,0 +1,3 @@
+{
+ "values": ["{id}:glowing_amethyst"]
+}
diff --git a/crates/cargo-horizon-mod/template/content/recipe.json b/crates/cargo-horizon-mod/template/content/recipe.json
new file mode 100644
index 000000000..5edb03d94
--- /dev/null
+++ b/crates/cargo-horizon-mod/template/content/recipe.json
@@ -0,0 +1,6 @@
+{
+ "type": "minecraft:crafting_shapeless",
+ "category": "building",
+ "ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
+ "result": { "id": "{id}:glowing_amethyst" }
+}
diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml
index ea5859c23..d7c6d812b 100644
--- a/crates/horizon-client/Cargo.toml
+++ b/crates/horizon-client/Cargo.toml
@@ -18,10 +18,12 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-server-files = { path = "../horizon-server-files" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
+# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
+horizon-shader-api = { path = "../horizon-shader-api" }
horizon-friends = { path = "../horizon-friends" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
# Their runtime is the `mods` feature.
-horizon-mods = { path = "../horizon-mods", default-features = false }
+horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
pollster = "0.4"
@@ -68,6 +70,11 @@ rustix = { version = "1", default-features = false, features = ["std", "time"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
+[target.'cfg(not(target_os = "macos"))'.dependencies]
+# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
+# DirectX 12 driver (`graphicsApi`, graphics.rs).
+wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
+
[target.'cfg(windows)'.build-dependencies]
# The icon resource in the .exe (build.rs).
winresource = "0.1"
diff --git a/crates/horizon-client/examples/probe/block_entity_render_fixture.rs b/crates/horizon-client/examples/probe/block_entity_render_fixture.rs
index a0e3471bc..1db978b77 100644
--- a/crates/horizon-client/examples/probe/block_entity_render_fixture.rs
+++ b/crates/horizon-client/examples/probe/block_entity_render_fixture.rs
@@ -367,6 +367,7 @@ pub fn main() -> Result<()> {
lid: &|_| open,
sign: &|_| sign.as_ref(),
banner: &|_| layers.clone(),
+ skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
};
let mut verts = Vec::new();
diff --git a/crates/horizon-client/examples/probe/chat_screen_fixture.rs b/crates/horizon-client/examples/probe/chat_screen_fixture.rs
index 229d8d861..db334797d 100644
--- a/crates/horizon-client/examples/probe/chat_screen_fixture.rs
+++ b/crates/horizon-client/examples/probe/chat_screen_fixture.rs
@@ -7,7 +7,7 @@
//! usage: chat_screen_fixture RESOURCES REGISTRIES CASES OUTPUT
use crate::pack_screen_fixture;
use horizon_client::{
- chat::{ChatAction, ChatScreen, Commands},
+ chat::{ChatAbilities, ChatAction, ChatScreen, ChatVisibility, Commands},
command::vanilla,
hud::HudAtlas,
lang,
@@ -53,6 +53,7 @@ pub fn main() -> horizon_client::Result<()> {
history.len(),
w,
h,
+ ChatAbilities::new(ChatVisibility::Full),
);
screen.auto_suggestions = case["autoSuggest"] == true;
let mut closed = false;
diff --git a/crates/horizon-client/examples/probe/hud_fixture/kinds.rs b/crates/horizon-client/examples/probe/hud_fixture/kinds.rs
index d1450f2e1..9ba66abe1 100644
--- a/crates/horizon-client/examples/probe/hud_fixture/kinds.rs
+++ b/crates/horizon-client/examples/probe/hud_fixture/kinds.rs
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec {
atlas,
comp(&m["text"]),
indicator,
+ horizon_client::chat::ChatSource::Client,
m["at"].as_u64().unwrap_or(0),
false,
);
diff --git a/crates/horizon-client/examples/probe/lore_tooltip_fixture.rs b/crates/horizon-client/examples/probe/lore_tooltip_fixture.rs
new file mode 100644
index 000000000..350d9be81
--- /dev/null
+++ b/crates/horizon-client/examples/probe/lore_tooltip_fixture.rs
@@ -0,0 +1,53 @@
+//! Lore tooltip companion for tools/test_lore_tooltip_parity.py: the spans
+//! `item_tooltip::lore_texts` gives each stack (SNBT, one per line).
+use horizon_client::{
+ item_data::parse_stack,
+ item_tooltip::{TooltipDisplay, lore_texts},
+};
+use std::{error::Error, fmt::Write};
+
+pub fn main() -> Result<(), Box> {
+ let args: Vec = crate::args().collect();
+ let input = std::fs::read_to_string(&args[1])?;
+ let mut output = String::new();
+ for line in input
+ .lines()
+ .filter(|l| !l.is_empty() && !l.starts_with('#'))
+ {
+ let stack = parse_stack(line)?.ok_or("empty stack")?;
+ let display = TooltipDisplay::for_stack(&stack);
+ if !display.shows("minecraft:lore") {
+ writeln!(output, "-")?;
+ continue;
+ }
+ let lines = lore_texts(&stack);
+ if lines.is_empty() {
+ writeln!(output, "-")?;
+ continue;
+ }
+ let mut rendered = Vec::new();
+ for text in lines {
+ let mut spans = Vec::new();
+ for span in &text.spans {
+ if span.text.is_empty() {
+ continue;
+ }
+ let style = &span.style;
+ spans.push(format!(
+ "{}\t{}\t{}\t{}\t{}\t{}\t{}",
+ span.text,
+ style.color.map(|c| c as i32).unwrap_or(-1),
+ style.bold == Some(true),
+ style.italic == Some(true),
+ style.underlined == Some(true),
+ style.strikethrough == Some(true),
+ style.obfuscated == Some(true),
+ ));
+ }
+ rendered.push(spans.join(" | "));
+ }
+ writeln!(output, "{}", rendered.join(" || "))?;
+ }
+ std::fs::write(&args[2], output)?;
+ Ok(())
+}
diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs
index f05c2a662..e80088fd0 100644
--- a/crates/horizon-client/examples/probe/main.rs
+++ b/crates/horizon-client/examples/probe/main.rs
@@ -49,6 +49,7 @@ mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
mod light_parity;
+mod lore_tooltip_fixture;
mod marker_particles_probe;
mod menu_fixture;
mod mipmap_probe;
@@ -133,6 +134,7 @@ probes! {
interaction_input_fixture,
lan_probe,
light_parity,
+ lore_tooltip_fixture,
marker_particles_probe,
menu_fixture,
mipmap_probe,
diff --git a/crates/horizon-client/examples/probe/marker_particles_probe.rs b/crates/horizon-client/examples/probe/marker_particles_probe.rs
index 190751d79..72ebaff22 100644
--- a/crates/horizon-client/examples/probe/marker_particles_probe.rs
+++ b/crates/horizon-client/examples/probe/marker_particles_probe.rs
@@ -49,7 +49,7 @@ pub fn main() -> Result<()> {
}
}
}
- let mut engine = ParticleEngine::new(root, &HashMap::new());
+ let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
diff --git a/crates/horizon-client/examples/probe/menu_fixture.rs b/crates/horizon-client/examples/probe/menu_fixture.rs
index 3c83b20bc..9ec558de3 100644
--- a/crates/horizon-client/examples/probe/menu_fixture.rs
+++ b/crates/horizon-client/examples/probe/menu_fixture.rs
@@ -169,6 +169,7 @@ pub fn main() -> horizon_client::Result<()> {
cooking_timer: menu.data[2],
cooking_total: menu.data[3],
speed: 1.0,
+ recipes_used: Default::default(),
});
menu.drops.clear();
menu.equipped.clear();
diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs
index 157baa74c..49f56c250 100644
--- a/crates/horizon-client/examples/probe/mob_ai_probe.rs
+++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs
@@ -598,6 +598,7 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
let _ = writeln!(out, " removed {reason}");
return;
}
+ let _ = write!(out, "\tleashed {}", u8::from(m.extra.leash.is_some()));
let p = m.position;
let v = m.velocity;
let _ = write!(
@@ -977,6 +978,7 @@ pub fn main() -> horizon_client::Result<()> {
}
// Set once the brain exists (below).
x if x.starts_with("cooldown=") => {}
+ "dead" => m.health = 0.0,
"leashed" => m.extra.leash = Some(horizon_client::mobs::leash::Holder::Player),
x if x.starts_with("leashto=") => {
let n: u32 = x[8..].parse().unwrap();
@@ -1097,6 +1099,7 @@ pub fn main() -> horizon_client::Result<()> {
}
};
}
+ let mut dropped_leads = 0;
for t in 1..=s.ticks {
world.game_time += 1;
if s.daycycle {
@@ -1208,6 +1211,16 @@ pub fn main() -> horizon_client::Result<()> {
if !alive {
m.removed = true;
}
+ // Apply the same detach/drop event as Game::break_leash.
+ for event in &events[from..] {
+ if let MobEvent::LeashBreak { mob, drop, .. } = event
+ && let Some(m) = mobs.iter_mut().find(|m| m.id == *mob)
+ && horizon_client::mobs::leash::remove(m).is_some()
+ && *drop
+ {
+ dropped_leads += 1;
+ }
+ }
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
villager_events(
&mut mobs,
@@ -1427,6 +1440,9 @@ pub fn main() -> horizon_client::Result<()> {
world.game_time,
);
}
+ if s.name.starts_with("leash_death_") {
+ let _ = writeln!(out, "{t} leads {dropped_leads}");
+ }
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
let _ = writeln!(
diff --git a/crates/horizon-client/examples/probe/particle_providers_probe.rs b/crates/horizon-client/examples/probe/particle_providers_probe.rs
index c8cd9d88d..68f23f3af 100644
--- a/crates/horizon-client/examples/probe/particle_providers_probe.rs
+++ b/crates/horizon-client/examples/probe/particle_providers_probe.rs
@@ -152,7 +152,7 @@ pub fn main() -> Result<()> {
world(&assets, &facts, false, true)?,
world(&assets, &facts, true, true)?,
];
- let mut engine = ParticleEngine::new(root, &HashMap::new());
+ let mut engine = ParticleEngine::new(root, &HashMap::new(), &Default::default());
for (ty, item) in [
(ParticleType::ItemSlime, "item_slime_ball"),
(ParticleType::ItemSnowball, "item_snowball"),
diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs
index ce34df510..48e8f16f4 100644
--- a/crates/horizon-client/src/animate_tick.rs
+++ b/crates/horizon-client/src/animate_tick.rs
@@ -63,6 +63,10 @@ enum Animate {
SculkSensor,
/// Potent sulfur that is not dry.
PotentSulfur,
+ DriedGhast {
+ waterlogged: bool,
+ },
+ EndPortal,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -137,6 +141,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
+ "dried_ghast" => Animate::DriedGhast {
+ waterlogged: prop("waterlogged").as_deref() == Some("true"),
+ },
+ "end_portal" => Animate::EndPortal,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -180,6 +188,8 @@ pub struct AnimateTables {
impermeable: Vec,
power_provider: Vec,
power_transmitter: Vec,
+ /// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
+ dried_ghast_sounds: Vec,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec>,
}
@@ -237,6 +247,7 @@ impl AnimateTables {
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
+ dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
ambient,
})
}
@@ -869,6 +880,45 @@ fn block_animate_tick(
];
add(engine, T::Smoke, p, [0.0; 3]);
}
+ Animate::DriedGhast { waterlogged } => {
+ // DriedGhastBlock.animateTick.
+ let c = [fx + 0.5, fy + 0.5, fz + 0.5];
+ if waterlogged {
+ if next_int(engine.random(), 40) == 0 {
+ let name = "minecraft:block.dried_ghast.ambient_water";
+ sound(engine, name, c, 1.0, 1.0);
+ }
+ let r = engine.random();
+ if next_int(r, 6) == 0 {
+ let x = c[0] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
+ let y = c[1] + 0.4;
+ let z = c[2] + f64::from((r.next_float() * 2.0 - 1.0) / 3.0);
+ let v = [0.0, f64::from(r.next_float()), 0.0];
+ add(engine, T::HappyVillager, [x, y, z], v);
+ }
+ } else {
+ let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
+ let triggers = || {
+ tables
+ .dried_ghast_sounds
+ .get(below.0 as usize)
+ .copied()
+ .unwrap_or(false)
+ };
+ if next_int(engine.random(), 40) == 0 && triggers() {
+ sound(engine, "minecraft:block.dried_ghast.ambient", c, 1.0, 1.0);
+ }
+ if next_int(engine.random(), 6) == 0 {
+ add(engine, T::WhiteSmoke, c, [0.0, 0.02, 0.0]);
+ }
+ }
+ }
+ Animate::EndPortal => {
+ // EndPortalBlock.animateTick: one smoke particle above the surface.
+ let r = engine.random();
+ let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
+ add(engine, T::Smoke, p, [0.0; 3]);
+ }
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
diff --git a/crates/horizon-client/src/bin/horizon-ctl/commands.rs b/crates/horizon-client/src/bin/horizon-ctl/commands.rs
index cf7be69a6..f722e2f9a 100644
--- a/crates/horizon-client/src/bin/horizon-ctl/commands.rs
+++ b/crates/horizon-client/src/bin/horizon-ctl/commands.rs
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
"font",
"packs",
"mods",
+ "shader-packs",
"graphics",
"graphics-advanced",
"extras",
diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs
index af4168307..10c5e2509 100644
--- a/crates/horizon-client/src/block_entity_render.rs
+++ b/crates/horizon-client/src/block_entity_render.rs
@@ -730,6 +730,10 @@ pub struct Scene<'a> {
pub sign: &'a dyn Fn([i32; 3]) -> Option<&'a crate::signs::SignData>,
/// Banner layers at a position: pattern texture and RGB tint, bottom first.
pub banner: &'a dyn Fn([i32; 3]) -> Vec<(String, u32)>,
+ /// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
+ /// profile → skin): a downloaded `skins/` slot or the profile's
+ /// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
+ pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option, f32),
}
@@ -1279,6 +1283,13 @@ impl BlockEntityRenderer {
SkullType::Piglin => ("piglin_head", "entity/piglin/piglin"),
SkullType::Player => ("player_head", "entity/player/slim/steve"),
};
+ // SkullBlockRenderer.getRenderType: the profile's skin when it
+ // resolves, `SKIN_BY_TYPE` (steve) otherwise.
+ let texture = if *kind == SkullType::Player {
+ (scene.skull)(pos).unwrap_or(texture)
+ } else {
+ texture
+ };
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1886,6 +1897,7 @@ mod tests {
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
+ skull: &|_| None,
glyph: &|_| (None, 0.0),
};
// Compare the cached world path with the original model renderer,
diff --git a/crates/horizon-client/src/cauldron.rs b/crates/horizon-client/src/cauldron.rs
index 9fbe1179f..03fb0676d 100644
--- a/crates/horizon-client/src/cauldron.rs
+++ b/crates/horizon-client/src/cauldron.rs
@@ -87,6 +87,19 @@ fn filled(player: &mut dyn Player, new: ItemStack, limit_creative: bool) {
}
}
+/// `LayeredCauldronBlock.handleEntityOnFireInside`: a burning entity in a
+/// water cauldron lowers it a level; a powder snow cauldron becomes a water
+/// cauldron of its level first, then lowers.
+pub fn extinguished(cauldron: &BlockState) -> BlockState {
+ if cauldron.name == "minecraft:powder_snow_cauldron" {
+ let mut water = cauldron.clone();
+ water.name = "minecraft:water_cauldron".into();
+ lowered(&water)
+ } else {
+ lowered(cauldron)
+ }
+}
+
/// `LayeredCauldronBlock.lowerFillLevel`.
fn lowered(cauldron: &BlockState) -> BlockState {
let level: u8 = cauldron
@@ -296,3 +309,24 @@ pub fn interact(
_ => pass(),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_burning_entity_lowers_water_and_melts_powder_snow() {
+ let water = |l| state("minecraft:water_cauldron", Some(l));
+ assert_eq!(extinguished(&water(3)), water(2));
+ assert_eq!(extinguished(&water(1)), state("minecraft:cauldron", None));
+ // Powder snow turns to water of its level, then lowers.
+ assert_eq!(
+ extinguished(&state("minecraft:powder_snow_cauldron", Some(3))),
+ water(2)
+ );
+ assert_eq!(
+ extinguished(&state("minecraft:powder_snow_cauldron", Some(1))),
+ state("minecraft:cauldron", None)
+ );
+ }
+}
diff --git a/crates/horizon-client/src/chat.rs b/crates/horizon-client/src/chat.rs
index 273b87153..04b9d486c 100644
--- a/crates/horizon-client/src/chat.rs
+++ b/crates/horizon-client/src/chat.rs
@@ -19,6 +19,92 @@ mod tests;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
+/// `ChatVisiblity`: the Chat option's three values.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ChatVisibility {
+ Full,
+ System,
+ Hidden,
+}
+impl ChatVisibility {
+ /// Options.chatVisibility's saved value (0 full, 1 system, 2 hidden).
+ pub fn from_option(value: u8) -> Self {
+ match value {
+ 0 => Self::Full,
+ 1 => Self::System,
+ _ => Self::Hidden,
+ }
+ }
+}
+
+/// `ChatAbilities`: what the Chat option allows, as `Minecraft.computeChatAbilities`
+/// derives it from the visibility alone (`ChatRestriction`).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct ChatAbilities {
+ pub send_messages: bool,
+ pub send_commands: bool,
+ pub receive_player_messages: bool,
+ pub receive_system_messages: bool,
+}
+impl ChatAbilities {
+ /// `ChatRestriction.CHAT_AND_COMMANDS_DISABLED_BY_OPTIONS` (Hidden) takes
+ /// chat, commands, player messages and system messages;
+ /// `CHAT_DISABLED_BY_OPTIONS` (System) takes the chat and player messages.
+ pub fn new(visibility: ChatVisibility) -> Self {
+ match visibility {
+ ChatVisibility::Full => Self {
+ send_messages: true,
+ send_commands: true,
+ receive_player_messages: true,
+ receive_system_messages: true,
+ },
+ ChatVisibility::System => Self {
+ send_messages: false,
+ send_commands: true,
+ receive_player_messages: false,
+ receive_system_messages: true,
+ },
+ ChatVisibility::Hidden => Self {
+ send_messages: false,
+ send_commands: false,
+ receive_player_messages: false,
+ receive_system_messages: false,
+ },
+ }
+ }
+ /// `visibleMessagesFilter`: the client's own lines always show.
+ pub fn shows(&self, source: ChatSource) -> bool {
+ match source {
+ ChatSource::Player => self.receive_player_messages,
+ ChatSource::Server => self.receive_system_messages,
+ ChatSource::Client => true,
+ }
+ }
+ /// Whether the chat screen may send `input` (`hasAllowedInput`).
+ pub fn allows(&self, input: &str) -> bool {
+ if input.starts_with('/') {
+ self.send_commands
+ } else {
+ self.send_messages
+ }
+ }
+}
+
+impl Default for ChatAbilities {
+ fn default() -> Self {
+ Self::new(ChatVisibility::Full)
+ }
+}
+
+/// Where a chat line came from (`GuiMessageSource`): the visibility filters
+/// player messages and the server's, while the client's own always show.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ChatSource {
+ Player,
+ Server,
+ Client,
+}
+
/// The chat options (doubles, as vanilla keeps them).
#[derive(Clone, Copy, Debug)]
pub struct ChatOptions {
@@ -71,6 +157,7 @@ struct Message {
added: u64,
content: Text,
indicator: Option,
+ source: ChatSource,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -105,6 +192,8 @@ pub struct Chat {
pub log: bool,
/// Messages added since the start, for the control socket's events.
pub received: u64,
+ /// The Chat option's `visibleMessageFilter` (`set_abilities`).
+ abilities: ChatAbilities,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -114,16 +203,32 @@ impl Chat {
fn max_width(&self) -> i32 {
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
}
- /// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
+ /// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
+ /// message the Chat option hides is dropped, unlogged.
pub fn add(
&mut self,
atlas: &HudAtlas,
content: Text,
indicator: Option,
+ source: ChatSource,
ticks: u64,
focused: bool,
) {
- // ChatComponent.logChatMessage: every message is in the log.
+ self.add_message(&atlas.font, content, indicator, source, ticks, focused);
+ }
+ fn add_message(
+ &mut self,
+ font: &crate::font::Font,
+ content: Text,
+ indicator: Option,
+ source: ChatSource,
+ ticks: u64,
+ focused: bool,
+ ) {
+ if !self.abilities.shows(source) {
+ return;
+ }
+ // ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
if self.log {
println!("[CHAT] {}", content.plain());
@@ -132,16 +237,17 @@ impl Chat {
added: ticks,
content,
indicator,
+ source,
};
- self.add_lines(atlas, &message, focused);
+ self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
- fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
+ fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
let content = self.displayed(&message.content);
- for line in atlas.font.wrap_components(&content, width) {
+ for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -167,16 +273,38 @@ impl Chat {
content.strip_legacy()
}
}
- /// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
+ /// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
+ /// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
+ self.rewrap(&atlas.font);
+ }
+ fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
- let messages: Vec = self.messages.iter().rev().cloned().collect();
+ let messages: Vec = self
+ .messages
+ .iter()
+ .rev()
+ .filter(|m| self.abilities.shows(m.source))
+ .cloned()
+ .collect();
for m in &messages {
- self.add_lines(atlas, m, false);
+ self.add_lines(font, m, false);
}
}
+ /// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
+ /// the messages it now hides leave the chat, those it shows again return.
+ pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
+ if self.abilities != abilities {
+ self.abilities = abilities;
+ self.rewrap(font);
+ }
+ }
+ /// Whether a message from `source` would show (`visibleMessagesFilter`).
+ pub fn shows(&self, source: ChatSource) -> bool {
+ self.abilities.shows(source)
+ }
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
@@ -437,6 +565,8 @@ pub struct ChatScreen {
allow_suggestions: bool,
keep_suggestions: bool,
pub auto_suggestions: bool,
+ /// `CommandSuggestions.setRestrictions`: what the Chat option allows.
+ pub abilities: ChatAbilities,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -482,6 +612,7 @@ impl ChatScreen {
history_len: usize,
width: i32,
height: i32,
+ abilities: ChatAbilities,
) -> Self {
let mut input = TextField::new(4, height - 12, width - 4, 12);
input.max_length = 256;
@@ -505,6 +636,7 @@ impl ChatScreen {
allow_suggestions: false,
keep_suggestions: false,
auto_suggestions: true,
+ abilities,
};
s.update_command_info(m, c);
s
@@ -522,7 +654,8 @@ impl ChatScreen {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
- *self = Self::new(m, c, &value, self.history_pos, width, height);
+ let abilities = self.abilities;
+ *self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
self.auto_suggestions = auto;
}
}
@@ -604,6 +737,14 @@ impl ChatScreen {
self.pending = Some((range.0, list));
self.usage_width = 0;
self.usage_position = 0;
+ // CommandSuggestions.updateCommandInfo: the Chat option may take
+ // the messages.
+ if !self.abilities.send_messages {
+ let line =
+ Text::translate("chat_screen.messages_not_allowed", &[]).with_color(text::RED);
+ self.usage_width = m.width(&line);
+ self.usage.push(line);
+ }
} else {
self.awaiting = None;
self.pending = None;
@@ -708,6 +849,15 @@ impl ChatScreen {
}
self.usage.extend(entries);
}
+ // CommandSuggestions.updateUsageInfo: the Chat option may take the
+ // commands, under the usage. (Vanilla also counts a command with a
+ // message argument, `/say hi`, as a message; Horizon's parse keeps no
+ // argument kinds, so only a bare message does.)
+ if !self.abilities.send_commands {
+ self.usage.push(
+ Text::translate("chat_screen.commands_not_allowed", &[]).with_color(text::RED),
+ );
+ }
self.usage_width = self.usage.iter().map(|u| m.width(u)).max().unwrap_or(0);
self.usage_position = if self.usage.is_empty() {
0
@@ -842,7 +992,14 @@ impl ChatScreen {
}
match key {
K::Escape => return ChatAction::Close,
- K::Enter | K::NumpadEnter => return ChatAction::Submit(self.normalized()),
+ K::Enter | K::NumpadEnter => {
+ // ChatScreen.keyPressed: without `hasAllowedInput` the input
+ // stays, unsent, and the screen stays open.
+ if !self.abilities.allows(&self.input.value) {
+ return ChatAction::None;
+ }
+ return ChatAction::Submit(self.normalized());
+ }
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
diff --git a/crates/horizon-client/src/chat_tests.rs b/crates/horizon-client/src/chat_tests.rs
index d0ce3f458..6b058c0fb 100644
--- a/crates/horizon-client/src/chat_tests.rs
+++ b/crates/horizon-client/src/chat_tests.rs
@@ -2,6 +2,11 @@ use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
+/// The Chat option's default: no restriction.
+fn full() -> ChatAbilities {
+ ChatAbilities::new(ChatVisibility::Full)
+}
+
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
@@ -139,7 +144,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
- let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360);
+ let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -176,7 +181,15 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
registries: ®istries,
};
let history = vec!["first message".to_owned(), "latest message".to_owned()];
- let mut screen = ChatScreen::new(&m, &commands, "unfinished draft", history.len(), 640, 360);
+ let mut screen = ChatScreen::new(
+ &m,
+ &commands,
+ "unfinished draft",
+ history.len(),
+ 640,
+ 360,
+ full(),
+ );
for (key, expected) in [
(ArrowUp, "latest message"),
(ArrowUp, "first message"),
@@ -213,7 +226,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: ®istries,
};
- let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
+ let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -259,7 +272,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
- let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
+ let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -281,7 +294,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: ®istries,
};
- let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360);
+ let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -327,3 +340,99 @@ fn a_server_component_keeps_its_click_and_hover_events() {
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
}
+
+/// `ChatVisiblity`: Commands Only keeps the chat out, Hidden takes the
+/// commands too (`ChatAbilities`), and Enter sends nothing the option hides.
+#[test]
+fn the_chat_option_decides_what_may_be_sent() {
+ let font = font();
+ let m = Measure(&font);
+ let registries = Registries::default();
+ let c = Commands {
+ dispatcher: crate::command::vanilla::game_dispatcher(),
+ registries: ®istries,
+ };
+ let enter = |screen: &mut ChatScreen| {
+ screen.key(&m, &c, &[], winit::keyboard::KeyCode::Enter, false, false)
+ };
+ for (visibility, chat, command) in [
+ (ChatVisibility::Full, true, true),
+ (ChatVisibility::System, false, true),
+ (ChatVisibility::Hidden, false, false),
+ ] {
+ let abilities = ChatAbilities::new(visibility);
+ assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
+ assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
+ for (line, allowed) in [("hi", chat), ("/time set day", command)] {
+ let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
+ screen.type_text(&m, &c, line);
+ match enter(&mut screen) {
+ ChatAction::Submit(sent) => {
+ assert!(allowed, "{visibility:?} sent {sent}");
+ assert_eq!(sent, line);
+ }
+ ChatAction::None => assert!(!allowed, "{visibility:?} kept {line}"),
+ ChatAction::Close => panic!("Enter does not close the chat"),
+ }
+ }
+ }
+}
+
+/// The restriction the option sets is a usage line: red, under the input, for
+/// a command the Chat option takes and for a message it takes.
+#[test]
+fn restricted_input_says_why_it_will_not_send() {
+ let font = font();
+ let m = Measure(&font);
+ let registries = Registries::default();
+ let c = Commands {
+ dispatcher: crate::command::vanilla::game_dispatcher(),
+ registries: ®istries,
+ };
+ let usage = |visibility, line: &str| {
+ let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
+ screen.type_text(&m, &c, line);
+ screen.usage.iter().map(|t| t.plain()).collect::>()
+ };
+ assert_eq!(usage(ChatVisibility::Full, "hi"), Vec::::new());
+ // The font has no language files here, so the line is its key.
+ assert_eq!(
+ usage(ChatVisibility::System, "hi"),
+ ["chat_screen.messages_not_allowed"]
+ );
+ // A command the option allows says nothing about the restriction.
+ assert!(
+ !usage(ChatVisibility::System, "/time set day")
+ .iter()
+ .any(|line| line == "chat_screen.commands_not_allowed")
+ );
+ assert!(
+ usage(ChatVisibility::Hidden, "/time set day")
+ .iter()
+ .any(|line| line == "chat_screen.commands_not_allowed")
+ );
+}
+
+/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
+/// on arrival is dropped; one already in the chat leaves it while the option
+/// hides its kind and comes back when it shows it again.
+#[test]
+fn the_chat_option_filters_the_lines_both_ways() {
+ let font = font();
+ let mut chat = Chat::default();
+ let says = |chat: &mut Chat, text: &str, source| {
+ chat.add_message(&font, Text::literal(text), None, source, 0, false);
+ };
+ let shown =
+ |chat: &Chat| -> Vec { chat.lines.iter().map(|l| l.content.plain()).collect() };
+ says(&mut chat, "player", ChatSource::Player);
+ says(&mut chat, "server", ChatSource::Server);
+ chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
+ assert_eq!(shown(&chat), ["server"]);
+ says(&mut chat, "dropped", ChatSource::Player);
+ chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
+ says(&mut chat, "client", ChatSource::Client);
+ assert_eq!(shown(&chat), ["client"]);
+ chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
+ assert_eq!(shown(&chat), ["client", "server", "player"]);
+}
diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs
index 3ff2cfdd6..7c32bd829 100644
--- a/crates/horizon-client/src/collisions.rs
+++ b/crates/horizon-client/src/collisions.rs
@@ -92,6 +92,13 @@ impl BlockCollisionWorld for EntityCollisions<'_, W> {
) -> std::result::Result {
self.blocks.is_suffocating(chunk, block, p)
}
+ fn may_reach_out(
+ &mut self,
+ chunk: Self::Chunk,
+ section_y: i32,
+ ) -> std::result::Result {
+ self.blocks.may_reach_out(chunk, section_y)
+ }
fn context_shape(
&mut self,
block: CollisionBlock,
@@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result {
self.binding.collision_block(chunk.block(p.x, p.y, p.z))
}
+ /// From the section's palette, without reading its blocks; a palette
+ /// of the whole registry reads every cell.
+ fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result {
+ let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
+ .ok()
+ .and_then(|s| chunk.sections.get(s))
+ .map(|section| §ion.blocks);
+ Ok(blocks.is_none_or(|blocks| {
+ blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
+ }))
+ }
fn is_suffocating(
&mut self,
_: Self::Chunk,
diff --git a/crates/horizon-client/src/command/tests.rs b/crates/horizon-client/src/command/tests.rs
index 2bdea1aba..d61fd8ae1 100644
--- a/crates/horizon-client/src/command/tests.rs
+++ b/crates/horizon-client/src/command/tests.rs
@@ -592,3 +592,21 @@ fn mod_commands_join_the_tree_for_every_player() {
// The game's own tree is left as it was.
assert!(vanilla::game_dispatcher().find("where").is_none());
}
+
+#[test]
+fn horizon_commands_are_off_unless_the_server_turns_them_on() {
+ // By default the game runs vanilla's tree: /help lists no /pregen.
+ assert!(vanilla::game_dispatcher().find("pregen").is_none());
+ for dev in [false, true] {
+ let vanilla_tree = vanilla::game_tree(dev, false);
+ assert!(vanilla_tree.find("pregen").is_none());
+ let d = vanilla::game_tree(dev, true);
+ let parse = d.parse("pregen start 100 circle", ®());
+ assert_eq!(parse.executable().unwrap().1, "pregen.start");
+ // Vanilla's commands are the same with or without it.
+ assert_eq!(
+ d.nodes[0].children.len(),
+ vanilla_tree.nodes[0].children.len() + 1
+ );
+ }
+}
diff --git a/crates/horizon-client/src/command/vanilla.rs b/crates/horizon-client/src/command/vanilla.rs
index cdd4848d7..2ce892077 100644
--- a/crates/horizon-client/src/command/vanilla.rs
+++ b/crates/horizon-client/src/command/vanilla.rs
@@ -538,12 +538,69 @@ fn apply_vanilla_order(d: &mut Dispatcher) {
}
/// The tree the game runs: a release integrated server's, or with
/// `HORIZON_DEV_COMMANDS` set the development one
-/// (SharedConstants.DEBUG_DEV_COMMANDS).
+/// (SharedConstants.DEBUG_DEV_COMMANDS), plus Horizon's own commands when
+/// the server turned them on (`set_horizon_commands`).
pub fn game_dispatcher() -> &'static Dispatcher {
- if std::env::var_os("HORIZON_DEV_COMMANDS").is_some() {
- dispatcher()
- } else {
- release_dispatcher()
+ game_tree(
+ std::env::var_os("HORIZON_DEV_COMMANDS").is_some(),
+ HORIZON_COMMANDS.load(std::sync::atomic::Ordering::Relaxed),
+ )
+}
+/// Whether this process's server has Horizon's own commands (`/pregen`). Off
+/// by default, so `/help` and the command tree are vanilla's; a dedicated
+/// server turns them on with `horizon-pregen=true` in server.properties, the
+/// game with `--pregen` (docs/pregeneration.md).
+static HORIZON_COMMANDS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
+/// Turns Horizon's own commands on or off for the trees `game_dispatcher`
+/// returns from now on.
+pub fn set_horizon_commands(on: bool) {
+ HORIZON_COMMANDS.store(on, std::sync::atomic::Ordering::Relaxed);
+}
+/// The server.properties key that turns `/pregen` on (Horizon's own; vanilla
+/// ignores keys it doesn't know).
+pub const PREGEN_PROPERTY: &str = "horizon-pregen";
+pub(crate) fn game_tree(dev: bool, horizon: bool) -> &'static Dispatcher {
+ static HORIZON: [std::sync::OnceLock; 2] =
+ [const { std::sync::OnceLock::new() }; 2];
+ match (dev, horizon) {
+ (true, false) => dispatcher(),
+ (false, false) => release_dispatcher(),
+ (dev, true) => HORIZON[usize::from(dev)].get_or_init(|| {
+ let mut d = build_with(dev);
+ horizon_commands(&mut d, 0);
+ d
+ }),
+ }
+}
+/// Commands vanilla doesn't have, after its own: `/pregen`
+/// (docs/pregeneration.md).
+fn horizon_commands(d: &mut Dispatcher, root: usize) {
+ let pregen = d.literal(root, "pregen");
+ d.requires(pregen, GAMEMASTERS);
+ let start = d.literal(pregen, "start");
+ let radius = d.argument(
+ start,
+ "radius",
+ ArgType::Integer {
+ min: 0,
+ max: 30_000_000,
+ },
+ );
+ d.executes(radius, "pregen.start");
+ for shape in ["square", "circle"] {
+ let shape = d.literal(radius, shape);
+ d.executes(shape, "pregen.start");
+ let center = d.argument(shape, "center", ArgType::ColumnPos);
+ d.executes(center, "pregen.start");
+ }
+ for (word, id) in [
+ ("pause", "pregen.pause"),
+ ("continue", "pregen.continue"),
+ ("cancel", "pregen.cancel"),
+ ("status", "pregen.status"),
+ ] {
+ let w = d.literal(pregen, word);
+ d.executes(w, id);
}
}
pub fn dispatcher() -> &'static Dispatcher {
diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs
index de2e1b2e9..d13c5e45b 100644
--- a/crates/horizon-client/src/containers.rs
+++ b/crates/horizon-client/src/containers.rs
@@ -44,6 +44,10 @@ pub struct Furnace {
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
+ /// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
+ /// made since a player last took from its output. The experience is owed per recipe
+ /// rather than per item, and a save keeps it as the `RecipesUsed` compound.
+ pub recipes_used: HashMap,
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -79,6 +83,60 @@ pub enum BlockEntity {
Beehive(Box),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box),
+ /// `SkullBlockEntity` of every skull and head block.
+ Skull(Skull),
+}
+/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
+/// a compound, or a string when it names a player only), the note block
+/// sound and `custom_name`.
+#[derive(Clone, Debug, Default)]
+pub struct Skull {
+ pub profile: Option,
+ pub note_block_sound: Option,
+ pub custom_name: Option,
+ /// What `profile` draws, read from it once rather than every frame.
+ pub skin: Option,
+}
+impl Skull {
+ pub fn new(
+ profile: Option,
+ note_block_sound: Option,
+ custom_name: Option,
+ ) -> Self {
+ let skin = profile.as_ref().map(SkullSkin::of);
+ Self {
+ profile,
+ note_block_sound,
+ custom_name,
+ skin,
+ }
+ }
+}
+/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
+/// with the `skins/` sprite it downloads to, and the
+/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
+/// profile with no id).
+#[derive(Clone, Debug)]
+pub struct SkullSkin {
+ pub download: Option<(String, String)>,
+ pub fallback: &'static str,
+}
+impl SkullSkin {
+ pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
+ let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
+ let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
+ Some((url, location))
+ });
+ // DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
+ let fallback = crate::skins::profile_uuid_nbt(profile)
+ .map_or("entity/player/slim/steve", crate::tab_list::default_skin);
+ Self { download, fallback }
+ }
+}
+/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
+/// `*_skull` and `*_head` but the piston's head.
+fn is_skull(name: &str) -> bool {
+ name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
@@ -190,6 +248,7 @@ impl BlockEntity {
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
+ recipes_used: HashMap::new(),
}));
}
if name == "minecraft:creaking_heart" {
@@ -233,6 +292,9 @@ impl BlockEntity {
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
}
+ if is_skull(name) {
+ return Some(Self::Skull(Skull::default()));
+ }
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
@@ -278,6 +340,7 @@ impl BlockEntity {
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
+ Self::Skull(_) => "minecraft:skull",
}
}
pub fn contents(&self) -> Vec {
@@ -293,7 +356,8 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
- | Self::CommandBlock(_) => Vec::new(),
+ | Self::CommandBlock(_)
+ | Self::Skull(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -310,7 +374,8 @@ impl BlockEntity {
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
- | Self::Banner(_) => &[],
+ | Self::Banner(_)
+ | Self::Skull(_) => &[],
Self::Crafter(c) => &c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -330,7 +395,8 @@ impl BlockEntity {
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
- | Self::Banner(_) => &mut [],
+ | Self::Banner(_)
+ | Self::Skull(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -483,6 +549,14 @@ impl Furnace {
self.cooking_timer = 0;
}
}
+ /// `recipesUsed`, each recipe with its `experience` (0 for one this data pack does
+ /// not have), in the order `getRecipesToAwardAndPopExperience` pays them.
+ pub fn recipe_experience(&self, recipes: &Recipes) -> Vec<(u32, f32)> {
+ self.recipes_used
+ .iter()
+ .map(|(id, count)| (*count, recipes.cooking_experience(id)))
+ .collect()
+ }
/// A menu click wrote its slots into `items`, and `before` was the input
/// slot until then: the menu called `setItem` on it only if it changed
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
@@ -564,6 +638,7 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
+ *self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -594,3 +669,41 @@ fn total_time(time: u32, speed: f32) -> i32 {
time as i32
}
}
+
+/// `AbstractFurnaceBlockEntity.createExperience`: `count * experience`, floored, and one
+/// more with the chance of what is left over (a level random draw only when there is some).
+pub fn create_experience(
+ count: u32,
+ experience: f32,
+ random: &mut impl horizon_core::random::RandomSource,
+) -> u32 {
+ let total = count as f32 * experience;
+ let whole = total.floor();
+ let fraction = total - whole;
+ let mut xp = whole.max(0.0) as u32;
+ if fraction != 0.0 && random.next_float() < fraction {
+ xp += 1;
+ }
+ xp
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use horizon_core::random::{LegacyRandom, RandomSource};
+
+ /// A whole amount pays itself without a draw; a fraction is a chance of one more.
+ #[test]
+ fn create_experience_rounds_the_fraction_by_chance() {
+ let mut random = LegacyRandom::new(7);
+ let mut untouched = LegacyRandom::new(7);
+ assert_eq!(create_experience(4, 0.5, &mut random), 2);
+ assert_eq!(create_experience(3, 0.0, &mut random), 0);
+ assert_eq!(random.next_long(), untouched.next_long());
+ let paid: u32 = (0..1000)
+ .map(|_| create_experience(1, 0.35, &mut random))
+ .sum();
+ assert!((250..450).contains(&paid), "{paid}");
+ assert!((0..100).all(|_| (1..=2).contains(&create_experience(1, 1.7, &mut random))));
+ }
+}
diff --git a/crates/horizon-client/src/cooldowns.rs b/crates/horizon-client/src/cooldowns.rs
new file mode 100644
index 000000000..31e882712
--- /dev/null
+++ b/crates/horizon-client/src/cooldowns.rs
@@ -0,0 +1,174 @@
+//! `Player.getCooldowns()` (`ItemCooldowns`) as the game keeps it: the
+//! `use_cooldown` component's entries by cooldown group, counted down once a
+//! tick, and the percent `GuiGraphicsExtractor.itemCooldown` shades a slot
+//! by. The world crate's `item_cooldowns` models vanilla's hash map and its
+//! observers for the parity harness; this is the same data with the lookup a
+//! slot needs, and no observer (vanilla's own player uses the plain class).
+use crate::items::ItemStack;
+use horizon_world::nbt::Tag;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+/// A stack's `minecraft:use_cooldown` component.
+#[derive(Clone, Debug, PartialEq)]
+pub struct UseCooldown {
+ pub seconds: f32,
+ /// `cooldown_group`: the group the entry is filed under, else the item.
+ pub group: Option>,
+}
+impl UseCooldown {
+ /// `UseCooldown.ticks`: the seconds in ticks, truncated.
+ pub fn ticks(&self) -> i32 {
+ (self.seconds * 20.0) as i32
+ }
+ /// The component as `items.json` holds it.
+ pub fn from_json(v: &serde_json::Value) -> Option {
+ let seconds = v.get("seconds")?.as_f64()? as f32;
+ Some(Self {
+ seconds,
+ group: v
+ .get("cooldown_group")
+ .and_then(|g| g.as_str())
+ .map(Arc::from),
+ })
+ }
+ /// The component as a stack's saved `components` holds it.
+ pub fn from_tag(t: &Tag) -> Option {
+ let Tag::Compound(c) = t else {
+ return None;
+ };
+ let text = |k: &str| match c.get(&horizon_world::nbt::JavaString::from(k)) {
+ Some(Tag::String(s)) => Some(s.to_string_lossy()),
+ _ => None,
+ };
+ let seconds = match c.get(&horizon_world::nbt::JavaString::from("seconds")) {
+ Some(Tag::Float(bits)) => f32::from_bits(*bits),
+ Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32,
+ _ => return None,
+ };
+ Some(Self {
+ seconds,
+ group: text("cooldown_group").map(Arc::from),
+ })
+ }
+}
+
+/// The component a stack carries: its own patch first (`ItemStack.get`), then
+/// the item's default from `items.json` (`ItemInfo::use_cooldown`).
+pub fn component(stack: &ItemStack, default: Option<&UseCooldown>) -> Option {
+ stack
+ .data()
+ .and_then(|d| d.other("minecraft:use_cooldown"))
+ .and_then(UseCooldown::from_tag)
+ .or_else(|| default.cloned())
+}
+
+/// `ItemCooldowns.getCooldownGroup`: the component's group, else the item id.
+pub fn group(stack: &ItemStack, default: Option<&UseCooldown>) -> Arc {
+ component(stack, default)
+ .and_then(|c| c.group)
+ .unwrap_or_else(|| Arc::clone(&stack.item))
+}
+
+/// `ItemCooldowns`: start and end ticks by group, and the tick counter.
+#[derive(Clone, Debug, Default)]
+pub struct Cooldowns {
+ tick_count: i32,
+ entries: HashMap, (i32, i32)>,
+}
+impl Cooldowns {
+ /// `getCooldownPercent`: what is left of the entry, from 1 to 0.
+ pub fn percent(&self, group: &str, partial_tick: f32) -> f32 {
+ let Some((start, end)) = self.entries.get(group) else {
+ return 0.0;
+ };
+ let duration = end.wrapping_sub(*start) as f32;
+ let remaining = *end as f32 - (self.tick_count as f32 + partial_tick);
+ (remaining / duration).clamp(0.0, 1.0)
+ }
+ /// `isOnCooldown`: whether the group still has time to run.
+ pub fn is_on_cooldown(&self, group: &str) -> bool {
+ self.percent(group, 0.0) > 0.0
+ }
+ /// `addCooldown`.
+ pub fn add(&mut self, group: Arc, ticks: i32) {
+ self.entries.insert(
+ group,
+ (self.tick_count, self.tick_count.wrapping_add(ticks)),
+ );
+ }
+ /// `ItemCooldowns.tick`: the counter, then every entry that has run out,
+ /// which it hands back (`onCooldownEnded`).
+ pub fn tick(&mut self) -> Vec> {
+ self.tick_count = self.tick_count.wrapping_add(1);
+ let done: Vec> = self
+ .entries
+ .iter()
+ .filter(|(_, (_, end))| *end <= self.tick_count)
+ .map(|(group, _)| Arc::clone(group))
+ .collect();
+ self.entries.retain(|_, (_, end)| *end > self.tick_count);
+ done
+ }
+ /// `removeCooldown`.
+ pub fn remove(&mut self, group: &str) {
+ self.entries.remove(group);
+ }
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ fn stack(item: &str) -> ItemStack {
+ ItemStack::new(item, 1)
+ }
+
+ #[test]
+ fn the_percent_falls_from_one_to_zero_over_the_entry() {
+ let mut c = Cooldowns::default();
+ c.add(Arc::from("minecraft:ender_pearl"), 20);
+ assert!(c.is_on_cooldown("minecraft:ender_pearl"));
+ assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 1.0);
+ for _ in 0..10 {
+ assert!(c.tick().is_empty());
+ }
+ assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.5);
+ // The partial tick moves it within the tick.
+ assert_eq!(c.percent("minecraft:ender_pearl", 0.5), 0.475);
+ for _ in 0..9 {
+ c.tick();
+ }
+ assert!(c.is_on_cooldown("minecraft:ender_pearl"));
+ // ItemCooldowns.tick drops the entry at its end tick, and says so.
+ assert_eq!(c.tick(), [Arc::::from("minecraft:ender_pearl")]);
+ assert!(!c.is_on_cooldown("minecraft:ender_pearl"));
+ assert!(c.is_empty());
+ assert_eq!(c.percent("minecraft:ender_pearl", 0.0), 0.0);
+ }
+
+ #[test]
+ fn the_group_comes_from_the_stack_then_the_item() {
+ let pearl = stack("minecraft:ender_pearl");
+ let default = UseCooldown::from_json(&json!({"seconds": 1.0})).expect("the component");
+ assert_eq!(&*group(&pearl, Some(&default)), "minecraft:ender_pearl");
+ // A stack's own component overrides the item's, group included.
+ let mut grouped = stack("minecraft:ender_pearl");
+ grouped.data_mut().set_other(
+ "minecraft:use_cooldown",
+ Some(Tag::compound([
+ ("seconds", Tag::Float(2.0f32.to_bits())),
+ ("cooldown_group", Tag::String("minecraft:pearls".into())),
+ ])),
+ );
+ let c = component(&grouped, Some(&default)).expect("the stack's component");
+ assert_eq!(c.ticks(), 40);
+ assert_eq!(&*group(&grouped, Some(&default)), "minecraft:pearls");
+ // An item without the component has no cooldown.
+ assert!(component(&stack("minecraft:dirt"), None).is_none());
+ }
+}
diff --git a/crates/horizon-client/src/crafting.rs b/crates/horizon-client/src/crafting.rs
index d0b8906ee..690383963 100644
--- a/crates/horizon-client/src/crafting.rs
+++ b/crates/horizon-client/src/crafting.rs
@@ -1610,6 +1610,14 @@ impl Recipes {
}
out
}
+ /// The `experience` of the cooking recipe with this file name, and 0 for one this data
+ /// pack does not have (a save from another pack can name it).
+ pub fn cooking_experience(&self, id: &str) -> f32 {
+ self.cooking
+ .iter()
+ .find(|c| c.id == id)
+ .map_or(0.0, |c| c.experience)
+ }
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs
index e1d1c262e..249e40b11 100644
--- a/crates/horizon-client/src/creative.rs
+++ b/crates/horizon-client/src/creative.rs
@@ -71,10 +71,52 @@ impl Names {
.cloned()
.unwrap_or_else(|| path.replace('_', " "))
}
+ /// An item's display name in the selected language: `item.*` then
+ /// `block.*`, then the pinned English map (`Names::item`).
+ pub fn display_item(&self, id: &str) -> String {
+ let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
+ if let Some(lang) = crate::lang::current() {
+ for prefix in ["item", "block"] {
+ if let Some(name) = lang.get(&format!("{prefix}.{namespace}.{path}")) {
+ return name.to_owned();
+ }
+ }
+ }
+ self.item(id)
+ }
+ /// A translation key's text in the selected language, else the pinned
+ /// English map (`HoverName` and tooltip keys such as potion names).
+ pub fn display_get(&self, key: &str) -> Option {
+ crate::lang::current()
+ .and_then(|lang| lang.get(key).map(str::to_owned))
+ .or_else(|| self.get(key).map(str::to_owned))
+ }
pub fn get(&self, key: &str) -> Option<&str> {
self.map.get(key).map(String::as_str)
}
}
+
+/// `CreativeModeTabs`' key for an exported tab id (`itemGroup.*`); the
+/// export's `title` is the English fallback.
+pub fn item_group_key(id: &str) -> Option<&'static str> {
+ Some(match id.trim_start_matches("minecraft:") {
+ "building_blocks" => "itemGroup.buildingBlocks",
+ "colored_blocks" => "itemGroup.coloredBlocks",
+ "natural_blocks" => "itemGroup.natural",
+ "functional_blocks" => "itemGroup.functional",
+ "redstone_blocks" => "itemGroup.redstone",
+ "hotbar" => "itemGroup.hotbar",
+ "search" => "itemGroup.search",
+ "tools_and_utilities" => "itemGroup.tools",
+ "combat" => "itemGroup.combat",
+ "food_and_drinks" => "itemGroup.foodAndDrink",
+ "ingredients" => "itemGroup.ingredients",
+ "spawn_eggs" => "itemGroup.spawnEggs",
+ "op_blocks" => "itemGroup.op",
+ "inventory" => "itemGroup.inventory",
+ _ => return None,
+ })
+}
impl Creative {
pub const WIDTH: f32 = 195.0;
pub const HEIGHT: f32 = 136.0;
@@ -303,7 +345,17 @@ impl Creative {
.collect();
}
// FullTextSearchTree: tooltip lines, and the item id after a colon.
+ // The export's lines are English (they come from the vanilla jars);
+ // the hover name in the selected language is matched too, so a
+ // localized search finds the item. It is the whole name (a tipped
+ // arrow's potion, the ominous banner), as the first line is.
let text = java_lower(term);
+ let localized = |i: usize| {
+ java_lower(&crate::item_tooltip::name_line(
+ &self.search_items[i],
+ &|k| names.display_get(k),
+ ))
+ };
all.filter(|&i| {
let e = &self.search_index[i];
let fallback;
@@ -314,11 +366,15 @@ impl Creative {
&e.lines
};
match text.split_once(':') {
- None => any(lines.iter().map(String::as_str), &text),
+ None => {
+ any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
+ }
Some((ns, path)) => {
let (ns, path) = (java_trim(ns), java_trim(path));
e.namespace.contains(ns)
- && (e.path.contains(path) || any(lines.iter().map(String::as_str), path))
+ && (e.path.contains(path)
+ || any(lines.iter().map(String::as_str), path)
+ || localized(i).contains(path))
}
}
})
@@ -759,6 +815,24 @@ mod tests {
assert_eq!(q("#craft"), Vec::::new());
}
+ #[test]
+ fn search_matches_the_display_name_in_the_selected_language() {
+ let c = creative(0);
+ let names = Names {
+ map: HashMap::from([
+ ("item.minecraft.anvil".to_owned(), "铁砧".to_owned()),
+ ("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
+ ]),
+ };
+ let q = |s: &str| c.search_results(s, &names);
+ assert_eq!(q("铁砧"), [0]);
+ assert_eq!(q("木锄"), [2]);
+ // The English tooltip lines the export indexed still match.
+ assert_eq!(q("anvil"), [0]);
+ // A colon's path searches the display name too.
+ assert_eq!(q("minecraft:铁砧"), [0]);
+ }
+
#[test]
fn scrollbar_drag_follows_the_mouse_while_held() {
let mut c = creative(90);
@@ -794,6 +868,20 @@ mod tests {
assert!(!c.scrolling);
}
+ #[test]
+ fn creative_tab_titles_have_vanilla_keys() {
+ assert_eq!(
+ item_group_key("minecraft:building_blocks"),
+ Some("itemGroup.buildingBlocks")
+ );
+ assert_eq!(
+ item_group_key("minecraft:food_and_drinks"),
+ Some("itemGroup.foodAndDrink")
+ );
+ assert_eq!(item_group_key("minecraft:hotbar"), Some("itemGroup.hotbar"));
+ assert_eq!(item_group_key("minecraft:nonsense"), None);
+ }
+
#[test]
fn wheel_moves_one_row_per_notch() {
let mut c = creative(90);
diff --git a/crates/horizon-client/src/debug_overlay.rs b/crates/horizon-client/src/debug_overlay.rs
index 3448ea411..7140905a8 100644
--- a/crates/horizon-client/src/debug_overlay.rs
+++ b/crates/horizon-client/src/debug_overlay.rs
@@ -84,6 +84,33 @@ impl Displayer {
}
}
+/// Horizon's own lines: the shader pack's GPU time, then its costliest passes (`passes`: each
+/// pass's median milliseconds, in the order they run).
+pub fn shader_pass_lines(passes: &[(String, f32)]) -> Vec {
+ const SHOWN: usize = 8;
+ if passes.is_empty() {
+ return Vec::new();
+ }
+ let total: f32 = passes.iter().map(|p| p.1).sum();
+ let mut lines = vec![format!(
+ "Shader pack GPU: {total:.2} ms in {} passes",
+ passes.len()
+ )];
+ let mut costliest: Vec<&(String, f32)> = passes.iter().collect();
+ costliest.sort_by(|a, b| b.1.total_cmp(&a.1));
+ lines.extend(
+ costliest
+ .iter()
+ .take(SHOWN)
+ .map(|(name, ms)| format!("{name}: {ms:.2} ms")),
+ );
+ if costliest.len() > SHOWN {
+ let rest: f32 = costliest[SHOWN..].iter().map(|p| p.1).sum();
+ lines.push(format!("{} more: {rest:.2} ms", costliest.len() - SHOWN));
+ }
+ lines
+}
+
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let wrapped = angle % 360.0;
@@ -337,4 +364,15 @@ mod tests {
(1.0, [640, 360])
);
}
+
+ #[test]
+ fn shader_pass_lines_list_the_costliest_passes_first() {
+ assert!(shader_pass_lines(&[]).is_empty());
+ let passes: Vec<(String, f32)> = (0..10).map(|i| (format!("p{i}"), i as f32)).collect();
+ let lines = shader_pass_lines(&passes);
+ assert_eq!(lines[0], "Shader pack GPU: 45.00 ms in 10 passes");
+ assert_eq!(lines[1], "p9: 9.00 ms");
+ assert_eq!(lines[8], "p2: 2.00 ms");
+ assert_eq!(lines[9], "2 more: 1.00 ms");
+ }
}
diff --git a/crates/horizon-client/src/dialog.rs b/crates/horizon-client/src/dialog.rs
new file mode 100644
index 000000000..7acd3e86a
--- /dev/null
+++ b/crates/horizon-client/src/dialog.rs
@@ -0,0 +1,794 @@
+//! Server dialogs (`net.minecraft.server.dialog`): what `Dialog.DIRECT_CODEC`
+//! decodes from a `show_dialog` packet, the values its inputs hold, and the
+//! click event an action makes of them (`Action.createAction`).
+use crate::text::Text;
+use horizon_world::nbt::Tag;
+use serde_json::{Value, json};
+
+/// `CommonDialogData` and the type's own fields.
+#[derive(Clone, Debug)]
+pub struct Dialog {
+ pub kind: Kind,
+ pub title: Text,
+ pub external_title: Option,
+ pub can_close_with_escape: bool,
+ pub pause: bool,
+ pub after_action: AfterAction,
+ pub body: Vec,
+ pub inputs: Vec,
+}
+
+/// The dialog types (`DialogTypes`).
+#[derive(Clone, Debug)]
+pub enum Kind {
+ /// `NoticeDialog`: one button, "OK" when the server names none.
+ Notice(Button),
+ Confirmation {
+ yes: Button,
+ no: Button,
+ },
+ MultiAction {
+ actions: Vec