Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5bb7be3765 Merge main into feat/lua-mods (Cargo.toml members one per line; stability.md server-mods row from main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:24 +01:00
Ludovic MarieandClaude Opus 5.5 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:52 +01:00
Ludovic Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +01:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
1041 changed files with 13814 additions and 106274 deletions

No files matched your search

-76
View File
@@ -1,76 +0,0 @@
---
name: vanilla-parity
description: Fix a Horizon bug or gap so it behaves 1:1 like vanilla 26.3, starting from the port ledger (tools/port_ledger.py) instead of grepping. Use for any issue, Discord report or PR that makes Horizon match vanilla: "X doesn't work like in Java", a missing sound, particle, animation, mob behaviour, block rule, screen or packet.
---
# Vanilla parity with the port ledger
The port ledger pairs every vanilla method with the Rust fn that ports it (or says it is missing, or needs
no port) and lists where a port isn't wired like vanilla. Ask it first; grep only for what it can't answer.
Full reference: `docs/port-ledger.md`.
## 0. Have a fresh ledger
```sh
python3 tools/port_ledger.py build # ~20 s; again after pulling main
```
It needs the vanilla jar (the launcher's `versions/26.3/26.3.jar`, or `--jar`) and/or the lab's decompiled
sources (`python3 tools/lab.py decompile`, or `--sources DIR`). If `build` says it has no vanilla code, run
the lab first; don't guess vanilla from memory.
## 1. From the report to the code
```sh
python3 tools/port_ledger.py issue 3236 # a GitHub issue number
python3 tools/port_ledger.py issue "mooshroom stays red after lightning" # or the report's text
```
It ranks the vanilla classes the report names and lists, per class, its Rust home files, where each method is
ported, and the known gaps (`missing` rows from the verdict map, seam/client/drift gaps). Often the cause is
already written there ("Type is not saved"). Then drill in:
```sh
python3 tools/port_ledger.py where MushroomCow.readAdditionalSaveData # ports, callers, callees, gaps
python3 tools/port_ledger.py class MushroomCow # every method: where, or missing
python3 tools/port_ledger.py what crates/horizon-client/src/mobs/farm.rs:120 # which vanilla method a fn ports
```
A ledger answer is a lead, not a verdict: about 1 in 12 map rows is wrong. Read the vanilla method (path and
line are in the output) and the Rust fn before changing anything.
## 2. Port it the vanilla way
- Translate the vanilla method, don't reinvent it: same order of operations, same constants, the same random
draws in the same order (`horizon_core::random`), Java `Math` through `horizon_core::jmath`.
- Open the fn's doc with the vanilla method it ports: `` /// `MushroomCow.readAdditionalSaveData`: ... ``.
That one line is what makes the next agent find it.
- Fix the path players run: single player is a client of the integrated server, so server logic lives in
`game/lan*.rs` and `src/lan/`, and what only the client's copy shows (particles, local sounds, hand
animation) must be reached from `game/remote*.rs`. A fix on the old in-frame path alone fixes nothing.
- Look at the neighbours the ledger lists for the same class: a `client` or `seam` gap next to your fix is
usually the same bug on the other path.
## 3. Before the PR
```sh
python3 tools/port_ledger.py check --base origin/main
```
It lists the ports your branch touches with their open gaps, citations to add, stale citations, and (against
the base) the gaps the branch closes and opens. Close what belongs to your fix, cite what you ported, leave
no new stale citation. If you ported a method the verdict map called `missing`, change its row in
`tools/port_map/*.tsv` to `port` with the fn (`file.rs::Owner::fn`) and run
`python3 tools/port_ledger.py map-check tools/port_map/<file>.tsv`.
Then the usual: the harness for the area (`docs/architecture.md`), `cargo check`/tests per `AGENTS.md`, and
a small PR that says which vanilla method it now matches.
## Finding work
```sh
python3 tools/port_ledger.py gaps --kind seam # ported on two paths, wired on one: the best leads
python3 tools/port_ledger.py gaps --kind client # client-only effects the server path never reaches
python3 tools/port_ledger.py gaps --kind drift # a port missing a sound, particle, item or constant
rg -n "player:" tools/port_map/*.tsv # missing behaviour a player would see
```
+1 -10
View File
@@ -1,11 +1,2 @@
#!/bin/sh
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
exec python3 tools/check_public_tree.py
+1 -10
View File
@@ -1,11 +1,2 @@
#!/bin/sh
# Git for Windows puts a `python3` on PATH that is only the Microsoft Store
# stub: it prints "Python" and exits non-zero, so the hook fails with no other
# output. Use the first name that actually runs Python 3.
for py in python3 python py; do
if "$py" -c "import sys; sys.exit(sys.version_info < (3,))" >/dev/null 2>&1; then
exec "$py" tools/check_public_tree.py --pre-push
fi
done
echo "check_public_tree: no working Python 3 found" >&2
exit 1
exec python3 tools/check_public_tree.py --pre-push
-2
View File
@@ -1,5 +1,3 @@
<!-- Opened by an AI agent? First line: "> 🤖 Opened by <your GitHub login>'s AI agent (<tool, model>), not by <your GitHub login> personally." Use your own login, not someone else's. -->
## What and why
<!-- What this changes, and why. Link the issue it fixes ("Fixes #123"). -->
-9
View File
@@ -1,9 +0,0 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
+4 -6
View File
@@ -16,14 +16,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.event.pull_request.head.ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
SHA: ${{ github.event.pull_request.head.sha }}
run: |
# Every head the pull request had, not only its last: a run-parity label added on an older head left that
# run queued after the merge (#3104, 2026-10-29). Its branch in its own repository names its runs (another
# fork can have a branch of the same name).
for status in queued in_progress waiting pending requested; do
gh api -X GET "repos/$REPO/actions/runs" -f branch="$BRANCH" -f event=pull_request -f status="$status" \
-f per_page=100 --jq ".workflow_runs[] | select(.head_repository.full_name == \"$HEAD_REPO\") | .id"
gh run list -R "$REPO" --branch "$BRANCH" --status "$status" --limit 100 \
--json databaseId,event,headSha \
-q ".[] | select(.event == \"pull_request\" and .headSha == \"$SHA\") | .databaseId"
done | sort -u | while read -r id; do
gh run cancel -R "$REPO" "$id" || true
done
-292
View File
@@ -1,292 +0,0 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
on:
workflow_call:
inputs:
ref:
description: The commit to build
type: string
required: true
version:
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
type: string
required: true
nightly:
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
type: string
default: ''
trusted:
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
# (tests/test_nightly.py pins all of it).
description: The source is main (a release); false for anything else (a nightly)
type: boolean
default: false
developer_id:
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
type: boolean
default: true
permissions:
contents: read
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
# goes to the Mac mini's nightly runner (macOS + horizon-nightly: ludovics-mac-mini-nightly, beside the one that
# runs pull requests' parity jobs, so a nightly never waits behind their queue), never the release runner
# (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]') }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
- 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: exporters
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
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
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: packaging/macos/keychain.sh lock
+1 -3
View File
@@ -192,11 +192,9 @@ jobs:
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*)
client="${clients[0]}"
echo "installed client: $client"
[[ -f "$client" ]] || { echo "::error::no game installed under versions/"; exit 1; }
# The game's Linux build is the game's to keep portable (release.yml checks it): a
# release linked to a Nix store is a warning here, not a broken launcher.
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
-278
View File
@@ -1,278 +0,0 @@
name: nightly build
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The Nightly, the only build Horizon ships since 2026-10-28 (docs/release-flow.md "Nightly"; Ludovic). When the batch
# train decides (release.yml, with the commit as `base`), once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py), or main alone with none picked. Main is its newest commit where
# checks and parity-main passed (or the `base` input), as for a release, and no Nightly is published while a
# release-blocker is open. It publishes the result as the `nightly` pre-release, which every launcher and
# `horizon-server update` install. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. A picked pull request's own .github/ is never read.
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# - The source ships with the build: `pick` writes horizon-source.tar.gz with `git archive` (git objects again, nothing
# run), `sign` signs it with the builds, and `publish` attaches it and tags the commit nightly-source/<date>-<short>
# for good (the dated release and the build branch go after 7 days; native mods pin the commit: docs/native-mods.md).
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
on:
schedule:
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
workflow_dispatch:
inputs:
force:
description: Build and publish even if tonight's pick is the commit already published
type: boolean
default: false
dry_run:
description: Build and sign with a throwaway key, publish nothing
type: boolean
default: false
issue:
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
type: string
default: ''
request:
description: The command's id, in the run's name so it can link the run
type: string
default: ''
prs:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
base:
description: The commit of main to build on (default main's newest with green checks and parity-main)
type: string
default: ''
urgent:
description: Past the day's cap of 12 builds (the train, for a release-now pull request or a forced run)
type: boolean
default: false
permissions:
contents: read
concurrency:
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
# is lost.
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
actions: read # main's newest green checks and parity-main runs (the base)
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
short: ${{ steps.pick.outputs.short }}
source: ${{ steps.pick.outputs.source }}
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
source_tag: ${{ steps.pick.outputs.source_tag }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- id: mode
# A dry run never publishes; a hand-started run off main is always one.
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
- id: pick
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
NIGHTLY_BASE: ${{ inputs.base }}
NIGHTLY_URGENT: ${{ inputs.urgent }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- name: Old nightly/build-* branches deleted (scheduled runs only)
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- name: The source of the build (horizon-source.tar.gz)
if: steps.pick.outputs.build == 'true'
env:
SHA: ${{ steps.pick.outputs.sha }}
run: git archive --format=tar.gz --prefix="horizon-${SHA:0:7}/" -o horizon-source.tar.gz "$SHA"
- uses: actions/upload-artifact@v4
if: steps.pick.outputs.build == 'true'
with:
name: horizon-source
path: horizon-source.tar.gz
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
path: nightly-notes.md
retention-days: 3
build:
needs: pick
if: needs.pick.outputs.build == 'true'
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.pick.outputs.sha }}
version: ${{ needs.pick.outputs.version }}
nightly: ${{ needs.pick.outputs.nightly }}
developer_id: false
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
sign:
needs: [pick, build]
runs-on: ubuntu-latest
timeout-minutes: 10
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [pick, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
issues: read # the open release-blocker issues (gate)
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test drew a world, no release-blocker is open)
# Right before publishing, as for a release: an issue labelled while the builds ran still holds it. A dry run
# only reports what a real run would do (that is how a blocker's fix is tried).
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish as the `nightly` pre-release
if: needs.pick.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
SOURCE_TAG: ${{ needs.pick.outputs.source_tag }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
# before the Nightly-only switch keep (they don't read Nightlies).
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py keep-source "$SHA" "$DATE"
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
run: |
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
ls -l dist
answer:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ inputs.issue }}
BUILD: ${{ needs.pick.outputs.build }}
PUBLISHED: ${{ needs.publish.result }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [[ "$BUILD" != true ]]; then
text="nothing new: the published Nightly is already this build."
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers install it at their next check."
else
text="the build failed, nothing was published: $RUN"
fi
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
-46
View File
@@ -1,46 +0,0 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
# is never read.
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
# a shell line.
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # the nightly-state branch (main is protected)
actions: write # starts `nightly build`
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+380 -34
View File
@@ -1,15 +1,22 @@
name: release
# Automatic game releases (docs/release-flow.md). Horizon ships only the Nightly since 2026-10-28 (Ludovic): this
# workflow is the batch train that decides when a new Nightly goes out, and `nightly build` (nightly-build.yml) builds,
# signs and publishes it. After every green `checks` run on main, every 10 minutes (so a batch 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 Nightly when 5 pull requests merged since the published
# Nightly 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 it; so does an open issue
# labelled release-blocker, forced runs too (nightly-build.yml checks again before publishing). One Nightly build at a
# time, at most 12 a day (release-now and force=true pass the cap). No `vX.Y.Z` game release is made any more.
# gh workflow run release --ref <branch> # dry run: decides, starts nothing
# gh workflow run release -f dry_run=false -f force=true # on main: a Nightly now, whatever the rules say
# 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,
# 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.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
# MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
# gh workflow run release -f dry_run=false -f force=true # on main: release now, whatever the rules say
on:
workflow_run:
# A game release ships a commit both passed on (parity-main runs on batches: the newest commit it passed on).
@@ -21,24 +28,19 @@ on:
workflow_dispatch:
inputs:
dry_run:
description: Decide only; start no Nightly build
description: Build everything and write the notes, but publish nothing
type: boolean
default: true
force:
description: A Nightly even without player-facing changes, before merges settle, while paused or past the day's cap
description: Release even without player-facing changes, before merges settle or while paused
type: boolean
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
# The checks, parity-main and nightly build runs (decide).
actions: read
concurrency:
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
group: game-release
cancel-in-progress: false
jobs:
decide:
@@ -49,8 +51,9 @@ jobs:
timeout-minutes: 10
outputs:
release: ${{ steps.decide.outputs.release }}
tag: ${{ steps.decide.outputs.tag }}
version: ${{ steps.decide.outputs.version }}
head: ${{ steps.decide.outputs.head }}
urgent: ${{ steps.decide.outputs.urgent }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
@@ -70,21 +73,364 @@ jobs:
run: |
python3 tools/release_train.py decide --kind game ${HEAD_SHA:+--head "$HEAD_SHA"} ${FORCE:+--force} | tee decision.json
echo "### $(jq -r '.reason' decision.json)" >> "$GITHUB_STEP_SUMMARY"
# The version the programs are built with (docs/versioning.md): the tag without its v.
tag=$(jq -r '.tag' decision.json)
echo "version=${tag#v}" >> "$GITHUB_OUTPUT"
# 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
nightly:
# The Nightly is the release: nightly-build.yml builds the decided commit plus the reviewers' picks, signs it for
# the Nightly channel and publishes it once its smoke test passed and no release-blocker is open.
exporters:
needs: decide
if: needs.decide.outputs.release == 'true' && needs.decide.outputs.dry_run != 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: write # starts `nightly build`
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:
- env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ needs.decide.outputs.head }}
URGENT: ${{ needs.decide.outputs.urgent }}
- 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: |
gh workflow run nightly-build.yml --repo "$GITHUB_REPOSITORY" --ref main -f base="$HEAD_SHA" -f urgent="$URGENT"
echo "### Started \`nightly build\` on ${HEAD_SHA:0:9}" >> "$GITHUB_STEP_SUMMARY"
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
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
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
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: packaging/macos/keychain.sh lock
sign:
needs: [decide, build]
runs-on: ubuntu-latest
timeout-minutes: 10
# The only job that holds the release signing key, a secret of the `release` environment, which only main's real
# releases enter (dry runs sign with a throwaway key). It runs no code from the repository but
# tools/ci/sign_release.sh, which calls only the runner's sha256sum and OpenSSL (docs/release-flow.md).
environment: ${{ needs.decide.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig)
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
bash tools/ci/sign_release.sh "$TAG" dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.decide.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [decide, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
issues: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Notes
env:
GH_TOKEN: ${{ github.token }}
run: |
python3 tools/release_train.py notes --kind game --tag "${{ needs.decide.outputs.tag }}" \
--head "${{ needs.decide.outputs.head }}" --out notes > /dev/null
{ echo "## $(cat notes/title.txt)"; echo; cat notes/body.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
TAG: ${{ needs.decide.outputs.tag }}
EPHEMERAL: ${{ needs.decide.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test passed, no release-blocker is open)
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
# reports what a real run would do.
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# The release and the latest at once: there is one channel (docs/release-flow.md). Each archive gets a
# label (file#label), which GitHub's page shows instead of the file name, so nobody takes the game's
# files for the download (#1647). Launchers, servers and install.sh match the name, which stays.
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --latest \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: release-notes
path: notes/
retention-days: 3
+2 -6
View File
@@ -5,15 +5,11 @@ name: run-parity
# skipped `verify`, and GitHub counts a skipped required check as passed.
on:
pull_request:
# A push starts a run only to cancel the old head's (see concurrency): the job runs on the label alone.
types: [labeled, synchronize]
types: [labeled]
permissions:
contents: read
concurrency:
# The run-parity label and pushes share one group per pull request, so the newest run cancels the one before: a
# pushed pull request's run tested a head the merge would no longer land. Any other label gets a group of its own:
# in the shared one, adding needs-proof cancelled the parity run queued seconds before (run 40766180150).
group: run-parity-${{ github.event.pull_request.number }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-parity' && format('-{0}', github.run_id) || '' }}
group: run-parity-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
-48
View File
@@ -1,48 +0,0 @@
name: triage command
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
# tools/ci/triage_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: >-
github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/close') ||
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/triage_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/triage_command.py command
+19 -29
View File
@@ -13,15 +13,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming the person whose agent opened it and
the tool: `> 🤖 Opened by <login>'s AI agent (<tool, model>), not by <login> personally.`, where `<login>`
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
with that agent's own "Generated with" line. Ludovic's agents use
`Co-Authored-By: Claude <model> <[email protected]>` and
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
@@ -37,12 +35,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~100 flat modules: gameplay, networking, title screens) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-client-input` | Key bindings and key names, toggle keys, mouse options, controller settings and glyphs, the clipboard, window options (`keybinds.rs`, `gamepad.rs`, `mouse.rs`, `window.rs`...). Re-exported under the old paths |
| `horizon-client-render` | The client's world data, rendering and screens (chunks, meshing, light, streaming, `gfx`, HUD, menus, settings): ~80 modules that horizon-client re-exports under the same paths. Nothing in it reaches gameplay |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -62,7 +57,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
@@ -76,7 +71,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -102,9 +97,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## Vanilla reference
- Start parity work from the port ledger, not grep: `python3 tools/port_ledger.py issue N` (or `where
Class.method`, `what file:line`), and `check --base origin/main` before the PR. How:
[.claude/skills/vanilla-parity/SKILL.md](.claude/skills/vanilla-parity/SKILL.md), [docs/port-ledger.md](docs/port-ledger.md).
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
@@ -116,20 +108,19 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `spans.rs`; `horizon-client-input/src/window.rs` |
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
@@ -138,7 +129,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
@@ -169,18 +160,17 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
+1 -4
View File
@@ -86,9 +86,6 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
release notes), `skip-release` when players see no change (tooling, docs, tests), nothing for a fix.
`release-now` is for crash fixes that shouldn't wait. pr_ready suggests one.
Who reviews and merges: the Horizon Team, on CI for ordinary pull requests; risky ones (saves, network, world
generation, the launcher's updates) wait for a local proof first ([merging](docs/release-flow.md#merging)).
Every merge to main is released automatically ([release flow](docs/release-flow.md)), so main always stays playable.
How agents, checks, releases and signed updates keep this safe: [security](docs/security.md).
@@ -101,5 +98,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+118 -117
View File
@@ -477,6 +477,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -876,12 +883,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.19.0"
@@ -1106,6 +1107,30 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1270,6 +1295,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1376,22 +1411,21 @@ name = "horizon-client"
version = "0.1.0"
dependencies = [
"ab_glyph",
"arboard",
"bytemuck",
"chrono",
"cpal",
"flate2",
"gilrs",
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1404,7 +1438,6 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"open",
"png",
"pollster",
"rustix 1.1.5",
@@ -1412,7 +1445,7 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"unicode-bidi",
"symphonia",
"ureq",
"wgpu",
"winit",
@@ -1428,60 +1461,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-client-input"
version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
]
[[package]]
name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-hdr",
"horizon-items",
"horizon-protocol",
"horizon-shader-api",
"horizon-shaders",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
"horizon-world",
"log",
"miniz_oxide 0.8.9",
"naga",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
"zip",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
@@ -1527,21 +1506,17 @@ name = "horizon-hdr"
version = "0.1.0"
dependencies = [
"ash",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wgpu",
]
[[package]]
name = "horizon-items"
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"serde",
"serde_json",
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
@@ -1928,25 +1903,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
@@ -2761,17 +2717,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]]
name = "orbclient"
version = "0.3.55"
@@ -2850,6 +2795,21 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2977,6 +2937,15 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -3077,6 +3046,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3287,9 +3277,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.16"
version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3556,6 +3546,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3673,6 +3669,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -3880,12 +3887,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
@@ -4467,14 +4468,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.10",
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.10"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
@@ -5340,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5381,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+4 -7
View File
@@ -8,10 +8,7 @@ members = [
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
@@ -33,6 +30,7 @@ members = [
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
@@ -43,6 +41,7 @@ members = [
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
@@ -62,10 +61,8 @@ large_enum_variant = "allow"
# wgpu-hal 30.0.1 with gfx-rs/wgpu#10124: wgpu 30 creates a Vulkan descriptor
# pool with no pool sizes while it creates the device, which Vulkan allows only
# since 1.3.215, and Intel's Windows driver for 6th-10th gen iGPUs (HD 520/530,
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
# UHD 620/630) crashes on it with an access violation (#746). Drop this and the
# folder once a wgpu release has the fix.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Copyright (c) 2026 Ludovic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+12 -17
View File
@@ -1,9 +1,9 @@
<p align="center">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by 281 differential harnesses" width="100%">
</p>
<p align="center">
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
@@ -15,7 +15,7 @@
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
@@ -23,15 +23,15 @@
# Horizon
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by 281 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
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** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** 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).
<table>
<tr>
@@ -46,9 +46,9 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation (vanilla's heights and biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mods, installed from the launcher. The official ones (Zoom, FPS Counter, Keystrokes and Minimap) are native Rust that the launcher builds into your game, with no Rust to install; older WebAssembly mods still run while they move. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -93,13 +93,13 @@ Commands block until the action is done and reply with what happened; errors are
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
Horizon contains no game files and never downloads them. It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/horizon-rust/horizon.git
git clone https://github.com/ludovic111/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
@@ -139,11 +139,6 @@ average; `tests/test_parity.py` keeps it in sync with this table. Last audited
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
them too (Rust uses Java's own float maths on each platform).
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
@@ -163,7 +158,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
+92 -173
View File
@@ -16,7 +16,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it ru
- **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**: A new launcher design; CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs; Faster world creation and entry on small CPUs; Servers that hold 20 TPS with 50 to 100 players; Low-end computers: Steam Deck, old Macs, 8 GB; Native upscaling; A batch of targeted optimisations; A Convert to Rust button for what isn't rewritten yet; Mods with code: machines, magic, mobs; Data packs in the mod browser; Prepare a version without Java; Server console, bans and a full admin API; Hosting polish: Windows service, safe updates; Guided hosting with Tailscale and an invite button; An official Docker image for servers; One single-player path: remove the in-frame world; Villages and combat, exactly as in vanilla; Choose the audio output device; Mods that read and change the world; Machines and blocks with inventories (API 0.4); Well-known shader looks, rewritten for Horizon; A visible beta label; Wayland and Hyprland polish; A versioned agent-control protocol; Port mods with your own AI agent; Horizon on X (coming soon); Pipeline protection and a GitHub App for agents; Signed updates everywhere, installers included; A server you install with one command; Ready-made setups for hosting panels.
**Next**: 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.
@@ -32,7 +32,7 @@ Making Horizon faster than vanilla on every computer, with smooth camera movemen
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
Frame pacing at render distance 32 ([#678](https://github.com/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)).
</details>
@@ -42,7 +42,7 @@ Speeding up the code that reads and writes the blocks of the world, which almost
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
`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)).
</details>
@@ -54,7 +54,7 @@ Making the world-generation mods of popular modpacks (new structures, YUNG's dun
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
`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)).
</details>
@@ -66,7 +66,7 @@ When you play on a server, other players, mobs, items and menus look and behave
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
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)).
</details>
@@ -76,7 +76,7 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
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)).
</details>
@@ -86,7 +86,7 @@ A /pregen command that builds an area of the world ahead of time, so players don
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
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'.
</details>
@@ -98,55 +98,43 @@ One place for mods, modpacks and resource packs made for Horizon: browse them on
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
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)
</details>
#### Native mods, built by the launcher
Horizon's own mods become native code that the launcher builds into your game, with no Rust to install: Zoom, FPS Counter, Keystrokes and Minimap already are, and Recipes is next.
<details><summary>Technical</summary>
Stable hook points in the game (game/native_hooks.rs, [#3345](https://github.com/horizon-rust/horizon/issues/3345), [#3346](https://github.com/horizon-rust/horizon/issues/3346); v2 with the item panel and recipe pages, [#3434](https://github.com/horizon-rust/horizon/issues/3434), [#3435](https://github.com/horizon-rust/horizon/issues/3435)), horizon-mixins applied by the launcher with its own portable Rust ([#3388](https://github.com/horizon-rust/horizon/issues/3388)), on/off in Options > Mods. The official mods live in horizon-mods/native/ and are in the registry as native mods. Next: Recipes, native mods on automatically when a player installs one (with a notice about the first build), then the WebAssembly runtime is removed ([#3350](https://github.com/horizon-rust/horizon/issues/3350), [#3351](https://github.com/horizon-rust/horizon/issues/3351), [#3353](https://github.com/horizon-rust/horizon/issues/3353), [#3371](https://github.com/horizon-rust/horizon/issues/3371)).
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### Mods that run on the server
Mods that run on the server side of a world, so a dedicated server or a LAN world can have mods that react to players joining, chat and blocks being broken or placed, and that add commands.
<details><summary>Technical</summary>
The same hook points on the server thread and the dedicated server: world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The earlier WebAssembly API 0.3 ([#1833](https://github.com/horizon-rust/horizon/issues/1833)) is the reference for what to cover.
More: [docs/native-mods.md](docs/native-mods.md)
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).
</details>
#### Screens, input and drawing for mods
Mods will be able to open their own screens and settings pages, react to keys and item use, and draw, so bigger mods, such as the Orbital Laser, run on the normal game.
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.
<details><summary>Technical</summary>
On native hook points ([docs/native-mods.md](docs/native-mods.md)): settings pages under Options > Mods and the item panel are there ([#3346](https://github.com/horizon-rust/horizon/issues/3346), [#3435](https://github.com/horizon-rust/horizon/issues/3435)). Next: mod-owned screens built from vanilla's widgets, an item's use, key binds and the crosshair target, and a budgeted draw list on the native shader API. Orbital Laser is the acceptance test.
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)
</details>
#### Tools for people who make mods
Better tools for mod makers: templates for each kind of mod, a test run with no game window, and clear promises about which hook points keep working from one version to the next.
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.
<details><summary>Technical</summary>
Native mod template and the content mod template (horizon-mods), `native.py check` that applies mods on a chosen Horizon and builds them together, a weekly check of every official mod on the newest Nightly. Left: a versioned list of stable hook points (NATIVE_HOOKS_API), a test runner without a window, and docs on the site for native mods.
`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: [#3388](https://github.com/horizon-rust/horizon/issues/3388) · [docs/native-mods.md](docs/native-mods.md)
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
@@ -158,7 +146,7 @@ Single player now runs a small server in the background, as vanilla does. We are
<details><summary>Technical</summary>
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
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).
</details>
@@ -168,7 +156,7 @@ Item names, container titles and server messages follow the language you pick, a
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
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)).
</details>
@@ -178,9 +166,9 @@ After you throw an ender pearl, its slot should show the cooldown sweeping away,
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
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).
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
</details>
@@ -190,9 +178,9 @@ The Hidden and Commands Only chat settings should hide chat, and team prefixes s
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
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.
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
</details>
@@ -202,9 +190,9 @@ The mouse pointer should turn into a pointing hand over menu buttons, as in vani
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
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).
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
</details>
@@ -214,9 +202,9 @@ Ctrl+Backspace should delete a whole word in the creative search, and every list
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
`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)).
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
</details>
@@ -226,9 +214,9 @@ Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
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).
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
</details>
@@ -240,9 +228,9 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
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/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
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)
</details>
@@ -252,21 +240,7 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
### Launcher
#### Minecraft Java instances in the launcher
Play the real Java game from the Horizon launcher too: vanilla and modded instances, any version, mods from Modrinth filtered to what fits, and your instances imported from other launchers.
<details><summary>Technical</summary>
For an account that owns the game: any version downloaded with the Java it needs ([#3406](https://github.com/horizon-rust/horizon/issues/3406)), Java instances, the version list and the CLI ([#3395](https://github.com/horizon-rust/horizon/issues/3395)), the start command read from the version JSON ([#3419](https://github.com/horizon-rust/horizon/issues/3419)). Next: Play for a Java instance, Fabric and Quilt, then NeoForge and Forge; Modrinth with compatibility filtering and dependencies; imports from Prism, MultiMC, the CurseForge app, the Modrinth App, ATLauncher and the official launcher, each from its documented on-disk format.
More: [#3506](https://github.com/horizon-rust/horizon/issues/3506)
`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'.
</details>
@@ -278,7 +252,7 @@ Your Discord profile can show that you are playing Horizon, with a switch to tur
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
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.
</details>
@@ -290,17 +264,7 @@ Cleaning up Horizon's code: removing what nothing uses and making it quicker to
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
#### Every vanilla method, tracked
A public list of every method of the Java game and where Horizon does it, so anyone can see what is ported, what is missing and pick something to fix.
<details><summary>Technical</summary>
[tools/port_ledger.py](tools/port_ledger.py) and tools/port_map/ ([#3139](https://github.com/horizon-rust/horizon/issues/3139)): every 26.3 method outside the skipped packages has a verdict (port, inline, missing, skip, trivial), linked to the harnesses that cover it ([#3526](https://github.com/horizon-rust/horizon/issues/3526)), with one tracking issue per area. Bug fixes start from it (.claude/skills/vanilla-parity).
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.
</details>
@@ -314,7 +278,7 @@ New public numbers comparing Horizon with vanilla on many kinds of computers, me
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/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)).
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)).
</details>
@@ -324,7 +288,7 @@ Creating a world and joining it should be quick even on a four-thread laptop, wh
<details><summary>Technical</summary>
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/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/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)).
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)).
</details>
@@ -356,7 +320,7 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
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).
</details>
@@ -366,7 +330,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
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)
@@ -400,7 +364,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
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)).
</details>
@@ -422,7 +386,7 @@ Set up Horizon, a server first, without Java on your computer: published tables
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
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)).
</details>
@@ -442,7 +406,7 @@ Smoother hosting: a real Windows service, a backup before every update, backups
<details><summary>Technical</summary>
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/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
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.
</details>
@@ -480,16 +444,6 @@ A player's idea (#horizon-ideas). `horizon-server` already runs headless; left:
</details>
#### Ready-made setups for hosting panels
Install a Horizon server from the panels hosts use, such as Pterodactyl and AMP, the usual way for each panel.
<details><summary>Technical</summary>
Asked in the suggestion queue for Pterodactyl eggs and AMP templates. Builds on the one-command install ('A server you install with one command') and horizon-server's server.toml ([docs/hosting.md](docs/hosting.md)).
</details>
### Mods and API
#### Mods that read and change the world
@@ -498,9 +452,9 @@ Mods that can see and change blocks, read inventories and items, and find and af
<details><summary>Technical</summary>
Hook points for reading blocks, states and biomes and changing them through the server's block log, so clients and the parity harnesses see vanilla's updates; the player's inventory and item stacks with components; entity queries near a position. Batched per tick, never per-block calls in hot paths.
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/native-mods.md](docs/native-mods.md) · [docs/modpacks.md](docs/modpacks.md)
More: [docs/modpacks.md](docs/modpacks.md)
</details>
@@ -510,7 +464,17 @@ Mods that add machines: blocks that tick, hold items and have their own menus. T
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
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).
</details>
#### 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.
<details><summary>Technical</summary>
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').
</details>
@@ -522,7 +486,7 @@ Pistons and slime blocks will push players, mobs and items as in vanilla. Today
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
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).
</details>
@@ -542,7 +506,7 @@ Villages and fights behaving as in vanilla: iron golems defending villages, vill
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
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)).
</details>
@@ -552,7 +516,7 @@ Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
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.
</details>
@@ -572,13 +536,15 @@ More: [docs/shaders.md](docs/shaders.md)
### Launcher
#### A new launcher design
#### Microsoft sign-in (coming soon)
A redesigned launcher: every button where you expect it, the fewest clicks to play, add an instance or add mods, smooth animations, and instant startup.
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
<details><summary>Technical</summary>
Based on the site concept by the Team (Bricolage Grotesque, a night sky and an animated pixel sunset). Screens and flows are designed first, then small PRs; tokens in [crates/horizon-theme](crates/horizon-theme) and rules in [docs/design-system.md](docs/design-system.md), the game's vanilla look unchanged. Measured before and after: startup, frame time and idle CPU/GPU, reduced motion honoured. Mods screens separate Horizon native mods from Java mods; a running instance can be stopped from the launcher.
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)
</details>
@@ -600,7 +566,7 @@ Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
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).
</details>
@@ -610,7 +576,7 @@ Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboar
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
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.
</details>
@@ -660,7 +626,7 @@ Safer releases: changes to the release machinery need the owner's review, and AI
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
`.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)).
</details>
@@ -670,7 +636,7 @@ Every download Horizon makes is checked against the project's signature, includi
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
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)).
</details>
@@ -684,7 +650,7 @@ Far terrain drawn from the world you really built and explored, caves and builds
<details><summary>Technical</summary>
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/horizon-rust/horizon/issues/718).
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).
</details>
@@ -726,7 +692,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/horizon-rust/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.
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.
</details>
@@ -738,19 +704,7 @@ Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible
<details><summary>Technical</summary>
Issue [#1270](https://github.com/horizon-rust/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)).
</details>
#### Join servers on older versions
Join servers that run older Minecraft versions, such as 1.21 or 1.8 PvP servers, from Horizon.
<details><summary>Technical</summary>
Asked often in the suggestion queue. Horizon speaks 26.3's protocol; the path is a native protocol adapter in Rust that translates older versions' packets, not a downloaded Java proxy. Java instances in the launcher already play any version with the real game.
More: [#3023](https://github.com/horizon-rust/horizon/issues/3023)
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)).
</details>
@@ -766,18 +720,6 @@ Play with Friends joins over an encrypted tunnel with an invite code ([docs/play
### Mods and API
#### Scripts for mods
Small mods written as scripts, in the style of KubeJS, with no compiler.
<details><summary>Technical</summary>
Waits for native mods to settle: the earlier plan ran Lua inside the WebAssembly sandbox, which is being removed. A script layer over the native hook points is to be designed.
More: [docs/native-mods.md](docs/native-mods.md)
</details>
#### 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.
@@ -790,13 +732,13 @@ A pull request on [https://github.com/Stink-O/orbital-laser](https://github.com/
#### Native versions of popular client mods
Built-in versions of the client mods players ask for: block info on hover, a world map, inventory tweaks and schematic overlays.
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.
<details><summary>Technical</summary>
Done: a mod list and menu, minimap, zoom, keystrokes, an FPS counter and a recipe viewer, as native mods (horizon-mods/native/). Asked for: an angled world map that shows hills, overhangs and caves, inventory tweaks (drag and scroll to move items), block info tooltips and schematic overlays.
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/extras.md](docs/extras.md) · [docs/native-mods.md](docs/native-mods.md)
More: [docs/mods.md](docs/mods.md)
</details>
@@ -806,7 +748,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
<details><summary>Technical</summary>
Issue [#805](https://github.com/horizon-rust/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.
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.
</details>
@@ -842,23 +784,13 @@ The game side is merged ([docs/controllers.md](docs/controllers.md)): pads read
</details>
#### Splitscreen
Play with a friend on the same screen, each with a controller.
<details><summary>Technical</summary>
Issue [#2054](https://github.com/horizon-rust/horizon/issues/2054); a contributor is working on it. Two local players on the same world, each with their own camera and HUD, built on the server thread's multiplayer.
</details>
#### The full debug screen
Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captures and an atlas dump.
<details><summary>Technical</summary>
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/horizon-rust/horizon/issues/562) asked for the F3 editor).
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).
</details>
@@ -882,7 +814,7 @@ An optional ray-tracing mode for graphics cards that support it (RTX and recent
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
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.
</details>
@@ -892,7 +824,7 @@ Output for HDR monitors: brighter highlights and richer colour where your screen
<details><summary>Technical</summary>
Issue [#1441](https://github.com/horizon-rust/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.
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.
</details>
@@ -902,7 +834,7 @@ More graphics options: multisample anti-aliasing, anisotropic filtering and an e
<details><summary>Technical</summary>
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, and SMAA or TAA are asked for too; exclusive fullscreen where the backend supports it.
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.
</details>
@@ -926,19 +858,7 @@ Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/horizon-rust/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`).
</details>
#### Horizon on Windows ARM64
A native Horizon for Windows on ARM computers, such as Snapdragon laptops.
<details><summary>Technical</summary>
Releases are x64 for Windows and Linux and arm64 for macOS. Needs an aarch64-pc-windows-msvc build in the release workflow and a machine to test on.
More: [docs/release-flow.md](docs/release-flow.md)
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`).
</details>
@@ -948,7 +868,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
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/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
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.
</details>
@@ -992,7 +912,7 @@ An optional AI teammate you can talk to by voice. It plays beside you, helps or
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/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.
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.
</details>
@@ -1018,7 +938,7 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/horizon-rust/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -1032,7 +952,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
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/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
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).
</details>
@@ -1042,20 +962,19 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`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/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
`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).
</details>
## Recently shipped
- **Microsoft sign-in** (launcher@0.2.17): Sign in with your Microsoft account in the launcher to play on online-mode servers. [docs/microsoft-account.md](docs/microsoft-account.md) · [#2879](https://github.com/horizon-rust/horizon/issues/2879)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **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/horizon-rust/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/horizon-rust/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/horizon-rust/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/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/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/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/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/horizon-rust/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/horizon-rust/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/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
- **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)
@@ -119,7 +119,7 @@ machines during the runs.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+1 -1
View File
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
## Reproduce
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
-4
View File
@@ -97,9 +97,6 @@ impl Report {
for r in &f.requires {
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
}
if self.kind == Kind::NativeMod {
out += " native source: unsandboxed; not executed by the registry check. Compile and test against the exact pinned Horizon and Mixins revisions before review.\n";
}
if self.kind == Kind::Mod && !self.code {
out += " no code: content only, nothing to run\n";
} else if self.kind == Kind::Mod {
@@ -121,7 +118,6 @@ impl Report {
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
"zip" => Some(Kind::ResourcePack),
"hshader" => Some(Kind::Shader),
+55 -9
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `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 <horizon-client>] [-- <game args>]`: build, then play with
@@ -25,7 +25,9 @@ use std::{
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -42,7 +44,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 <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
let (sdk, lua) = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
}
None => SDK_GIT.to_string(),
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".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")),
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
};
for &(file, text) in files {
let file = fill(file);
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -386,6 +412,26 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
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()));
@@ -424,7 +470,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
"python".into()
])
.is_err()
);
+2 -2
View File
@@ -1,5 +1,5 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
@@ -8,7 +8,7 @@ use horizon_mods::index::{
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
@@ -0,0 +1,17 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,12 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -0,0 +1,2 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
@@ -61,16 +61,6 @@ fn f32_tag(v: f32) -> Tag {
Tag::Float(v.to_bits())
}
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
let Tag::IntArray(v) = tag else { return None };
let p: [i32; 3] = v.as_slice().try_into().ok()?;
((-30_000_000..30_000_000).contains(&p[0])
&& (-20_000_000..20_000_000).contains(&p[1])
&& (-30_000_000..30_000_000).contains(&p[2]))
.then_some(p)
}
/// What a block entity reads and writes, in `saveAdditional` order.
struct Fields<'a> {
input: &'a Tag,
@@ -434,7 +424,10 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:end_gateway" => {
f.long("Age", 0);
f.keep("exit_portal", |t| gateway_exit(t).is_some());
f.keep("exit_portal", |t| {
is_pos(t)
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
});
if flag(tag.get("ExactTeleport")) == Some(true) {
f.put("ExactTeleport", boolean(true));
}
@@ -494,11 +487,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
_ => string("minecraft:air"),
},
);
// Direction.LEGACY_ID_CODEC: the 3D data value as a byte.
f.put(
"facing",
Tag::Byte(int(tag.get("facing")).unwrap_or(0) as i8),
);
f.put("facing", Tag::Int(int(tag.get("facing")).unwrap_or(0)));
f.float("progress", 0.0);
f.flag("extending", false);
f.flag("source", false);
@@ -877,23 +866,6 @@ mod tests {
);
}
/// `PistonMovingBlockEntity.saveAdditional`: `facing` through
/// `Direction.LEGACY_ID_CODEC` (a byte), `progress` a float.
#[test]
fn moving_piston_fields_match_vanilla() {
let out = canon(
"{id:\"minecraft:piston\",x:1,y:2,z:3,blockState:{Name:\"minecraft:stone\"},\
facing:5,progress:0.5f,extending:1b,source:0b}",
);
assert_eq!(out.get("facing"), Some(&Tag::Byte(5)));
assert_eq!(out.get("progress"), Some(&f32_tag(0.5)));
assert_eq!(out.get("extending"), Some(&Tag::Byte(1)));
assert_eq!(out.get("source"), Some(&Tag::Byte(0)));
let fresh = canon("{id:\"minecraft:piston\",x:1,y:2,z:3}");
assert_eq!(fresh.get("facing"), Some(&Tag::Byte(0)));
assert_eq!(fresh.get("blockState"), Some(&string("minecraft:air")));
}
#[test]
fn loot_table_replaces_items_and_zero_seed_is_omitted() {
let out = canon(
+57 -151
View File
@@ -1,7 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
@@ -11,18 +10,13 @@ pub fn parse_snbt(input: &str) -> Result<(Value, usize), String> {
let mut p = Parser {
s: input.as_bytes(),
i: 0,
depth: 0,
};
let v = p.value()?;
Ok((v, p.i))
}
/// Nesting deeper than NBT's own limit (`NbtAccounter`'s 512) is refused
/// rather than overflowing the stack.
const SNBT_MAX_DEPTH: usize = 512;
struct Parser<'a> {
s: &'a [u8],
i: usize,
depth: usize,
}
impl Parser<'_> {
fn ws(&mut self) {
@@ -46,22 +40,57 @@ impl Parser<'_> {
self.ws();
match self.peek() {
Some(b'{') => {
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
}
self.depth += 1;
let v = self.object();
self.depth -= 1;
v
}
Some(b'[') => {
if self.depth >= SNBT_MAX_DEPTH {
return Err(format!("tag nesting too deep at {}", self.i));
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
}
self.depth += 1;
let v = self.array();
self.depth -= 1;
v
}
Some(b'"' | b'\'') => Ok(Value::String(self.string()?)),
Some(_) => {
@@ -75,20 +104,10 @@ impl Parser<'_> {
_ => {
let digits = word
.trim_end_matches(['b', 'B', 's', 'S', 'l', 'L', 'f', 'F', 'd', 'D']);
// Only digits (and an exponent) make a number: `nan`
// or `Infinity`, which Rust's parse would take, stays
// a string.
if digits
.bytes()
.any(|c| c.is_ascii_alphabetic() && c != b'e' && c != b'E')
{
Value::String(word)
} else {
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
}
match (digits.parse::<i64>(), digits.parse::<f64>()) {
(Ok(n), _) if digits.len() + 1 >= word.len() => Value::from(n),
(_, Ok(f)) if digits.len() + 1 >= word.len() => Value::from(f),
_ => Value::String(word),
}
}
})
@@ -96,59 +115,6 @@ impl Parser<'_> {
None => Err("expected a value".into()),
}
}
fn object(&mut self) -> Result<Value, String> {
self.i += 1;
let mut map = Map::new();
self.ws();
if self.peek() == Some(b'}') {
self.i += 1;
return Ok(Value::Object(map));
}
loop {
self.ws();
let key = self.string()?;
self.expect(b':')?;
map.insert(key, self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b'}') => {
self.i += 1;
return Ok(Value::Object(map));
}
_ => return Err("expected ',' or '}'".into()),
}
}
}
fn array(&mut self) -> Result<Value, String> {
self.i += 1;
let mut list = Vec::new();
self.ws();
// Typed arrays [B;...], [I;...], [L;...].
if matches!(
self.s.get(self.i..self.i + 2),
Some([b'B' | b'I' | b'L', b';'])
) {
self.i += 2;
}
self.ws();
if self.peek() == Some(b']') {
self.i += 1;
return Ok(Value::Array(list));
}
loop {
list.push(self.value()?);
self.ws();
match self.peek() {
Some(b',') => self.i += 1,
Some(b']') => {
self.i += 1;
return Ok(Value::Array(list));
}
_ => return Err("expected ',' or ']'".into()),
}
}
}
fn unquoted(&mut self) -> String {
let start = self.i;
while let Some(c) = self.peek() {
@@ -207,53 +173,20 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
}
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
// HoverEvent.ShowText.
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
&& e.get("action").and_then(Value::as_str) == Some("show_text")
&& let Some(value) = e.get("value")
{
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
style.insertion = o
.get("insertion")
.and_then(Value::as_str)
.map(str::to_owned);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
@@ -338,15 +271,6 @@ pub fn to_text_with(
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -384,22 +308,4 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.spans[0].style.font, None);
}
#[test]
fn snbt_nesting_is_bounded() {
let nest = |n: usize| format!("{}1{}", "[".repeat(n), "]".repeat(n));
assert!(parse_snbt(&nest(512)).is_ok());
assert!(parse_snbt(&nest(513)).is_err());
assert!(parse_snbt(&nest(100_000)).is_err());
}
#[test]
fn bare_words_that_look_numeric_stay_strings() {
let (v, _) = parse_snbt("{text:nan}").unwrap();
assert_eq!(v["text"], Value::String("nan".into()));
let (v, _) = parse_snbt("{text:Infinity}").unwrap();
assert_eq!(v["text"], Value::String("Infinity".into()));
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
}
@@ -16,20 +16,3 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
/// `Identifier.CODEC` parsing an entity id: lowercase namespace and path
/// characters, `minecraft` when the namespace is left out.
pub fn normalize_id(s: &str) -> Option<String> {
let (ns, path) = match s.split_once(':') {
Some((ns, path)) => (if ns.is_empty() { "minecraft" } else { ns }, path),
None => ("minecraft", s),
};
let ok = |c: char, slash: bool| {
c.is_ascii_lowercase()
|| c.is_ascii_digit()
|| matches!(c, '_' | '-' | '.')
|| slash && c == '/'
};
(ns.chars().all(|c| ok(c, false)) && path.chars().all(|c| ok(c, true)))
.then(|| format!("{ns}:{path}"))
}
+2 -63
View File
@@ -4,10 +4,7 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
sync::{Arc, RwLock},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -26,24 +23,15 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
fn read(&self, path: &str) -> Option<Vec<u8>>;
/// Whether `path` is there, without reading it where the source can tell.
fn exists(&self, path: &str) -> bool {
self.read(path).is_some()
}
/// The namespaces under `assets/` (resource packs may add their own).
fn namespaces(&self) -> Vec<String> {
vec!["minecraft".into()]
@@ -60,9 +48,6 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
@@ -75,9 +60,6 @@ impl ResourceSource for std::path::PathBuf {
fn read(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(self.join(path)).ok()
}
fn exists(&self, path: &str) -> bool {
self.join(path).is_file()
}
fn namespaces(&self) -> Vec<String> {
std::fs::read_dir(self.join("assets"))
.into_iter()
@@ -152,12 +134,8 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -200,7 +178,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
for (code, info) in value["language"].as_object().into_iter().flatten() {
// Only languages whose file exists somewhere are selectable.
let file = format!("assets/minecraft/lang/{code}.json");
if code != DEFAULT && !sources.iter().any(|s| s.exists(&file)) {
if code != DEFAULT && !sources.iter().any(|s| s.read(&file).is_some()) {
continue;
}
found.insert(
@@ -219,16 +197,11 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
@@ -269,18 +242,6 @@ mod tests {
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
let ru = Language::load(&[&empty], "ru_ru");
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
// Every bundled string carries both languages: a key added to one file
// and forgotten in the other shows as a key name on screen.
for key in en.map().keys() {
assert!(ru.get(key).is_some(), "{key} has no ru_ru value");
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let uk = Language::load(&[&empty], "uk_ua");
assert_eq!(
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
}
#[test]
@@ -306,26 +267,4 @@ mod tests {
);
assert_eq!(langs[1].label(), "Français (France)");
}
#[test]
fn languages_are_listed_without_reading_their_files() {
struct NoLangReads;
impl ResourceSource for NoLangReads {
fn read(&self, path: &str) -> Option<Vec<u8>> {
assert!(!path.contains("/lang/"), "read {path}");
(path == "assets/pack.mcmeta").then(|| {
br#"{"language":{"fr_fr":{"name":"French"},"de_de":{"name":"German"}}}"#
.to_vec()
})
}
fn exists(&self, path: &str) -> bool {
path == "assets/minecraft/lang/fr_fr.json"
}
}
let langs = available(&[&NoLangReads]);
assert_eq!(
langs.iter().map(|l| l.code.as_str()).collect::<Vec<_>>(),
["en_us", "fr_fr"]
);
}
}
+2 -7
View File
@@ -1,9 +1,8 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::text`, ...).
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
@@ -14,15 +13,11 @@ pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod pattern;
pub mod potions;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub mod who;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -1,32 +0,0 @@
//! Reading and writing single NBT values the way vanilla's codecs accept them.
use horizon_world::nbt::Tag;
pub fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
/// Any numeric tag as f64, keeping a float's or double's fraction.
pub fn float(tag: Option<&Tag>) -> Option<f64> {
Some(match tag? {
Tag::Float(b) => f64::from(f32::from_bits(*b)),
Tag::Double(b) => f64::from_bits(*b),
other => number(Some(other))? as f64,
})
}
-250
View File
@@ -1,250 +0,0 @@
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
//! tables): each effect's colour and category, each potion's effects, and the
//! potion colour mix. horizon-client re-exports them from `brewing`.
use std::{collections::HashMap, sync::OnceLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Beneficial,
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
pub color: u32,
pub category: Category,
pub instant: bool,
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
color,
category,
instant: false,
}
}
use Category::{Beneficial as B, Harmful as H, Neutral as N};
/// `MobEffects` in registration order.
pub const EFFECTS: &[EffectDef] = &[
fx("speed", 3402751, B),
fx("slowness", 9154528, H),
fx("haste", 14270531, B),
fx("mining_fatigue", 4866583, H),
fx("strength", 16762624, B),
EffectDef {
id: "instant_health",
color: 16262179,
category: B,
instant: true,
},
EffectDef {
id: "instant_damage",
color: 11101546,
category: H,
instant: true,
},
fx("jump_boost", 16646020, B),
fx("nausea", 5578058, H),
fx("regeneration", 13458603, B),
fx("resistance", 9520880, B),
fx("fire_resistance", 16750848, B),
fx("water_breathing", 10017472, B),
fx("invisibility", 16185078, B),
fx("blindness", 2039587, H),
fx("night_vision", 12779366, B),
fx("hunger", 5797459, H),
fx("weakness", 4738376, H),
fx("poison", 8889187, H),
fx("wither", 7561558, H),
fx("health_boost", 16284963, B),
fx("absorption", 2445989, B),
EffectDef {
id: "saturation",
color: 16262179,
category: B,
instant: true,
},
fx("glowing", 9740385, N),
fx("levitation", 13565951, H),
fx("luck", 5882118, B),
fx("unluck", 12624973, H),
fx("slow_falling", 15978425, B),
fx("conduit_power", 1950417, B),
fx("dolphins_grace", 8954814, B),
fx("bad_omen", 745784, N),
fx("hero_of_the_village", 4521796, B),
fx("darkness", 2696993, H),
fx("trial_omen", 1484454, N),
fx("raid_omen", 14565464, N),
fx("wind_charged", 12438015, H),
fx("weaving", 7891290, H),
fx("oozing", 10092451, H),
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(key)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
pub struct PotionDef {
pub id: &'static str,
pub name: &'static str,
pub effects: &'static [(&'static str, i32, i32)],
}
const fn p(
id: &'static str,
name: &'static str,
effects: &'static [(&'static str, i32, i32)],
) -> PotionDef {
PotionDef { id, name, effects }
}
/// `Potions` in registration order.
pub const POTIONS: &[PotionDef] = &[
p("water", "water", &[]),
p("mundane", "mundane", &[]),
p("thick", "thick", &[]),
p("awkward", "awkward", &[]),
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
p(
"long_night_vision",
"night_vision",
&[("night_vision", 9600, 0)],
),
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
p(
"long_invisibility",
"invisibility",
&[("invisibility", 9600, 0)],
),
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
p(
"fire_resistance",
"fire_resistance",
&[("fire_resistance", 3600, 0)],
),
p(
"long_fire_resistance",
"fire_resistance",
&[("fire_resistance", 9600, 0)],
),
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
p("slowness", "slowness", &[("slowness", 1800, 0)]),
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
p(
"turtle_master",
"turtle_master",
&[("slowness", 400, 3), ("resistance", 400, 2)],
),
p(
"long_turtle_master",
"turtle_master",
&[("slowness", 800, 3), ("resistance", 800, 2)],
),
p(
"strong_turtle_master",
"turtle_master",
&[("slowness", 400, 5), ("resistance", 400, 3)],
),
p(
"water_breathing",
"water_breathing",
&[("water_breathing", 3600, 0)],
),
p(
"long_water_breathing",
"water_breathing",
&[("water_breathing", 9600, 0)],
),
p("healing", "healing", &[("instant_health", 1, 0)]),
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
p("harming", "harming", &[("instant_damage", 1, 0)]),
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
p("poison", "poison", &[("poison", 900, 0)]),
p("long_poison", "poison", &[("poison", 1800, 0)]),
p("strong_poison", "poison", &[("poison", 432, 1)]),
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
p(
"long_regeneration",
"regeneration",
&[("regeneration", 1800, 0)],
),
p(
"strong_regeneration",
"regeneration",
&[("regeneration", 450, 1)],
),
p("strength", "strength", &[("strength", 3600, 0)]),
p("long_strength", "strength", &[("strength", 9600, 0)]),
p("strong_strength", "strength", &[("strength", 1800, 1)]),
p("weakness", "weakness", &[("weakness", 1800, 0)]),
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
p("luck", "luck", &[("luck", 6000, 0)]),
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
p(
"long_slow_falling",
"slow_falling",
&[("slow_falling", 4800, 0)],
),
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
p("weaving", "weaving", &[("weaving", 3600, 0)]),
p("oozing", "oozing", &[("oozing", 3600, 0)]),
p("infested", "infested", &[("infested", 3600, 0)]),
];
pub fn potion(id: &str) -> Option<&'static PotionDef> {
let id = id.trim_start_matches("minecraft:");
POTIONS.iter().find(|p| p.id == id)
}
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
total += w;
}
match (
r.checked_div(total),
g.checked_div(total),
b.checked_div(total),
) {
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
_ => BASE_POTION_COLOR,
}
}
@@ -54,12 +54,6 @@ pub struct Registries {
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// `ClientSuggestionProvider.getSelectedEntities`: the UUID of the
/// entity under the crosshair (`Entity.getStringUUID`), if any.
pub selected_entities: Vec<String>,
/// `ClientSuggestionProvider.getAbsoluteCoordinates`: the exact point of
/// that block hit, each coordinate as `%.2f` (`Locale.ROOT`).
pub target_point: Option<[String; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
+4 -62
View File
@@ -662,22 +662,10 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
}
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
}
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
let Some(team) = board.players_team(dying) else {
return true;
};
let same_team = board
.players_team(other)
.is_some_and(|t| t.name == team.name);
match team.death_message_visibility {
Visibility::Always => true,
Visibility::Never => false,
Visibility::HideForOtherTeams => same_team && other != dying,
Visibility::HideForOwnTeam => !same_team,
}
/// `ServerPlayer.die`: whether a player's death message reaches the player
/// themselves (the team broadcasts leave out the dying player).
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
}
// ---- objectives and scores ---------------------------------------------
@@ -1563,49 +1551,3 @@ mod tests {
);
}
}
/// `DamageSource.getLocalizedDeathMessage`: the cause's item with a custom
/// name gets the `<id>.item` message, with the item's name as a third
/// argument.
pub fn death_message_args(
key: &str,
victim: &Text,
cause: &Text,
item: Option<&Text>,
) -> (String, Vec<Text>) {
match item {
Some(item) => (
format!("{key}.item"),
vec![victim.clone(), cause.clone(), item.clone()],
),
None => (key.to_owned(), vec![victim.clone(), cause.clone()]),
}
}
#[cfg(test)]
mod death_message_tests {
use super::*;
#[test]
fn the_death_message_names_the_causes_item_when_it_has_a_custom_name() {
let victim = Text::literal("Steve");
let cause = Text::literal("Zombie");
// DamageSource.getLocalizedDeathMessage: the item variant, with the
// item's name as the third argument.
let (key, args) = death_message_args(
"death.attack.arrow",
&victim,
&cause,
Some(&Text::literal("My Sword")),
);
assert_eq!(key, "death.attack.arrow.item");
assert_eq!(args.len(), 3);
assert_eq!(args[0].plain(), "Steve");
assert_eq!(args[1].plain(), "Zombie");
assert_eq!(args[2].plain(), "My Sword");
// Without a custom name the message keeps its two arguments.
let (key, args) = death_message_args("death.attack.arrow", &victim, &cause, None);
assert_eq!(key, "death.attack.arrow");
assert_eq!(args.len(), 2);
}
}
+26 -68
View File
@@ -42,17 +42,8 @@ pub fn named_color(name: &str) -> Option<u32> {
/// Click actions carried by locally constructed text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
OpenUrl(String),
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
ChangePage(i32),
SuggestCommand(String),
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// `ClickEvent.ShowDialog`: a `minecraft:dialog` registry id, or the
/// dialog itself as the component carried it.
ShowDialog(horizon_world::nbt::Tag),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
OpenFile(String),
@@ -70,10 +61,6 @@ pub struct Style {
pub click: Option<ClickEvent>,
/// Hover text.
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
@@ -103,12 +90,6 @@ impl Style {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
}
if self.font.is_none() {
self.font.clone_from(&base.font);
}
@@ -403,7 +384,31 @@ impl Text {
continue;
}
let Some(code) = chars.next() else { break };
let next = legacy_format(&style, code, &span.style);
let code = code.to_ascii_lowercase();
let next = if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// Style.applyLegacyFormat: a colour clears the decorations.
Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
}
} else {
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = span.style.clone(),
_ => {}
}
s
};
if next != style {
if !current.is_empty() {
out.spans.push(Span {
@@ -490,7 +495,7 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 10] = [
const STYLE_KEYS: [&str; 9] = [
"color",
"bold",
"italic",
@@ -499,7 +504,6 @@ const STYLE_KEYS: [&str; 10] = [
"obfuscated",
"click_event",
"hover_event",
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
@@ -525,22 +529,12 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
}
if let Some(click) = &style.click {
let event = match click {
ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }),
ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }),
ClickEvent::SuggestCommand(command) => {
json!({ "action": "suggest_command", "command": command })
}
ClickEvent::CopyToClipboard(value) => {
json!({ "action": "copy_to_clipboard", "value": value })
}
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
// ClickEvent.ShowDialog.CODEC: the dialog the server sent it with.
ClickEvent::ShowDialog(dialog) => json!({
"action": "show_dialog",
"dialog": crate::scoreboard::tag_to_json(dialog),
}),
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -550,13 +544,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"hover_event".to_owned(),
json!({ "action": "show_text", "value": hover.component() }),
));
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
}
if let Some(font) = &style.font {
out.push(("font".to_owned(), font.clone().into()));
@@ -564,35 +551,6 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
out
}
/// `Style.applyLegacyFormat` for the code after a `§`, `§r` going back to
/// `reset` (`StringDecomposer.iterateFormatted`); an unknown code changes nothing.
pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
let code = code.to_ascii_lowercase();
if let Some(c) = COLORS.iter().find(|c| c.0 == code) {
// A colour clears the decorations.
return Style {
color: Some(c.2),
bold: Some(false),
italic: Some(false),
underlined: Some(false),
strikethrough: Some(false),
obfuscated: Some(false),
..style.clone()
};
}
let mut s = style.clone();
match code {
'k' => s.obfuscated = Some(true),
'l' => s.bold = Some(true),
'm' => s.strikethrough = Some(true),
'n' => s.underlined = Some(true),
'o' => s.italic = Some(true),
'r' => s = reset.clone(),
_ => {}
}
s
}
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim.
-8
View File
@@ -1,8 +0,0 @@
//! Who an action came from or is aimed at: the player or a mob by id.
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "horizon-client-input"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's input layer: key bindings and their names, mouse, controllers, clipboard and window options"
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
[lints]
workspace = true
-14
View File
@@ -1,14 +0,0 @@
//! The client's input layer, below gameplay and screens: key bindings and their
//! names, toggle keys, mouse settings, controllers, the clipboard and window
//! options. horizon-client re-exports each module under its old path
//! (`horizon_client::keybinds`, ...).
#![forbid(unsafe_code)]
pub mod clipboard;
pub mod gamepad;
pub mod input;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod mouse;
pub mod window;
-67
View File
@@ -1,67 +0,0 @@
[package]
name = "horizon-client-render"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-items = { path = "../horizon-items" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"] }
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha2 = "0.10.9"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
# Sound output: horizon-client's `audio` feature.
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[lints]
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -1,151 +0,0 @@
//! Two offscreen frames presented in one window, without game resources or player data.
//! `cargo run --profile fast -p horizon-client-render --example compositor -- --smoke`
use horizon_client_render::gfx::compositor::Compositor;
use std::sync::Arc;
use winit::{
application::ApplicationHandler,
event::WindowEvent,
event_loop::{ActiveEventLoop, EventLoop},
window::{Window, WindowId},
};
struct Frames {
window: Arc<Window>,
compositor: Compositor,
textures: [wgpu::Texture; 2],
}
impl Frames {
fn new(event_loop: &ActiveEventLoop) -> horizon_client_render::Result<Self> {
let window = Arc::new(
event_loop.create_window(
Window::default_attributes()
.with_title("Compositor: red and blue views")
.with_inner_size(winit::dpi::PhysicalSize::new(640, 360)),
)?,
);
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
Box::new(window.clone()),
));
let surface = instance.create_surface(window.clone())?;
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
..Default::default()
}))?;
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default()))?;
let textures = [[0, 0, 255, 255], [255, 0, 0, 255]].map(|pixel| {
let size = wgpu::Extent3d {
width: 320,
height: 360,
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Offscreen view"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
&pixel.repeat(320 * 360),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(320 * 4),
rows_per_image: Some(360),
},
size,
);
texture
});
let compositor = Compositor::new(
surface,
&adapter,
&device,
&queue,
window.inner_size().into(),
)?;
Ok(Self {
window,
compositor,
textures,
})
}
}
#[derive(Default)]
struct Demo {
frames: Option<Frames>,
error: Option<String>,
presented: usize,
smoke: bool,
}
impl ApplicationHandler for Demo {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.frames.is_none() {
match Frames::new(event_loop) {
Ok(frames) => self.frames = Some(frames),
Err(error) => {
self.error = Some(error.to_string());
event_loop.exit();
}
}
}
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, _: WindowId, event: WindowEvent) {
let Some(f) = &mut self.frames else { return };
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => f.compositor.resize(size.width, size.height),
// Uncovered pixels are black; resize smaller to exercise clipping.
WindowEvent::RedrawRequested
if f.compositor
.present(&[(&f.textures[0], [0, 0]), (&f.textures[1], [320, 0])]) =>
{
self.presented += 1;
if self.smoke && self.presented == 10 {
f.compositor.set_vsync(false);
f.compositor.resize(0, 0);
assert!(!f.compositor.present(&[]));
f.compositor.resize(u32::MAX, u32::MAX);
assert!(!f.compositor.present(&[]));
f.compositor.resize(400, 240);
let _ = f
.window
.request_inner_size(winit::dpi::PhysicalSize::new(400, 240));
}
if self.smoke && self.presented == 20 {
println!("compositor: 20 frames, resize, suspension and clipping passed");
event_loop.exit();
}
}
_ => {}
}
}
fn about_to_wait(&mut self, _: &ActiveEventLoop) {
if let Some(f) = &self.frames {
f.window.request_redraw();
}
}
}
fn main() -> horizon_client_render::Result<()> {
let mut demo = Demo {
smoke: std::env::args().any(|a| a == "--smoke"),
..Default::default()
};
EventLoop::new()?.run_app(&mut demo)?;
if let Some(error) = demo.error {
return Err(error.into());
}
if demo.smoke && demo.presented < 20 {
return Err("smoke test closed early".into());
}
Ok(())
}
-302
View File
@@ -1,302 +0,0 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -1,469 +0,0 @@
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
//! and its payloads). Plain data, as vanilla's block entities are world data;
//! their behaviour lives with the features that use them (`containers`,
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
use horizon_client_core::{text::Text, who::Who};
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
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<String, u32>,
}
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
/// `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<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` 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)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
impl Default for Spawner {
fn default() -> Self {
Self {
spawn_delay: 20,
potentials: Vec::new(),
next: None,
min_spawn_delay: 200,
max_spawn_delay: 800,
spawn_count: 4,
max_nearby_entities: 6,
required_player_range: 16,
spawn_range: 4,
client_delay: 20,
spin: 0.0,
o_spin: 0.0,
}
}
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
impl Default for TrialConfig {
/// `TrialSpawnerConfig.Builder`'s defaults.
fn default() -> Self {
Self {
spawn_range: 4,
total_mobs: 6.0,
simultaneous_mobs: 2.0,
total_mobs_added_per_player: 2.0,
simultaneous_mobs_added_per_player: 1.0,
ticks_between_spawn: 40,
spawn_potentials: Vec::new(),
loot_tables_to_eject: vec![
("minecraft:spawners/trial_chamber/consumables".into(), 1),
("minecraft:spawners/trial_chamber/key".into(), 1),
],
items_to_drop_when_ominous:
"minecraft:spawners/trial_chamber/items_to_drop_when_ominous".into(),
}
}
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
impl Default for TrialSpawner {
fn default() -> Self {
Self {
normal: ConfigSlot::default(),
ominous: ConfigSlot::default(),
target_cooldown_length: 36000,
required_player_range: 14,
detected_players: Vec::new(),
current_mobs: Vec::new(),
cooldown_ends_at: 0,
next_mob_spawns_at: 0,
total_mobs_spawned: 0,
next_spawn_data: None,
ejecting_loot_table: None,
is_ominous: false,
has_display: false,
spin: 0.0,
o_spin: 0.0,
dispensing: None,
}
}
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
@@ -1,561 +0,0 @@
//! Container block entities: chests/barrels (27 slots) and the three furnace
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
Self::Furnace => "minecraft:smelting",
Self::BlastFurnace => "minecraft:blasting",
Self::Smoker => "minecraft:smoking",
}
}
pub fn fast(self) -> bool {
self != Self::Furnace
}
pub fn from_block(name: &str) -> Option<Self> {
match name {
"minecraft:furnace" => Some(Self::Furnace),
"minecraft:blast_furnace" => Some(Self::BlastFurnace),
"minecraft:smoker" => Some(Self::Smoker),
_ => None,
}
}
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
/// ("Duplicate entry for key").
pub fn recipe_used_key(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
/// `getDefaultName` of the `BaseContainerBlockEntity` that `block` holds: the
/// translation key of its menu's title when it has no custom name. Copper
/// chests hold a `ChestBlockEntity`, shulker boxes of every colour a
/// `ShulkerBoxBlockEntity`.
pub fn default_name_key(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" => "container.chest",
n if n.ends_with("copper_chest") => "container.chest",
"minecraft:barrel" => "container.barrel",
n if n.ends_with("shulker_box") => "container.shulkerBox",
"minecraft:dispenser" => "container.dispenser",
"minecraft:dropper" => "container.dropper",
"minecraft:hopper" => "container.hopper",
"minecraft:furnace" => "container.furnace",
"minecraft:smoker" => "container.smoker",
"minecraft:blast_furnace" => "container.blast_furnace",
"minecraft:brewing_stand" => "container.brewing",
"minecraft:crafter" => "container.crafter",
_ => return None,
})
}
/// `BaseContainerBlockEntity.getDisplayName` (`getName`), the title of the
/// `ClientboundOpenScreenPacket` that opens it: the block entity's custom
/// name (`CustomName`, a literal), else its default name.
pub fn display_name(custom: Option<&str>, default_key: &str) -> horizon_world::nbt::Tag {
match custom {
Some(name) => horizon_protocol::text::literal(name),
None => horizon_protocol::text::translatable(default_key, &[]),
}
}
/// `ChestBlock.MENU_PROVIDER_COMBINER`'s `getDisplayName` for a double chest:
/// the first half's custom name (the right half, `DoubleBlockCombiner`'s
/// `FIRST`), else the second's, else `container.chestDouble`.
pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_world::nbt::Tag {
display_name(first.or(second), "container.chestDouble")
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
let Some(slot) = self.items.iter().position(Option::is_none) else {
return false;
};
self.total[slot] = cooking_time;
self.progress[slot] = 0;
self.items[slot] = Some(ItemStack {
count: 1,
..held.clone()
});
held.count = held.count.saturating_sub(1);
true
}
/// `cookTick` (lit): each item's progress rises by one; the slots whose
/// progress reached their cooking time give up their item (the caller
/// drops its result).
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
let mut done = Vec::new();
for i in 0..4 {
if self.items[i].is_none() {
continue;
}
self.progress[i] += 1;
if self.progress[i] >= self.total[i]
&& let Some(item) = self.items[i].take()
{
done.push((i, item));
}
}
done
}
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
/// Whether anything changed (`setChanged`).
pub fn cooldown_tick(&mut self) -> bool {
let mut changed = false;
for i in 0..4 {
if self.progress[i] > 0 {
changed = true;
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
}
}
changed
}
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
note_block_sound: Option<String>,
custom_name: Option<horizon_world::nbt::Tag>,
) -> Self {
let skin = profile.as_ref().map(SkullSkin::of);
Self {
profile,
note_block_sound,
custom_name,
skin,
}
}
}
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))
});
// A `Dynamic` profile (a name or id only) resolves through the
// services when the skull is drawn.
let dynamic = crate::skins::dynamic_nbt(profile);
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self {
download,
dynamic,
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")
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
slot < 9 && self.items[slot].is_none()
}
/// `setSlotState`.
pub fn set_slot_state(&mut self, slot: usize, enabled: bool) {
if self.slot_can_be_disabled(slot) {
self.disabled[slot] = !enabled;
}
}
/// `getRedstoneSignal`: filled or disabled slots.
pub fn redstone_signal(&self) -> i32 {
(0..9)
.filter(|&i| self.items[i].is_some() || self.disabled[i])
.count() as i32
}
/// `canPlaceItem`: a hopper fills the smallest stacks first.
pub fn can_place(&self, slot: usize, _stack: &ItemStack, max_stack: u32) -> bool {
if slot >= 9 || self.disabled[slot] {
return false;
}
let Some(current) = &self.items[slot] else {
return true;
};
if current.count >= max_stack {
return false;
}
!(slot + 1..9).any(|i| {
!self.disabled[i]
&& match &self.items[i] {
None => true,
Some(other) => {
other.count < current.count && other.same_item_same_components(current)
}
}
})
}
}
impl BlockEntity {
pub fn for_block(name: &str) -> Option<Self> {
if let Some(hanging) = crate::signs::sign_kind(name) {
return Some(Self::Sign(crate::signs::SignData {
hanging,
..Default::default()
}));
}
if let Some(kind) = FurnaceKind::from_block(name) {
return Some(Self::Furnace(Furnace {
kind,
items: [None, None, None],
lit_remaining: 0,
lit_total: 0,
cooking_timer: 0,
cooking_total: 0,
speed: 1.0,
recipes_used: HashMap::new(),
}));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
if name == "minecraft:spawner" {
return Some(Self::Spawner(Box::default()));
}
if name == "minecraft:trial_spawner" {
return Some(Self::TrialSpawner(Box::default()));
}
if name == "minecraft:vault" {
return Some(Self::Vault(Box::default()));
}
if name == "minecraft:beehive" || name == "minecraft:bee_nest" {
return Some(Self::Beehive(Box::default()));
}
if name == "minecraft:lectern" {
return Some(Self::Lectern(Lectern::default()));
}
if crate::command_block::Mode::of_block(name).is_some() {
// CommandBlock.newBlockEntity: setAutomatic(this.automatic).
return Some(Self::CommandBlock(Box::new(
crate::command_block::CommandBlock {
auto: crate::command_block::automatic_block(name),
..Default::default()
},
)));
}
if let Some(sculk) = crate::sculk::SculkEntity::for_block(name) {
return Some(Self::Sculk(Box::new(sculk)));
}
if name == "minecraft:crafter" {
// CrafterBlock.newBlockEntity copies the placed state's flag: the
// first tick sees it as a change from untriggered.
return Some(Self::Crafter(Crafter {
state_triggered: Some(false),
..Crafter::default()
}));
}
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
}
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
if name == "minecraft:beacon" {
return Some(Self::Beacon(Box::new(Beacon {
last_check_y: i32::MIN / 2,
..Default::default()
})));
}
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
"minecraft:hopper" => HOPPER_SLOTS,
"minecraft:dispenser" | "minecraft:dropper" => 9,
n if n.ends_with("shulker_box") => 27,
_ => return None,
};
Some(Self::Chest(vec![None; size]))
}
pub fn id(&self) -> &'static str {
match self {
Self::Chest(items) => match items.len() {
HOPPER_SLOTS => "minecraft:hopper",
9 => "minecraft:dispenser",
_ => "minecraft:chest",
},
Self::Furnace(f) => match f.kind {
FurnaceKind::Furnace => "minecraft:furnace",
FurnaceKind::BlastFurnace => "minecraft:blast_furnace",
FurnaceKind::Smoker => "minecraft:smoker",
},
Self::Brewing(_) => "minecraft:brewing_stand",
Self::Sign(s) => s.id(),
Self::Jukebox(_) => "minecraft:jukebox",
Self::Beacon(_) => "minecraft:beacon",
Self::Lectern(_) => "minecraft:lectern",
Self::Sculk(s) => s.id(),
Self::Banner(_) => "minecraft:banner",
Self::Crafter(_) => "minecraft:crafter",
Self::CreakingHeart(_) => "minecraft:creaking_heart",
Self::Spawner(_) => "minecraft:mob_spawner",
Self::TrialSpawner(_) => "minecraft:trial_spawner",
Self::Vault(_) => "minecraft:vault",
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
match self {
Self::Chest(items) => items.iter().flatten().cloned().collect(),
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
Self::Sign(_) => Vec::new(),
Self::Jukebox(j) => j.record.iter().cloned().collect(),
Self::Beacon(_)
| Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
match self {
Self::Chest(items) => items,
Self::Furnace(f) => &f.items,
Self::Brewing(b) => &b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
// Not a Container (only Clearable): hoppers don't reach in.
| Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
match self {
Self::Chest(items) => items,
Self::Furnace(f) => &mut f.items,
Self::Brewing(b) => &mut b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
pub use crate::providers::Providers;
/// A `ChestBlock`, which `ChestBlock.combine` can join into a double chest:
/// chests, trapped chests and copper chests in every weathering state, waxed
/// or not (`CopperChestBlock` extends `ChestBlock`). Ender chests are
/// `EnderChestBlock`s and always single.
pub fn is_chest_block(name: &str) -> bool {
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
}
/// A chest block's name with its `type` and `facing` properties.
#[derive(Clone, Copy, Debug)]
pub struct ChestHalf<'a> {
pub block: &'a str,
pub kind: &'a str,
pub facing: &'a str,
}
impl ChestHalf<'_> {
/// `ChestBlock.getConnectedDirection` as an (x, z) step to the other
/// half: clockwise from the facing for a left half, counter-clockwise for
/// a right one. None for a single chest.
pub fn connected_step(&self) -> Option<(i32, i32)> {
let step = |facing: &str| match facing {
"north" => Some((0, -1)),
"south" => Some((0, 1)),
"west" => Some((-1, 0)),
"east" => Some((1, 0)),
_ => None,
};
let (x, z) = step(self.facing)?;
// Turning clockwise seen from above takes (x, z) to (-z, x).
match self.kind {
"left" => Some((-z, x)),
"right" => Some((z, -x)),
_ => None,
}
}
/// `DoubleBlockCombiner.combineWithNeigbour` as `ChestBlock.combine` calls
/// it: `other`, the block in this half's connected direction, is its other
/// half when it is the same block, a half of the other side and faces the
/// same way. Copper chests in different weathering states never join
/// (`CopperChestBlock` keeps both halves in one state instead).
pub fn pairs_with(&self, other: &ChestHalf) -> bool {
is_chest_block(self.block)
&& self.kind != "single"
&& other.block == self.block
&& other.kind != "single"
&& other.kind != self.kind
&& other.facing == self.facing
}
}
/// `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
}
/// The `MenuType` the block entity of a one-block storage container opens
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
pub fn block_menu_type(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
"minecraft:generic_9x3"
}
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
"minecraft:hopper" => "minecraft:hopper",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
let mut beef = ItemStack::new("minecraft:beef", 2);
// placeFood: the first free slot, one item, its recipe's time.
assert!(c.place_food(&mut beef, 600));
assert_eq!(beef.count, 1);
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
assert_eq!((c.progress[0], c.total[0]), (0, 600));
let mut fish = ItemStack::new("minecraft:cod", 1);
assert!(c.place_food(&mut fish, 3));
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
// cookTick: the cod is done on its third tick, the beef keeps cooking.
assert!(c.cook_tick().is_empty());
assert!(c.cook_tick().is_empty());
let done = c.cook_tick();
assert_eq!(done.len(), 1);
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
assert!(c.items[1].is_none());
assert_eq!(c.progress[0], 3);
// cooldownTick: two a tick, never below zero.
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 1);
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 0);
// Full: four slots, no fifth.
for _ in 0..3 {
let mut more = ItemStack::new("minecraft:potato", 1);
assert!(c.place_food(&mut more, 600));
}
let mut fifth = ItemStack::new("minecraft:potato", 1);
assert!(!c.place_food(&mut fifth, 600));
assert_eq!(fifth.count, 1);
}
/// 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))));
}
}
@@ -1,214 +0,0 @@
//! Splitscreen: several players' renderers draw offscreen on one device, and the
//! compositor copies each frame into its place on a window's surface. A window holds views but
//! never owns them, so a view can later move to another window (another monitor, detached).
use super::Renderer;
use crate::Result;
use std::sync::Arc;
use winit::window::Window;
pub struct Compositor {
surface: wgpu::Surface<'static>,
device: wgpu::Device,
queue: wgpu::Queue,
config: wgpu::SurfaceConfiguration,
present_modes: Vec<wgpu::PresentMode>,
suspended: bool,
}
impl Renderer {
/// A compositor for `window` on this renderer's device, presenting on the surface it gave up.
pub fn compositor(&self, window: Arc<Window>) -> Result<Compositor> {
// The surface this renderer set aside for its successor (`release_surface`), else a new
// one: a window can't keep two.
let spare = self
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
let surface = match spare {
Some(surface) => surface,
None => self.instance.create_surface(window.clone())?,
};
Compositor::new(
surface,
&self.adapter,
&self.device,
&self.queue,
window.inner_size().into(),
)
}
/// The last offscreen frame (`--offscreen`), for the compositor.
pub fn offscreen_frame(&self) -> Option<&wgpu::Texture> {
self.offscreen.as_ref()
}
/// Splitscreen turned off: a compositor's surface, set aside for the renderer that draws on
/// the window again (it takes it, as after `release_surface`).
pub fn take_back(&self, compositor: Compositor) {
*self
.spare_surface
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(compositor.surface);
}
}
impl Compositor {
/// Present offscreen BGRA frames on a compatible surface using their existing device.
/// The surface must support COPY_DST and Bgra8Unorm; unsupported surfaces return an error.
pub fn new(
surface: wgpu::Surface<'static>,
adapter: &wgpu::Adapter,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
) -> Result<Self> {
let caps = surface.get_capabilities(adapter);
// ponytail: a plain texture copy needs the offscreen frames' format; a blit pass would
// take any surface format (Rgba8Unorm on some Linux drivers).
if !caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
return Err("surface does not support copying offscreen frames".into());
}
let format = wgpu::TextureFormat::Bgra8Unorm;
if !caps.formats.contains(&format) {
return Err(format!(
"splitscreen needs a {format:?} surface; this one offers {:?}",
caps.formats
)
.into());
}
let max = device.limits().max_texture_dimension_2d;
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
format,
width: size[0].clamp(1, max),
height: size[1].clamp(1, max),
present_mode: wgpu::PresentMode::Fifo,
desired_maximum_frame_latency: 2,
alpha_mode: wgpu::CompositeAlphaMode::Opaque,
view_formats: vec![],
color_space: wgpu::SurfaceColorSpace::default(),
};
surface.configure(device, &config);
Ok(Compositor {
surface,
device: device.clone(),
queue: queue.clone(),
config,
present_modes: caps.present_modes,
suspended: !super::drawable_size(size[0], size[1], max),
})
}
/// Vsync on: presents wait for the display. Off: they don't (Mailbox, else Immediate).
pub fn set_vsync(&mut self, vsync: bool) {
let mode = [wgpu::PresentMode::Mailbox, wgpu::PresentMode::Immediate]
.into_iter()
.find(|m| !vsync && self.present_modes.contains(m))
.unwrap_or(wgpu::PresentMode::Fifo);
if mode != self.config.present_mode {
self.config.present_mode = mode;
self.surface.configure(&self.device, &self.config);
}
}
pub fn resize(&mut self, width: u32, height: u32) {
self.suspended =
!super::drawable_size(width, height, self.device.limits().max_texture_dimension_2d);
if !self.suspended {
self.config.width = width;
self.config.height = height;
self.surface.configure(&self.device, &self.config);
}
}
/// Copy each view's frame to its top-left corner on the window and present. Frames that
/// don't fit are clipped; parts of the window no view covers (a view before its first frame,
/// one still at its old size after a resize) are black. Nothing when the window has no frame
/// to give (occluded, resized, timed out). Returns true when a frame was submitted.
pub fn present(&mut self, views: &[(&wgpu::Texture, [u32; 2])]) -> bool {
if self.suspended {
return false;
}
let frame = match self.surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(f)
| wgpu::CurrentSurfaceTexture::Suboptimal(f) => f,
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
self.surface.configure(&self.device, &self.config);
return false;
}
_ => return false,
};
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Splitscreen compositor"),
});
let target = frame.texture.create_view(&Default::default());
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Splitscreen compositor clear"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
for &(texture, [x, y]) in views {
let [width, height] = clipped_size(
[texture.width(), texture.height()],
[x, y],
[self.config.width, self.config.height],
);
if width == 0 || height == 0 {
continue;
}
encoder.copy_texture_to_texture(
texture.as_image_copy(),
wgpu::TexelCopyTextureInfo {
origin: wgpu::Origin3d { x, y, z: 0 },
..frame.texture.as_image_copy()
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
self.queue.submit([encoder.finish()]);
self.queue.present(frame);
true
}
}
/// Copies never reach past the surface, including while a resized view still has its old frame.
fn clipped_size(view: [u32; 2], at: [u32; 2], surface: [u32; 2]) -> [u32; 2] {
[
view[0].min(surface[0].saturating_sub(at[0])),
view[1].min(surface[1].saturating_sub(at[1])),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frames_are_clipped_to_the_window_during_resize() {
assert_eq!(clipped_size([640, 480], [0, 0], [1280, 720]), [640, 480]);
assert_eq!(
clipped_size([640, 480], [1000, 600], [1280, 720]),
[280, 120]
);
assert_eq!(clipped_size([640, 480], [1280, 720], [1280, 720]), [0, 0]);
assert_eq!(
clipped_size([640, 480], [u32::MAX, 0], [1280, 720]),
[0, 480]
);
}
}
@@ -1,311 +0,0 @@
//! The entity outline (`LevelRenderer.entityOutlineTarget`): the glowing entities'
//! silhouettes in their team colours, drawn without depth so walls do not hide them
//! (`RenderTypes.outline`), the `entity_outline` post chain over them (`entity_sobel`, then
//! `entity_outline_box_blur` across and up), and `LevelRenderer.blitEntityOutline` onto
//! the frame after the hand.
use super::Vertex;
/// `entityOutlineTarget`'s `GpuFormat.RGBA8_UNORM`.
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
struct Targets {
size: [u32; 2],
/// `entityOutlineTarget`.
outline: wgpu::TextureView,
/// The chain's `swap` target.
swap: wgpu::TextureView,
/// What each pass reads: the outline (nearest), swap (bilinear), the outline
/// (bilinear), swap (nearest).
groups: [wgpu::BindGroup; 4],
}
pub(super) struct EntityOutline {
silhouette: wgpu::RenderPipeline,
/// Sobel, blur across, blur up (into `FORMAT`) and the blended blit (onto the frame).
passes: [wgpu::RenderPipeline; 4],
layout: wgpu::BindGroupLayout,
nearest: wgpu::Sampler,
bilinear: wgpu::Sampler,
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
targets: Option<Targets>,
}
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is the frame's.
pub(super) fn new(
device: &wgpu::Device,
entity: (
&wgpu::ShaderModule,
&wgpu::PipelineLayout,
&[Option<wgpu::VertexBufferLayout>],
),
format: wgpu::TextureFormat,
) -> Self {
let (entity_shader, entity_layout, entity_vertex) = entity;
// RenderPipelines.OUTLINE_NO_CULL: no depth test, the colour replaced.
let silhouette = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Entity outline silhouettes"),
layout: Some(entity_layout),
vertex: wgpu::VertexState {
module: entity_shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: entity_vertex,
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: entity_shader,
entry_point: Some("fs_outline"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity outline"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_outline.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Entity outline bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity outline"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pass = |entry: &str, target: wgpu::ColorTargetState| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_quad"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(target)],
}),
multiview_mask: None,
cache: None,
})
};
// BlendFunction.ENTITY_OUTLINE_BLIT over the colour only (write mask 7).
let blit = wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::COLOR,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
Self {
silhouette,
passes: [
pass("fs_sobel", FORMAT.into()),
pass("fs_blur_x", FORMAT.into()),
pass("fs_blur_y", FORMAT.into()),
pass("fs_blit", blit),
],
layout,
nearest: sampler("Entity outline nearest", wgpu::FilterMode::Nearest),
bilinear: sampler("Entity outline bilinear", wgpu::FilterMode::Linear),
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
targets: None,
}
}
fn vertex_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity outline vertices"),
size: capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
return;
}
let vertices = silhouettes.len() as u64;
if vertices > self.capacity {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
let texture = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let outline = texture("Entity outline");
let swap = texture("Entity outline swap");
let group = |view: &wgpu::TextureView, sampler: &wgpu::Sampler| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity outline pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
};
let groups = [
group(&outline, &self.nearest),
group(&swap, &self.bilinear),
group(&outline, &self.bilinear),
group(&swap, &self.nearest),
];
self.targets = Some(Targets {
size,
outline,
swap,
groups,
});
}
/// `LevelRenderer.executeOutline`, the post chain and `blitEntityOutline` onto `frame`.
pub(super) fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
let steps = [
("Entity outline sobel", &t.swap, clear),
("Entity outline blur across", &t.outline, clear),
("Entity outline blur up", &t.swap, clear),
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
}
}
}
fn begin<'e>(
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
..Default::default()
})
}
@@ -1,60 +0,0 @@
// The entity_outline post chain (post_effect/entity_outline.json) over the silhouettes of
// the glowing entities, then RenderTarget.blitAndBlendToTexture onto the frame.
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// screenquad.vsh, with v running down the rows as wgpu's textures do.
@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> PostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: PostOut;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn tap(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(source, source_sampler, uv, 0.0);
}
// entity_sobel.fsh: alpha marks where coverage changes, the colour is the mean of the
// covered texels around.
@fragment fn fs_sobel(in: PostOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(source));
let center = tap(in.uv);
let left = tap(in.uv - vec2(texel.x, 0.0));
let right = tap(in.uv + vec2(texel.x, 0.0));
let up = tap(in.uv - vec2(0.0, texel.y));
let down = tap(in.uv + vec2(0.0, texel.y));
let total = clamp(abs(center.a - left.a) + abs(center.a - right.a)
+ abs(center.a - up.a) + abs(center.a - down.a), 0.0, 1.0);
let color = center.rgb * center.a + left.rgb * left.a + right.rgb * right.a
+ up.rgb * up.a + down.rgb * down.a;
return vec4(color * 0.2, total);
}
// entity_outline_box_blur.fsh at Radius 2: bilinear taps at -1.5 and 0.5 texels and half
// a tap at 2; the colour averaged, the alpha summed.
fn blur(uv: vec2<f32>, step: vec2<f32>) -> vec4<f32> {
let blurred = tap(uv + step * -1.5) + tap(uv + step * 0.5) + tap(uv + step * 2.0) / 2.0;
return vec4(blurred.rgb / 2.5, blurred.a);
}
// BlurDir (1, 0).
@fragment fn fs_blur_x(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(1.0 / f32(textureDimensions(source).x), 0.0));
}
// BlurDir (0, 1): up the screen in GL's bottom-up textures, towards row 0 here.
@fragment fn fs_blur_y(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(0.0, -1.0 / f32(textureDimensions(source).y)));
}
// blit_screen.fsh, blended by RenderPipelines.ENTITY_OUTLINE_BLIT.
@fragment fn fs_blit(in: PostOut) -> @location(0) vec4<f32> {
return tap(in.uv);
}
@@ -1,213 +0,0 @@
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
//! then drawn onto the swapchain image as a fullscreen triangle.
/// The texture a frame to read back is drawn into, and the pass that puts it
/// on the swapchain afterwards.
#[derive(Default)]
pub struct Readback {
texture: Option<wgpu::Texture>,
blitter: Option<(wgpu::TextureFormat, wgpu::util::TextureBlitter)>,
/// A frame drew into `texture` and still has to reach the swapchain.
drawn: bool,
}
impl Readback {
/// The texture this frame draws into instead of the swapchain image
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
/// image cannot be copied from.
pub fn target(
&mut self,
device: &wgpu::Device,
surface: &wgpu::Texture,
screenshot: bool,
) -> Option<wgpu::Texture> {
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
return None;
}
let format = surface.format().remove_srgb_suffix();
if self
.texture
.as_ref()
.is_none_or(|t| t.size() != surface.size() || t.format() != format)
{
self.texture = Some(device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot frame"),
size: surface.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
}));
}
self.drawn = true;
self.texture.clone()
}
/// Draws the frame `target` gave onto the swapchain image `surface`; nothing
/// when this frame drew straight into it.
pub fn present(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::Texture,
) {
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = &self.texture else {
return;
};
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let target = surface.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
let source = texture.create_view(&Default::default());
if let Some((_, blitter)) = &self.blitter {
blitter.copy(device, encoder, &source, &target);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
fn texture(device: &wgpu::Device, usage: wgpu::TextureUsages) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 70,
height: 3,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
usage,
view_formats: &[],
})
}
fn clear(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: wgpu::Color) {
let view = texture.create_view(&Default::default());
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(color),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
}
fn read(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
buffer.get_mapped_range(..).unwrap().to_vec()
}
/// The last pixel of the last row of a 70x3 frame read back by `copy_frame`.
fn last_pixel(device: &wgpu::Device, buffer: &wgpu::Buffer) -> Vec<u8> {
let pixels = read(device, buffer);
let row = 70usize * 4;
let last = row.next_multiple_of(256) * 2 + row - 4;
pixels[last..last + 4].to_vec()
}
/// The renderer's screenshot path on a surface like OpenGL's (#3510): the
/// frame is drawn into `target`, read back with `copy_frame` and drawn on
/// the swapchain, with no validation error (the copy used to need the
/// swapchain's missing `COPY_SRC`), and the swapchain shows the frame.
#[test]
fn screenshot_of_a_surface_without_copy_src() {
let Some((device, queue)) = device() else {
return;
};
// Sampled only so the test can look at what reached it.
let surface = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
let check = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
let mut readback = Readback::default();
let colors = [
(1.0, [0, 0, 255, 255]),
// A second screenshot reuses the texture.
(0.0, [255, 0, 0, 255]),
];
for (r, bgra) in colors {
let color = wgpu::Color {
r,
g: 0.0,
b: 1.0 - r,
a: 1.0,
};
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, true)
.unwrap_or_else(|| surface.clone());
clear(&mut encoder, &frame, color);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
readback.present(&device, &mut encoder, &surface);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{error}");
}
assert_eq!(last_pixel(&device, &buffer), bgra);
let mut encoder = device.create_command_encoder(&Default::default());
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
blitter.copy(&device, &mut encoder, &view(&surface), &view(&check));
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &check, [70, 3]);
queue.submit([encoder.finish()]);
assert_eq!(last_pixel(&device, &buffer), bgra);
}
assert!(!readback.drawn);
}
/// A surface that can be copied from, or a frame nobody reads back,
/// draws straight into the swapchain image.
#[test]
fn frames_draw_straight_to_a_readable_surface() {
let Some((device, _)) = device() else {
return;
};
let mut readback = Readback::default();
let readable = texture(
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
assert!(readback.target(&device, &readable, true).is_none());
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
assert!(readback.target(&device, &surface, false).is_none());
assert!(readback.texture.is_none() && !readback.drawn);
}
}
@@ -1,131 +0,0 @@
//! The sunrise/sunset fan (`SkyRenderer.renderSunriseAndSunset`) as the sky
//! shader tests it. The fan's 16 triangles depend only on the sunrise alpha
//! and the horizon the sun is on, so their vertices are built here once a
//! frame instead of in every sky pixel (#3143: with the per-pixel build, four
//! `sin`/`cos` per triangle, the sky pass took 10.2 ms at dawn at 1080p on an
//! Intel UHD 730 against 0.6 ms by day; now 1.8 ms).
//!
//! Möller-Trumbore from the camera reduces to triple products: with apex A
//! and rim vertices r0, r1, a ray d hits at barycentrics proportional to
//! (`[d, r0, r1]`, `[A, d, r1]`, `[A, r0, d]`), each a dot of d with a
//! constant normal, and their sum S is the negated determinant. A hit needs
//! S on the side of `[A, r0, r1]`, so only the triangles whose rim normals'
//! dots change sign there are tested in full.
use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; 2 * TRIANGLES];
for i in 0..TRIANGLES {
let (r0, r1) = (rim[i], rim[(i + 1) % TRIANGLES]);
out[i] = r0.cross(r1).extend(apex.dot(r0.cross(r1))).to_array();
out[TRIANGLES + i] = r0.cross(apex).extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
fn vertices(alpha: f32, sun: Vec3) -> (Vec3, [Vec3; TRIANGLES]) {
// sin(sunAngle) < 0 flips the fan to the other horizon; sun.z = -sin(sunAngle).
let flip = if -sun.z < 0.0 { 180f32 } else { 0.0 };
let (s, c) = (flip + 90.0).to_radians().sin_cos();
let rotate_x90 = |v: Vec3| Vec3::new(v.x, -v.z, v.y);
let apex = rotate_x90(Vec3::new(-100.0 * s, 100.0 * c, 0.0));
let rim = std::array::from_fn(|i| {
let a = i as f32 * std::f32::consts::TAU / TRIANGLES as f32;
let l = Vec3::new(a.sin() * 120.0, a.cos() * 120.0, -a.cos() * 40.0 * alpha);
rotate_x90(Vec3::new(l.x * c - l.y * s, l.x * s + l.y * c, l.z))
});
(apex, rim)
}
#[cfg(test)]
mod tests {
use super::*;
/// terrain.wgsl's `sunrise_weight` over the normals.
fn weight(normals: &[[f32; 4]; 2 * TRIANGLES], d: Vec3) -> f32 {
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
let mut best = 0.0f32;
let mut k0 = d.dot(n(TRIANGLES));
for i in 0..TRIANGLES {
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
let w = normals[i][3];
if k0 * w <= 0.0 && k1 * w >= 0.0 {
let apex = d.dot(n(i));
let s = apex + k1 - k0;
if s.abs() >= 1e-6
&& s * w > 0.0
&& apex * s >= 0.0
&& k1 * s >= 0.0
&& k0 * s <= 0.0
{
best = best.max(apex / s);
}
}
k0 = k1;
}
best
}
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
let (apex, rim) = vertices(alpha, sun);
let mut best = 0.0f32;
for i in 0..TRIANGLES {
let e1 = rim[i] - apex;
let e2 = rim[(i + 1) % TRIANGLES] - apex;
let pv = d.cross(e2);
let det = e1.dot(pv);
if det.abs() < 1e-6 {
continue;
}
let tv = -apex;
let u = tv.dot(pv) / det;
let qv = tv.cross(e1);
let v = d.dot(qv) / det;
let t = e2.dot(qv) / det;
if u >= 0.0 && v >= 0.0 && u + v <= 1.0 && t > 0.0 {
best = best.max(1.0 - u - v);
}
}
best
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
for (alpha, sun_z) in [(1.0f32, -0.2f32), (0.6, 0.3), (0.05, -0.9), (0.9, 0.05)] {
let sun = Vec3::new(0.0, (1.0 - sun_z * sun_z).sqrt(), sun_z);
let normals = normals(alpha, sun);
for yaw in 0..180 {
for pitch in -45..=45 {
let (yaw, pitch) = (
(yaw as f32 * 2.0).to_radians(),
(pitch as f32 * 2.0).to_radians(),
);
let d = Vec3::new(
yaw.cos() * pitch.cos(),
pitch.sin(),
yaw.sin() * pitch.cos(),
);
let (got, want) = (weight(&normals, d), reference(alpha, sun, d));
assert!(
(got - want).abs() < 1e-4,
"{alpha} {sun_z} {d}: {got} vs {want}"
);
hits += usize::from(want > 0.0);
}
}
}
// The fan covers part of the sky, not none of it.
assert!(hits > 1000, "{hits}");
}
}
@@ -1,133 +0,0 @@
//! The views a frame is drawn from. A flat screen has one; a headset has
//! one per eye. Every view shares the frame's camera position, so the
//! camera-relative world (section origins, entity offsets) is built once and
//! only each view's rotation and lens differ.
use glam::Mat4;
/// The most views one frame draws.
const MAX_VIEWS: usize = 2;
/// One eye's view of the camera-relative world.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct View {
/// Camera-relative world to eye: a rotation, plus the eye's offset from
/// the camera on a headset.
pub view: Mat4,
/// Reversed-Z perspective: the near plane at depth 1, far at 0.
pub projection: Mat4,
/// Width over height of the image this view fills.
pub aspect: f32,
/// `tan(fov_y / 2)`, for shaders rebuilding view rays from NDC.
pub tan_half_fov: f32,
}
impl View {
/// The flat-screen view: vanilla's camera rotation and a symmetric
/// vertical field of view.
pub fn screen(yaw: f32, pitch: f32, fov_degrees: f32, aspect: f32, far: f32) -> Self {
Self {
view: crate::camera::view_matrix(yaw, pitch),
projection: super::world_projection(fov_degrees, aspect, far),
aspect,
tan_half_fov: (fov_degrees.to_radians() / 2.0).tan(),
}
}
/// Clip space from the camera-relative world, with `pose` (view bobbing,
/// nausea spin) between the lens and the rotation.
pub fn view_proj(&self, pose: Mat4) -> Mat4 {
self.projection * pose * self.view
}
}
/// Every view of a frame, and the one geometry is culled with.
#[derive(Clone, Copy, Debug)]
pub struct ViewSet {
views: [View; MAX_VIEWS],
len: usize,
cull: View,
}
impl ViewSet {
/// A frame drawn from `view` alone, culled with it.
pub fn single(view: View) -> Self {
Self {
views: [view; MAX_VIEWS],
len: 1,
cull: view,
}
}
/// The view the HUD, hand and screen-space effects follow.
pub fn primary(&self) -> &View {
&self.views[0]
}
pub fn views(&self) -> &[View] {
&self.views[..self.len]
}
/// The view whose frustum covers every other view's.
pub fn cull(&self) -> &View {
&self.cull
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The formulas `Renderer` used before views existed, kept to pin the
/// screen view to them bit for bit.
fn legacy_view_proj(fov: f32, yaw: f32, pitch: f32, pose: Mat4, aspect: f32, far: f32) -> Mat4 {
super::super::world_projection(fov, aspect, far)
* pose
* crate::camera::view_matrix(yaw, pitch)
}
fn poses() -> [Mat4; 3] {
[
Mat4::IDENTITY,
Mat4::from_translation(glam::Vec3::new(0.013, -0.027, 0.0))
* Mat4::from_rotation_z(0.021),
Mat4::from_rotation_y(0.4) * Mat4::from_scale(glam::Vec3::new(1.1, 0.9, 1.0)),
]
}
#[test]
fn the_screen_view_matches_the_legacy_projection_bit_for_bit() {
for (fov, yaw, pitch, aspect, far) in [
(70.0, 0.0, 0.0, 16.0 / 9.0, 512.0),
(110.0, -137.25, 61.5, 21.0 / 9.0, 8192.0),
(30.0, 359.9, -90.0, 4.0 / 3.0, 96.0),
(82.333, 12.5, 7.75, 1.0, 1024.0),
] {
let view = View::screen(yaw, pitch, fov, aspect, far);
for pose in poses() {
let old = legacy_view_proj(fov, yaw, pitch, pose, aspect, far);
let new = view.view_proj(pose);
assert_eq!(
old.to_cols_array().map(f32::to_bits),
new.to_cols_array().map(f32::to_bits),
"fov {fov} yaw {yaw} pitch {pitch}"
);
}
assert_eq!(view.view, crate::camera::view_matrix(yaw, pitch));
assert_eq!(
view.tan_half_fov.to_bits(),
(fov.to_radians() / 2.0).tan().to_bits()
);
assert_eq!(view.aspect.to_bits(), aspect.to_bits());
}
}
#[test]
fn a_single_view_set_draws_and_culls_with_its_view() {
let view = View::screen(45.0, 10.0, 70.0, 16.0 / 9.0, 512.0);
let set = ViewSet::single(view);
assert_eq!(set.views(), &[view]);
assert_eq!(*set.primary(), view);
assert_eq!(*set.cull(), view);
}
}
@@ -1,564 +0,0 @@
//! Resolution scale, FXAA and TAA (Horizon settings): the world renders into its
//! own target at the scaled size, then one full-screen pass copies or
//! anti-aliases it onto the screen, under the hand and HUD. With none on,
//! the world renders straight to the screen.
//!
//! TAA jitters the world's projection by a sub-pixel offset each frame and blends
//! the frame into a history reprojected through the depth buffer: its motion
//! vectors are the camera's, so what moves on its own leans on the neighbourhood
//! clamp alone.
use glam::{DMat4, DVec3, Mat4, Vec2};
/// The TAA history format: 8 bits would band as frames accumulate.
const HISTORY_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
struct WorldTarget {
size: [u32; 2],
color: wgpu::TextureView,
depth: wgpu::TextureView,
group: wgpu::BindGroup,
taa: Option<History>,
}
/// TAA's two history textures at the output size (the world's, times the upscale), written
/// in turn: `resolve[i]` writes `history[i]` from the world and `history[1 - i]`; `show[i]`
/// copies `history[i]` to the screen.
struct History {
size: [u32; 2],
history: [wgpu::TextureView; 2],
resolve: [wgpu::BindGroup; 2],
show: [wgpu::BindGroup; 2],
}
/// `world_post.wgsl`'s `Taa`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct TaaUniform {
/// This frame's unjittered clip space to the previous frame's.
reproject: [[f32; 4]; 4],
/// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
jitter: [f32; 4],
}
/// TAA's GPU resources, absent when the device cannot compile the shader
/// (the OpenGL backend's naga GLSL emitter does not support `textureLoad`
/// from depth textures).
struct TaaResources {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
uniform: wgpu::Buffer,
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
copy: wgpu::RenderPipeline,
fxaa: wgpu::RenderPipeline,
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
pub antialias: bool,
pub taa: bool,
/// Output pixels per rendered pixel while TAA is on (1: native): the world renders
/// this much smaller and TAA rebuilds it at the resolution scale's size.
pub upscale: f32,
/// TAA runs this frame (`jitter`): not on the title screen or under a shader pack.
taa_on: bool,
target: Option<WorldTarget>,
taa_resources: Option<TaaResources>,
/// Frames jittered so far (the Halton index), and which history the next resolve writes.
taa_frame: u32,
/// The last TAA frame's unjittered `view_proj` and camera, `None` once a frame ran without it.
taa_previous: Option<(DMat4, DVec3)>,
taa_this_frame: TaaUniform,
}
impl Post {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon post"),
source: wgpu::ShaderSource::Wgsl(include_str!("../world_post.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon post bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let float = wgpu::TextureSampleType::Float { filterable: true };
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon post"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = |entry: &str, layout: &wgpu::PipelineLayout, format| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon post"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
// The OpenGL backend's GLSL translator does not support textureLoad
// from depth textures: on devices running through OpenGL (such as older
// Intel GPUs on Linux), TAA cannot compile its pipeline.
let taa_resources = {
let taa_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon TAA bindings"),
entries: &[
texture(0, float),
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
texture(2, float),
texture(3, wgpu::TextureSampleType::Depth),
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let taa_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon TAA"),
bind_group_layouts: &[Some(&taa_layout)],
..Default::default()
});
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let taa_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon TAA"),
layout: Some(&taa_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_post"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_taa"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: HISTORY_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => {
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon TAA"),
size: std::mem::size_of::<TaaUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Some(TaaResources {
layout: taa_layout,
pipeline: taa_pipeline,
uniform,
})
}
Some(e) => {
log::warn!("Horizon renderer: temporal anti-aliasing unavailable ({e})");
None
}
}
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Horizon post"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
copy: pipeline("fs_copy", &pipeline_layout, format),
fxaa: pipeline("fs_fxaa", &pipeline_layout, format),
layout,
sampler,
format,
scale: 1.0,
antialias: false,
taa: false,
upscale: 1.0,
taa_on: false,
target: None,
taa_resources,
taa_frame: 0,
taa_previous: None,
taa_this_frame: bytemuck::Zeroable::zeroed(),
}
}
/// Whether TAA is supported on this GPU/backend.
pub fn supports_taa(&self) -> bool {
self.taa_resources.is_some()
}
/// The world's rendered size and the size it is shown at (TAA's history).
fn sizes(&self, window: [u32; 2]) -> ([u32; 2], [u32; 2]) {
let output = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
let upscale = if self.taa_on { self.upscale } else { 1.0 };
(
output.map(|v| ((v as f32 / upscale).round() as u32).max(1)),
output,
)
}
/// TAA's sub-pixel jitter this frame, as a clip-space matrix to multiply into the
/// world's projection (identity while `on` is false); `view_proj` is the unjittered
/// projection, relative to `camera`. Call it before `prepare`, once per frame.
pub fn jitter(&mut self, window: [u32; 2], on: bool, view_proj: Mat4, camera: DVec3) -> Mat4 {
self.taa_on = on && self.taa && self.taa_resources.is_some();
if !self.taa_on {
self.taa_previous = None;
return Mat4::IDENTITY;
}
self.taa_frame = self.taa_frame.wrapping_add(1);
let (size, _) = self.sizes(window);
// Halton (2, 3) over 8 frames per output pixel (FSR's count), in rendered
// pixels around the centre.
let phases = (8.0 * self.upscale * self.upscale).ceil() as u32;
let i = self.taa_frame % phases + 1;
let offset = Vec2::new(halton(i, 2), halton(i, 3)) - 0.5;
let jitter = offset * 2.0 / Vec2::new(size[0] as f32, size[1] as f32);
let current = view_proj.as_dmat4();
let (reproject, reset) = match self.taa_previous {
Some((previous, previous_camera)) => (
reprojection(previous, previous_camera, current, camera),
0.0,
),
None => (DMat4::IDENTITY, 1.0),
};
self.taa_previous = Some((current, camera));
self.taa_this_frame = TaaUniform {
reproject: reproject.as_mat4().to_cols_array_2d(),
jitter: [jitter.x, jitter.y, reset, 0.0],
};
Mat4::from_translation(jitter.extend(0.0))
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
pub fn prepare(
&mut self,
device: &wgpu::Device,
window: [u32; 2],
always: bool,
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
if self.scale == 1.0 && !self.antialias && !self.taa_on && !always {
self.target = None;
return None;
}
let (size, output) = self.sizes(window);
let history_size = self.taa_on.then_some(output);
if self
.target
.as_ref()
.is_none_or(|t| t.size != size || t.taa.as_ref().map(|h| h.size) != history_size)
{
let make_tex = |label, size: [u32; 2], format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let sampled =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = make_tex("Scaled world", size, self.format, sampled)
.create_view(&Default::default());
// TEXTURE_BINDING: occlusion culling and TAA read it.
let depth = make_tex(
"Scaled world depth",
size,
wgpu::TextureFormat::Depth32Float,
sampled,
)
.create_view(&Default::default());
let group = self.bind(device, &color);
let taa = history_size.and_then(|output| {
let res = self.taa_resources.as_ref()?;
let history = [0, 1].map(|_| {
make_tex("TAA history", output, HISTORY_FORMAT, sampled)
.create_view(&Default::default())
});
let resolve = [0, 1].map(|i| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon TAA"),
layout: &res.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&color),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&history[1 - i]),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&depth),
},
wgpu::BindGroupEntry {
binding: 4,
resource: res.uniform.as_entire_binding(),
},
],
})
});
let show = [0, 1].map(|i| self.bind(device, &history[i]));
Some(History {
size: output,
history,
resolve,
show,
})
});
// New textures: no history yet.
self.taa_this_frame.jitter[2] = 1.0;
self.target = Some(WorldTarget {
size,
color,
depth,
group,
taa,
});
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// The size of the world's own target, when it has one.
pub fn size(&self) -> Option<[u32; 2]> {
self.target.as_ref().map(|t| t.size)
}
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
})
}
/// Draw the scaled world onto `screen`, through TAA when this frame's `jitter` had it on.
pub fn finish(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
) {
let Some(target) = &self.target else { return };
let Some(taa) = target.taa.as_ref().filter(|_| self.taa_previous.is_some()) else {
self.finish_with(encoder, screen, &target.group);
return;
};
let Some(res) = &self.taa_resources else {
self.finish_with(encoder, screen, &target.group);
return;
};
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&res.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
self.finish_with(encoder, screen, &taa.show[i]);
}
/// Draw what `group` binds (`bind`) onto `screen`.
pub fn finish_with(
&self,
encoder: &mut wgpu::CommandEncoder,
screen: &wgpu::TextureView,
group: &wgpu::BindGroup,
) {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: screen,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon post"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(if self.antialias {
&self.fxaa
} else {
&self.copy
});
pass.set_bind_group(0, group, &[]);
pass.draw(0..3, 0..1);
}
}
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
}
/// The radical inverse of `i` in `base`: a low-discrepancy sequence in 0..1.
fn halton(mut i: u32, base: u32) -> f32 {
let (mut f, mut r) = (1.0, 0.0);
while i > 0 {
f /= base as f32;
r += f * (i % base) as f32;
i /= base;
}
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reprojection_finds_where_a_block_was_last_frame() {
let projection = DMat4::perspective_rh(1.2, 16.0 / 9.0, 1024.0, 0.05);
let previous = projection * DMat4::from_rotation_y(0.3);
let current = projection * DMat4::from_rotation_y(0.35);
let (previous_camera, camera) = (DVec3::new(10.0, 64.0, 5.0), DVec3::new(10.5, 64.2, 4.0));
let block = DVec3::new(14.0, 66.0, -8.0);
let clip = |m: DMat4, eye: DVec3| m * (block - eye).extend(1.0);
let now = clip(current, camera);
let then = reprojection(previous, previous_camera, current, camera) * (now / now.w);
let expected = clip(previous, previous_camera);
assert!((then.truncate() / then.w - expected.truncate() / expected.w).length() < 1e-9);
}
#[test]
fn jitter_stays_inside_the_pixel() {
for i in 1..=8 {
let (x, y) = (halton(i, 2), halton(i, 3));
assert!((0.0..1.0).contains(&x) && (0.0..1.0).contains(&y));
}
assert_eq!(
[halton(1, 2), halton(2, 2), halton(3, 2)],
[0.5, 0.25, 0.75]
);
}
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance =
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
#[test]
fn world_post_pipelines_compile_or_fall_back() {
let Some((device, _)) = test_device() else {
return;
};
let post = Post::new(&device, wgpu::TextureFormat::Rgba8Unorm);
// Copy and FXAA pipelines always compile; TAA compiles if supported by backend.
let _ = post.supports_taa();
}
}
-116
View File
@@ -1,116 +0,0 @@
//! The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer,
//! the HUD, menus and settings. Gameplay (horizon-client) builds on it; nothing here reaches gameplay.
#![forbid(unsafe_code)]
pub mod assets;
pub mod atlas_sources;
pub mod audio;
pub mod auth;
pub mod bidi;
pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_render;
pub mod border_render;
pub mod breaking;
pub mod brewing;
pub mod camera;
pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod clouds;
pub mod collisions;
pub mod command_block;
pub mod containers;
pub mod crash;
pub mod datapack;
pub mod dynamic_light;
pub mod effects;
pub mod environment;
pub mod extras;
pub mod fast_hash;
pub mod fog;
pub mod font;
pub mod gfx;
pub mod graphics;
pub mod graphics_style;
pub mod graphics_ui;
pub mod hud;
pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod level_loading;
pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_screen;
pub mod packs;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod settings;
pub mod shader_pack_list;
pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
pub mod tab_list;
pub mod telemetry;
pub mod testgen;
pub mod title_edition;
pub mod trims;
pub mod ui;
pub mod vault;
pub mod weather;
pub mod widgets;
pub mod world;
pub mod worldborder;
pub mod zip;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
pub use horizon_client_core::bossbar;
pub use horizon_client_core::component;
pub use horizon_client_core::fastutil_order;
pub use horizon_client_core::gamerules;
pub use horizon_client_core::lang;
pub use horizon_client_core::nbt_path;
pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_client_input::clipboard;
pub use horizon_client_input::gamepad;
pub use horizon_client_input::input;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
@@ -1,122 +0,0 @@
//! Vanilla's `context_int_provider` and `context_float_provider` values (furnace
//! fuel times, brewing times), evaluated from the data pack's JSON.
use crate::items::{ItemStack, Items};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// Evaluator for `context_int_provider` / `context_float_provider` values.
pub struct Providers {
ints: HashMap<String, Value>,
floats: HashMap<String, Value>,
}
fn load_dir(dir: &Path, prefix: &str, out: &mut HashMap<String, Value>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let p = e.path();
let name = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned();
if p.is_dir() {
load_dir(&p, &format!("{prefix}{name}/"), out);
} else if let Ok(bytes) = fs::read(&p)
&& let Ok(v) = serde_json::from_slice(&bytes)
{
out.insert(format!("minecraft:{prefix}{name}"), v);
}
}
}
impl Providers {
pub fn load(root: &Path) -> Self {
let mut ints = HashMap::new();
let mut floats = HashMap::new();
load_dir(
&root.join("data/minecraft/context_int_provider"),
"",
&mut ints,
);
load_dir(
&root.join("data/minecraft/context_float_provider"),
"",
&mut floats,
);
Self { ints, floats }
}
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
if r == 0.0 {
0.0
} else {
(f("left") / r).trunc()
}
}
"minecraft:mul" => f("left") * f("right"),
"minecraft:add" | "minecraft:sum" => f("left") + f("right"),
"minecraft:sub" => f("left") - f("right"),
"minecraft:constant" => f("value"),
"minecraft:conditional" => {
// The only condition in the pinned data: block/fast_cooking.
let condition = o.get("condition").and_then(Value::as_str)
== Some("minecraft:block/fast_cooking");
if condition && fast {
f("on_true")
} else {
f("on_false")
}
}
_ => 0.0,
}
}
_ => 0.0,
}
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
pub fn empty() -> Self {
Self {
ints: HashMap::new(),
floats: HashMap::new(),
}
}
pub fn burn_time(&self, items: &Items, stack: &ItemStack, fast: bool) -> i32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
File diff suppressed because it is too large. Load diff
@@ -1,272 +0,0 @@
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
//! its window (`place`).
use winit::dpi::PhysicalSize;
/// Players at most: P1 and fifteen more.
pub const MAX_PLAYERS: usize = 16;
/// A player's view: its window (an index into the windows), its corner there and its size.
pub type View = (usize, [u32; 2], PhysicalSize<u32>);
/// Rows of five to sixteen players sharing a window, widest first (columns on a tall window): every
/// view stays near a screen's shape, where a full grid would leave a long thin one.
const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[3, 2],
&[3, 3],
&[3, 2, 2],
&[3, 3, 2],
&[3, 3, 3],
&[4, 3, 3],
&[4, 4, 3],
&[4, 4, 4],
&[4, 3, 3, 3],
&[4, 4, 3, 3],
&[4, 4, 4, 3],
&[4, 4, 4, 4],
];
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
let mut out: Vec<usize> = Vec::with_capacity(n);
for &i in order {
if i < n && !out.contains(&i) {
out.push(i);
}
}
out.extend((0..n).filter(|i| !order.contains(i)));
out
}
/// `place` with the players sharing a window in `order` (`ordered`): the first left or on top.
/// The views are still by player.
pub fn place_ordered(
windows: &[usize],
order: &[usize],
sizes: &[PhysicalSize<u32>],
two: u8,
three: u8,
) -> Vec<View> {
let order = ordered(order, windows.len());
let in_order: Vec<usize> = order.iter().map(|&i| windows[i]).collect();
let placed = place(&in_order, sizes, two, three);
let mut views = placed.clone();
for (k, &i) in order.iter().enumerate() {
views[i] = placed[k];
}
views
}
/// Each player's view, given the window each is in and the windows' sizes. Players in one window
/// share it in the order they come: two take halves (`two`: 0 side by side when wider than
/// tall, 1 side by side, 2 top and bottom); three (`three`: 0 the first wide on top and two below,
/// 1 the first tall on the left and two on the right, 2 three columns); four its quarters; five
/// to sixteen in balanced rows (`ROWS`).
pub fn place(windows: &[usize], sizes: &[PhysicalSize<u32>], two: u8, three: u8) -> Vec<View> {
windows
.iter()
.enumerate()
.map(|(i, &window)| {
let here = windows.iter().filter(|&&w| w == window).count();
let slot = windows[..i].iter().filter(|&&w| w == window).count() as u32;
let size = sizes
.get(window)
.copied()
.unwrap_or(PhysicalSize::new(1, 1));
let (w, h) = (size.width, size.height);
let side_by_side = match two {
1 => true,
2 => false,
_ => w >= h,
};
// The view's cell as fractions of the window: (x0, x1, y0, y1) in halves or thirds.
let cell = |x0: u32, x1: u32, y0: u32, y1: u32, of: u32| {
[w * x0 / of, w * x1 / of, h * y0 / of, h * y1 / of]
};
let [x0, x1, y0, y1] = match (here, slot) {
(1, _) => cell(0, 1, 0, 1, 1),
(2, s) if side_by_side => cell(s, s + 1, 0, 2, 2),
(2, s) => cell(0, 2, s, s + 1, 2),
(3, s) => match (three, s) {
(2, s) => cell(s, s + 1, 0, 3, 3),
(1, 0) => cell(0, 1, 0, 2, 2),
(1, s) => cell(1, 2, s - 1, s, 2),
(_, 0) => cell(0, 2, 0, 1, 2),
(_, s) => cell(s - 1, s, 1, 2, 2),
},
(4, s) => cell(s % 2, s % 2 + 1, s / 2, s / 2 + 1, 2),
(n, s) => rows(n, s, w, h),
};
(
window,
[x0, y0],
PhysicalSize::new((x1 - x0).max(1), (y1 - y0).max(1)),
)
})
.collect()
}
/// Five to sixteen players: `slot`'s cell `[x0, x1, y0, y1]` in balanced rows (`ROWS`), or
/// in columns when the window is taller than wide.
fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
let counts = ROWS[n.clamp(5, MAX_PLAYERS) - 5];
let bands = counts.len() as u32;
let (mut band, mut k) = (0, slot);
while band + 1 < bands && k >= counts[band as usize] {
k -= counts[band as usize];
band += 1;
}
let across = counts[band as usize];
// Along: the long side, divided between a band's views; down: the bands.
let (along, down) = if w >= h { (w, h) } else { (h, w) };
let (a0, a1) = (along * k / across, along * (k + 1) / across);
let (d0, d1) = (down * band / bands, down * (band + 1) / bands);
if w >= h {
[a0, a1, d0, d1]
} else {
[d0, d1, a0, a1]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
PhysicalSize::new(w, h)
}
#[test]
fn players_on_one_screen_share_it() {
let wide = [size(1920, 1080)];
assert_eq!(
place(&[0, 0], &wide, 0, 0)[1],
(0, [960, 0], size(960, 1080))
);
assert_eq!(
place(&[0, 0], &wide, 2, 0)[1],
(0, [0, 540], size(1920, 540))
);
// Auto on a tall screen: top and bottom.
assert_eq!(place(&[0, 0], &[size(1080, 1920)], 0, 0)[1].1, [0, 960]);
assert_eq!(
place(&[0; 4], &wide, 0, 0)[3],
(0, [960, 540], size(960, 540))
);
}
#[test]
fn desk_and_couch() {
// P1 alone at the desk; P2 and P3 share the TV.
let screens = [size(3024, 1964), size(3840, 2160)];
let views = place(&[0, 1, 1], &screens, 0, 0);
assert_eq!(views[0], (0, [0, 0], size(3024, 1964)));
assert_eq!(views[1], (1, [0, 0], size(1920, 2160)));
assert_eq!(views[2], (1, [1920, 0], size(1920, 2160)));
}
#[test]
fn three_on_a_screen() {
let tv = [size(1920, 1080)];
// One wide on top, two below.
let top = place(&[0; 3], &tv, 0, 0);
assert_eq!(top[0], (0, [0, 0], size(1920, 540)));
assert_eq!(top[2], (0, [960, 540], size(960, 540)));
// One tall on the left, two stacked on the right.
let left = place(&[0; 3], &tv, 0, 1);
assert_eq!(left[0], (0, [0, 0], size(960, 1080)));
assert_eq!(left[2], (0, [960, 540], size(960, 540)));
// Three columns.
assert_eq!(
place(&[0; 3], &tv, 0, 2)[2],
(0, [1280, 0], size(640, 1080))
);
}
#[test]
fn two_windows_two_players_each() {
// P1 and P2 at the desk, P3 and P4 on the TV: halves in each window.
let screens = [size(3024, 1964), size(3840, 2160)];
let views = place(&[0, 0, 1, 1], &screens, 0, 0);
assert_eq!(views[1], (0, [1512, 0], size(1512, 1964)));
assert_eq!(views[2], (1, [0, 0], size(1920, 2160)));
assert_eq!(views[3], (1, [1920, 0], size(1920, 2160)));
}
#[test]
fn five_to_eight_in_rows_of_three_and_two() {
let tv = [size(1920, 1080)];
let eight = place(&[0; 8], &tv, 0, 0);
assert_eq!(eight[0], (0, [0, 0], size(640, 360)));
assert_eq!(eight[5], (0, [1280, 360], size(640, 360)));
assert_eq!(eight[7], (0, [960, 720], size(960, 360)));
let seven = place(&[0; 7], &tv, 0, 0);
assert_eq!(seven[3], (0, [0, 360], size(960, 360)));
// Every count tiles the window exactly: no gap, no overlap.
for n in 5..=MAX_PLAYERS {
for screen in [size(1920, 1080), size(1080, 1920), size(1711, 997)] {
let views = place(&vec![0; n], &[screen], 0, 0);
for (i, (_, [x, y], size)) in views.iter().enumerate() {
assert!(x + size.width <= screen.width && y + size.height <= screen.height);
for (_, [ox, oy], other) in &views[..i] {
assert!(
x + size.width <= *ox
|| ox + other.width <= *x
|| y + size.height <= *oy
|| oy + other.height <= *y,
"overlapping views for {n} in {screen:?}"
);
}
}
let area: u64 = views
.iter()
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
.sum();
assert_eq!(
area,
u64::from(screen.width) * u64::from(screen.height),
"{n} in {screen:?}"
);
}
}
// A tall window: columns.
let tall = place(&[0; 5], &[size(1080, 1920)], 0, 0);
assert_eq!(tall[0], (0, [0, 0], size(540, 640)));
assert_eq!(tall[4], (0, [540, 960], size(540, 960)));
}
#[test]
fn sixteen_players_form_a_four_by_four_grid() {
let views = place(&[0; 16], &[size(3840, 2160)], 0, 0);
for (i, view) in views.iter().enumerate() {
assert_eq!(
*view,
(
0,
[(i as u32 % 4) * 960, (i as u32 / 4) * 540],
size(960, 540)
)
);
}
}
#[test]
fn an_order_names_each_player_once_and_the_others_come_last() {
assert_eq!(ordered(&[], 3), [0, 1, 2]);
assert_eq!(ordered(&[2, 0], 3), [2, 0, 1]);
// Gone players and repeats are dropped.
assert_eq!(ordered(&[5, 1, 1], 3), [1, 0, 2]);
}
#[test]
fn players_sharing_a_window_fill_it_in_their_order() {
let wide = [size(2000, 1000)];
// P2 first: P2 on the left, P1 on the right.
let views = place_ordered(&[0, 0], &[1, 0], &wide, 0, 0);
assert_eq!(views[1].1, [0, 0]);
assert_eq!(views[0].1, [1000, 0]);
// P3 first of three: the wide view on top is P3's.
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
assert_eq!(views[2].2, size(2000, 500));
}
}
@@ -1,345 +0,0 @@
//! Decoded block sprites kept on disk between launches (M-11): reading and
//! decoding ~1300 small PNGs is most of `Sprites::load`. One file per resource
//! root, valid only while the `textures` trees still have the same paths,
//! sizes and modification times and the format version is the same; anything
//! else (missing, corrupt, stale, unwritable) means the normal decoding path.
use crate::mipmap::Strategy;
use std::{
collections::HashMap,
fs,
hash::{DefaultHasher, Hash, Hasher},
path::{Path, PathBuf},
};
const MAGIC: &[u8; 4] = b"HSPR";
/// Bump when `Decoded` or the decoding in `Sprites::decode` changes.
const VERSION: u32 = 1;
const FILE: &str = "horizon-sprites.cache";
/// What decoding one texture gives, before it takes a layer.
#[derive(Clone)]
pub(crate) struct Decoded {
pub side: u32,
pub frames: Vec<Vec<u8>>,
/// The `.mcmeta` `animation` value, as text.
pub animation: Option<String>,
pub strategy: Strategy,
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub opaque: bool,
pub translucent: bool,
}
pub(crate) struct SpriteCache {
path: PathBuf,
fingerprint: u64,
entries: HashMap<String, Decoded>,
dirty: bool,
}
/// Paths, sizes and modification times of every file under `assets/*/textures`,
/// summed so the directory order does not matter.
fn fingerprint(root: &Path) -> Option<u64> {
fn walk(dir: &Path, base: &Path, sum: &mut u64) -> Option<()> {
for entry in fs::read_dir(dir).ok()? {
let entry = entry.ok()?;
let meta = entry.metadata().ok()?;
if meta.is_dir() {
walk(&entry.path(), base, sum)?;
continue;
}
let mut hasher = DefaultHasher::new();
entry.path().strip_prefix(base).ok()?.hash(&mut hasher);
meta.len().hash(&mut hasher);
meta.modified().ok()?.hash(&mut hasher);
*sum = sum.wrapping_add(hasher.finish());
}
Some(())
}
let mut sum = 0;
for namespace in fs::read_dir(root.join("assets")).ok()? {
let textures = namespace.ok()?.path().join("textures");
if textures.is_dir() {
walk(&textures, root, &mut sum)?;
}
}
Some(sum)
}
impl SpriteCache {
/// The cache of `root`: its stored entries when still valid, else empty.
/// `None` when the textures cannot be fingerprinted (no caching then).
pub fn open(root: &Path) -> Option<Self> {
let fingerprint = fingerprint(root)?;
let path = root.join(FILE);
let entries = fs::read(&path)
.ok()
.and_then(|bytes| parse(&bytes, fingerprint))
.unwrap_or_default();
Some(Self {
path,
fingerprint,
entries,
dirty: false,
})
}
pub fn get(&self, name: &str) -> Option<&Decoded> {
self.entries.get(name)
}
pub fn insert(&mut self, name: &str, decoded: Decoded) {
self.entries.insert(name.to_owned(), decoded);
self.dirty = true;
}
/// Write new entries; a failure only costs the next launch the decoding.
pub fn save(&mut self) {
if !self.dirty {
return;
}
self.dirty = false;
let temporary = self
.path
.with_extension(format!("{}.tmp", std::process::id()));
if fs::write(&temporary, serialize(self.fingerprint, &self.entries)).is_ok()
&& fs::rename(&temporary, &self.path).is_err()
{
let _ = fs::remove_file(&temporary);
}
}
}
fn serialize(fingerprint: u64, entries: &HashMap<String, Decoded>) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.extend_from_slice(&VERSION.to_le_bytes());
out.extend_from_slice(&fingerprint.to_le_bytes());
out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
for (name, d) in entries {
put_bytes(&mut out, name.as_bytes());
out.extend_from_slice(&d.side.to_le_bytes());
out.extend_from_slice(&(d.frames.len() as u32).to_le_bytes());
for frame in &d.frames {
put_bytes(&mut out, frame);
}
put_bytes(&mut out, d.animation.as_deref().unwrap_or("").as_bytes());
out.push(u8::from(d.animation.is_some()));
out.push(d.strategy as u8);
out.extend_from_slice(&d.alpha_cutoff_bias.to_bits().to_le_bytes());
out.push(
u8::from(d.has_transparent) | u8::from(d.opaque) << 1 | u8::from(d.translucent) << 2,
);
}
out
}
fn put_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
out.extend_from_slice(bytes);
}
struct Reader<'a>(&'a [u8]);
impl<'a> Reader<'a> {
fn take(&mut self, n: usize) -> Option<&'a [u8]> {
let (head, tail) = self.0.split_at_checked(n)?;
self.0 = tail;
Some(head)
}
fn u32(&mut self) -> Option<u32> {
Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?))
}
fn bytes(&mut self) -> Option<&'a [u8]> {
let n = self.u32()? as usize;
self.take(n)
}
}
fn parse(bytes: &[u8], fingerprint: u64) -> Option<HashMap<String, Decoded>> {
let mut r = Reader(bytes);
if r.take(4)? != MAGIC || r.u32()? != VERSION {
return None;
}
if u64::from_le_bytes(r.take(8)?.try_into().ok()?) != fingerprint {
return None;
}
let count = r.u32()?;
let mut entries = HashMap::new();
for _ in 0..count {
let name = String::from_utf8(r.bytes()?.to_vec()).ok()?;
let side = r.u32()?;
let frame_count = r.u32()?;
let mut frames = Vec::new();
for _ in 0..frame_count {
frames.push(r.bytes()?.to_vec());
}
let animation = String::from_utf8(r.bytes()?.to_vec()).ok()?;
let has_animation = r.take(1)?[0] != 0;
let strategy = match r.take(1)?[0] {
0 => Strategy::Auto,
1 => Strategy::Mean,
2 => Strategy::Cutout,
3 => Strategy::StrictCutout,
4 => Strategy::DarkCutout,
_ => return None,
};
let alpha_cutoff_bias = f32::from_bits(r.u32()?);
let flags = r.take(1)?[0];
let frame_len = (side as usize).checked_mul(side as usize)?.checked_mul(4)?;
if frames.is_empty() || frames.iter().any(|f| f.len() != frame_len) {
return None;
}
entries.insert(
name,
Decoded {
side,
frames,
animation: has_animation.then_some(animation),
strategy,
alpha_cutoff_bias,
has_transparent: flags & 1 != 0,
opaque: flags & 2 != 0,
translucent: flags & 4 != 0,
},
);
}
r.0.is_empty().then_some(entries)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(blue: u8, animation: Option<&str>) -> Decoded {
Decoded {
side: 2,
frames: vec![vec![blue; 16], vec![blue + 1; 16]],
animation: animation.map(str::to_owned),
strategy: Strategy::Cutout,
alpha_cutoff_bias: 0.25,
has_transparent: true,
opaque: false,
translucent: true,
}
}
fn scratch(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-sprite-cache-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("assets/minecraft/textures/block")).unwrap();
dir
}
#[test]
fn round_trips() {
let mut entries = HashMap::new();
entries.insert("a:b".to_owned(), sample(1, Some("{\"frametime\":2}")));
entries.insert("a:c".to_owned(), sample(9, None));
let back = parse(&serialize(7, &entries), 7).unwrap();
for (name, d) in &entries {
let e = &back[name];
assert_eq!(
(d.side, &d.frames, &d.animation),
(e.side, &e.frames, &e.animation)
);
assert_eq!(d.alpha_cutoff_bias, e.alpha_cutoff_bias);
assert_eq!(
(d.has_transparent, d.opaque, d.translucent, d.strategy as u8),
(e.has_transparent, e.opaque, e.translucent, e.strategy as u8)
);
}
}
#[test]
fn wrong_fingerprint_truncation_and_garbage_are_misses() {
let mut entries = HashMap::new();
entries.insert("a:b".to_owned(), sample(1, None));
let bytes = serialize(7, &entries);
assert!(parse(&bytes, 8).is_none());
for cut in 0..bytes.len() {
assert!(parse(&bytes[..cut], 7).is_none(), "cut {cut}");
}
assert!(parse(b"garbage", 7).is_none());
let mut extra = bytes.clone();
extra.push(0);
assert!(parse(&extra, 7).is_none());
}
#[test]
fn changed_texture_invalidates_and_corrupt_file_is_ignored() {
let root = scratch("invalidate");
let png = root.join("assets/minecraft/textures/block/x.png");
fs::write(&png, b"one").unwrap();
let mut cache = SpriteCache::open(&root).unwrap();
cache.insert("minecraft:block/x", sample(1, None));
cache.save();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_some()
);
// Same length, new content and time: still stale.
std::thread::sleep(std::time::Duration::from_millis(30));
fs::write(&png, b"two").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
// A new file also invalidates.
let mut cache = SpriteCache::open(&root).unwrap();
cache.insert("minecraft:block/x", sample(1, None));
cache.save();
fs::write(root.join("assets/minecraft/textures/block/y.png"), b"y").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
// Corrupt cache file: empty, no error.
fs::write(root.join(FILE), b"HSPR junk").unwrap();
assert!(
SpriteCache::open(&root)
.unwrap()
.get("minecraft:block/x")
.is_none()
);
let _ = fs::remove_dir_all(&root);
}
fn write_png(path: &Path, rgba: [u8; 4]) {
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = rgba.iter().copied().cycle().take(16 * 16 * 4).collect();
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
}
fn sprite_pixels(root: &Path) -> Vec<u8> {
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
sprites.insert(root, "minecraft:block/x").unwrap();
sprites.save_disk_cache();
sprites.finish();
sprites.pixels
}
#[test]
fn altered_png_is_not_served_stale_and_cache_matches_decoding() {
let root = scratch("stale");
let png = root.join("assets/minecraft/textures/block/x.png");
write_png(&png, [255, 0, 0, 255]);
let decoded = sprite_pixels(&root);
assert!(root.join(FILE).exists());
assert_eq!(
sprite_pixels(&root),
decoded,
"cache hit differs from decoding"
);
std::thread::sleep(std::time::Duration::from_millis(30));
write_png(&png, [0, 255, 0, 255]);
let fresh = sprite_pixels(&root);
assert_eq!(&fresh[..4], &[0, 255, 0, 255]);
// Corrupt and deleted caches fall back to decoding.
fs::write(root.join(FILE), b"HSPR\x01\0\0\0 junk").unwrap();
assert_eq!(sprite_pixels(&root), fresh);
fs::remove_file(root.join(FILE)).unwrap();
assert_eq!(sprite_pixels(&root), fresh);
let _ = fs::remove_dir_all(&root);
}
}
@@ -1,133 +0,0 @@
// Horizon's world post pass: the world, rendered at the resolution scale,
// copied (bilinear) or anti-aliased with FXAA onto the screen before the
// hand and HUD. Only used when a non-vanilla setting asks for it.
@group(0) @binding(0) var world: texture_2d<f32>;
@group(0) @binding(1) var world_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex fn vs_post(@builtin(vertex_index) i: u32) -> PostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: PostOut;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
@fragment fn fs_copy(in: PostOut) -> @location(0) vec4<f32> {
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return textureSampleLevel(world, world_sampler, in.uv, 0.0);
}
fn luma(c: vec3<f32>) -> f32 {
return dot(c, vec3(0.299, 0.587, 0.114));
}
// FXAA 3.11 (console quality): blend along the local edge direction.
@fragment fn fs_fxaa(in: PostOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(world));
let centre = textureSampleLevel(world, world_sampler, in.uv, 0.0);
let rgb_m = centre.rgb;
let nw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, -0.5) * texel, 0.0).rgb);
let ne = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, -0.5) * texel, 0.0).rgb);
let sw = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(-0.5, 0.5) * texel, 0.0).rgb);
let se = luma(textureSampleLevel(world, world_sampler, in.uv + vec2(0.5, 0.5) * texel, 0.0).rgb);
let m = luma(rgb_m);
let lo = min(m, min(min(nw, ne), min(sw, se)));
let hi = max(m, max(max(nw, ne), max(sw, se)));
if hi - lo < max(0.0312, hi * 0.125) {
return centre;
}
var dir = vec2(-((nw + ne) - (sw + se)), (nw + sw) - (ne + se));
let reduce = max((nw + ne + sw + se) * 0.25 * 0.125, 1.0 / 128.0);
let scale = 1.0 / (min(abs(dir.x), abs(dir.y)) + reduce);
dir = clamp(dir * scale, vec2(-8.0), vec2(8.0)) * texel;
let a = 0.5 * (textureSampleLevel(world, world_sampler, in.uv + dir * (1.0 / 3.0 - 0.5), 0.0).rgb
+ textureSampleLevel(world, world_sampler, in.uv + dir * (2.0 / 3.0 - 0.5), 0.0).rgb);
let b = a * 0.5 + 0.25 * (textureSampleLevel(world, world_sampler, in.uv - dir * 0.5, 0.0).rgb
+ textureSampleLevel(world, world_sampler, in.uv + dir * 0.5, 0.0).rgb);
let lb = luma(b);
if lb < lo || lb > hi {
return vec4(a, centre.a);
}
return vec4(b, centre.a);
}
// TAA (`world_post.rs`): blend this frame into the history, reprojected from the
// depth buffer through the camera's motion and clamped to this frame's 3x3
// neighbourhood so that what moved or appeared does not ghost.
struct Taa {
reproject: mat4x4<f32>,
// xy: this frame's jitter in clip space; z: 1 when the history is unusable.
jitter: vec4<f32>,
};
@group(0) @binding(2) var history: texture_2d<f32>;
@group(0) @binding(3) var depth: texture_depth_2d;
@group(0) @binding(4) var<uniform> taa: Taa;
// Catmull-Rom over the history in 5 bilinear taps: sharper than one under motion.
fn sample_history(uv: vec2<f32>) -> vec3<f32> {
let size = vec2<f32>(textureDimensions(history));
let pos = uv * size;
let centre = floor(pos - 0.5) + 0.5;
let f = pos - centre;
let w0 = f * (-0.5 + f * (1.0 - 0.5 * f));
let w1 = 1.0 + f * f * (-2.5 + 1.5 * f);
let w2 = f * (0.5 + f * (2.0 - 1.5 * f));
let w3 = f * f * (-0.5 + 0.5 * f);
let w12 = w1 + w2;
let t0 = (centre - 1.0) / size;
let t3 = (centre + 2.0) / size;
let t12 = (centre + w2 / w12) / size;
let c = textureSampleLevel(history, world_sampler, vec2(t12.x, t0.y), 0.0).rgb * (w12.x * w0.y)
+ textureSampleLevel(history, world_sampler, vec2(t0.x, t12.y), 0.0).rgb * (w0.x * w12.y)
+ textureSampleLevel(history, world_sampler, t12, 0.0).rgb * (w12.x * w12.y)
+ textureSampleLevel(history, world_sampler, vec2(t3.x, t12.y), 0.0).rgb * (w3.x * w12.y)
+ textureSampleLevel(history, world_sampler, vec2(t12.x, t3.y), 0.0).rgb * (w12.x * w3.y);
let weight = w12.x * w0.y + w0.x * w12.y + w12.x * w12.y + w3.x * w12.y + w12.x * w3.y;
return max(c / weight, vec3(0.0));
}
// It runs at the history's size, which upscaling makes larger than the world's.
// The history is unjittered: each output pixel takes the world pixel whose
// jittered sample lands nearest its centre, weighted by how near it lands.
@fragment fn fs_taa(in: PostOut) -> @location(0) vec4<f32> {
let size = vec2<f32>(textureDimensions(world));
// The jitter moved the scene by `jitter` in clip space: where this pixel's
// point was drawn, in world pixels.
let drawn = (in.uv + taa.jitter.xy * vec2(0.5, -0.5)) * size;
let last = vec2<i32>(size) - 1;
let p = clamp(vec2<i32>(floor(drawn)), vec2(0), last);
let centre = textureLoad(world, p, 0);
var lo = centre.rgb;
var hi = centre.rgb;
// Reversed depth: the nearest of the 3x3 is the largest, and its motion keeps
// the edges of what is in front from trailing.
var near = 0.0;
for (var y = -1; y <= 1; y++) {
for (var x = -1; x <= 1; x++) {
let q = clamp(p + vec2(x, y), vec2(0), last);
let c = textureLoad(world, q, 0).rgb;
lo = min(lo, c);
hi = max(hi, c);
near = max(near, textureLoad(depth, q, 0));
}
}
let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
let previous = taa.reproject * vec4(ndc, near, 1.0);
let uv = vec2(0.5, -0.5) * previous.xy / previous.w + 0.5;
if taa.jitter.z > 0.0 || any(uv != clamp(uv, vec2(0.0), vec2(1.0))) {
return vec4(centre.rgb, centre.a);
}
let past = clamp(sample_history(uv), lo, hi);
// How far the sample landed from this pixel's centre, in output pixels: a
// Gaussian of it scales the new frame's share, so that frames whose sample
// fell far away (most of them when upscaling) barely move the pixel.
let off = (drawn - (vec2<f32>(p) + 0.5)) * vec2<f32>(textureDimensions(history)) / size;
let share = 0.1 * exp(-2.0 * dot(off, off));
// The alpha carries the emissive mark through to HDR output (hdr.wgsl).
return vec4(mix(past, centre.rgb, share), centre.a);
}
+6 -10
View File
@@ -10,10 +10,7 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -44,6 +41,9 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
@@ -54,6 +54,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
@@ -61,15 +62,12 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# DirectX 12, where Vulkan has no adapter or device, or by choice (`graphicsApi`, graphics.rs).
# Shaders compile with FXC (d3dcompiler_47.dll, part of Windows), or DXC when dxcompiler.dll is
# on the PATH.
@@ -88,9 +86,9 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
audio = ["dep:cpal"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
@@ -106,7 +104,5 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -11,57 +11,5 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"horizon.options.chat_delay": "Chat Delay",
"horizon.options.lightbar.health": "Health",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointer",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "This feature is not yet available in Horizon.",
"horizon.options.search": "Search settings...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader Packs",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
"horizon.pages.open.horizon_graphics": "Horizon Graphics...",
"horizon.pages.open.controller_binds": "Controller Binds...",
"horizon.pages.open.horizon_settings": "Horizon Settings...",
"horizon.mods.empty": "No mods: put .hmod files in the horizon-mods folder",
"horizon.mods.stopped": " (Stopped)",
"horizon.mods.dev": " (Dev)",
"horizon.mods.settings": "Settings",
"horizon.mods.open_folder": "Open Mod Folder",
"horizon.shader_packs.empty": "No shader packs: put them in the shaderpacks folder",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
}
@@ -11,57 +11,5 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"horizon.options.chat_delay": "Задержка чата",
"horizon.options.lightbar.health": "Здоровье",
"horizon.options.lightbar.dimension": "Измерение",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Указатель",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Эта возможность в Horizon пока недоступна.",
"horizon.options.search": "Поиск настроек...",
"horizon.pages.mods": "Моды",
"horizon.pages.shader_packs": "Шейдерпаки",
"horizon.pages.controller": "Настройки контроллера",
"horizon.pages.controller_binds": "Назначения кнопок контроллера",
"horizon.pages.extras": "Дополнения Horizon",
"horizon.pages.settings": "Настройки Horizon",
"horizon.pages.agent": "ИИ-агент",
"horizon.pages.settings.tooltip": "Собственные настройки Horizon: графика, контроллеры, дополнения и моды.",
"horizon.pages.graphics.tooltip": "Графика без лишнего: у каждой настройки написано, чего она стоит.",
"horizon.pages.graphics_advanced.tooltip": "Все графические настройки, включая собственные настройки Horizon, и их цена.",
"horizon.pages.controller.tooltip": "Контроллеры: кнопки, стики, вибрация и навигация по меню.",
"horizon.pages.extras.tooltip": "Дополнительные возможности, все выключены по умолчанию: игра остаётся ванильной, пока вы не включите что-то.",
"horizon.pages.agent.tooltip": "Пусть играет ваш ИИ-агент: horizon-ctl и MCP-клиенты на этом компьютере.",
"horizon.pages.shader_packs.tooltip": "Шейдерпаки из папки shaderpacks: собственные паки Horizon и паки в формате shaders/. Одновременно работает один.",
"horizon.pages.mods.tooltip": "Моды для Horizon из папки horizon-mods. Каждый работает в песочнице и выключен, пока вы его не включите.",
"horizon.pages.open.graphics": "Графика...",
"horizon.pages.open.graphics_advanced": "Продвинутая графика...",
"horizon.pages.open.shader_packs": "Шейдерпаки...",
"horizon.pages.open.controller": "Настройки контроллера...",
"horizon.pages.open.extras": "Дополнения...",
"horizon.pages.open.mods": "Моды...",
"horizon.pages.open.agent": "ИИ-агент...",
"horizon.pages.open.horizon_graphics": "Графика Horizon...",
"horizon.pages.open.controller_binds": "Назначения кнопок...",
"horizon.pages.open.horizon_settings": "Настройки Horizon...",
"horizon.mods.empty": "Модов нет: положите файлы .hmod в папку horizon-mods",
"horizon.mods.stopped": " (остановлен)",
"horizon.mods.dev": " (разработка)",
"horizon.mods.settings": "Настройки",
"horizon.mods.open_folder": "Открыть папку модов",
"horizon.shader_packs.empty": "Шейдерпаков нет: положите их в папку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.agent.copy_details": "Скопировать данные подключения",
"horizon.multiplayer.join": "Подключиться",
"horizon.multiplayer.cancel_reconnecting": "Отменить переподключение",
"horizon.multiplayer.join_friend": "Играть с другом",
"horizon.multiplayer.invite_code": "Код приглашения",
"horizon.multiplayer.relay": "Ретранслятор (необязательно)"
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
}
@@ -1,15 +0,0 @@
{
"horizon.options.controller": "Геймпад",
"horizon.options.vibration": "Вібрація",
"horizon.options.adaptive_triggers": "Адаптивні тригери",
"horizon.options.lightbar": "Світлова панель",
"horizon.options.look_sensitivity": "Чутливість огляду",
"horizon.options.invert_x": "Інвертувати огляд по X",
"horizon.options.invert_y": "Інвертувати огляд по Y",
"horizon.options.sneak_button": "Кнопка присідання",
"horizon.options.move_deadzone": "Мертва зона руху",
"horizon.options.look_deadzone": "Мертва зона огляду",
"horizon.options.button_glyphs": "Значки кнопок",
"horizon.options.menu_navigation": "Навігація в меню",
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
}
@@ -2,7 +2,7 @@
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../../horizon-items/src/blocks/decode.rs"]
#[path = "../src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
@@ -161,7 +161,6 @@ pub fn main() -> Result<()> {
"sound {} {:08x} {:08x}",
match sound {
ArrowSound::HitGround => "hit",
ArrowSound::CrossbowHit => "crossbow_hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
@@ -369,7 +369,6 @@ pub fn main() -> Result<()> {
banner: &|_| layers.clone(),
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
@@ -87,11 +87,7 @@ pub fn main() {
}
"tick" => {
out.push_str("tick");
for (p, _) in entities
.tickers(|p| states.get(&p).copied().unwrap_or(0))
.iter()
.copied()
{
for (p, _) in entities.tickers(|p| states.get(&p).copied().unwrap_or(0)) {
out.push_str(&format!(" {},{},{}", p.0, p.1, p.2));
}
out.push('\n');
@@ -302,7 +302,7 @@ fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
match name {
"tick" => state.tick([0.0; 3]),
"tick" => state.tick(),
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
"reset" => state.reset_bob(),
@@ -43,7 +43,6 @@ pub fn registries(r: &Value, permission: u8) -> Registries {
reg.timelines = strings(&r["timelines"]);
reg.dimensions = strings(&r["dimensions"]);
reg.players = strings(&r["players"]);
reg.selected_entities = strings(&r["selected_entities"]);
reg.teams = strings(&r["teams"]);
reg.objectives = strings(&r["objectives"]);
reg.tracked = strings(&r["tracked"]);
@@ -1,74 +0,0 @@
//! Double chests for tools/test_chest_pair_parity.py: reads one JSON case per
//! line (see VanillaChestPairOracle), a chest's block state at the origin and
//! another block state at the offset "at", and prints what
//! `horizon_client::containers::ChestHalf` pairs them into, as the game's
//! menus and hoppers do: "double" with the right half's x,z then the left
//! half's, or "single".
//!
//! usage: chest_pair_fixture INPUT OUTPUT
use horizon_client::containers::{ChestHalf, is_chest_block};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
/// `minecraft:chest[facing=north,type=left]` as its name and properties.
fn parse(state: &str) -> Option<(&str, BTreeMap<&str, &str>)> {
let (name, props) = match state.split_once('[') {
Some((name, rest)) => (name, rest.strip_suffix(']')?),
None => (state, ""),
};
let props = props
.split(',')
.filter(|p| !p.is_empty())
.map(|p| p.split_once('='))
.collect::<Option<_>>()?;
Some((name, props))
}
fn half<'a>(name: &'a str, props: &BTreeMap<&str, &'a str>) -> Option<ChestHalf<'a>> {
Some(ChestHalf {
block: name,
kind: props.get("type")?,
facing: props.get("facing")?,
})
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let (name, props) = parse(case["state"].as_str()?)?;
let (other_name, other_props) = parse(case["other"].as_str()?)?;
let at = (
i32::try_from(case["at"][0].as_i64()?).ok()?,
i32::try_from(case["at"][1].as_i64()?).ok()?,
);
if !is_chest_block(name) {
return None;
}
let own = half(name, &props)?;
let paired = own.connected_step() == Some(at)
&& half(other_name, &other_props).is_some_and(|other| own.pairs_with(&other));
Some(if !paired {
"single".to_owned()
} else if own.kind == "right" {
format!("double 0,0 {},{}", at.0, at.1)
} else {
format!("double {},{} 0,0", at.0, at.1)
})
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: chest_pair_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -178,7 +178,6 @@ pub fn main() -> horizon_client::Result<()> {
reg.timelines = strings(&r["timelines"]);
reg.dimensions = strings(&r["dimensions"]);
reg.players = strings(&r["players"]);
reg.selected_entities = strings(&r["selected_entities"]);
reg.teams = strings(&r["teams"]);
reg.objectives = strings(&r["objectives"]);
reg.tracked = strings(&r["tracked"]);
@@ -1,98 +0,0 @@
//! Container menu titles for tools/test_container_title_parity.py: reads one
//! JSON case per line (see VanillaContainerTitleOracle) and prints the title
//! the server opens the container's menu with, as canonical NBT. A single
//! block entity is placed as the game places it (`block_entity_item::place`,
//! whose `CustomName` the game keeps in `World::block_names`), then named by
//! `containers::display_name`; a double chest's halves by
//! `containers::double_chest_name`.
//!
//! usage: container_title_fixture INPUT OUTPUT
use horizon_client::block_entity_item::place;
use horizon_client::block_entity_nbt::canonical;
use horizon_client::containers::{default_name_key, display_name, double_chest_name};
use horizon_client::save::text;
use horizon_world::nbt::{Tag, parse_snbt};
use serde_json::Value;
use std::fs;
/// ParityData.canon of a text component: sorted keys.
fn canon(tag: &Tag, out: &mut String) {
match tag {
Tag::Compound(map) => {
let mut keys: Vec<_> = map.iter().map(|(k, v)| (k.to_string_lossy(), v)).collect();
keys.sort_by(|a, b| a.0.cmp(&b.0));
out.push('{');
for (i, (k, v)) in keys.into_iter().enumerate() {
if i > 0 {
out.push(',');
}
out.push_str(&k);
out.push(':');
canon(v, out);
}
out.push('}');
}
Tag::List(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
canon(item, out);
}
out.push(']');
}
other => out.push_str(&horizon_client::item_data::canon_tag(other)),
}
}
fn snbt(value: &Value) -> Option<Tag> {
parse_snbt(value.as_str()?).ok().map(|(tag, _)| tag)
}
/// The custom name the game keeps for a block entity saved as `value`.
fn custom_name(value: &Value) -> Option<Option<String>> {
let saved = canonical(&snbt(value)?)?;
Some(text(saved.get("CustomName")))
}
fn case(line: &str) -> Option<String> {
let case: Value = serde_json::from_str(line).ok()?;
let title = if let Some(first) = case.get("first") {
let first = custom_name(first)?;
let second = custom_name(&case["second"])?;
double_chest_name(first.as_deref(), second.as_deref())
} else {
let saved = canonical(&snbt(&case["nbt"])?)?;
// BlockItem.updateBlockEntityComponents, as Game::apply_placed_item runs it.
let placed = match case.get("patch") {
Some(p) => match snbt(p)? {
Tag::Compound(patch) => place(&saved, &patch, true),
_ => return None,
},
None => saved,
};
let key = default_name_key(case["block"].as_str()?)?;
display_name(text(placed.get("CustomName")).as_deref(), key)
};
let mut out = String::new();
canon(&title, &mut out);
Some(out)
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: container_title_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
particles::along_axis(&mut random, axis as usize, p, |p, v| {
lines.push(line(p, v))
});
}
@@ -150,7 +150,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
mob.extra.crouching = flag("isCrouching");
mob.extra.passenger = flag("isPassenger");
if flag("isHoldingBow") {
mob.extra.set_main_hand(Some("minecraft:bow".into()));
mob.extra.main_hand = Some("minecraft:bow".into());
}
}
Species::IronGolem => {
@@ -298,7 +298,7 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
}
}
Species::Witch if flag("isHoldingItem") => {
mob.extra.set_main_hand(Some("minecraft:potion".into()));
mob.extra.main_hand = Some("minecraft:potion".into());
}
_ => {}
}
@@ -386,7 +386,7 @@ pub fn main() -> horizon_client::Result<()> {
.cloned()
.unwrap_or_else(|| kind.model.clone());
let mut mob = Mob::new(7, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
mob.extra.set_main_hand(None);
mob.extra.main_hand = None;
let case = &v["case"];
let f = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
if baby {
@@ -24,8 +24,6 @@ fn stack(spec: &str) -> LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
components: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -134,7 +132,7 @@ pub fn main() -> horizon_client::Result<()> {
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
let index = match *slot {
"mainhand" => {
m.extra.set_main_hand(item);
m.extra.main_hand = item;
Some(gear::MAIN)
}
"offhand" => {
@@ -223,7 +223,6 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
..HealthHud::default()
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
@@ -24,11 +24,9 @@ mod breaking_fixture;
mod camera_probe;
mod cauldron_fixture;
mod chat_screen_fixture;
mod chest_pair_fixture;
mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod container_title_fixture;
mod creative_mining_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
@@ -88,12 +86,10 @@ mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
mod text_display_fixture;
mod text_display_render_fixture;
mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
@@ -119,11 +115,9 @@ probes! {
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
chest_pair_fixture,
cloud_fixture,
command_parse_fixture,
container_screen_probe,
container_title_fixture,
creative_search_fixture,
emitter_particles_probe,
enchanting_fixture,
@@ -180,14 +174,12 @@ probes! {
sound_event_fixture,
spawn_rules_fixture,
sprint_probe,
storage_menu_fixture,
subtitle_fixture,
tab_list_fixture,
team_rules_fixture,
toggle_keys_probe,
tooltip_display_fixture,
text_display_fixture,
text_display_render_fixture,
trading_fixture,
translucency_fixture,
weather_fixture,
@@ -74,14 +74,7 @@ pub fn main() -> Result<()> {
biomes: &biomes,
registry: assets.registry,
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
gateway_particles: &|_, _| 0,
thundering: false,
ultrawarm: false,
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
@@ -7,7 +7,6 @@
use horizon_client::{
containers::{Furnace, FurnaceKind},
crafting::Recipes,
furnace::FurnaceCooking,
item_data::{canon_stack, parse_stack},
items::Items,
menu::{Env, Input, Kind, Menu},
@@ -5,7 +5,7 @@
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
use horizon_client::{
assets::Assets,
beehive::{BeehiveExt, HiveHost},
beehive::HiveHost,
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, MIN_Y},
@@ -67,9 +67,8 @@ struct Scenario {
/// The player hits a mob for 1 at the start of a tick: (tick, mob index).
hurts: Vec<(i32, usize)>,
/// The player feeds an animal at the start of a tick (Animal.mobInteract):
/// (tick, mob index, None); the scenario only feeds each animal its food.
/// `interact` uses another item on a horse: (tick, mob index, Some(item)).
feeds: Vec<(i32, usize, Option<String>)>,
/// (tick, mob index); the scenario only feeds each animal its food.
feeds: Vec<(i32, usize)>,
hand: Option<String>,
mobs: Vec<MobSpec>,
}
@@ -109,14 +108,7 @@ fn parse(text: &str) -> Vec<Scenario> {
}
"heart" => s.hearts.push(f.iter().map(|x| (*x).to_owned()).collect()),
"hurt" => s.hurts.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"feed" => s
.feeds
.push((f[1].parse().unwrap(), f[2].parse().unwrap(), None)),
"interact" => s.feeds.push((
f[1].parse().unwrap(),
f[2].parse().unwrap(),
Some(f[3].to_owned()),
)),
"feed" => s.feeds.push((f[1].parse().unwrap(), f[2].parse().unwrap())),
"player" => {
s.player = Some([
f[1].parse().unwrap(),
@@ -575,13 +567,7 @@ fn print_heart(
/// The damage of a mob's direct hit on the player (others count as none).
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
target: Who::Player,
..
} => 10.0,
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
_ => 0.0,
}
}
@@ -592,15 +578,9 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::SonicBoom {
target: Who::Player,
..
} => true,
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius, _) => {
MobEvent::Explode(at, radius) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
@@ -733,15 +713,6 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
.map_or("-".into(), |p| format!("{},{},{}", p[0], p[1], p[2]));
let _ = write!(out, "\tcg {} {held} {opened}", m.extra.variant3);
}
if m.species() == Species::Wolf {
// NeutralMob anger left (the angry texture), -1 when calm.
let anger = if m.extra.anger > 0 {
i64::from(m.extra.anger)
} else {
-1
};
let _ = write!(out, "\twolf {anger}");
}
if let Some(b) = horizon_client::mobs::bee::get(m) {
let anger = if b.anger_end > 0 {
b.anger_end - now
@@ -1074,29 +1045,22 @@ pub fn main() -> horizon_client::Result<()> {
alive: true,
creative: s.creative,
sneaking: false,
swimming: false,
look: [0.0, 0.0, 1.0],
main_hand: s.hand.as_deref().map(Into::into),
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
sprinting: false,
riding: false,
hero: false,
effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1106,8 +1070,6 @@ pub fn main() -> horizon_client::Result<()> {
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m, s.creative, world.game_time);
}
// No player has a chest open in the scenarios.
let lid_openers = std::collections::HashMap::new();
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
@@ -1119,7 +1081,6 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
@@ -1129,7 +1090,6 @@ pub fn main() -> horizon_client::Result<()> {
others_grid: $grid,
others_index: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
monsters_burn: !(12542..23460).contains(&world.day_time.rem_euclid(24000)),
game_time: world.game_time,
day_time: world.day_time,
raining: s.rain,
@@ -1138,15 +1098,12 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
}
};
}
@@ -1226,22 +1183,10 @@ pub fn main() -> horizon_client::Result<()> {
}
events.append(&mut mobs[i].outbox);
}
for (tick, i, item) in &s.feeds {
let (tick, i) = (*tick, *i);
for &(tick, i) in &s.feeds {
if tick != t || i >= mobs.len() || mobs[i].removed {
continue;
}
// Horse.mobInteract's held-item branch (makeMad).
if let Some(item) = item {
horizon_client::mobs::equine::held_item(
&mut mobs[i],
item,
false,
item == "minecraft:golden_dandelion",
&mut events,
);
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
{
@@ -36,14 +36,11 @@ use horizon_client::{
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
spawn_box,
},
spawner::{
SpawnDataExt, Spawner, SpawnerAdded, SpawnerEvent, SpawnerExt, SpawnerLevel,
SpawnerSpawn,
},
spawner::{Spawner, SpawnerAdded, SpawnerEvent, SpawnerLevel, SpawnerSpawn},
species::{self, Baby},
trial_spawner::{
Omen, TRIAL_OMEN_TICKS_PER_LEVEL, TrialEvent, TrialPlayer, TrialSpawner,
TrialSpawnerExt, TrialSpawnerLevel, TrialState,
TrialSpawnerLevel, TrialState,
},
},
vault::{Vault, VaultEvent, VaultLevel, VaultPlayer, VaultState},
@@ -126,7 +126,6 @@ pub fn main() -> horizon_client::Result<()> {
.collect();
let mut screen = PackScreen::new(
&m,
Text::translate(c["title"].as_str().unwrap_or("resourcePack.title"), &[]),
int(&c["width"]),
int(&c["height"]),
c["inWorld"] == true,
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
muffled: true,
in_water: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
@@ -353,7 +353,6 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
@@ -1,37 +0,0 @@
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
//! block id per line and prints the menu type the server opens it with
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
//! sends it) and whether that menu's slots take a shulker box: the server
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
//! `lan::containers`).
//!
//! usage: storage_menu_fixture INPUT OUTPUT
use horizon_client::containers::block_menu_type;
use std::fs;
fn case(block: &str) -> Option<String> {
let menu = block_menu_type(block.trim())?;
let accepts = if menu == "minecraft:shulker_box" {
"refuses"
} else {
"accepts"
};
Some(format!("{menu} {accepts} shulker_box"))
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -1,70 +0,0 @@
//! A text display's text for differential tests against vanilla's
//! `TextDisplayRenderer.submitInner` (tools/test_text_display_render_parity.py):
//! decodes each case's entity data from vanilla's wire bytes, lays the text out
//! with `horizon_client::text_display` and prints the pose and every quad in
//! text units [x, y, z, ARGB], as VanillaTextDisplayRenderOracle does.
//!
//! usage: text_display_render_fixture RESOURCES INPUTS OUTPUT
use horizon_client::{
hud::{HudAtlas, HudBatch},
hud_layout::background_color,
text_display::{self, Lines, TextDisplayData},
};
use horizon_protocol::{ids, packets::play::Clientbound};
use serde_json::{Value, json};
use std::{error::Error, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = crate::args().collect();
let [_, resources, inputs, output] = args.as_slice() else {
return Err("usage: text_display_render_fixture RESOURCES INPUTS OUTPUT".into());
};
let atlas = HudAtlas::build(std::path::Path::new(resources))?;
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for line in std::fs::read_to_string(inputs)?.lines() {
let input: Value = serde_json::from_str(line)?;
let hex = input["wire"].as_str().unwrap_or_default();
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16))
.collect::<Result<_, _>>()?;
let Clientbound::SetEntityData(packet) =
Clientbound::decode(ids::play::clientbound::SET_ENTITY_DATA, &bytes)?
else {
return Err("not entity data".into());
};
let mut d = TextDisplayData::default();
d.apply(&packet.values);
let lines = Lines::split(&atlas.font, &d.text, d.line_width);
let mut batch = HudBatch::new([1, 1]);
let default_background = background_color(
input["chat_only"].as_bool().unwrap_or(false),
input["background_opacity"].as_f64().unwrap_or(0.0),
0.25,
);
text_display::draw(&mut batch, &atlas, &d, &lines, default_background);
let quads: Vec<Value> = batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| {
let corners: Vec<Value> = [0, 1, 2, 5]
.map(|i| {
let v = q[i];
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
let argb = (a << 24 | r << 16 | g << 8 | b) as i32;
json!([v.pos[0], v.pos[1], v.depth, argb])
})
.into();
corners.into()
})
.collect();
writeln!(
out,
"{}",
json!({"pose": lines.pose().to_cols_array(), "quads": quads})
)?;
}
Ok(())
}
@@ -1,58 +0,0 @@
//! M-11 gate: block assets loaded through the sprite disk cache are byte-identical
//! to the ones decoded from the PNGs. Deletes the cache file in ROOT, loads (decodes
//! and writes it), loads again (reads it) and compares the sprite arrays.
//! cargo run --profile fast -p horizon-client --example sprite_cache_parity -- ROOT
use horizon_client::assets::{Assets, RenderAssets};
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::Path,
time::Instant,
};
fn digest(render: &RenderAssets) -> u64 {
let mut h = DefaultHasher::new();
render.pixels.hash(&mut h);
(
render.sprite_size,
render.sprite_minimum_size,
render.texture_layers,
)
.hash(&mut h);
render.sprite_layers.hash(&mut h);
render.translucent_layers.hash(&mut h);
format!("{:?}", render.sprite_mips).hash(&mut h);
for animation in &render.animations {
animation.layer.hash(&mut h);
for frame in 0..animation.frame_count() {
animation.pixels(frame).hash(&mut h);
}
for tick in 0..400 {
format!("{:?}", animation.blend(tick)).hash(&mut h);
}
}
h.finish()
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("usage: sprite_cache_parity ROOT")?;
let root = Path::new(&root);
let assets = Assets::load(root)?;
let _ = std::fs::remove_file(root.join("horizon-sprites.cache"));
let start = Instant::now();
let cold = digest(&assets.reload(root)?);
let cold_ms = start.elapsed().as_secs_f64() * 1000.;
let size = std::fs::metadata(root.join("horizon-sprites.cache"))?.len();
let start = Instant::now();
let warm = digest(&assets.reload(root)?);
let warm_ms = start.elapsed().as_secs_f64() * 1000.;
println!(
"cold={cold:016x} ({cold_ms:.1} ms) warm={warm:016x} ({warm_ms:.1} ms) cache_bytes={size}"
);
if cold != warm {
return Err("cached sprites differ from decoded sprites".into());
}
println!("PARITY OK");
Ok(())
}
+23 -222
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
collections::{BTreeMap, HashMap},
path::Path,
};
@@ -437,15 +437,11 @@ pub struct EntityView {
/// mainhand, offhand, head, chest, legs, feet, body.
pub equipment: [Option<ItemView>; 7],
pub vehicle: Option<Box<EntityView>>,
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
pub looking_at: Option<Box<EntityView>>,
pub effects: Vec<(String, i32, i32)>,
/// Player: game mode name.
pub gamemode: Option<String>,
/// Lightning: entities it struck.
pub struck: Vec<EntityView>,
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
pub blocks_set_on_fire: i32,
pub fishing_open_water: Option<bool>,
pub variant: Option<String>,
/// The data components the entity reports (`Entity.get`): id and value.
@@ -462,7 +458,7 @@ fn range_f(v: &Value, x: f64) -> bool {
_ => false,
}
}
pub fn range_i(v: &Value, x: i64) -> bool {
fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
@@ -477,51 +473,6 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
_ => false,
}
}
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
/// over the potion's effects (`contains`, `count`, `size`).
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
if v.is_null() {
return true;
}
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
{
return false;
}
let c = &v["effects"];
if c.is_null() {
return true;
}
let effects: Vec<(String, i32, i32)> = potion
.and_then(crate::brewing::potion)
.map(|d| {
d.effects
.iter()
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
.collect()
})
.unwrap_or_default();
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
let test = |pred: &Value, e: &(String, i32, i32)| {
pred.as_object().is_some_and(|m| {
m.get(&e.0).is_some_and(|want| {
range_i(&want["amplifier"], i64::from(e.1))
&& range_i(&want["duration"], i64::from(e.2))
&& want["ambient"].as_bool().is_none_or(|b| !b)
&& want["visible"].as_bool().is_none_or(|b| b)
})
})
};
c["contains"]
.as_array()
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
&& c["count"].as_array().is_none_or(|l| {
l.iter().all(|entry| {
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
range_i(&entry["count"], n as i64)
})
})
&& range_i(&c["size"], effects.len() as i64)
}
/// `ItemPredicate`.
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
if v.is_null() {
@@ -540,11 +491,6 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
{
return false;
}
if let Some(d) = comps["minecraft:damage"].as_u64()
&& u64::from(it.damage) != d
{
return false;
}
let preds = &v["predicates"];
if let Some(list) = preds["minecraft:enchantments"].as_array() {
for e in list {
@@ -668,18 +614,6 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
})
})
}
/// `DistancePredicate.matches` between two points.
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
range_f(&v["x"], d[0].abs())
&& range_f(&v["y"], d[1].abs())
&& range_f(&v["z"], d[2].abs())
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&v["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -695,7 +629,21 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
"minecraft:stepping_on" => {
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" | "distance" => distance(p, e.position, origin),
"minecraft:distance" | "distance" => {
let d = [
e.position[0] - origin[0],
e.position[1] - origin[1],
e.position[2] - origin[2],
];
range_f(&p["x"], d[0].abs())
&& range_f(&p["y"], d[1].abs())
&& range_f(&p["z"], d[2].abs())
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&p["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
"minecraft:flags" | "flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
@@ -730,18 +678,13 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
})
})
}),
"minecraft:type_specific/player" => {
p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}) && (p["looking_at"].is_null()
|| e.looking_at
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}),
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
range_i(&p["blocks_set_on_fire"], 0)
&& (p["entity_struck"].is_null()
|| e.struck
.iter()
@@ -760,93 +703,6 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
/// The damage type id (`minecraft:arrow`).
pub kind: String,
/// `DamageSource.getDirectEntity` and `getEntity`.
pub direct: Option<EntityView>,
pub source: Option<EntityView>,
/// `DamageSource.isDirect`: the direct entity is the causing one.
pub is_direct: bool,
/// The damage before and after the shield took its share.
pub original: f32,
pub actual: f32,
pub blocked: bool,
}
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
return true;
}
let by = |key: &str, e: &Option<EntityView>| {
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
};
if !range_f(&v["dealt"], f64::from(d.original))
|| !range_f(&v["taken"], f64::from(d.actual))
|| !by("source_entity", &d.source)
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
{
return false;
}
damage_source(&v["type"], d, w, origin)
}
/// `DamageSourcePredicate`: the damage type's tags and the entities behind it.
pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if ty.is_null() {
return true;
}
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
});
let direct_ok = ty["direct_entity"].is_null()
|| d.direct
.as_ref()
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
let source_ok = ty["source_entity"].is_null()
|| d.source
.as_ref()
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
}
/// The entities a loot-style condition list may refer to.
pub struct Entities<'a> {
pub this: Option<&'a EntityView>,
@@ -877,22 +733,6 @@ pub fn item_entity(
&& item(&v["item"], stack, w)
}
/// `RecipeCraftedTrigger.TriggerInstance.matches`: each ingredient predicate
/// takes a different one of the stacks used, in order.
pub fn recipe_ingredients(v: &Value, used: &[ItemView], w: &dyn World) -> bool {
let Some(preds) = v.as_array() else {
return true;
};
let mut remaining: Vec<&ItemView> = used.iter().collect();
preds.iter().all(|p| {
let Some(i) = remaining.iter().position(|it| item(p, it, w)) else {
return false;
};
remaining.remove(i);
true
})
}
/// A `ContextAwarePredicate`: a list of loot conditions (or one condition).
pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
match v {
@@ -1355,47 +1195,8 @@ mod tests {
fn in_tag(&self, _: &str, tag: &str, id: &str) -> bool {
(tag == "minecraft:stone_tool_materials" && id == "minecraft:cobblestone")
|| (tag == "minecraft:sulfur_cube_archetype/explosive" && id == "minecraft:tnt")
|| (tag == "minecraft:is_projectile"
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
/// projectile, kills it for the advancement; a sword or an arrow does not.
#[test]
fn killing_blow_needs_the_fireball() {
let blow = serde_json::json!({
"direct_entity": {"minecraft:entity_type": "minecraft:fireball"},
"tags": [{"expected": true, "id": "#minecraft:is_projectile"}]
});
let entity = |kind: &str| EntityView {
kind: kind.into(),
..Default::default()
};
let player = entity("minecraft:player");
let fireball = DamageView {
kind: "minecraft:fireball".into(),
direct: Some(entity("minecraft:fireball")),
source: Some(player.clone()),
..Default::default()
};
assert!(damage_source(&blow, &fireball, &Flat, [0.0; 3]));
let sword = DamageView {
kind: "minecraft:player_attack".into(),
direct: Some(player.clone()),
source: Some(player.clone()),
is_direct: true,
..Default::default()
};
assert!(!damage_source(&blow, &sword, &Flat, [0.0; 3]));
let arrow = DamageView {
kind: "minecraft:arrow".into(),
direct: Some(entity("minecraft:arrow")),
source: Some(player),
..Default::default()
};
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
}
#[test]
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
+28 -551
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
world::World,
};
use horizon_core::{
@@ -36,9 +36,8 @@ enum Animate {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea); and
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
Leaves(Option<(T, f32)>, Option<u32>, bool),
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,9 +48,8 @@ enum Animate {
CandleCake,
BrewingStand,
EnderChest,
/// FallingBlock with its dust colour; sand also has the ambient desert sound
/// (`SandBlock.animateTick`).
Falling(u32, bool),
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
@@ -75,32 +73,6 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// A powered lever: its facing and the direction it is attached toward.
Lever {
facing: [i32; 3],
connected: [i32; 3],
},
/// A powered repeater.
Repeater {
facing: [i32; 3],
delay: i32,
},
/// A free-hanging stalactite tip that is not waterlogged.
PointedDripstone,
WitherRose,
EndGateway,
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
LightningRod(usize),
/// `WetSpongeBlock.animateTick`.
WetSponge,
FireflyBush,
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
OpenEyeblossom,
PaleHangingMoss,
/// `DryVegetationBlock`: a dead bush.
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -115,7 +87,6 @@ fn step(facing: &str) -> [i32; 3] {
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let lit = prop("lit").as_deref() == Some("true");
let powered = prop("powered").as_deref() == Some("true");
let facing = || step(&prop("facing").unwrap_or_default());
match name {
"torch" => Animate::Torch(T::Flame),
@@ -136,17 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
}
"spruce_leaves" => Animate::Leaves(None, None, false),
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -155,17 +126,16 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"mycelium" => Animate::Mycelium,
"brewing_stand" => Animate::BrewingStand,
"ender_chest" => Animate::EnderChest,
"sand" => Animate::Falling(0xDBD3A0, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
"dragon_egg" => Animate::Falling(0x000000, false),
_ if name.ends_with("_concrete_powder") => Animate::Falling(
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
false,
),
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
@@ -197,47 +167,6 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"lever" if powered => Animate::Lever {
facing: facing(),
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
connected: match prop("face").as_deref() {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing(),
},
},
"repeater" if powered => Animate::Repeater {
facing: facing(),
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
},
"pointed_dripstone"
if prop("vertical_direction").as_deref() == Some("down")
&& prop("thickness").as_deref() == Some("tip")
&& prop("waterlogged").as_deref() != Some("true") =>
{
Animate::PointedDripstone
}
"wither_rose" => Animate::WitherRose,
"end_gateway" => Animate::EndGateway,
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
let [dx, dy, _] = facing();
Animate::LightningRod(if dx != 0 {
0
} else if dy != 0 {
1
} else {
2
})
}
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -304,20 +233,10 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
desert_dry_vegetation_sounds: Vec<bool>,
terracotta: Vec<bool>,
pale_oak_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
dripstone: Vec<u8>,
}
impl AnimateTables {
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
@@ -383,36 +302,12 @@ impl AnimateTables {
}
}
}
let dripstone = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(0, |s| {
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
if name != "pointed_dripstone" {
0
} else if s.properties.get("vertical_direction").map(String::as_str)
== Some("down")
{
2
} else {
1
}
})
})
.collect();
Ok(Self {
kinds,
dripstone,
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"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
desert_dry_vegetation_sounds: by_state(
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
),
terracotta: by_state("minecraft:terracotta"),
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
burnable,
ambient,
})
@@ -438,22 +333,8 @@ pub struct AnimateContext<'a> {
pub registry: &'a BlockRegistry,
/// `Level.isRainingAt`.
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
/// `Level.isThundering`.
pub thundering: bool,
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
pub ultrawarm: bool,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
@@ -502,15 +383,6 @@ impl AnimateContext<'_> {
],
))
}
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
/// sky light less the sky darkening.
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
let light = self.world.light(p[0], p[1], p[2]);
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
}
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -524,10 +396,6 @@ impl AnimateContext<'_> {
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add(&ParticleOptions::simple(ty), p, v);
}
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
}
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
engine.sounds.push((name, p, volume, pitch));
}
@@ -566,7 +434,7 @@ fn do_animate_tick(
return;
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
block_animate_tick(tables, engine, ctx, state, pos);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
@@ -722,22 +590,6 @@ fn beehive_drip(
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
/// sand or terracotta two blocks deep.
fn desert_dry_vegetation_sounds(
tables: &AnimateTables,
ctx: &AnimateContext,
below: [i32; 3],
) -> bool {
let triggers = |p: [i32; 3]| {
tables
.desert_dry_vegetation_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -813,7 +665,6 @@ fn block_animate_tick(
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
player: [i32; 3],
) {
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
match tables.kind(state) {
@@ -920,7 +771,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling, fixed_tint, poplar) => {
Animate::Leaves(falling, fixed_tint) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -932,9 +783,6 @@ fn block_animate_tick(
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
if poplar {
leaves_ambient_sound(tables, engine, ctx, state, pos);
}
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
if let Some((ty, chance)) = falling
&& engine.random().next_float() < chance
@@ -1107,7 +955,7 @@ fn block_animate_tick(
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
add(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
@@ -1115,36 +963,6 @@ fn block_animate_tick(
}
}
}
Animate::WetSponge => {
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
// The sponge occludes, so only a sturdy neighbour face hides the drip.
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
let r = engine.random();
let p = match d {
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
[dx, _, 0] => {
let y = fy + r.next_double() * 0.8;
let z = fz + r.next_double();
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
}
[_, _, dz] => {
let y = fy + r.next_double() * 0.8;
let x = fx + r.next_double();
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
}
};
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
@@ -1203,198 +1021,12 @@ fn block_animate_tick(
}
}
}
Animate::FireflyBush => {
// FireflyBushBlock.animateTick: the idle sound on a quiet night
// under open sky, and fireflies in the dark within 5 blocks.
if next_int(engine.random(), 30) == 0
&& ctx.firefly_bush_sounds
&& (ctx.height_no_leaves)(pos) <= pos[1]
{
let name = "minecraft:block.firefly_bush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
if ctx.max_local_raw_brightness(pos) <= 13 {
let r = engine.random();
if r.next_double() <= 0.7 {
let p = [
fx + r.next_double() * 10.0 - 5.0,
fy + r.next_double() * 5.0,
fz + r.next_double() * 10.0 - 5.0,
];
add(engine, T::Firefly, p, [0.0; 3]);
}
}
}
Animate::OpenEyeblossom => {
// EyeblossomBlock.animateTick: pale moss below.
if next_int(engine.random(), 700) == 0 {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
if ctx
.registry
.state(below)
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
{
let name = "minecraft:block.eyeblossom.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::PaleHangingMoss => {
// HangingMossBlock.animateTick: under pale oak logs or leaves.
if next_int(engine.random(), 500) == 0 {
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
let pale_leaves = ctx
.registry
.state(above)
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
if pale_leaves
|| tables
.pale_oak_logs
.get(above.0 as usize)
.copied()
.unwrap_or(false)
{
let name = "minecraft:block.pale_hanging_moss.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
let below = [pos[0], pos[1] - 1, pos[2]];
let state = ctx.state(below);
let red_sand = ctx
.registry
.state(state)
.is_some_and(|s| s.name == "minecraft:red_sand");
let terracotta = tables
.terracotta
.get(state.0 as usize)
.copied()
.unwrap_or(false);
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
return;
}
if desert_dry_vegetation_sounds(tables, ctx, below) {
let name = "minecraft:block.deadbush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DryGrass => {
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
// played at the player.
if next_int(engine.random(), 200) == 0
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
{
let at = [
f64::from(player[0]) + 0.5,
f64::from(player[1]),
f64::from(player[2]) + 0.5,
];
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.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::Lever { facing, connected } => {
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
if engine.random().next_float() < 0.25 {
// LeverBlock.makeParticle with scale 0.5.
let at = |base: f64, i: usize| {
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
}
}
Animate::Repeater { facing, delay } => {
// RepeaterBlock.animateTick: dust at one of the torches.
let r = engine.random();
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
let mut offset = -5.0f32;
if r.next_boolean() {
offset = (delay * 2 - 1) as f32;
}
offset /= 16.0;
let xo = f64::from(offset * facing[0] as f32);
let zo = f64::from(offset * facing[2] as f32);
redstone(engine, [x + xo, y, z + zo]);
}
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
Animate::WitherRose => {
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
});
let offset = ctx.offset_of(state, pos);
let x = fx + center[0] + offset[0];
let z = fz + center[1] + offset[2];
for _ in 0..3 {
let r = engine.random();
if r.next_boolean() {
let p = [
x + r.next_double() / 5.0,
fy + (0.5 - r.next_double()),
z + r.next_double() / 5.0,
];
add(engine, T::Smoke, p, [0.0; 3]);
}
}
}
Animate::EndGateway => {
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
for _ in 0..(ctx.gateway_particles)(state, pos) {
let r = engine.random();
let mut p = [
fx + r.next_double(),
fy + r.next_double(),
fz + r.next_double(),
];
let mut v = [
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
];
let flip = next_int(r, 2) * 2 - 1;
if r.next_boolean() {
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
} else {
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
}
add(engine, T::Portal, p, v);
}
}
Animate::LightningRod(axis) => {
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
// topmost rod, more often as the game time within 200 ticks grows.
if ctx.thundering
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
&& pos[1]
== ctx
.world
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
- 1
{
let mut sparks = Vec::new();
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
sparks.push((p, v));
});
let options = ParticleOptions::simple(T::ElectricSpark);
for (p, v) in sparks {
engine.add(&options, p, v);
}
}
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -1413,7 +1045,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color, sand) => {
Animate::Falling(color) => {
if next_int(engine.random(), 16) == 0 {
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
// FallingBlock.isFree: air, fire, liquids or replaceable.
@@ -1430,14 +1062,6 @@ fn block_animate_tick(
);
}
}
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
if sand
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
&& next_int(engine.random(), 2100) == 0
&& sand_sound_nearby(tables, ctx, pos)
{
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
@@ -1555,7 +1179,7 @@ fn block_animate_tick(
}
Animate::BubbleColumn { drag } => {
if drag {
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1568,7 +1192,7 @@ fn block_animate_tick(
);
}
} else {
add_visible(
add(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1580,7 +1204,7 @@ fn block_animate_tick(
fy + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1658,153 +1282,6 @@ fn block_animate_tick(
}
}
}
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
/// one log and three leaves of the same kind around the block.
fn leaves_ambient_sound(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if next_int(engine.random(), 300) != 0 {
return;
}
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
return;
};
let (mut logs, mut leaves) = (0, 0);
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
if tables
.poplar_leaf_logs
.get(neighbor.0 as usize)
.copied()
.unwrap_or(false)
{
logs += 1;
}
if ctx
.registry
.state(neighbor)
.is_some_and(|s| &s.name == name)
{
leaves += 1;
}
if logs == 1 && leaves == 3 {
let p = pos.map(f64::from);
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
return;
}
}
}
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
/// fluid above the root of its stalactite.
fn dripstone_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
let roll = engine.random().next_float();
if roll > 0.12 {
return;
}
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
// stalactite and stops at the first block that is not pointed dripstone.
let mut root = None;
for step in 1..11 {
let p = [pos[0], pos[1] + step, pos[2]];
match tables
.dripstone
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(0)
{
0 => {
root = Some(p);
break;
}
2 => {}
_ => return,
}
}
let Some(root) = root else {
return;
};
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
// Mud above the root counts as a water source unless water evaporates here.
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
(Fluid::Water, true)
} else {
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
};
// canFillCauldron: only a water or lava source passes below a 2% roll.
if roll >= 0.02 && !(fluid != Fluid::None && source) {
return;
}
// getDripParticle: no fluid drips what the dimension's default says.
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
let ty = if lava {
T::DrippingDripstoneLava
} else {
T::DrippingDripstoneWater
};
let offset = ctx.offset_of(state, pos);
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
let p = [
f64::from(pos[0]) + 0.5 + offset[0],
f64::from(pos[1]) + 0.3125 - 0.0625,
f64::from(pos[2]) + 0.5 + offset[2],
];
add(engine, ty, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
-13
View File
@@ -130,8 +130,6 @@ pub enum ArrowSound {
Splash,
/// The arrow's `soundEvent` (`entity.arrow.hit`), for blocks and entities.
HitGround,
/// An arrow shot from a crossbow: `item.crossbow.hit`.
CrossbowHit,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
@@ -161,8 +159,6 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -212,7 +208,6 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
@@ -784,16 +779,8 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
// CrossbowItem.createProjectile: its arrows hit with the crossbow's sound.
let crossbow = self
.st
.fired_from_weapon
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:crossbow");
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else if crossbow {
ArrowSound::CrossbowHit
} else {
ArrowSound::HitGround
};
@@ -42,11 +42,11 @@ impl HeightmapState for StateFacts {
}
#[derive(Clone)]
pub struct Properties(Arc<Vec<StateFacts>>);
#[cfg(any(test, feature = "test-support"))]
#[cfg(test)]
impl Properties {
/// Heightmap facts for tests, per state: (air, has a fluid, blocks
/// motion, blocks motion but is not leaves). State 0 is minecraft:air.
pub fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
pub(crate) fn for_tests(states: &[(bool, bool, bool, bool)]) -> Self {
Self(Arc::new(
states
.iter()
@@ -196,7 +196,7 @@ impl Assets {
impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
let mut sprites = crate::sprites::Sprites::default();
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
@@ -217,10 +217,7 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
sprites.finish();
// `finish` grows the buffer by doubling: keep only the bytes in use.
sprites.pixels.shrink_to_fit();
Ok(Self {
sprite_size: sprites.size,
sprite_minimum_size: sprites.minimum_size,
@@ -237,81 +234,3 @@ impl RenderAssets {
})
}
}
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
@@ -3,8 +3,8 @@
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
//! against the resource tree; a later source replaces an earlier sprite of
//! the same id and a filter removes the matching ones.
use crate::packs::pattern::Pattern;
use horizon_assets::palette::apply_palette;
use horizon_client_core::pattern::Pattern;
use serde_json::Value;
use std::{
collections::BTreeMap,
@@ -187,14 +187,7 @@ pub fn list_textures(root: &Path) -> Vec<String> {
};
for e in entries.flatten() {
let p = e.path();
// `file_type` comes with the listing; `Path::is_dir` is a stat per
// entry, kept for links (which it follows).
let dir = match e.file_type() {
Ok(t) if t.is_symlink() => p.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => p.is_dir(),
};
if dir {
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
@@ -144,7 +144,6 @@ type Stream = cpal::Stream;
type Stream = std::convert::Infallible;
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
@@ -544,48 +543,15 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
)?;
Ok(stream)
}
/// Available audio output device names.
#[cfg(feature = "audio")]
pub fn output_devices() -> Vec<String> {
if output_disabled(|name| std::env::var_os(name)).is_some() {
return Vec::new();
}
let host = cpal::default_host();
let mut names = Vec::new();
if let Ok(devices) = host.output_devices() {
for device in devices {
if let Ok(name) = device.name()
&& !names.contains(&name)
{
names.push(name);
}
}
}
names
}
#[cfg(not(feature = "audio"))]
pub fn output_devices() -> Vec<String> {
Vec::new()
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_device(root, "")
}
pub fn with_device(root: &Path, device_name: &str) -> Self {
Self::with_output(root, true, device_name)
Self::with_output(root, true)
}
/// No output device: the dedicated server.
pub fn silent(root: &Path) -> Self {
Self::with_output(root, false, "")
Self::with_output(root, false)
}
/// The output device asked for (`soundDevice`), empty for the system's default; the
/// default plays when that one can't be opened, as in vanilla.
pub fn device(&self) -> &str {
&self.device_name
}
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
fn with_output(root: &Path, output: bool) -> Self {
let registry = SoundRegistry::load(root);
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
@@ -598,7 +564,7 @@ impl Audio {
}
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open_named(mixer.clone(), target_device)
Self::open(mixer.clone())
} else {
Err("no output on a dedicated server".into())
};
@@ -615,7 +581,6 @@ impl Audio {
engine.loaded = live;
Self {
_stream: stream,
device_name: target_device.to_owned(),
engine,
backend: MixerBackend {
mixer,
@@ -637,61 +602,10 @@ impl Audio {
}
}
#[cfg(not(feature = "audio"))]
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
#[cfg(feature = "audio")]
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
if !target.is_empty()
&& let Ok(devices) = host.output_devices()
{
for device in devices {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if name == target {
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => {
log::warn!(
"Horizon: could not open audio device '{target}': {e}; falling back to default"
);
}
}
break;
}
}
}
Self::open(mixer)
}
#[cfg(feature = "audio")]
pub fn set_device(&mut self, name: &str) -> Result<()> {
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
self.device_name = name.to_owned();
return Ok(());
}
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
self.device_name = name.to_owned();
self.backend.live = true;
self.engine.loaded = true;
Ok(())
}
#[cfg(not(feature = "audio"))]
pub fn set_device(&mut self, name: &str) -> Result<()> {
self.device_name = name.to_owned();
Ok(())
}
#[cfg(feature = "audio")]
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
@@ -805,6 +719,11 @@ impl Audio {
self.play(event, at, volume, pitch);
self.capture = capture;
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
}
/// The discovery document, fetched once per game (a failure is retried next time).
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
if let Some(d) = CACHE.lock().unwrap().clone() {
return Ok(d);
+53 -1
View File
@@ -3,10 +3,24 @@
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BannerLayer {
pub pattern: Arc<str>,
pub color: Arc<str>,
}
impl BannerLayer {
pub fn new(pattern: &str, color: &str) -> Self {
Self {
pattern: Arc::from(pattern),
color: Arc::from(color),
}
}
}
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
pub const MAX_LAYERS: usize = 6;
@@ -210,6 +224,44 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
layers(banner).len() >= MAX_LAYERS
}
/// The `banner_patterns` NBT list of layers.
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
Tag::List(
layers
.iter()
.map(|l| {
Tag::compound([
("pattern", Tag::String((&*l.pattern).into())),
("color", Tag::String((&*l.color).into())),
])
})
.collect(),
)
}
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
let Some(Tag::List(list)) = tag else {
return Vec::new();
};
list.iter()
.filter_map(|e| {
let text = |k: &str| match e.get(k) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let pattern = text("pattern")?;
let color = text("color")?;
Some(BannerLayer::new(
&if pattern.contains(':') {
pattern
} else {
format!("minecraft:{pattern}")
},
&color,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+36 -59
View File
@@ -3,7 +3,6 @@
//! the rain, nor through a blocked front, unless the hive is emptied in an
//! emergency), honey for every bee coming home with nectar. The host (the game
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
pub use crate::chunk::block_entity::{Beehive, Occupant};
use crate::mobs::{Mob, Species, bee};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::{Compound, Tag};
@@ -39,6 +38,20 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
"UUID",
];
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
/// `BeehiveBlockEntity.BeeReleaseStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
@@ -83,19 +96,10 @@ fn pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
}
}
/// The bee occupant's codec and constructors, over the data in `chunk::block_entity`.
pub trait OccupantExt: Sized {
impl Occupant {
/// `Occupant.of`: the bee's saved tag without the ignored tags, staying
/// 2400 ticks with nectar, else 600.
fn of(tag: Tag) -> Self;
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
fn create(ticks_in_hive: i32) -> Self;
fn has_nectar(&self) -> bool;
fn from_nbt(tag: &Tag) -> Option<Self>;
fn to_nbt(&self) -> Tag;
}
impl OccupantExt for Occupant {
fn of(mut tag: Tag) -> Self {
pub fn of(mut tag: Tag) -> Self {
if let Tag::Compound(c) = &mut tag {
strip_ignored(c);
}
@@ -106,7 +110,8 @@ impl OccupantExt for Occupant {
min_ticks_in_hive: if nectar { 2400 } else { 600 },
}
}
fn create(ticks_in_hive: i32) -> Self {
/// `Occupant.create`: a worldgen nest's bee, a bare bee tag.
pub fn create(ticks_in_hive: i32) -> Self {
Self {
entity_data: Tag::compound([("id", Tag::String("minecraft:bee".into()))]),
ticks_in_hive,
@@ -136,50 +141,13 @@ impl OccupantExt for Occupant {
}
}
/// `BeehiveBlockEntity`'s behaviour, over the data in `chunk::block_entity`.
pub trait BeehiveExt: Sized {
fn full(&self) -> bool;
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
/// and `flower_pos`.
fn from_nbt(tag: &Tag) -> Self;
/// `saveAdditional`.
fn to_nbt(&self) -> Vec<(&'static str, Tag)>;
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
/// the bee's flower, or now and then swaps its own for it. The host then
/// discards the bee.
fn add_occupant(
&mut self,
at: [i32; 3],
tag: Tag,
bee_flower: Option<[i32; 3]>,
host: &mut impl HiveHost,
) -> bool;
/// `serverTick`: the bees whose time is up come out, then the hive
/// hums now and then while it holds any. `honey` is the hive state's
/// honey level as the tick starts (`None` without the property);
/// `facing` its front.
fn server_tick(
&mut self,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
host: &mut impl HiveHost,
);
/// `releaseAllOccupants`: the ids of the bees let out.
fn release_all(
&mut self,
at: [i32; 3],
facing: [i32; 2],
honey: Option<i32>,
status: Release,
host: &mut impl HiveHost,
) -> Vec<u32>;
}
impl BeehiveExt for Beehive {
fn full(&self) -> bool {
impl Beehive {
pub fn full(&self) -> bool {
self.bees.len() == MAX_OCCUPANTS
}
fn from_nbt(tag: &Tag) -> Self {
/// `loadAdditional`: `bees` (all or none, as the list codec reads them)
/// and `flower_pos`.
pub fn from_nbt(tag: &Tag) -> Self {
let bees = match tag.get("bees") {
Some(Tag::List(list)) => list
.iter()
@@ -193,7 +161,8 @@ impl BeehiveExt for Beehive {
flower: pos(tag.get("flower_pos")),
}
}
fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
/// `saveAdditional`.
pub fn to_nbt(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![(
"bees",
Tag::List(self.bees.iter().map(Occupant::to_nbt).collect()),
@@ -203,7 +172,10 @@ impl BeehiveExt for Beehive {
}
out
}
fn add_occupant(
/// `addOccupant` (the bee's own tag, saved by the host): the hive keeps
/// the bee's flower, or now and then swaps its own for it. The host then
/// discards the bee.
pub fn add_occupant(
&mut self,
at: [i32; 3],
tag: Tag,
@@ -223,7 +195,11 @@ impl BeehiveExt for Beehive {
host.sound("block.beehive.enter", corner);
true
}
fn server_tick(
/// `serverTick`: the bees whose time is up come out, then the hive
/// hums now and then while it holds any. `honey` is the hive state's
/// honey level as the tick starts (`None` without the property);
/// `facing` its front.
pub fn server_tick(
&mut self,
at: [i32; 3],
facing: [i32; 2],
@@ -260,7 +236,8 @@ impl BeehiveExt for Beehive {
host.sound("block.beehive.work", hum);
}
}
fn release_all(
/// `releaseAllOccupants`: the ids of the bees let out.
pub fn release_all(
&mut self,
at: [i32; 3],
facing: [i32; 2],
-48
View File
@@ -450,54 +450,6 @@ mod tests {
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
/// The status a fake control answers with: the player is where it should
/// be, nothing is queued, and the world holds `chunks` resident chunks.
fn status(chunks: u64) -> String {
serde_json::json!({
"player": {"position": [0.0, 64.0, 0.0]},
"pending_chunks": 0,
"screen": "playing",
"render_distance": 12,
"chunks": chunks,
"generated_chunks": chunks,
"meshed_sections": 0,
"streaming": {},
})
.to_string()
}
#[test]
fn the_load_waits_for_the_chunks_a_teleport_starts() {
// Right after a teleport the game reports steady counts and nothing
// queued for over a second, before the integrated server has sent the
// first chunk: the quiet rule alone would call that loaded, and the
// scene would be measured over an empty view (#1749).
const LATE: usize = 30;
let (tx, rx) = mpsc::channel::<Request>();
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = std::sync::Arc::clone(&polls);
let server = thread::spawn(move || {
let mut seen = 0;
while let Ok(request) = rx.recv() {
if request.line == "status" {
let chunks = if seen < LATE { 0 } else { 400 };
seen += 1;
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
let _ = request.reply.send(status(chunks));
} else {
let _ = request.reply.send("{}".to_owned());
}
}
});
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
"the load finished before the chunks arrived"
);
drop(control);
let _ = server.join();
}
#[test]
fn yaw_matches_vanilla() {
@@ -146,7 +146,6 @@ const PAGES: Ty = Ty::Enum(&[
"shader-packs",
"graphics",
"graphics-advanced",
"graphics-hdr",
"extras",
"horizon",
"agent",
@@ -426,7 +425,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
Loaded 100 of 1041 files, more files were not shown because too many files have changed in this diff. Show more