From 1102fa42dfcca21bb248cdf6ead32ebf7424034f Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:06:05 +0200 Subject: [PATCH 001/968] macOS on Intel: x86_64 builds of the game and the launcher (#1161) release.yml and launcher.yml build x86_64-apple-darwin on the Mac mini's runner beside Apple Silicon: horizon-macos-x64.zip, and the launcher's horizon-launcher-macos-x64.zip and .dmg, signed and published with the rest, on the same unsigned path as arm64. Their checks and smoke tests run the x86_64 programs under Rosetta 2. A launcher picks its assets from one table (PLATFORMS) by OS and processor, and a test checks the workflows publish every asset in it. package.sh names the assets per architecture and refuses a binary built for the other one. The server installer, the website's Download button (Chromium tells Intel Macs apart; other browsers get an Intel Mac link) and the docs know the Intel build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/launcher.yml | 40 ++++++--- .github/workflows/release.yml | 39 ++++++--- README.md | 4 +- docs/hosting.md | 2 +- launcher/README.md | 4 +- launcher/src/backend.rs | 144 +++++++++++++++++++++++++++------ packaging/macos/package.sh | 28 ++++--- packaging/server/install.sh | 7 +- website/docs/hosting.md | 2 +- website/docs/index.md | 3 +- website/docs/install.md | 2 +- website/src/main.js | 28 ++++++- 12 files changed, 234 insertions(+), 69 deletions(-) diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml index 79ea852fbc..3de91429ed 100644 --- a/.github/workflows/launcher.yml +++ b/.github/workflows/launcher.yml @@ -8,7 +8,8 @@ name: launcher # feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump # commits. # Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases. -# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with +# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test +# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with # Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md); # unset (forks), they are signed ad hoc. # gh workflow run launcher --ref # dry run: builds everything, publishes nothing @@ -101,6 +102,13 @@ jobs: mac: true asset: horizon-launcher-macos-arm64.zip binary: horizon-launcher + mac_arch: arm64 + - name: macos-x64 + mac: true + target: x86_64-apple-darwin + asset: horizon-launcher-macos-x64.zip + binary: horizon-launcher + mac_arch: x64 - name: windows os: windows-latest asset: horizon-launcher-windows-x64.zip @@ -113,6 +121,8 @@ jobs: timeout-minutes: ${{ matrix.mac && 60 || 40 }} env: RUSTFLAGS: ${{ matrix.rustflags }} + # Where cargo puts the launcher: target/release, or target//release for another target. + BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }} # Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository # variable, not a secret. Unset, the launcher says sign-in is not configured. HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }} @@ -136,6 +146,8 @@ jobs: # parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch). clean: true - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 if: runner.environment == 'github-hosted' with: @@ -156,17 +168,17 @@ jobs: echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV" fi # Every test target (the library, the binary, tests/), on each system the launcher ships for. - - run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} - - run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} + - run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} + - run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"} - name: The version is the release's run: | - out=$(target/release/${{ matrix.binary }} --version) + out=$("$BIN/${{ matrix.binary }}" --version) echo "$out" expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}" [[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; } - name: Only system libraries run: | - file=target/release/${{ matrix.binary }} + file="$BIN/${{ matrix.binary }}" case "$RUNNER_OS" in Windows) python ../tools/check_imports.py "$file" ;; macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true) @@ -181,16 +193,24 @@ jobs: export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke" export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game" rm -rf "$HORIZON_LAUNCHER_HOME" - launcher="$PWD/target/release/${{ matrix.binary }}" + launcher="$PWD/$BIN/${{ matrix.binary }}" # GitHub's API rate-limits shared runner addresses now and then: try a few times. retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; } retry "$launcher" versions - retry "$launcher" install latest - "$launcher" versions --installed "$launcher" links --json "$launcher" menu-style classic grep -qx 'menuStyle:classic' "$HORIZON_HOME/horizon.txt" "$launcher" menu-style horizon + # A new platform's launcher can come out before any stable game release has its build (Intel Macs, #1161): + # then there is nothing to install yet. + stable_build='import json, sys; sys.exit(not any(r["available_here"] and r["channel"] == "stable" for r in json.load(sys.stdin)))' + if ! retry "$launcher" --json versions | ${{ runner.os == 'Windows' && 'python' || 'python3' }} -c "$stable_build"; then + echo "::warning::No stable game release has a build for ${{ matrix.name }} yet: install not tested" + rm -rf "$HORIZON_LAUNCHER_HOME" + exit 0 + fi + retry "$launcher" install latest + "$launcher" versions --installed clients=("$HORIZON_LAUNCHER_HOME"/versions/v*/horizon-client*) client="${clients[0]}" echo "installed client: $client" @@ -221,11 +241,11 @@ jobs: # Developer ID): packaging/macos. python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5 version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)} - DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out + DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }} rm -rf out/Horizon.app else mkdir -p dist - cp target/release/${{ matrix.binary }} dist/ + cp "$BIN/${{ matrix.binary }}" dist/ if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then (cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }}) else diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 354f7d4930..ce5ff4fd98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,7 +5,8 @@ name: release # ago (the oldest an hour ago, if merges never pause), or at once for a pull request labelled release-now. # RELEASES_PAUSED=1 (repository variable) stops them. # Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and -# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then +# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub): Apple Silicon, and +# Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161), 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 a beta (a pre-release) that promote.yml makes # stable after 6 hours. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with @@ -94,11 +95,20 @@ jobs: mac: true dir: horizon-macos-arm64 asset: horizon-macos-arm64.zip + # Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64 + # programs under Rosetta 2. + - name: macos-x64 + mac: true + target: x86_64-apple-darwin + dir: horizon-macos-x64 + asset: horizon-macos-x64.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 }} + # Where cargo puts the programs: target/release, or target//release for another target. + BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }} defaults: run: shell: bash @@ -113,6 +123,8 @@ jobs: # keeps its preparations outside the checkout. clean: true - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 if: runner.environment == 'github-hosted' with: @@ -121,11 +133,11 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev - name: Build the client, horizon-ctl, horizon-prepare and horizon-server - run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server + run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -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; } + test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; } done - name: The signing keychain, unlocked if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' @@ -140,15 +152,15 @@ jobs: 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" + --identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary" + codesign --verify --strict --verbose=2 "$BIN/$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 }}" + file="$BIN/$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; } @@ -159,7 +171,7 @@ jobs: - name: Every program starts run: | set -euo pipefail - bin=target/release + bin=$BIN "$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources' "$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null "$bin/horizon-prepare${{ matrix.exe }}" --version @@ -179,7 +191,7 @@ jobs: 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" + client="$PWD/$BIN/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 \ @@ -194,8 +206,9 @@ jobs: EOF # Published with the release: a beta becomes stable only with it (tools/release_channels.py). cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json" + # One smoke-test.json per release, the Apple Silicon build's; the Intel build must pass its own run too. - uses: actions/upload-artifact@v4 - if: runner.environment == 'self-hosted' + if: runner.environment == 'self-hosted' && !matrix.target with: name: smoke-test.json path: ${{ runner.temp }}/smoke-test.json @@ -204,7 +217,7 @@ jobs: - name: Package run: | set -euo pipefail - cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}" + cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}" cd dist rm -f "${{ matrix.asset }}" case "${{ matrix.asset }}" in @@ -288,7 +301,7 @@ jobs: 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 + dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz - uses: actions/upload-artifact@v4 with: name: signature @@ -341,8 +354,8 @@ jobs: # A beta (pre-release): promote.yml makes it stable and latest after 6 hours (docs/release-flow.md). gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \ --notes-file notes/body.md --prerelease --latest=false 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/SHA256SUMS "${signature[@]}" "${smoke[@]}" + dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip \ + dist/horizon-server-linux-x86_64.tar.gz dist/SHA256SUMS "${signature[@]}" "${smoke[@]}" - name: Dry run, the release that would be published if: needs.decide.outputs.dry_run == 'true' run: | diff --git a/README.md b/README.md index 72b6a34966..225f990653 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re ## 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/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. +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 (Intel Macs from the next release) or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself. -**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install). +**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 JDK, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or a Mac on macOS 11 or later. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install). diff --git a/docs/hosting.md b/docs/hosting.md index 963083b16b..a278b7b04c 100644 --- a/docs/hosting.md +++ b/docs/hosting.md @@ -41,7 +41,7 @@ plan are in [docs/server.md](https://github.com/ludovic111/horizon/blob/main/doc ## 1. Install -Linux (x86-64) and macOS (Apple Silicon): +Linux (x86-64) and macOS (Apple Silicon; Intel from the next release): ```sh curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh diff --git a/launcher/README.md b/launcher/README.md index 74054ea552..2858510ed8 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -115,8 +115,8 @@ set `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` to keep launcher updates with the packag Launcher releases are automatic (docs/release-flow.md in the repository): when main changes the launcher, `horizon-prepare` or `horizon-theme`, the `launcher` workflow builds Linux, macOS and Windows and publishes `launcher-vX.Y.Z` (the next patch, or the next minor after a pull request -labelled `feature`) with `horizon-launcher-linux-x86_64.tar.gz`, `horizon-launcher-macos-arm64.zip` -and `horizon-launcher-windows-x64.zip`, never marked latest. The version is built in from the tag +labelled `feature`) with `horizon-launcher-linux-x86_64.tar.gz`, `horizon-launcher-macos-arm64.zip`, +`horizon-launcher-macos-x64.zip` (Intel Macs) and `horizon-launcher-windows-x64.zip`, never marked latest. The version is built in from the tag (`HORIZON_LAUNCHER_VERSION`); `Cargo.toml`'s version is only what local builds report. ## Command line diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index bcd932536b..6485ce2157 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -39,31 +39,77 @@ pub struct Release { pub beta: bool, } -#[cfg(all(target_os = "linux", target_arch = "x86_64"))] -pub const ASSET: Option<&str> = Some("horizon-linux-x86_64.tar.gz"); -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] -pub const ASSET: Option<&str> = Some("horizon-macos-arm64.zip"); -#[cfg(all(target_os = "windows", target_arch = "x86_64"))] -pub const ASSET: Option<&str> = Some("horizon-windows-x64.zip"); -#[cfg(not(any( - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "windows", target_arch = "x86_64") -)))] -pub const ASSET: Option<&str> = None; +/// The release assets of each system Horizon ships for: (`std::env::consts::OS`, `ARCH`, the game, +/// the launcher). release.yml and launcher.yml publish exactly these (`platform_assets_are_published`). +pub const PLATFORMS: [(&str, &str, &str, &str); 4] = [ + ( + "linux", + "x86_64", + "horizon-linux-x86_64.tar.gz", + "horizon-launcher-linux-x86_64.tar.gz", + ), + ( + "macos", + "aarch64", + "horizon-macos-arm64.zip", + "horizon-launcher-macos-arm64.zip", + ), + // Intel Macs (#1161). + ( + "macos", + "x86_64", + "horizon-macos-x64.zip", + "horizon-launcher-macos-x64.zip", + ), + ( + "windows", + "x86_64", + "horizon-windows-x64.zip", + "horizon-launcher-windows-x64.zip", + ), +]; -#[cfg(all(target_os = "linux", target_arch = "x86_64"))] -pub const LAUNCHER_ASSET: Option<&str> = Some("horizon-launcher-linux-x86_64.tar.gz"); -#[cfg(all(target_os = "macos", target_arch = "aarch64"))] -pub const LAUNCHER_ASSET: Option<&str> = Some("horizon-launcher-macos-arm64.zip"); -#[cfg(all(target_os = "windows", target_arch = "x86_64"))] -pub const LAUNCHER_ASSET: Option<&str> = Some("horizon-launcher-windows-x64.zip"); -#[cfg(not(any( - all(target_os = "linux", target_arch = "x86_64"), - all(target_os = "macos", target_arch = "aarch64"), - all(target_os = "windows", target_arch = "x86_64") -)))] -pub const LAUNCHER_ASSET: Option<&str> = None; +/// `a == b`, in a const context. +const fn same(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} + +/// This system's entry of `PLATFORMS`: the game's asset and the launcher's. +const fn platform_assets(os: &str, arch: &str) -> Option<(&'static str, &'static str)> { + let mut i = 0; + while i < PLATFORMS.len() { + let (o, a, game, launcher) = PLATFORMS[i]; + if same(o, os) && same(a, arch) { + return Some((game, launcher)); + } + i += 1; + } + None +} + +const OWN_ASSETS: Option<(&str, &str)> = + platform_assets(std::env::consts::OS, std::env::consts::ARCH); +/// The game build this launcher installs, None where Horizon has none. +pub const ASSET: Option<&str> = match OWN_ASSETS { + Some((game, _)) => Some(game), + None => None, +}; +/// The launcher build this launcher updates itself from. +pub const LAUNCHER_ASSET: Option<&str> = match OWN_ASSETS { + Some((_, launcher)) => Some(launcher), + None => None, +}; const LAUNCHER_BINARY: &str = if cfg!(windows) { "horizon-launcher.exe" @@ -2165,6 +2211,56 @@ mod tests { use super::*; use std::cmp::Ordering::*; + #[test] + fn each_mac_picks_the_build_for_its_processor() { + assert_eq!( + platform_assets("macos", "aarch64"), + Some(( + "horizon-macos-arm64.zip", + "horizon-launcher-macos-arm64.zip" + )) + ); + assert_eq!( + platform_assets("macos", "x86_64"), + Some(("horizon-macos-x64.zip", "horizon-launcher-macos-x64.zip")) + ); + assert_eq!( + platform_assets("windows", "x86_64").map(|(game, _)| game), + Some("horizon-windows-x64.zip") + ); + assert_eq!(platform_assets("linux", "aarch64"), None); + assert_eq!(platform_assets("macos", "x86"), None); + assert_eq!( + ASSET, + platform_assets(std::env::consts::OS, std::env::consts::ARCH).map(|(game, _)| game) + ); + } + + /// Every asset a launcher looks for is one the release workflows build, sign and publish. + #[test] + fn platform_assets_are_published() { + let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); + let read = |path: &str| fs::read_to_string(repo.join(path)).unwrap(); + let release = read(".github/workflows/release.yml"); + let launcher = read(".github/workflows/launcher.yml"); + for (os, arch, game, launcher_asset) in PLATFORMS { + assert!( + release.contains(&format!("asset: {game}")), + "release.yml builds no {game}" + ); + // Signed (SHA256SUMS), then published. + assert_eq!( + release.matches(&format!("dist/{game}")).count(), + 2, + "release.yml must sign and publish {game} ({os} {arch})" + ); + assert!( + launcher.contains(&format!("asset: {launcher_asset}")), + "launcher.yml builds no {launcher_asset}" + ); + } + } + #[test] fn the_game_plays_under_its_own_audio_stream_name() { if std::env::var_os("PIPEWIRE_PROPS").is_some() { diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 646de23d2f..17263229db 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash # Packages the macOS launcher: Horizon.app (the binary, Info.plist, the icon), signed, then the -# two release assets, horizon-launcher-macos-arm64.zip (what the launcher updates itself from; -# older launchers find the binary inside it) and horizon-launcher-macos-arm64.dmg (the installer: -# the app and an Applications link over a background, to drag one onto the other). +# two release assets, horizon-launcher-macos-ARCH.zip (what the launcher updates itself from; +# older launchers find the binary inside it) and horizon-launcher-macos-ARCH.dmg (the installer: +# the app and an Applications link over a background, to drag one onto the other). ARCH is the +# binary's: arm64 (Apple Silicon, the default) or x64 (Intel). # -# packaging/macos/package.sh BINARY VERSION OUTDIR +# packaging/macos/package.sh BINARY VERSION OUTDIR [arm64|x64] # # Needs macOS (ditto, codesign, hdiutil) and dmgbuild (pip install dmgbuild==1.6.5; $DMGBUILD # names it if it isn't on PATH). Signing: ad hoc by default, which needs no Apple account. With @@ -17,6 +18,16 @@ here=$(cd "$(dirname "$0")" && pwd) binary=$1 version=$2 out=$(mkdir -p "$3" && cd "$3" && pwd) +arch=${4:-arm64} +case "$arch" in + arm64) cpu=arm64 ;; + x64) cpu=x86_64 ;; + *) echo "unknown architecture $arch (arm64 or x64)" >&2; exit 1 ;; +esac +# The asset names say which Macs the binary runs on: it must be built for that processor. +[[ "$(lipo -archs "$binary")" == "$cpu" ]] || { echo "$binary is $(lipo -archs "$binary"), not $cpu" >&2; exit 1; } +zip=horizon-launcher-macos-$arch.zip +dmg=$out/horizon-launcher-macos-$arch.dmg app=$out/Horizon.app rm -rf "$app" @@ -43,9 +54,9 @@ notarize=$([[ "$identity" != "-" && -n "${MACOS_NOTARY_PROFILE:-}" ]] && echo ye # The app first, so the zip and the .dmg both carry it stapled. [[ -z "$notarize" ]] || "$here/notarize.sh" "$app" -rm -f "$out/horizon-launcher-macos-arm64.zip" "$out/horizon-launcher-macos-arm64.dmg" +rm -f "$out/$zip" "$dmg" # No resource forks or extended attributes: they would land as ._ files inside the bundle. -(cd "$out" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Horizon.app horizon-launcher-macos-arm64.zip) +(cd "$out" && ditto -c -k --norsrc --noextattr --noqtn --keepParent Horizon.app "$zip") cat > "$out/dmg.json" < "$out/dmg.json" </dev/null)" = 1 ] && MACHINE=arm64 +case "$(uname -s)-$MACHINE" in Linux-x86_64) ASSETS="horizon-server-linux-x86_64.tar.gz horizon-linux-x86_64.tar.gz" ;; Darwin-arm64) ASSETS=horizon-macos-arm64.zip ;; + # Intel Macs: releases from 0.2 on (#1161). + Darwin-x86_64) ASSETS=horizon-macos-x64.zip ;; *) die "no Horizon release for $(uname -s) $(uname -m) yet: build it from source (docs/server.md)" ;; esac diff --git a/website/docs/hosting.md b/website/docs/hosting.md index 7a36693d8a..fc32930718 100644 --- a/website/docs/hosting.md +++ b/website/docs/hosting.md @@ -86,7 +86,7 @@ This works today, on a VPS or any computer. Everything below is the same program ### 1. Install -On Linux (x86-64) and macOS (Apple Silicon): +On Linux (x86-64) and macOS (Apple Silicon; Intel from the next release): ```sh curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh diff --git a/website/docs/index.md b/website/docs/index.md index 4e84a6591d..b8bda1ef81 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -21,9 +21,10 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust. You install it with a - **Linux, x86-64.** A graphics card with Vulkan drivers. Builds are made on Ubuntu 22.04, so use a distribution at least that recent. - **macOS, Apple Silicon.** Any M1 or newer, on macOS 11 or later. +- **macOS, Intel.** From the next release on, macOS 11 or later. This build is checked under Rosetta 2 on an Apple Silicon Mac, not yet on an Intel one. - **Windows, x64.** Vulkan or DirectX 12. Horizon picks DirectX 12 by itself when Vulkan does not work. -There is no build for Intel Macs, 32-bit systems, or ARM Linux and Windows. The server can be built from source on other systems. +There is no build for 32-bit systems, or ARM Linux and Windows. The server can be built from source on other systems. ## Where to go next diff --git a/website/docs/install.md b/website/docs/install.md index 1ac3da95ad..771e7972c0 100644 --- a/website/docs/install.md +++ b/website/docs/install.md @@ -13,7 +13,7 @@ Download the launcher, let it install Horizon, then prepare the game from your o Get the newest launcher from the [Download button on the home page](/), which picks the right file for your system, or from [the launcher releases on GitHub](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true). - **Linux:** download `horizon-launcher-linux-x86_64.tar.gz`, extract it and run `horizon-launcher`. -- **macOS:** download `horizon-launcher-macos-arm64.dmg`, open it and drag Horizon onto Applications. The first time, macOS says it cannot check the app, because it is not signed with a paid Apple developer account. On macOS 14 and earlier, right-click Horizon, choose **Open**, then **Open** again. On macOS 15 and later, open it once, then go to **System Settings, Privacy & Security** and choose **Open Anyway**. After that it opens normally. +- **macOS:** download `horizon-launcher-macos-arm64.dmg` (Apple Silicon: M1 and later; Intel Macs: `horizon-launcher-macos-x64.dmg`, from the next release on), open it and drag Horizon onto Applications. The first time, macOS says it cannot check the app, because it is not signed with a paid Apple developer account. On macOS 14 and earlier, right-click Horizon, choose **Open**, then **Open** again. On macOS 15 and later, open it once, then go to **System Settings, Privacy & Security** and choose **Open Anyway**. After that it opens normally. - **Windows:** download `horizon-launcher-windows-x64.zip`, extract it and run `horizon-launcher.exe`. Every release also carries `SHA256SUMS` and a signature, `SHA256SUMS.sig`, if you want to check your download. The launcher does not check that signature for you yet. diff --git a/website/src/main.js b/website/src/main.js index e614537305..155d2d9d96 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -80,24 +80,44 @@ async function setUpContributors() { // --- Download ------------------------------------------------------------------------ +// Apple Silicon first: Safari and Firefox say "Intel Mac OS X" on every Mac. +const MACS = [ + { key: "macos-arm64", label: "macOS on Apple Silicon", other: "Apple Silicon" }, + { key: "macos-x64", label: "macOS on an Intel Mac", other: "Intel Mac" }, +]; + +// Only Chromium's client hints tell an Intel Mac apart. +async function intelMac() { + try { + return (await navigator.userAgentData.getHighEntropyValues(["architecture"])).architecture === "x86"; + } catch { + return false; + } +} + function platform() { const ua = navigator.userAgent; const hint = (navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ""; if (/iPhone|iPad|iPod|Android/i.test(ua) || (navigator.maxTouchPoints > 1 && /Mac/.test(hint))) return null; if (/Win/i.test(hint) || /Windows/.test(ua)) return { key: "windows-x64", label: "Windows x64" }; - if (/Mac/i.test(hint) || /Mac OS X/.test(ua)) return { key: "macos-arm64", label: "macOS on Apple Silicon" }; + if (/Mac/i.test(hint) || /Mac OS X/.test(ua)) return MACS[0]; if (/Linux|X11/i.test(hint) || /Linux/.test(ua)) return { key: "linux-x86_64", label: "Linux x86-64" }; return null; } async function setUpDownload() { - const os = platform(); + let os = platform(); + if (os === MACS[0] && (await intelMac())) os = MACS[1]; const list = await releases; const launcher = list.filter(isLauncher).sort((a, b) => newestFirst(a.tag_name, b.tag_name))[0]; if (!launcher) return; const version = launcherVersion(launcher.tag_name); - const asset = os && launcher.assets.find((a) => a.name.includes(os.key)); - const others = `Other platforms`; + const find = (key) => launcher.assets.find((a) => a.name.includes(key)); + const asset = os && find(os.key); + // On a Mac, the other processor's build too: the browser may have guessed wrong. + const mac = MACS.includes(os) && MACS.find((m) => m !== os); + const otherMac = mac && find(mac.key); + const others = `${otherMac ? `${escape(mac.other)} · ` : ""}Other platforms`; document.querySelectorAll("[data-download]").forEach((a) => (a.href = asset ? asset.browser_download_url : launcher.html_url || RELEASES_PAGE)); const mb = asset && asset.size ? ` · ${(asset.size / 1e6).toFixed(0)} MB` : ""; const meta = asset From 4eb85dbdf96468d2d8b38ca522372bd9006aa3dd Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:46:38 +0100 Subject: [PATCH 002/968] World generation: workers build free structure starts instead of queueing ChunkGenerator.createReferences asks for the structure starts of the 17x17 chunks around each chunk. Every worker asked for them in the same order and waited on the chunk's work lock while another worker built its starts, so at a cold start (a new world, the first structures of a seed) the workers queued one behind the other: in a fresh generator with 12 workers on the Mac mini, 39% of the generation time was spent blocked in that lock (sampled). references() now builds the starts no other worker is building first, and waits for the rest only then; the work locks' map is sharded by chunk, as the start cache already is, because every worker now asks for the locks of the whole neighbourhood. Starts and the order of the references are the same: each chunk's starts are still built once and kept by the first builder. Blocked time in the same run: 39% -> 1.4% of generation samples. Co-Authored-By: Claude Opus 5.5 --- .../horizon-world/src/worldgen/generator.rs | 23 ++++++-- .../src/worldgen/generator_structures.rs | 58 ++++++++++++------- 2 files changed, 56 insertions(+), 25 deletions(-) diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index 2595173add..3a9908379f 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -661,17 +661,24 @@ const CACHE_LIMIT: usize = 16384; /// Coordinates only equal missing neighbour keys. Weak entries retain no /// computed values across eviction or explicit invalidation. Only one work /// lock may be held at a time; finish shared prerequisites before acquiring it. -pub(super) struct ChunkWork(Mutex>>>); +/// Sharded by key: every worker asks for the locks of the same neighbourhoods. +pub(super) struct ChunkWork(Box<[Mutex>>>]>); +const WORK_SHARDS: usize = 64; +/// The shard of `key` among `shards` (a power of two). +pub(super) fn shard_of(key: (i32, i32), shards: usize) -> usize { + let hash = (key.0 as u32).wrapping_mul(0x9E37_79B9) ^ (key.1 as u32).wrapping_mul(0x85EB_CA6B); + (hash >> (32 - shards.trailing_zeros())) as usize +} impl ChunkWork { pub(super) fn new() -> Self { - Self(Mutex::new(HashMap::new())) + Self((0..WORK_SHARDS).map(|_| Mutex::default()).collect()) } pub(super) fn at(&self, key: (i32, i32)) -> Arc> { - let mut work = self.0.lock().unwrap(); + let mut work = self.0[shard_of(key, WORK_SHARDS)].lock().unwrap(); if let Some(lock) = work.get(&key).and_then(std::sync::Weak::upgrade) { return lock; } - if work.len() >= 4096 { + if work.len() >= 4096 / WORK_SHARDS { // Keep every active builder/waiter on its original lock, including // when the result cache was cleared while it was in flight. work.retain(|_, lock| lock.strong_count() > 0); @@ -2971,7 +2978,13 @@ mod tests { for x in 1..8192 { drop(work.at((x, 1))); } - assert!(work.0.lock().unwrap().len() <= 4096); + assert!( + work.0 + .iter() + .map(|s| s.lock().unwrap().len()) + .sum::() + <= 4096 + ); assert!(Arc::ptr_eq(&same, &work.at((0, 0)))); let weak = Arc::downgrade(&first); drop(held); diff --git a/crates/horizon-world/src/worldgen/generator_structures.rs b/crates/horizon-world/src/worldgen/generator_structures.rs index 1e4913c95c..dbc74e9589 100644 --- a/crates/horizon-world/src/worldgen/generator_structures.rs +++ b/crates/horizon-world/src/worldgen/generator_structures.rs @@ -2,7 +2,7 @@ use super::{ biome::BiomeId, block_feature::FeatureBlockBindings, - generator::{ChunkWork, GeneratorData, GeneratorError}, + generator::{ChunkWork, GeneratorData, GeneratorError, shard_of}, structure::{ StructureResources, start::{ @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use std::{ collections::{BTreeSet, HashMap}, sync::{ - Arc, Condvar, Mutex, OnceLock, RwLock, + Arc, Condvar, Mutex, OnceLock, RwLock, TryLockError, atomic::{AtomicUsize, Ordering}, }, }; @@ -126,9 +126,7 @@ impl StartCache { } } fn shard(&self, key: (i32, i32)) -> &RwLock> { - let hash = - (key.0 as u32).wrapping_mul(0x9E37_79B9) ^ (key.1 as u32).wrapping_mul(0x85EB_CA6B); - &self.shards[(hash >> (32 - START_SHARDS.trailing_zeros())) as usize] + &self.shards[shard_of(key, START_SHARDS)] } fn get(&self, key: (i32, i32)) -> Option { self.shard(key).read().unwrap().get(&key).cloned() @@ -258,16 +256,31 @@ impl StructureState { context: &mut impl super::structure::start::StartContext, chunk: ChunkPos, ) -> Starts { + self.build_starts(context, chunk, true) + .expect("waits for another worker's build") + } + /// `starts_at`, or `None` when another worker is building them and + /// `wait` is false. + fn build_starts( + &self, + context: &mut impl super::structure::start::StartContext, + chunk: ChunkPos, + wait: bool, + ) -> Option { if let Some(found) = self.starts.get((chunk.x, chunk.z)) { - return found; + return Some(found); } // Ring searches enlist other workers and wait for them: never hold a // chunk-work lock while helping, even when this chunk has no starts. let rings = self.rings(context); let work = self.start_work.at((chunk.x, chunk.z)); - let _work = work.lock().unwrap(); + let _work = match work.try_lock() { + Ok(guard) => guard, + Err(TryLockError::WouldBlock) if !wait => return None, + Err(_) => work.lock().unwrap(), + }; if let Some(found) = self.starts.get((chunk.x, chunk.z)) { - return found; + return Some(found); } let starts: Starts = Arc::new( create_starts( @@ -283,7 +296,7 @@ impl StructureState { .map(Arc::new) .collect(), ); - self.starts.insert_first((chunk.x, chunk.z), starts) + Some(self.starts.insert_first((chunk.x, chunk.z), starts)) } /// ChunkGenerator.findNearestMapStructure: the nearest start of one of /// `names` from block `from` (rings: every ring chunk; random spreads: @@ -386,18 +399,23 @@ impl StructureState { chunk: ChunkPos, ) -> Vec> { let (bx, bz) = (chunk.x * 16, chunk.z * 16); + let around: Vec = (chunk.x - 8..=chunk.x + 8) + .flat_map(|sx| (chunk.z - 8..=chunk.z + 8).map(move |sz| ChunkPos::new(sx, sz))) + .collect(); + // Workers generating neighbouring chunks ask for the same starts in the + // same order: build those no other worker is building first, and wait + // for the others only then, rather than all queueing behind each one. + let found: Vec> = around + .iter() + .map(|&at| self.build_starts(context, at, false)) + .collect(); let mut out = Vec::new(); - for sx in chunk.x - 8..=chunk.x + 8 { - for sz in chunk.z - 8..=chunk.z + 8 { - for start in self.starts_at(context, ChunkPos::new(sx, sz)).iter() { - let b = start.bounding_box; - if b.max[0] >= bx - && b.min[0] <= bx + 15 - && b.max[2] >= bz - && b.min[2] <= bz + 15 - { - out.push(start.clone()); - } + for (&at, found) in around.iter().zip(found) { + let starts = found.unwrap_or_else(|| self.starts_at(context, at)); + for start in starts.iter() { + let b = start.bounding_box; + if b.max[0] >= bx && b.min[0] <= bx + 15 && b.max[2] >= bz && b.min[2] <= bz + 15 { + out.push(start.clone()); } } } From cc2a2a8741cc51b5acfac6b92c82e3f764cef36a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:56:59 +0100 Subject: [PATCH 003/968] Lithostitched's limited and guaranteed elements and its processor-list modifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game reads lithostitched:limited and lithostitched:guaranteed pool elements as written (spec-lithostitched §3): a limited element places its delegate at most `limit` times per start, a guaranteed one is tried first at each jigsaw of its pool until `count` are placed, and neither goes below a parent shallower than `min_depth` (IQ-10). Layouts stay vanilla's (L5); with no such element vanilla's draws are unchanged. Saved pieces of these types read as their delegate. The carry keeps them (delegating still places its delegate), writes add_processor_list_processors as a processor-list addition (§2.2, 1.8.1's `processor_lists` key) and both modifier kinds with their priority; the game merges additions by `horizon:priority`, then mod and file order. Harnesses: the layout harness checks the probe pack's limits on Horizon's own probe villages, and the blocks harness gets a lithostitched-probe profile; the probe pack is built into a jar from the oracle's profile. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mod_content.rs | 31 ++- .../horizon-client/src/mod_content_tests.rs | 28 +- crates/horizon-port-pack/src/lithostitched.rs | 113 +++++--- .../src/worldgen/structure/jigsaw.rs | 254 +++++++++++++++--- docs/modpacks.md | 9 +- docs/mods.md | 28 +- tools/test_structure_blocks_parity.py | 20 +- tools/test_structure_layout_parity.py | 91 ++++++- 8 files changed, 461 insertions(+), 113 deletions(-) diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index cb96fea062..294d21c36a 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -729,24 +729,29 @@ fn worldgen_entry(file: &str) -> Option<(&'static str, String)> { Some((bundle, format!("{ns}:{}", path.strip_suffix(".json")?))) } -/// The key of a mod's structure set or template pool file that adds its -/// entries to other sets or pools (vanilla's, another mod's) instead of -/// being one of its own. Vanilla data never has it. +/// The key of a mod's structure set, template pool or processor list file +/// that adds its entries to other sets, pools or lists (vanilla's, another +/// mod's) instead of being one of its own. Vanilla data never has it. const ADD_TO: &str = "horizon:add_to"; +/// An addition's place among the others (lower first; 1000 by default), as +/// Lithostitched's modifiers' `priority`. +const PRIORITY: &str = "horizon:priority"; /// The bundles a mod may add to, and the list each entry goes into. -const ADDABLE: [(&str, &str); 2] = [ +const ADDABLE: [(&str, &str); 3] = [ ("worldgen/structure_sets.json", "structures"), ("worldgen/template_pools.json", "elements"), + ("worldgen/processor_lists.json", "processors"), ]; /// A mod file's additions (`horizon:add_to`): its entries appended to the -/// list of each set or pool it names, as lithostitched's -/// `add_structure_set_entries` and `add_template_pool_elements` do for -/// NeoForge and Fabric packs. +/// list of each set, pool or processor list it names, as Lithostitched's +/// `add_structure_set_entries`, `add_template_pool_elements` and +/// `add_processor_list_processors` do for NeoForge and Fabric packs. struct Addition { targets: Vec, list: &'static str, entries: Vec, + priority: i64, } impl Addition { /// What a mod file of `bundle` adds, if it is an addition file. @@ -776,10 +781,16 @@ impl Addition { let Some(J::Arr(entries)) = doc.get(list) else { return Err(format!("{ADD_TO} without a {list} list").into()); }; + let priority = match doc.get(PRIORITY) { + None => 1000, + Some(J::Num(n)) => n.as_i64().ok_or(format!("{PRIORITY}: not an integer"))?, + Some(_) => return Err(format!("{PRIORITY}: not an integer").into()), + }; Ok(Some(Self { targets, list, entries: entries.clone(), + priority, })) } /// Appends the entries to each target in the bundle `doc` (id → the @@ -1215,7 +1226,11 @@ pub fn build(base: &Path, mods: &[ContentMod], out: &Path) -> Result overlay.write(BIOME_TAGS, &serde_json::to_vec(&doc)?)?; } // Structures and the pools, sets, processors and features they use, then - // what mods add to sets and pools, in mod and file order. + // what mods add to sets, pools and lists, by priority, then in mod and + // file order. + for list in additions.values_mut() { + list.sort_by_key(|a| a.priority); + } for (bundle, entries) in &worldgen { if !base.join(bundle).exists() { continue; diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 3d38fae4d6..27c4f694e8 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -605,7 +605,8 @@ fn content_root_carries_a_structure_mod() { "data/huts/tags/worldgen/biome/has_hut.json", r##"{"values": ["#minecraft:is_overworld", "#c:is_plains", "othermod:glade", "minecraft:plains"]}"##, ), - // Additions to vanilla's set and pool; a target nobody defines is skipped. + // Additions to vanilla's set, pool and processor list; a target nobody + // defines is skipped. A lower priority goes first. ( "data/huts/worldgen/structure_set/added/villages.json", r#"{"horizon:add_to": "minecraft:villages", @@ -617,6 +618,16 @@ fn content_root_carries_a_structure_mod() { "elements": [{"weight": 2, "element": {"element_type": "minecraft:single_pool_element", "location": "huts:hut", "projection": "rigid", "processors": "minecraft:empty"}}]}"#, ), + ( + "data/huts/worldgen/template_pool/added/more_houses.json", + r#"{"horizon:add_to": "minecraft:village/plains/houses", "horizon:priority": 10, + "elements": [{"weight": 1, "element": {"element_type": "minecraft:empty_pool_element"}}]}"#, + ), + ( + "data/huts/worldgen/processor_list/added/mossy.json", + r#"{"horizon:add_to": "minecraft:mossify_10_percent", + "processors": [{"processor_type": "minecraft:nop"}]}"#, + ), ]; for (name, text) in files { let path = dir.join(name); @@ -652,14 +663,23 @@ fn content_root_carries_a_structure_mod() { "structure_sets.json", "minecraft:villages", "structures", - serde_json::json!({"structure": "huts:hut", "weight": 3}), + vec![serde_json::json!({"structure": "huts:hut", "weight": 3})], ), ( "template_pools.json", "minecraft:village/plains/houses", "elements", - serde_json::json!({"weight": 2, "element": {"element_type": "minecraft:single_pool_element", + vec![ + serde_json::json!({"weight": 1, "element": {"element_type": "minecraft:empty_pool_element"}}), + serde_json::json!({"weight": 2, "element": {"element_type": "minecraft:single_pool_element", "location": "huts:hut", "projection": "rigid", "processors": "minecraft:empty"}}), + ], + ), + ( + "processor_lists.json", + "minecraft:mossify_10_percent", + "processors", + vec![serde_json::json!({"processor_type": "minecraft:nop"})], ), ] { let (ours, vanilla) = (bundle(name, &root), bundle(name, &base)); @@ -668,7 +688,7 @@ fn content_root_carries_a_structure_mod() { v[list].as_array().unwrap().clone() }; let mut expected = entries(&vanilla[id]); - expected.push(added); + expected.extend(added); assert_eq!(entries(&ours[id]), expected, "{id}"); assert!(!ours.keys().any(|k| k.starts_with("huts:added/")), "{name}"); assert!(!ours.contains_key("othermod:houses")); diff --git a/crates/horizon-port-pack/src/lithostitched.rs b/crates/horizon-port-pack/src/lithostitched.rs index e65efcf0e7..cfb1013b79 100644 --- a/crates/horizon-port-pack/src/lithostitched.rs +++ b/crates/horizon-port-pack/src/lithostitched.rs @@ -10,10 +10,12 @@ //! the processors nested in them carried in turn. CTOV's //! `ctov:modular_compat` (a reference that runs only when its mod is loaded) //! becomes a `reference` when the mod is ported along, else nothing. -//! - `add_template_pool_elements` modifiers become `horizon:add_to` pool files -//! (`docs/mods.md`), when their load conditions pass. Lithostitched's own -//! pool elements (`limited`, `guaranteed`, `delegating`) place their -//! `delegate`; what they add to it is counted as lost. +//! - `add_template_pool_elements` and `add_processor_list_processors` +//! modifiers become `horizon:add_to` pool and processor list files +//! (`docs/mods.md`), with their `priority`, when their load conditions pass. +//! - Its `limited` and `guaranteed` pool elements are the game's own +//! (`horizon_world`'s `structure::jigsaw`); `delegating` places its +//! `delegate`, and what it adds is counted as lost. use crate::{processors::ProcessorData, worldgen::Upgrade}; use serde_json::{Value, json}; use std::collections::BTreeMap; @@ -88,17 +90,21 @@ pub(crate) fn upgrade_pool_element( let Some(kind) = element["element_type"].as_str() else { return false; }; - match kind { - "lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => { - count(lost, kind); - let delegate = element["delegate"].take(); - *element = delegate; - // A delegate wrapping another (only `delegating` allows it). - upgrade_pool_element(element, lost); - true - } - _ => false, + let unwrap = match kind { + "lithostitched:guaranteed" => false, + // A `limit` is what it adds (spec-lithostitched §3). + "lithostitched:limited" => element["limit"].as_i64().is_none(), + "lithostitched:delegating" => true, + _ => return false, + }; + if unwrap { + count(lost, kind); + let delegate = element["delegate"].take(); + *element = delegate; + // A delegate wrapping another (only `delegating` allows it). + upgrade_pool_element(element, lost); } + true } /// The ids a modifier targets: an id or a list of ids (`None` for tags and @@ -140,8 +146,9 @@ fn conditions_pass(v: &Value, loaded: &dyn Fn(&str) -> bool) -> bool { } /// A worldgen modifier as a file the game merges: `("template_pool", file)` -/// for `add_template_pool_elements`; the others are counted in -/// `report.modifiers_left`. +/// for `add_template_pool_elements`, `("processor_list", file)` for +/// `add_processor_list_processors` (spec-lithostitched §2.1, §2.2); the +/// others are counted in `report.modifiers_left`. pub(crate) fn modifier( v: &Value, loaded: &dyn Fn(&str) -> bool, @@ -152,22 +159,31 @@ pub(crate) fn modifier( count(&mut report.modifiers_left, "conditions"); return None; } - if kind != "lithostitched:add_template_pool_elements" { - count(&mut report.modifiers_left, kind); - return None; - } - let (Some(targets), Some(elements)) = (ids(&v["template_pools"]), v["elements"].as_array()) - else { + // 1.8.1 reads `processor_lists`, not the wiki's `processor_list`. + let (to, targets, list) = match kind { + "lithostitched:add_template_pool_elements" => { + ("template_pool", "template_pools", "elements") + } + "lithostitched:add_processor_list_processors" => { + ("processor_list", "processor_lists", "processors") + } + _ => { + count(&mut report.modifiers_left, kind); + return None; + } + }; + let (Some(targets), Some(entries)) = (ids(&v[targets]), v[list].as_array()) else { count(&mut report.modifiers_left, "target"); return None; }; for target in &targets { - *report.added_to.entry(target.clone()).or_default() += elements.len(); + *report.added_to.entry(target.clone()).or_default() += entries.len(); } - Some(( - "template_pool", - json!({"horizon:add_to": targets, "elements": elements}), - )) + let mut file = json!({"horizon:add_to": targets, list: entries}); + if let Some(priority) = v["priority"].as_i64() { + file["horizon:priority"] = priority.into(); + } + Some((to, file)) } /// Whether the game reads a Lithostitched processor as it is: one of its @@ -322,16 +338,28 @@ mod tests { } #[test] - fn wrapped_elements_place_their_delegate() { + fn limited_and_guaranteed_elements_stay_theirs() { let mut lost = BTreeMap::new(); let delegate = json!({"element_type": "minecraft:single_pool_element", "location": "a:b", "projection": "rigid", "processors": "minecraft:empty"}); - let mut e = - json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate}); - assert!(upgrade_pool_element(&mut e, &mut lost)); - assert_eq!(e, delegate); - assert_eq!(lost["lithostitched:limited"], 1); - assert!(!upgrade_pool_element(&mut e, &mut lost)); + for kept in [ + json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate}), + json!({"element_type": "lithostitched:guaranteed", "count": 1, "min_depth": 3, + "delegate": delegate}), + ] { + let mut e = kept.clone(); + assert!(upgrade_pool_element(&mut e, &mut lost)); + assert_eq!(e, kept); + } + assert!(lost.is_empty()); + // Without a limit, and `delegating`: the delegate, the rest counted. + for kind in ["lithostitched:limited", "lithostitched:delegating"] { + let mut e = json!({"element_type": kind, "delegate": delegate}); + assert!(upgrade_pool_element(&mut e, &mut lost)); + assert_eq!(e, delegate); + assert_eq!(lost[kind], 1); + } + assert!(!upgrade_pool_element(&mut delegate.clone(), &mut lost)); } #[test] @@ -357,12 +385,27 @@ mod tests { assert!(modifier(&add("waystones", json!("#a:pools")), &loaded, &mut report).is_none()); let aliases = json!({"type": "lithostitched:set_pool_aliases"}); assert!(modifier(&aliases, &loaded, &mut report).is_none()); + // Processors appended to lists, with the modifier's priority; 1.8.1 + // reads `processor_lists` (spec-lithostitched §2.2). + let nop = json!([{"processor_type": "minecraft:nop"}]); + let processors = json!({"type": "lithostitched:add_processor_list_processors", + "priority": 10, "processor_lists": ["minecraft:mossify_10_percent"], "processors": nop}); + let (kind, file) = modifier(&processors, &loaded, &mut report).unwrap(); + assert_eq!(kind, "processor_list"); + assert_eq!( + file, + json!({"horizon:add_to": ["minecraft:mossify_10_percent"], "processors": nop, + "horizon:priority": 10}) + ); + let wiki = json!({"type": "lithostitched:add_processor_list_processors", + "processor_list": "minecraft:mossify_10_percent", "processors": nop}); + assert!(modifier(&wiki, &loaded, &mut report).is_none()); assert_eq!( report.modifiers_left, BTreeMap::from([ ("conditions".into(), 1), ("lithostitched:set_pool_aliases".into(), 1), - ("target".into(), 1), + ("target".into(), 2), ]) ); } diff --git a/crates/horizon-world/src/worldgen/structure/jigsaw.rs b/crates/horizon-world/src/worldgen/structure/jigsaw.rs index f53f712f33..0f43e6cbe6 100644 --- a/crates/horizon-world/src/worldgen/structure/jigsaw.rs +++ b/crates/horizon-world/src/worldgen/structure/jigsaw.rs @@ -119,6 +119,11 @@ impl PoolElement { }; Ok(match kind { "minecraft:empty_pool_element" => Self::Empty, + // Lithostitched's elements place what their `delegate` places; + // their limits are the pool entry's (`Pool::limits`). + "lithostitched:limited" | "lithostitched:guaranteed" | "lithostitched:delegating" => { + Self::decode(&value["delegate"], resources, bindings, inherited)? + } // YUNG's API's element places what a single element places; its // `name` and `max_count` are the pool entry's (`Pool::limits`). "minecraft:single_pool_element" @@ -321,11 +326,14 @@ pub(super) fn can_attach(source: &Jigsaw, target: &Jigsaw) -> bool { pub struct Pool { /// Each element repeated by its weight. pub(super) templates: Vec>, + /// The entry of `raw` each of `templates` repeats. + entries: Vec, /// The elements with their weights, in file order (`rawTemplates`). pub(super) raw: Vec<(Arc, i32)>, - /// Per entry of `raw`: a YUNG element's `name` and `max_count`, the most - /// pieces of that name in one start. - limits: Vec, i32)>>, + /// Per entry of `raw`: how many pieces of it one start may hold. + limits: Vec>, + /// The entries of `raw` that are Lithostitched's `guaranteed` elements. + guaranteed: Vec, pub(super) fallback: String, max_size: OnceLock, } @@ -339,10 +347,11 @@ impl Pool { .unwrap_or(0) as usize] .clone() } - fn shuffled(&self, random: &mut impl RandomSource) -> Vec> { - let mut copy = self.templates.clone(); - shuffle(&mut copy, random); - copy + /// The entries of `templates`, shuffled. + fn shuffled(&self, random: &mut impl RandomSource) -> Vec { + let mut order: Vec = (0..self.templates.len()).collect(); + shuffle(&mut order, random); + order.into_iter().map(|i| self.entries[i]).collect() } /// YUNG's API's pick: `nextInt` of the entries' total weight, walked in /// `order` (indices into `raw`); the entry that takes it below zero. @@ -365,6 +374,52 @@ impl Pool { }) } } +/// How many pieces of a pool entry one start may hold, and where. +#[derive(Clone, Debug)] +struct Limit { + /// The pieces counted together: a YUNG element's `name`; a Lithostitched + /// element itself (its JSON), wherever it is listed. + key: Arc, + max: i32, + /// The parent piece's least depth (the start is at 0). + min_depth: i32, + /// `lithostitched:guaranteed`: tried before the pool's other entries. + guaranteed: bool, +} +impl Limit { + /// A pool entry's element's limit: YUNG's `max_count` (spec-yungsapi §3), + /// Lithostitched's `limited` and `guaranteed` (spec-lithostitched §3). + fn of(element: &Value) -> Option { + let int = |key: &str| { + element[key] + .as_i64() + .map(|v| v.clamp(0, i32::MAX.into()) as i32) + }; + let litho = |max: Option, guaranteed| { + Some(Self { + key: element.to_string().into(), + max: max?, + min_depth: int("min_depth").unwrap_or(0), + guaranteed, + }) + }; + match element["element_type"].as_str()? { + "yungsapi:yung_single_element" => Some(Self { + key: element["name"].as_str()?.into(), + max: int("max_count")?, + min_depth: 0, + guaranteed: false, + }), + "lithostitched:limited" => litho(int("limit"), false), + "lithostitched:guaranteed" => litho(Some(int("count").unwrap_or(1)), true), + _ => None, + } + } + /// Whether one more piece may go below a parent at `depth`. + fn allows(&self, counts: &HashMap, i32>, depth: i32) -> bool { + depth >= self.min_depth && counts.get(&self.key).copied().unwrap_or(0) < self.max + } +} /// Lazily decoded template pools. pub struct Pools { raw: BTreeMap, @@ -393,7 +448,8 @@ impl Pools { } let pool = self.raw.get(name).and_then(|json| { let value: Value = serde_json::from_str(json).ok()?; - let (mut templates, mut raw, mut limits) = (Vec::new(), Vec::new(), Vec::new()); + let (mut templates, mut entries, mut raw, mut limits) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); for entry in value["elements"].as_array()? { let element = match PoolElement::decode(&entry["element"], resources, bindings, None) { @@ -408,24 +464,21 @@ impl Pools { let weight = entry["weight"].as_i64().unwrap_or(1); for _ in 0..weight { templates.push(element.clone()); + entries.push(raw.len()); } raw.push((element, weight as i32)); - let fields = &entry["element"]; - limits.push( - match (fields["name"].as_str(), fields["max_count"].as_i64()) { - (Some(name), Some(max)) - if fields["element_type"] == "yungsapi:yung_single_element" => - { - Some((name.into(), max as i32)) - } - _ => None, - }, - ); + limits.push(Limit::of(&entry["element"])); } + let guaranteed = (limits.iter().enumerate()) + .filter(|(_, l)| l.as_ref().is_some_and(|l| l.guaranteed)) + .map(|(i, _)| i) + .collect(); Some(Arc::new(Pool { templates, + entries, raw, limits, + guaranteed, fallback: value["fallback"] .as_str() .unwrap_or("minecraft:empty") @@ -669,9 +722,11 @@ pub enum Draws { /// The elements a jigsaw tries, in order. enum Candidates { - /// Vanilla: the target pool's weight-expanded elements, then the - /// fallback's, each shuffled up front. - Listed(std::vec::IntoIter>), + /// Vanilla: the target pool's weight-expanded entries, then the + /// fallback's, each shuffled up front. Lithostitched's `guaranteed` + /// entries of the target pool come first, in file order, and are not + /// drawn again (spec-lithostitched §3, OPEN-L4). + Listed(std::vec::IntoIter<(Arc, usize)>), /// YUNG's: weighted picks from each pool in turn (the fallback's only /// once the target's are spent), each pool's entries shuffled when it /// is reached. @@ -694,27 +749,36 @@ impl Candidates { vec![fallback.clone()] }; match draws { - Draws::Vanilla => Self::Listed( - pools - .iter() - .flat_map(|pool| pool.shuffled(random)) - .collect::>() - .into_iter(), - ), + Draws::Vanilla => { + let first = (target.guaranteed.iter()).filter(|_| below_max_depth); + let mut listed: Vec<_> = first.map(|&e| (target.clone(), e)).collect(); + for pool in &pools { + let drawn = pool.shuffled(random).into_iter(); + let drawn = drawn.filter(|&e| !pool.guaranteed.contains(&e)); + listed.extend(drawn.map(|e| (pool.clone(), e))); + } + Self::Listed(listed.into_iter()) + } Draws::Yung => Self::Weighted { pools: pools.into_iter().rev().collect(), current: None, }, } } - /// The next element to try, with its YUNG name and limit. + /// The next element to try below a parent at `depth`, with the key + /// its pieces are counted under. fn next( &mut self, counts: &HashMap, i32>, + depth: i32, random: &mut impl RandomSource, ) -> Option<(Arc, Option>)> { match self { - Self::Listed(elements) => elements.next().map(|e| (e, None)), + Self::Listed(entries) => entries.find_map(|(pool, entry)| { + let limit = pool.limits[entry].as_ref(); + (limit.is_none_or(|l| l.allows(counts, depth))) + .then(|| (pool.raw[entry].0.clone(), limit.map(|l| l.key.clone()))) + }), Self::Weighted { pools, current } => loop { let (pool, order) = match current { Some((pool, order)) if !order.is_empty() => (pool, order), @@ -731,13 +795,11 @@ impl Candidates { continue; }; let entry = order.remove(at); - let limit = pool.limits[entry].clone(); - if let Some((name, max)) = &limit - && counts.get(name).copied().unwrap_or(0) >= *max - { + let limit = pool.limits[entry].as_ref(); + if limit.is_some_and(|l| !l.allows(counts, depth)) { continue; } - return Some((pool.raw[entry].0.clone(), limit.map(|(name, _)| name))); + return Some((pool.raw[entry].0.clone(), limit.map(|l| l.key.clone()))); }, } } @@ -830,7 +892,7 @@ pub fn assemble_drawing( let rotation = Rotation::random(random); let entry = order[center_pool.weighted(&order, random)?]; let (element, _) = ¢er_pool.raw[entry]; - let limit = center_pool.limits[entry].clone().map(|(name, _)| name); + let limit = center_pool.limits[entry].as_ref().map(|l| l.key.clone()); (rotation, element.clone(), limit) } }; @@ -972,7 +1034,7 @@ struct Placer<'a, 'p> { /// SequencedPriorityIterator: highest priority first, FIFO within one. queue: BTreeMap>, draws: Draws, - /// Pieces placed per YUNG element name. + /// Pieces placed per limited element (`Limit::key`). counts: HashMap, i32>, } impl Placer<'_, '_> { @@ -1052,7 +1114,7 @@ impl Placer<'_, '_> { depth != self.config.max_depth, random, ); - while let Some((target, name)) = candidates.next(&self.counts, random) { + while let Some((target, name)) = candidates.next(&self.counts, depth, random) { if matches!(target.as_ref(), PoolElement::Empty) { break; } @@ -1175,3 +1237,117 @@ impl Placer<'_, '_> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A pool of empty elements, told apart by identity, with these limits. + fn pool(entries: &[(i32, Value)]) -> Arc { + let (mut templates, mut indices, mut raw, mut limits) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for (i, (weight, element)) in entries.iter().enumerate() { + let e = Arc::new(PoolElement::Empty); + for _ in 0..*weight { + templates.push(e.clone()); + indices.push(i); + } + raw.push((e, *weight)); + limits.push(Limit::of(element)); + } + let guaranteed = (0..entries.len()) + .filter(|&i| limits[i].as_ref().is_some_and(|l: &Limit| l.guaranteed)) + .collect(); + Arc::new(Pool { + templates, + entries: indices, + raw, + limits, + guaranteed, + fallback: "minecraft:empty".into(), + max_size: OnceLock::new(), + }) + } + fn delegate() -> Value { + json!({"element_type": "minecraft:single_pool_element", "location": "a:b"}) + } + /// The entries a vanilla jigsaw tries below a parent at `depth`, by index. + fn tried( + pool: &Arc, + counts: &HashMap, i32>, + depth: i32, + below_max: bool, + ) -> Vec { + let empty = self::pool(&[]); + let mut random = LegacyRandom::new(7); + let mut candidates = Candidates::new(Draws::Vanilla, pool, &empty, below_max, &mut random); + let mut out = Vec::new(); + while let Some((element, _)) = candidates.next(counts, depth, &mut random) { + out.push( + pool.raw + .iter() + .position(|(e, _)| Arc::ptr_eq(e, &element)) + .unwrap(), + ); + } + out + } + + #[test] + fn limits_read_yungs_and_lithostitcheds_fields() { + let yung = + json!({"element_type": "yungsapi:yung_single_element", "name": "pile", "max_count": 2}); + let yung = Limit::of(&yung).unwrap(); + assert_eq!( + (&*yung.key, yung.max, yung.min_depth, yung.guaranteed), + ("pile", 2, 0, false) + ); + let limited = + json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate()}); + let l = Limit::of(&limited).unwrap(); + assert_eq!((l.max, l.min_depth, l.guaranteed), (1, 0, false)); + assert_eq!(*l.key, *limited.to_string()); + let guaranteed = json!({"element_type": "lithostitched:guaranteed", "count": 2, "min_depth": 3, + "delegate": delegate()}); + let g = Limit::of(&guaranteed).unwrap(); + assert_eq!((g.max, g.min_depth, g.guaranteed), (2, 3, true)); + // No limit: none to keep. + for none in [ + json!({"element_type": "lithostitched:limited", "delegate": delegate()}), + json!({"element_type": "yungsapi:yung_single_element", "name": "pile"}), + delegate(), + ] { + assert!(Limit::of(&none).is_none(), "{none}"); + } + } + + #[test] + fn vanilla_jigsaws_keep_lithostitcheds_limits() { + let limited = + json!({"element_type": "lithostitched:limited", "limit": 1, "delegate": delegate()}); + let guaranteed = json!({"element_type": "lithostitched:guaranteed", "count": 1, "min_depth": 3, + "delegate": delegate()}); + let p = pool(&[ + (3, delegate()), + (2, limited.clone()), + (1, guaranteed.clone()), + ]); + let mut counts = HashMap::new(); + // Too shallow for the guaranteed entry: the weight-expanded list without it. + let mut shallow = tried(&p, &counts, 2, true); + shallow.sort(); + assert_eq!(shallow, [0, 0, 0, 1, 1]); + // Deep enough: it comes first, and is not drawn again. + let deep = tried(&p, &counts, 3, true); + assert_eq!(deep[0], 2); + assert_eq!(deep[1..].iter().filter(|&&e| e == 2).count(), 0); + // At the most depth only the fallback is tried. + assert!(tried(&p, &counts, 3, false).is_empty()); + // Entries at their limit are skipped. + counts.insert(Arc::from(limited.to_string()), 1); + counts.insert(Arc::from(guaranteed.to_string()), 1); + let full = tried(&p, &counts, 3, true); + assert_eq!(full, [0, 0, 0]); + } +} diff --git a/docs/modpacks.md b/docs/modpacks.md index 8e3980dd2c..101fe3c08f 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -449,7 +449,9 @@ Two cases the oracle fills in: - `lithostitched:jigsaw`, MoogsStructureLib's and Repurposed Structures' generic jigsaws become `minecraft:jigsaw`; - their `advanced_random_spread` becomes `minecraft:random_spread`; - - their versioned, legacy and empty pool elements become vanilla's. + - their versioned, legacy and empty pool elements become vanilla's; Lithostitched's + `limited` and `guaranteed` elements are the game's own (its `delegating` places its + `delegate`). Three more rewrites make the mapped structures decode and start where their mod would: - **Block states.** 1.21.1 writes a block or fluid state as `{"Name", "Properties"}`, 26.3 @@ -516,6 +518,11 @@ Two cases the oracle fills in: CTOV's compat hooks for other mods (its `modular_compat`, 30 of them in 17 lists) become a `reference` when the mod they name is ported along, and are taken out (a no-op, as in the mod) when it is not. In ATM10 they all name Nature's Spirit, which the pack does not have. + Lithostitched's `add_template_pool_elements` and `add_processor_list_processors` modifiers + (CTOV's houses for 11 of ATM10's mods) become the game's additions, when the mods their + load conditions name are ported along. The houses are `limited` (at most once in a + village) or `guaranteed` (tried first, from the depth they name, until placed): the game + keeps both. In Java, Lithostitched also lays out every jigsaw structure its own way (villages, outposts, trial chambers; CTOV's villages are `lithostitched:jigsaw`, carried as vanilla's jigsaw). Horizon keeps vanilla's layouts, so with Lithostitched in a pack, structure layouts diff --git a/docs/mods.md b/docs/mods.md index d17b15d655..8f5c25618d 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -333,21 +333,31 @@ vanilla's entries and those of adds are left out, ...". Tags are the only `minecraft` files a mod may hold: its other content stays in its own namespaces. -A mod adds to structure sets and template pools that are not its own -(vanilla's villages, another mod's houses) with a set or pool file in its own -namespace holding `horizon:add_to`, a key vanilla data never has. Its entries -are appended to each set or pool named, after every mod's own files, in mod -and file order; the file is no set or pool itself. A name nobody defines is -skipped. +A mod adds to structure sets, template pools and processor lists that are not +its own (vanilla's villages, another mod's houses) with a set, pool or +processor list file in its own namespace holding `horizon:add_to`, a key +vanilla data never has. Its entries are appended to each set, pool or list +named, after every mod's own files, by `horizon:priority` (lower first, 1000 +when it has none), then in mod and file order; the file is no set, pool or +list itself. A name nobody defines is skipped. ```json { "horizon:add_to": "minecraft:villages", "structures": [ { "structure": "mymod:village_beach", "weight": 10 } ] } ``` -A pool addition holds `"elements"` as a pool does. This is what lithostitched's -`add_structure_set_entries` and `add_template_pool_elements` modifiers do on -NeoForge and Fabric, and `horizon-port-pack carry` writes them from those. +A pool addition holds `"elements"` as a pool does, a processor list addition +`"processors"`. This is what Lithostitched's `add_structure_set_entries`, +`add_template_pool_elements` and `add_processor_list_processors` modifiers do +on NeoForge and Fabric, and `horizon-port-pack carry` writes the last two from +those. + +A pool element may be one of Lithostitched's: `lithostitched:limited` +(`delegate`, `limit`, `min_depth`) places its `delegate` at most `limit` times +in one structure start, and `lithostitched:guaranteed` (`delegate`, `count`, +`min_depth`) is tried before the pool's other elements at each jigsaw of that +pool until `count` are placed. Neither goes below a piece shallower than +`min_depth` (the start is at depth 0). A structure (and any of its pool elements, in place of the structure's) may hold `horizon:enhanced_terrain_adaptation`: MoogsStructureLib's terrain diff --git a/tools/test_structure_blocks_parity.py b/tools/test_structure_blocks_parity.py index fc4151f8da..b245f2a91e 100644 --- a/tools/test_structure_blocks_parity.py +++ b/tools/test_structure_blocks_parity.py @@ -21,7 +21,8 @@ A mismatch is counted by the oracle's no-mod block, the expected block and Horiz missing rule reads as one line. KNOWN names the mismatch classes the implementation does not cover yet, with the reason; they are reported, not failed. -usage: test_structure_blocks_parity.py [--profile betterdungeons|ctov] [--filter REGEX] [--limit N] +usage: test_structure_blocks_parity.py [--profile betterdungeons|ctov|lithostitched-probe] [--filter REGEX] + [--limit N] """ import argparse import gzip @@ -61,6 +62,13 @@ KNOWN = { 'changed the terrain', r'^minecraft:village_plains$': 'decoration (grass, trees) differs where the village changed the terrain', }, + # The probe pack's swaps and references match block for block; its random choices do not (OPEN-L6). + 'lithostitched-probe': { + r'^minecraft:village_plains$': "Lithostitched's random is not known (spec-lithostitched OPEN-L6): the " + 'probe\'s blackstone/deepslate and per-piece palettes match by share, not ' + 'by block; and decoration (grass, trees) differs where the village changed ' + 'the terrain', + }, } @@ -81,11 +89,9 @@ def chunk_rect(box): def saved_element(element): - """A saved pool element as the game reads it: Lithostitched's wrappers place their `delegate` - (spec-lithostitched §3), and 1.21.1's `{"Name", "Properties"}` states are 26.3's `{"id", "properties"}`.""" + """A saved pool element as the game reads it: 1.21.1's `{"Name", "Properties"}` states are 26.3's + `{"id", "properties"}` (Lithostitched's wrapped elements are read as saved, spec-lithostitched §7.3).""" if isinstance(element, dict): - if 'delegate' in element and str(element.get('element_type', '')).startswith('lithostitched:'): - return saved_element(element['delegate']) if 'Name' in element and set(element) <= {'Name', 'Properties'}: return {'id': element['Name'], **({'properties': element['Properties']} if 'Properties' in element else {})} return {k: saved_element(v) for k, v in element.items()} @@ -193,10 +199,12 @@ def main(): profile = PROFILES[args.profile] results = sorted((layout.ORACLE / args.profile).glob('seed-*.json.gz')) missing = [jar for jar in profile['mods'].values() if not layout.jar_path(jar).exists()] + missing += [n for n in profile.get('packs', {}).values() if not (layout.ORACLE / 'profiles' / f'{n}.json').exists()] if not results or missing: print(f'SKIP: oracle results or mod jars missing under {layout.ORACLE} (see test_structure_layout_parity.py)') return 0 - resources = layout.overlay(args.profile, layout.carry(args.profile, profile['mods'], profile['carry'])) + data = layout.carry(args.profile, profile['mods'], profile['carry'], profile.get('packs')) + resources = layout.overlay(args.profile, data) binary = layout.probe_binary() names = state_names(lab.RESOURCES) # structure -> [starts, compared by region, mismatches by region] diff --git a/tools/test_structure_layout_parity.py b/tools/test_structure_layout_parity.py index 7af8026fb9..29eca2ff02 100644 --- a/tools/test_structure_layout_parity.py +++ b/tools/test_structure_layout_parity.py @@ -21,9 +21,17 @@ Mod profiles compare the mod world's `pieces` instead, through the path players' (`.local/structure-oracle/jars/`, or HORIZON_STRUCTURE_JARS) are carried by `horizon-port-pack carry` into a fresh work folder, and an overlay of the 26.3 resources adds the carried structures, sets, pools, processor lists, biome tags and templates. The jars are the player's; nothing of them enters the -repository. +repository. A profile whose oracle run added a data pack of the spec team's own (`lithostitched-probe`) +carries that pack as one more mod, built at run time from the oracle's profile file +(`.local/structure-oracle/profiles/.json`). -usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons] [--filter REGEX] [--verbose] +Lithostitched lays jigsaws out its own way (spec-lithostitched OPEN-L5) and Horizon keeps vanilla's +layouts, so the `lithostitched-probe` profile checks its pool elements' limits instead of the layout: in +every start Horizon builds, no `limited` or `guaranteed` element's template appears more often than its +limit, and how many starts hold one is printed beside the oracle's count. + +usage: test_structure_layout_parity.py [--profile lithostitched|betterdungeons|lithostitched-probe] + [--filter REGEX] [--verbose] """ import argparse import gzip @@ -33,6 +41,7 @@ import re import shutil import subprocess import sys +import zipfile from pathlib import Path try: @@ -55,6 +64,12 @@ PROFILES = { 'betterdungeons': {'side': 'pieces', 'mods': {'yungsapi': 'YungsApi-1.21.1-NeoForge-5.1.9.jar', 'betterdungeons': 'YungsBetterDungeons-1.21.1-NeoForge-5.1.4.jar'}, 'carry': ['betterdungeons'], 'structures': r'^betterdungeons:(small|skeleton|zombie)_dungeon$'}, + # The probe pack's limited (limit 1) and guaranteed (count 1) elements on vanilla's plains houses. + 'lithostitched-probe': {'side': 'pieces', 'mods': {'lithostitched': 'lithostitched-1.8.1-neoforge-21.1.jar'}, + 'packs': {'probe': 'lithostitched-probe'}, 'carry': ['lithostitched', 'probe'], + 'structures': r'^minecraft:village_plains$', + 'limits': {'minecraft:village/taiga/houses/taiga_small_house_1': 1, + 'minecraft:village/snowy/houses/snowy_library_1': 1}}, } BUNDLES = {'structure': 'structures', 'structure_set': 'structure_sets', 'template_pool': 'template_pools', 'processor_list': 'processor_lists'} @@ -101,14 +116,28 @@ CENSUS = ['blockstates', 'item_models', 'recipes', 'loot_tables', 'tags', 'world 'textures', 'sounds', 'classes', 'mixin_configs'] -def carry(profile, mods, carried): +def pack_jar(work, mod, profile): + """The data pack an oracle profile added to its world, as a jar of mod `mod`.""" + files = json.loads((ORACLE / 'profiles' / f'{profile}.json').read_text())['config_files'] + jar = work / f'{mod}.jar' + with zipfile.ZipFile(jar, 'w') as z: + for path, text in files.items(): + parts = Path(path).parts + if parts[:2] == ('world', 'datapacks') and len(parts) > 3: + z.writestr(str(Path(*parts[3:])), text) + return jar + + +def carry(profile, mods, carried, packs=None): """`horizon-port-pack carry` of the profile's mods; the carried mods' data folders.""" work = lab.LOCAL / 'structure-layout' / profile / 'work' shutil.rmtree(work, ignore_errors=True) work.mkdir(parents=True) + mods = {key: str(jar_path(jar)) for key, jar in mods.items()} + mods.update({key: str(pack_jar(work, key, name)) for key, name in (packs or {}).items()}) records = [{'key': key, 'name': key, 'version': '', 'path': jar, 'tier': 'data', 'confidence': 'known', 'class': 'content' if key in carried else 'library', 'source': 'file', 'license': '', - 'client': '', 'server': '', 'depends': [], 'layer': 0, 'jar': str(jar_path(jar)), + 'client': '', 'server': '', 'depends': [], 'layer': 0, 'jar': jar, 'info': {'mod_id': key, 'name': key, 'version': '', 'license': '', 'depends': [], 'census': dict.fromkeys(CENSUS, 0)}} for key, jar in mods.items()] @@ -149,7 +178,8 @@ def overlay(profile, data_dirs): elif path.suffix == '.json' and kind == 'worldgen' and parts[2] in BUNDLES: text = path.read_text() if 'horizon:add_to' in text: - additions.append((BUNDLES[parts[2]], json.loads(text))) + value = json.loads(text) + additions.append((value.get('horizon:priority', 1000), BUNDLES[parts[2]], value)) else: merged[BUNDLES[parts[2]]][f'{namespace}:' + '/'.join(parts[3:])[:-5]] = text elif path.suffix == '.json' and parts[1:4] == ('tags', 'worldgen', 'biome'): @@ -158,11 +188,12 @@ def overlay(profile, data_dirs): if tag in merged['biome_tags'] and not value.get('replace'): value['values'] = json.loads(merged['biome_tags'][tag])['values'] + value['values'] merged['biome_tags'][tag] = json.dumps(value) - # A carried file with `horizon:add_to` adds its entries to other sets or pools, as the game's - # mod_content.rs merges them. - for bundle, addition in additions: + # A carried file with `horizon:add_to` adds its entries to other sets, pools or processor lists, by + # priority, as the game's mod_content.rs merges them. + lists = {'structure_sets': 'structures', 'template_pools': 'elements', 'processor_lists': 'processors'} + for _, bundle, addition in sorted(additions, key=lambda a: a[0]): targets = addition['horizon:add_to'] - key = 'structures' if bundle == 'structure_sets' else 'elements' + key = lists[bundle] for target in [targets] if isinstance(targets, str) else targets: if target in merged[bundle]: value = json.loads(merged[bundle][target]) @@ -209,6 +240,40 @@ def compare(expected, actual): return None +def check_limits(binary, resources, results, profile, pattern): + """Each limited template at most its limit in every start Horizon builds; starts holding it, Horizon + beside the oracle.""" + limits, failed = profile['limits'], [] + holding = {template: [0, 0] for template in limits} + starts = 0 + for path in results: + oracle = json.load(gzip.open(path)) + seed = oracle['seed'] + wanted = [s for s in oracle['structures'] if s.get('pieces') and re.search(pattern, s['id']) + and re.search(profile['structures'], s['id'])] + chunks = sorted({tuple(s['start_chunk']) for s in wanted}) + actual = horizon_starts(binary, resources, seed, 'overworld', chunks) + for start in wanted: + got = actual.get((start['id'], *start['start_chunk'])) + if got is None: + print(f'skip seed {seed} {start["id"]} chunk {start["start_chunk"]}: no start in Horizon') + continue + starts += 1 + for template, limit in limits.items(): + have = sum(p.get('element') == template for p in got) + want = sum(element(p['pool_element'].get('delegate', p['pool_element'])) == template + for p in start['pieces'] if p['id'] == 'minecraft:jigsaw') + holding[template][0] += have > 0 + holding[template][1] += want > 0 + if have > limit: + failed.append(f'seed {seed} {start["id"]} chunk {start["start_chunk"]}: {have} {template}') + for line in failed: + print(f'FAIL {line} (over its limit)') + for template, (ours, theirs) in holding.items(): + print(f'{template} (limit {limits[template]}): in {ours} of {starts} Horizon starts; the oracle: {theirs}') + return 1 if failed or not starts else 0 + + def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--profile', default='lithostitched', choices=sorted(PROFILES)) @@ -221,13 +286,17 @@ def main(): return 0 profile = PROFILES[args.profile] missing = [jar for jar in profile['mods'].values() if not jar_path(jar).exists()] + missing += [f'profiles/{n}.json' for n in profile.get('packs', {}).values() if not (ORACLE / 'profiles' / f'{n}.json').exists()] if missing: - print(f'SKIP: mod jars not in {JARS}: {", ".join(missing)}') + print(f'SKIP: not in {ORACLE}: {", ".join(missing)}') return 0 resources = lab.RESOURCES if profile['carry']: - resources = overlay(args.profile, carry(args.profile, profile['mods'], profile['carry'])) + data = carry(args.profile, profile['mods'], profile['carry'], profile.get('packs')) + resources = overlay(args.profile, data) binary = probe_binary() + if 'limits' in profile: + return check_limits(binary, resources, results, profile, args.filter) matched, known, failed = 0, 0, [] for path in results: oracle = json.load(gzip.open(path)) From 9b88df32d33ada4a3fc5335bb38a6a1e5714d625 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 19:54:15 +0100 Subject: [PATCH 004/968] Make goat horns play their sound and show the horn pose A goat horn did nothing when used. It now plays its instrument at the player, is held up for the instrument's duration, goes on cooldown, posts the instrument_play game event and counts as used. Other players hear it, see the arm raised to the head, and the tooltip names the call. --- crates/horizon-client/src/game.rs | 50 ++++++++++++++- crates/horizon-client/src/game/lan_actions.rs | 21 ++++++ crates/horizon-client/src/game/remote.rs | 36 +++++++++++ crates/horizon-client/src/item_tooltip.rs | 7 ++ crates/horizon-client/src/items.rs | 64 +++++++++++++++++++ crates/horizon-client/src/mobs/anim.rs | 12 ++++ crates/horizon-client/src/settings.rs | 3 + 7 files changed, 191 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index cca1f1ee65..fd4bf4f93b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3592,9 +3592,13 @@ impl Game<'_> { return; }; let group = horizon_client::cooldowns::group(stack, default.as_ref()); - self.cooldowns.add(Arc::clone(&group), component.ticks()); + self.add_cooldown(group, component.ticks()); + } + /// `ItemCooldowns.addCooldown` (a guest also gets the packet). + pub(super) fn add_cooldown(&mut self, group: Arc, ticks: i32) { + self.cooldowns.add(Arc::clone(&group), ticks); if self.lan_acting.is_some() { - self.guest_cooldowns.push((group, component.ticks())); + self.guest_cooldowns.push((group, ticks)); } } /// One hand's turn in `startUseItem`: `useItemOn` the block aimed at, @@ -3741,6 +3745,10 @@ impl Game<'_> { self.use_spyglass(); return Ok(true); } + if !on_block && &*stack.item == "minecraft:goat_horn" { + self.use_instrument(&stack); + return Ok(true); + } if !on_block && &*stack.item == "minecraft:bow" { if self.using.is_none() && (self.creative || self.projectile_slot("minecraft:bow").is_some()) @@ -3888,6 +3896,34 @@ impl Game<'_> { } true } + /// `InstrumentItem.use` for a goat horn: the player holds it up for the + /// instrument's `use_duration`, its sound plays at the player + /// (`SoundSource.RECORDS`, volume `range / 16`), `INSTRUMENT_PLAY` is + /// posted, the used statistic counts and the horn is on cooldown as long. + /// A horn whose instrument is unknown fails, which still ends the click. + fn use_instrument(&mut self, stack: &ItemStack) { + let Some(instrument) = self.res.items().instrument(stack).cloned() else { + return; + }; + if self.using.is_none() { + self.using = Some((self.held_slot(), 0)); + } + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + self.audio + .play(&instrument.sound, at, instrument.range / 16.0, 1.0); + self.post_entity_event( + "minecraft:instrument_play", + horizon_client::mobs::Who::Player, + ); + if instrument.use_duration > 0.0 { + let group = horizon_client::cooldowns::group(stack, None); + self.add_cooldown(group, instrument.use_ticks()); + } + self.stats.add("used", &stack.item, 1); + } fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) { use horizon_client::items::ConsumeEffect; match effect { @@ -4010,6 +4046,16 @@ impl Game<'_> { } return; } + if let Some(duration) = self + .res + .items() + .instrument(&stack) + .map(|i| i.use_ticks().max(0) as u32) + { + // InstrumentItem.getUseDuration: held up that long, nothing more. + self.using = (ticks + 1 < duration).then_some((slot, ticks + 1)); + return; + } let Some(consumable) = info.consumable.as_ref() else { self.using = None; return; diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 43bb573dc7..73bba39e01 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1097,6 +1097,7 @@ impl Game<'_> { let hand = if p.hand == 1 { Hand::Off } else { Hand::Main }; let g = &mut host.hub.guests[gi]; (g.yaw, g.pitch) = (p.yaw, p.pitch); + let heard = self.audio.capture.as_ref().map_or(0, Vec::len); let swung = self.as_guest(host, conn, |g| { g.guest_use = Some(GuestUse::Item(hand)); let swings = g.swings_started; @@ -1105,6 +1106,7 @@ impl Game<'_> { g.swings_started != swings }); self.lan_swing_back(host, conn, swung); + self.guest_instrument_sound(host, conn, heard); } S::Attack(p) => self.guest_attack(host, conn, p.id), S::Interact(p) => { @@ -2758,6 +2760,25 @@ impl Game<'_> { } } + /// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear + /// a guest's horn now, the guest's own client played it as it used it. + fn guest_instrument_sound(&mut self, host: &mut LanHost, conn: u32, heard: usize) { + let Some(capture) = self.audio.capture.as_mut() else { + return; + }; + let instruments = &self.res.items().instruments; + let mut i = heard.min(capture.len()); + while i < capture.len() { + if instruments.values().any(|x| x.sound == capture[i].0) { + let (name, at, volume, pitch) = capture.remove(i); + let source = horizon_client::settings::sound_source(&name) as i32; + host.hub.sound(&name, at, volume, pitch, source, Some(conn)); + } else { + i += 1; + } + } + } + /// Positional sounds and level events of the tick, for the guests. pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) { let sounds = self.audio.capture.take().unwrap_or_default(); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 3cfbd15e34..ebfbf8b153 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4334,6 +4334,12 @@ impl Game<'_> { } // SpyglassItem.use (its sound is the server's). "minecraft:spyglass" => true, + // InstrumentItem.use: the horn plays here too (`playSound(player, ...)` + // skips the player's own client), the server's copy tells the others. + "minecraft:goat_horn" => { + self.remote_use_instrument(held); + return; + } // ShieldItem.use: anything that blocks attacks, off cooldown. _ => { self.raise_shield(self.held_slot()); @@ -4344,6 +4350,27 @@ impl Game<'_> { self.using = Some((self.held_slot(), 0)); } } + /// `InstrumentItem.use` on this client: unless the horn is on cooldown, + /// it is held up, plays its sound at the player and starts its cooldown. + fn remote_use_instrument(&mut self, held: &ItemStack) { + let Some(instrument) = self.res.items().instrument(held).cloned() else { + return; + }; + let group = horizon_client::cooldowns::group(held, None); + if self.cooldowns.is_on_cooldown(&group) { + return; + } + self.using = Some((self.held_slot(), 0)); + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + self.audio + .play(&instrument.sound, at, instrument.range / 16.0, 1.0); + if instrument.use_duration > 0.0 { + self.add_cooldown(group, instrument.use_ticks()); + } + } /// Whether `stack` stays in use until the button lets go (bows, /// crossbows, tridents, spyglasses, shields): its use only counts ticks here. fn held_until_released(&self, stack: &ItemStack) -> bool { @@ -4389,6 +4416,15 @@ impl Game<'_> { self.using = in_hand.then_some((slot, ticks + 1)); return; } + // A goat horn is held up for its instrument's use duration. + if let Some(duration) = stack + .as_ref() + .and_then(|s| self.res.items().instrument(s)) + .map(|i| i.use_ticks().max(0) as u32) + { + self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1)); + return; + } let consumable = stack .as_ref() .and_then(|s| self.res.items().get(&s.item)) diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 88f01439e1..00285d0a2c 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -278,6 +278,13 @@ pub fn detail_lines( .replacen("%s", "5", 1), ); } + // InstrumentComponent.addToTooltip: the instrument's description. + if let Some(id) = crate::items::instrument_id(stack) + && TooltipDisplay::for_stack(stack).shows("minecraft:instrument") + { + let (namespace, name) = id.split_once(':').unwrap_or(("minecraft", &id)); + lines.push(text(&format!("instrument.{namespace}.{name}"))); + } let data = stack.data(); // PotDecorations.addToTooltip. if let Some(sherds) = data diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index b4092a2014..8d86cce9fe 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -255,6 +255,8 @@ pub struct Items { loot: HashMap, /// The `jukebox_song` registry. pub songs: HashMap, + /// The `instrument` registry (goat horns). + pub instruments: HashMap, /// The enchantment registry: loot functions enchant the items they roll /// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the /// station menus share this copy. @@ -274,6 +276,61 @@ impl Song { ticks >= self.length_ticks + 20 } } +/// An `Instrument`: what a goat horn plays. +#[derive(Clone, Debug)] +pub struct Instrument { + pub sound: String, + /// Seconds the horn stays up, and its cooldown. + pub use_duration: f32, + /// How far it carries, in blocks (`range / 16` is its volume). + pub range: f32, +} +impl Instrument { + /// `InstrumentItem.getUseDuration`: `Mth.floor(useDuration * 20)`. + pub fn use_ticks(&self) -> i32 { + (self.use_duration * 20.0).floor() as i32 + } +} +/// The id of a goat horn's instrument (`InstrumentItem.getInstrument`): the +/// stack's own `instrument` component, else the item's `ponder_goat_horn`. +pub fn instrument_id(stack: &ItemStack) -> Option { + if &*stack.item != "minecraft:goat_horn" { + return None; + } + match stack.data().and_then(|d| d.other("minecraft:instrument")) { + Some(horizon_world::nbt::Tag::String(id)) => Some(id.to_string_lossy()), + _ => Some("minecraft:ponder_goat_horn".to_owned()), + } +} +fn load_instruments(root: &Path) -> HashMap { + let mut instruments = HashMap::new(); + let Ok(dir) = fs::read_dir(root.join("data/minecraft/instrument")) else { + return instruments; + }; + for entry in dir.flatten() { + let path = entry.path(); + let (Some(stem), Ok(bytes)) = (path.file_stem().and_then(|s| s.to_str()), fs::read(&path)) + else { + continue; + }; + let Ok(v) = serde_json::from_slice::(&bytes) else { + continue; + }; + let sound = match &v["sound_event"] { + Value::String(s) => s.clone(), + other => other["sound_id"].as_str().unwrap_or("").to_owned(), + }; + instruments.insert( + format!("minecraft:{stem}"), + Instrument { + sound, + use_duration: v["use_duration"].as_f64().unwrap_or(0.0) as f32, + range: v["range"].as_f64().unwrap_or(0.0) as f32, + }, + ); + } + instruments +} fn load_songs(root: &Path) -> HashMap { let mut songs = HashMap::new(); let Ok(dir) = fs::read_dir(root.join("data/minecraft/jukebox_song")) else { @@ -714,12 +771,17 @@ impl Items { item_tag_order: load_tag_lists(root, "item")?, loot, songs: load_songs(root), + instruments: load_instruments(root), enchantments: Arc::new(crate::enchant::Enchantments::load(root)), }) } pub fn get(&self, id: &str) -> Option<&ItemInfo> { self.items.get(id) } + /// The instrument a goat horn plays, if its `instrument` names one. + pub fn instrument(&self, stack: &ItemStack) -> Option<&Instrument> { + self.instruments.get(&instrument_id(stack)?) + } /// Only the block and item tags (and the enchantment registry, which the /// station rules and loot functions are built from), for tools that need /// tag membership. @@ -733,6 +795,7 @@ impl Items { item_tag_order: load_tag_lists(root, "item")?, loot: HashMap::new(), songs: HashMap::new(), + instruments: HashMap::new(), enchantments: Arc::new(crate::enchant::Enchantments::load(root)), }) } @@ -751,6 +814,7 @@ impl Items { item_tags, loot: HashMap::new(), songs: HashMap::new(), + instruments: HashMap::new(), enchantments: Arc::new(crate::enchant::Enchantments::default()), } } diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index b81f224ae7..e776083594 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -243,6 +243,8 @@ pub enum ArmPose { CrossbowCharge, /// Raising a trident to throw it (or to launch with Riptide). ThrowTrident, + /// Playing a goat horn. + TootHorn, } /// HumanoidModel.setupAnim with empty hands (endermen). pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) { @@ -376,6 +378,10 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) { p.xrot(arm, x * 0.5 - PI); p.yrot(arm, 0.0); } + ArmPose::TootHorn => { + p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8); + p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 }); + } ArmPose::CrossbowHold => crossbow_hold(p, hx, hy, right), ArmPose::CrossbowCharge => crossbow_charge(p, h.charge.0, h.charge.1, right), } @@ -677,6 +683,7 @@ pub fn setup( Some(_) if using_main && m.extra.blocking => ArmPose::Block, Some("minecraft:bow") if using_main => ArmPose::Bow, Some("minecraft:trident") if using_main => ArmPose::ThrowTrident, + Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn, Some("minecraft:crossbow") if using_main && !m.extra.charged => { ArmPose::CrossbowCharge } @@ -692,6 +699,11 @@ pub fn setup( let off = if player && m.extra.off_hand.is_some() && !pose.affects_offhand() { if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking { ArmPose::Block + } else if m.extra.using_item > 0 + && m.extra.using_off_hand + && m.extra.off_hand.as_deref() == Some("minecraft:goat_horn") + { + ArmPose::TootHorn } else { ArmPose::Item } diff --git a/crates/horizon-client/src/settings.rs b/crates/horizon-client/src/settings.rs index 74c4289236..2d5bb12dcb 100644 --- a/crates/horizon-client/src/settings.rs +++ b/crates/horizon-client/src/settings.rs @@ -53,6 +53,9 @@ pub fn sound_source(event: &str) -> usize { index("ambient") } else if e.starts_with("music_disc.") { index("record") + } else if e.starts_with("item.goat_horn.") { + // InstrumentItem.play: SoundSource.RECORDS. + index("record") } else if e.starts_with("music.") { index("music") } else if e.starts_with("weather.") || e.starts_with("entity.lightning_bolt") { From 75267d2fc644780486de1f7330f3548f653abbff Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Mon, 26 Oct 2026 21:43:33 +0100 Subject: [PATCH 005/968] Read inline goat horn instruments as well as registry ids --- crates/horizon-client/src/game.rs | 2 +- crates/horizon-client/src/game/lan_actions.rs | 28 ++++++++--- crates/horizon-client/src/game/remote.rs | 2 +- crates/horizon-client/src/item_tooltip.rs | 14 ++++-- crates/horizon-client/src/items.rs | 49 +++++++++++++------ 5 files changed, 66 insertions(+), 29 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index fd4bf4f93b..178988192d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3902,7 +3902,7 @@ impl Game<'_> { /// posted, the used statistic counts and the horn is on cooldown as long. /// A horn whose instrument is unknown fails, which still ends the click. fn use_instrument(&mut self, stack: &ItemStack) { - let Some(instrument) = self.res.items().instrument(stack).cloned() else { + let Some(instrument) = self.res.items().instrument(stack) else { return; }; if self.using.is_none() { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 73bba39e01..f0a3d6a6cd 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1098,15 +1098,24 @@ impl Game<'_> { let g = &mut host.hub.guests[gi]; (g.yaw, g.pitch) = (p.yaw, p.pitch); let heard = self.audio.capture.as_ref().map_or(0, Vec::len); - let swung = self.as_guest(host, conn, |g| { + let used = self.as_guest(host, conn, |g| { + // A horn's sound, inline instruments included. + let horn = g.with_hand(hand, |g| { + let held = g.held()?; + g.res.items().instrument(held).map(|i| i.sound) + }); g.guest_use = Some(GuestUse::Item(hand)); let swings = g.swings_started; let _ = g.use_item(); g.guest_use = None; - g.swings_started != swings + (g.swings_started != swings, horn) }); + let (swung, horn) = match used { + Some((swung, horn)) => (Some(swung), horn), + None => (None, None), + }; self.lan_swing_back(host, conn, swung); - self.guest_instrument_sound(host, conn, heard); + self.guest_instrument_sound(host, conn, heard, horn); } S::Attack(p) => self.guest_attack(host, conn, p.id), S::Interact(p) => { @@ -2762,14 +2771,19 @@ impl Game<'_> { /// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear /// a guest's horn now, the guest's own client played it as it used it. - fn guest_instrument_sound(&mut self, host: &mut LanHost, conn: u32, heard: usize) { - let Some(capture) = self.audio.capture.as_mut() else { + fn guest_instrument_sound( + &mut self, + host: &mut LanHost, + conn: u32, + heard: usize, + horn: Option, + ) { + let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else { return; }; - let instruments = &self.res.items().instruments; let mut i = heard.min(capture.len()); while i < capture.len() { - if instruments.values().any(|x| x.sound == capture[i].0) { + if capture[i].0 == horn { let (name, at, volume, pitch) = capture.remove(i); let source = horizon_client::settings::sound_source(&name) as i32; host.hub.sound(&name, at, volume, pitch, source, Some(conn)); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index ebfbf8b153..714b3a2f91 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4353,7 +4353,7 @@ impl Game<'_> { /// `InstrumentItem.use` on this client: unless the horn is on cooldown, /// it is held up, plays its sound at the player and starts its cooldown. fn remote_use_instrument(&mut self, held: &ItemStack) { - let Some(instrument) = self.res.items().instrument(held).cloned() else { + let Some(instrument) = self.res.items().instrument(held) else { return; }; let group = horizon_client::cooldowns::group(held, None); diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 00285d0a2c..d3edffed13 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -279,11 +279,15 @@ pub fn detail_lines( ); } // InstrumentComponent.addToTooltip: the instrument's description. - if let Some(id) = crate::items::instrument_id(stack) - && TooltipDisplay::for_stack(stack).shows("minecraft:instrument") - { - let (namespace, name) = id.split_once(':').unwrap_or(("minecraft", &id)); - lines.push(text(&format!("instrument.{namespace}.{name}"))); + if TooltipDisplay::for_stack(stack).shows("minecraft:instrument") { + if let Some(id) = crate::items::instrument_id(stack) { + let (namespace, name) = id.split_once(':').unwrap_or(("minecraft", &id)); + lines.push(text(&format!("instrument.{namespace}.{name}"))); + } else if let Some(description) = + crate::items::inline_instrument(stack).and_then(|tag| tag.get("description")) + { + lines.push(crate::entity_nbt::name_text(description).plain()); + } } let data = stack.data(); // PotDecorations.addToTooltip. diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index 8d86cce9fe..89348badf1 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -293,15 +293,41 @@ impl Instrument { } /// The id of a goat horn's instrument (`InstrumentItem.getInstrument`): the /// stack's own `instrument` component, else the item's `ponder_goat_horn`. +/// None for an inline instrument (see `inline_instrument`). pub fn instrument_id(stack: &ItemStack) -> Option { if &*stack.item != "minecraft:goat_horn" { return None; } match stack.data().and_then(|d| d.other("minecraft:instrument")) { Some(horizon_world::nbt::Tag::String(id)) => Some(id.to_string_lossy()), + Some(horizon_world::nbt::Tag::Compound(_)) => None, _ => Some("minecraft:ponder_goat_horn".to_owned()), } } +/// A goat horn's inline `instrument` component (`Instrument.CODEC` also +/// takes the instrument itself instead of a registry id). +pub fn inline_instrument(stack: &ItemStack) -> Option<&horizon_world::nbt::Tag> { + if &*stack.item != "minecraft:goat_horn" { + return None; + } + match stack.data().and_then(|d| d.other("minecraft:instrument")) { + Some(tag @ horizon_world::nbt::Tag::Compound(_)) => Some(tag), + _ => None, + } +} +/// `Instrument.DIRECT_CODEC`: a `sound_event` that is an id or `{sound_id}`, +/// `use_duration` and `range`. +fn parse_instrument(v: &Value) -> Instrument { + let sound = match &v["sound_event"] { + Value::String(s) => s.clone(), + other => other["sound_id"].as_str().unwrap_or("").to_owned(), + }; + Instrument { + sound, + use_duration: v["use_duration"].as_f64().unwrap_or(0.0) as f32, + range: v["range"].as_f64().unwrap_or(0.0) as f32, + } +} fn load_instruments(root: &Path) -> HashMap { let mut instruments = HashMap::new(); let Ok(dir) = fs::read_dir(root.join("data/minecraft/instrument")) else { @@ -316,18 +342,7 @@ fn load_instruments(root: &Path) -> HashMap { let Ok(v) = serde_json::from_slice::(&bytes) else { continue; }; - let sound = match &v["sound_event"] { - Value::String(s) => s.clone(), - other => other["sound_id"].as_str().unwrap_or("").to_owned(), - }; - instruments.insert( - format!("minecraft:{stem}"), - Instrument { - sound, - use_duration: v["use_duration"].as_f64().unwrap_or(0.0) as f32, - range: v["range"].as_f64().unwrap_or(0.0) as f32, - }, - ); + instruments.insert(format!("minecraft:{stem}"), parse_instrument(&v)); } instruments } @@ -778,9 +793,13 @@ impl Items { pub fn get(&self, id: &str) -> Option<&ItemInfo> { self.items.get(id) } - /// The instrument a goat horn plays, if its `instrument` names one. - pub fn instrument(&self, stack: &ItemStack) -> Option<&Instrument> { - self.instruments.get(&instrument_id(stack)?) + /// The instrument a goat horn plays: its `instrument` component's + /// registry entry or inline instrument. + pub fn instrument(&self, stack: &ItemStack) -> Option { + if let Some(tag) = inline_instrument(stack) { + return Some(parse_instrument(&crate::scoreboard::tag_to_json(tag))); + } + self.instruments.get(&instrument_id(stack)?).cloned() } /// Only the block and item tags (and the enchantment registry, which the /// station rules and loot functions are built from), for tools that need From 730184c64f5ae6d78a64b41106aa00370a0a90ed Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 27 Oct 2026 04:06:09 +0500 Subject: [PATCH 006/968] Riding a horse survives a save and a reopen The player's file now names the vehicle it rides, the way vanilla's ServerPlayer.saveParentVehicle does, and the ride comes back on the next login in both single player's modes. - The player compound holds a "RootVehicle" compound whose "Attach" is the UUID of the vehicle at the root of the rider chain (Entity.getRootVehicle). Horizon keeps the vehicle in the world, so the file names it instead of holding a second copy of it: the horse and its equipment stay in the chunk's entity file, and nothing is duplicated. - The single player's own player writes the vehicle it rides, and a server's guests write theirs; on the next login the vehicle is looked up by its UUID among the entities in the world, and the player rides it again. - A guest whose file names a vehicle gets back on it once the vehicle is loaded, as ServerPlayer.loadAndSpawnParentVehicle does. Co-Authored-By: minimax-m3.1-flash (Claude Code) --- crates/horizon-client/src/game.rs | 11 ++++ crates/horizon-client/src/game/dedicated.rs | 9 ++- crates/horizon-client/src/game/lan.rs | 50 ++++++++++++-- crates/horizon-client/src/game/persist.rs | 51 +++++++++++++++ crates/horizon-client/src/lan/guest.rs | 4 ++ crates/horizon-client/src/lan/playerdata.rs | 72 +++++++++++++++++++-- crates/horizon-client/src/save.rs | 20 ++++++ 7 files changed, 204 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 807a1d2cfd..4816dc28d0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1548,6 +1548,9 @@ struct Game<'a> { cart_models: minecarts::CartModels, /// The boat the player rides. riding: Option, + /// The vehicle the player's file names, waiting for it to be in the world + /// (`ServerPlayer.loadAndSpawnParentVehicle`). + pending_vehicle: Option<[i32; 4]>, /// The ender inventory (27 slots) and the ender chest showing it. ender_items: Vec>, ender_open: Option<(i32, i32, i32)>, @@ -4752,6 +4755,8 @@ impl Game<'_> { horizon_client::spans::time("touch_arrows", || self.touch_arrows()); } horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities()); + // The ride the player's file names, once the vehicle is in the world. + self.restore_root_vehicle(); if running { horizon_client::spans::time("tick_mobs", || self.tick_mobs())?; horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers()); @@ -8202,6 +8207,8 @@ impl Game<'_> { self.world = world; self.inventory = inventory; self.save = save; + // The ride the player's file names, once the vehicle is in the world. + self.pending_vehicle = level.root_vehicle; self.level = level; // ServerRecipeBook.loadUntrusted: recipes the manager does not have are dropped. let index = &self.res.stations().recipe_index; @@ -10455,6 +10462,8 @@ impl Game<'_> { self.level.game_rules = self.ui.rules.by_name(); self.level.selected_slot = self.selected as i32; let mode = self.mode_id(); + // The vehicle the player rides (`ServerPlayer.saveParentVehicle`). + let vehicle = self.root_vehicle_uuid(self.riding); let level = &mut self.level; level.creative = self.creative; level.game_mode = mode; @@ -10467,6 +10476,7 @@ impl Game<'_> { level.xp_level = self.experience.level; level.xp_progress = self.experience.progress; level.xp_total = self.experience.total; + level.root_vehicle = vehicle; if let Some(p) = &self.player { level.position = Some([p.position.x, p.position.y, p.position.z]); level.health = p.health.health(); @@ -12786,6 +12796,7 @@ impl<'a> Game<'a> { trim_slots: Vec::new(), cart_models: prepared.cart_models, riding: None, + pending_vehicle: None, ender_items: vec![None; 27], ender_open: None, pending: Default::default(), diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index fa53651958..803000dc23 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -1271,10 +1271,13 @@ impl Game<'_> { fn stop_dedicated(&mut self) -> Result<()> { log("INFO", "Stopping server"); log("INFO", "Saving players"); - if let Some(h) = self.lan.as_mut() { - for g in &h.hub.guests { - horizon_client::lan::playerdata::save(g, &self.world_root); + if let Some(host) = self.lan.take() { + for g in &host.hub.guests { + // The vehicle the guest rides, for the file that names it. + let vehicle = self.root_vehicle_uuid(g.vehicle); + horizon_client::lan::playerdata::save(g, &self.world_root, vehicle); } + self.lan = Some(host); } if let Some(h) = self.lan.as_ref() { for g in &h.hub.guests { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index ce65c52bc6..efe77d4d0a 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -677,15 +677,37 @@ impl Game<'_> { /// Save a guest when they leave or the world closes: an integrated /// server's owner with the world (`save_game`), anyone else in their file. pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) { + // The vehicle the guest rides, for the file that names it. + let vehicle = self.root_vehicle_uuid(guest.vehicle); if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) { if guest.placed { - playerdata::store(guest, &mut self.level); + playerdata::store(guest, &mut self.level, vehicle); } } else { - playerdata::save(guest, &self.world_root); + playerdata::save(guest, &self.world_root, vehicle); } self.save_guest_records(host, guest); } + /// `ServerPlayer.loadAndSpawnParentVehicle`: a guest whose file names the + /// vehicle it rode rides it again, once the vehicle is in the world. + fn restore_guest_vehicles(&mut self, host: &mut LanHost) { + let waiting: Vec<(usize, [i32; 4])> = host + .hub + .guests + .iter() + .enumerate() + .filter_map(|(i, g)| Some((i, g.saved_vehicle?))) + .filter(|(i, _)| host.hub.guests[*i].vehicle.is_none()) + .collect(); + for (i, uuid) in waiting { + let Some(id) = self.vehicle_by_uuid(uuid) else { + continue; + }; + let g = &mut host.hub.guests[i]; + g.vehicle = Some(id); + g.saved_vehicle = None; + } + } /// The file name of a player's advancements and statistics: an /// integrated server's owner keeps single player's. fn records_uuid(&self, host: &LanHost, uuid: u128) -> Option<[i32; 4]> { @@ -756,14 +778,28 @@ impl Game<'_> { } /// The integrated server's owner into the world's player, for `Level::write`. pub(super) fn store_owner(&mut self) { + let Some(uuid) = self + .lan + .as_ref() + .and_then(|h| h.owner.as_ref()) + .map(|o| o.uuid) + else { + return; + }; + let riding = self.lan.as_ref().and_then(|h| { + h.hub + .guests + .iter() + .find(|g| g.uuid == uuid && g.placed) + .and_then(|g| g.vehicle) + }); + // The vehicle the owner rides, by the UUID it is saved with. + let vehicle = self.root_vehicle_uuid(riding); let Some(host) = &self.lan else { return; }; - let Some(uuid) = host.owner.as_ref().map(|o| o.uuid) else { - return; - }; if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) { - playerdata::store(g, &mut self.level); + playerdata::store(g, &mut self.level, vehicle); self.save_guest_records(host, g); } } @@ -921,6 +957,8 @@ impl Game<'_> { m.rider = None; } } + // A guest whose file names the vehicle it rode rides it again. + self.restore_guest_vehicles(&mut host); // A dedicated server runs the hub without a host player. if state.is_some() || host.hub.config.dedicated { let has_guests = !host.hub.guests.is_empty(); diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index b5e650e708..d24aaf09b2 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -517,6 +517,57 @@ impl Game<'_> { } i } + /// The vehicle at the root of the rider chain (`Entity.getRootVehicle`): + /// the mob the ridden vehicle rides, when it rides one. + pub(super) fn root_vehicle(&self, id: u32) -> u32 { + let mut root = id; + for _ in 0..8 { + let Some(v) = self + .mobs + .iter() + .find(|m| m.id == root) + .and_then(|m| m.extra.mount) + else { + break; + }; + root = v; + } + root + } + /// The UUID of the vehicle the player rides, for the player's file + /// (`ServerPlayer.saveParentVehicle`). + pub(super) fn root_vehicle_uuid(&mut self, id: Option) -> Option<[i32; 4]> { + let id = self.root_vehicle(id?); + Some(self.uuid_for(id)) + } + /// The vehicle with this UUID, among the ones in the world. + pub(super) fn vehicle_by_uuid(&self, uuid: [i32; 4]) -> Option { + let id = *self + .persist + .uuids + .iter() + .find(|(_, u)| **u == uuid) + .map(|(id, _)| id)?; + let there = self.mobs.iter().any(|m| m.id == id && m.alive()) + || self.boats.iter().any(|b| b.id == id) + || self.carts.iter().any(|c| c.id == id); + there.then_some(id) + } + /// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file + /// names, once the vehicle is in the world. + pub(super) fn restore_root_vehicle(&mut self) { + if self.riding.is_some() || self.player.is_none() { + return; + } + let Some(uuid) = self.pending_vehicle else { + return; + }; + let Some(id) = self.vehicle_by_uuid(uuid) else { + return; + }; + self.pending_vehicle = None; + self.riding = Some(id); + } /// A mob's entity tag with the mobs riding it under `Passengers`. pub(super) fn mob_tree_nbt(&mut self, i: usize) -> Tag { let id = self.mobs[i].id; diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 7e43cf04e8..3a4c47fe58 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -160,6 +160,9 @@ pub struct Guest { /// What the player's file says of the body (`Entity.load`: the fire /// and air ticks and the fall distance), for the first body it gets. pub loaded_body: Option, + /// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s + /// "RootVehicle"), for the ride to come back on the next login. + pub saved_vehicle: Option<[i32; 4]>, /// The hurt and attack logs of the body it had before it respawned, /// until the new body takes them over (the control socket's `hurtlog` /// reads across a death). @@ -319,6 +322,7 @@ impl Guest { tracker: Tracker::default(), motion: None, loaded_body: None, + saved_vehicle: None, logs_kept: None, inventory: vec![None; 36], armor: [None, None, None, None], diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index f5eb935c9b..744754a849 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -97,6 +97,7 @@ pub fn apply(guest: &mut Guest, level: &Level) { air: short("Air", 300), fall_distance: level.fall_distance, }); + guest.saved_vehicle = level.root_vehicle; guest.recipe_book = level.recipe_book.clone(); guest.dimension = match level.dimension.as_str() { d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(), @@ -128,12 +129,12 @@ pub fn apply(guest: &mut Guest, level: &Level) { } /// Save a guest (PlayerDataStorage.save). -pub fn save(guest: &Guest, world: &Path) { +pub fn save(guest: &Guest, world: &Path, vehicle: Option<[i32; 4]>) { let mut level = Level { player_uuid: Some(uuid_ints(guest.uuid)), ..Level::default() }; - store(guest, &mut level); + store(guest, &mut level, vehicle); let file = path(world, guest.uuid); if let Some(dir) = file.parent() { let _ = std::fs::create_dir_all(dir); @@ -142,7 +143,7 @@ pub fn save(guest: &Guest, world: &Path) { } /// Put a guest's state in `level`'s player fields. -pub fn store(guest: &Guest, level: &mut Level) { +pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<[i32; 4]>) { level.position = Some(guest.position); level.dimension = guest.dimension.clone(); level.rotation = [guest.yaw, guest.pitch]; @@ -169,6 +170,8 @@ pub fn store(guest: &Guest, level: &mut Level) { level.on_ground = guest.on_ground; level.seen_credits = guest.seen_credits; level.player_extra = guest.saved.clone(); + // The vehicle the player rides (`ServerPlayer.saveParentVehicle`). + level.root_vehicle = vehicle; // The live body's fire, air and fall distance (Entity.saveWithoutId), // or what the file had while the player has no body yet. let body = guest @@ -249,7 +252,7 @@ mod tests { fall_distance: 9.5, }); let mut level = Level::default(); - store(&g, &mut level); + store(&g, &mut level, None); let tag = level.player_tag(); let mut read = Level::default(); read.read_player(&tag); @@ -258,4 +261,65 @@ mod tests { apply(&mut back, &read); assert_eq!(back.loaded_body, g.loaded_body); } + + /// The resources the prepared game data holds, for a body to breathe with. + fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> { + let root = std::env::var_os("HORIZON_TEST_RESOURCES") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION) + }); + if !root.join("movement-properties.nbt").exists() { + return None; + } + let assets = crate::assets::Assets::load(&root).ok()?; + let resources = crate::motion::MotionResources::load(&root, &assets).ok()?; + Some((assets, resources)) + } + + #[test] + fn a_saved_air_comes_back() { + // A player saved underwater (#1321): the file keeps the remaining air + // (Entity.saveWithoutId's Air), and the body that comes back breathes + // on it instead of starting with a full supply. + let Some((_assets, resources)) = resources() else { + eprintln!("skipped the air round trip: prepared resources unavailable"); + return; + }; + let mut g = guest(); + let mut motion = + crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO) + .expect("a body"); + motion.air = 99; + g.motion = Some(Box::new(motion)); + let mut level = Level::default(); + store(&g, &mut level, None); + let tag = level.player_tag(); + let mut read = Level::default(); + read.read_player(&tag); + read.player_extra = Some(tag); + let mut back = guest(); + apply(&mut back, &read); + assert_eq!(back.loaded_body.expect("a body").air, 99); + } + + #[test] + fn a_saved_root_vehicle_comes_back() { + // A player saved riding a horse (#1474): the file names the vehicle + // (ServerPlayer.saveParentVehicle's RootVehicle), and the next login + // finds it and rides it again. + let vehicle = [1, 2, 3, 4]; + let g = guest(); + let mut level = Level::default(); + store(&g, &mut level, Some(vehicle)); + let tag = level.player_tag(); + let mut read = Level::default(); + read.read_player(&tag); + read.player_extra = Some(tag); + let mut back = guest(); + apply(&mut back, &read); + assert_eq!(back.saved_vehicle, Some(vehicle)); + } } diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 5431adcd62..4bb6ec428d 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -2797,6 +2797,9 @@ pub struct Level { pub food_tick_timer: i32, /// Abilities.flying: whether creative/spectator flight is active. pub flying: bool, + /// The vehicle the player rides, by UUID (`ServerPlayer.saveParentVehicle`'s + /// "RootVehicle"), so the ride comes back on the next login. + pub root_vehicle: Option<[i32; 4]>, /// The player compound as loaded, for fields Horizon does not model /// (ender chest, effects, abilities, recipe book, attributes, ...). pub player_extra: Option, @@ -3049,6 +3052,12 @@ impl Level { .collect(), _ => Vec::new(), }; + // ServerPlayer.saveParentVehicle: the vehicle the player rides, by + // UUID. Horizon keeps the vehicle in the world, so the file names it + // instead of holding a second copy of it. + self.root_vehicle = p + .get("RootVehicle") + .and_then(|r| uuid_from_tag(r.get("Attach"))); } /// The player compound: the loaded one with Horizon's state written over it. pub fn player_tag(&self) -> Tag { @@ -3063,6 +3072,7 @@ impl Level { "Pos", "respawn", "recipeBook", + "RootVehicle", ] { map.remove(&JavaString::from(key)); } @@ -3242,6 +3252,16 @@ impl Level { map.entry(key.into()).or_insert(value); } } + // ServerPlayer.saveParentVehicle: the vehicle the player rides, by + // UUID. Horizon keeps the vehicle in the world, so the file names it + // instead of holding a second copy of it. + if let Some(vehicle) = self.root_vehicle { + put( + &mut p, + "RootVehicle", + Tag::compound([("Attach", uuid_tag(vehicle))]), + ); + } p } /// Write every file; a world without a player UUID gets a random one. From 32467320878b006eaa10017b18ea0815e3eb0ac4 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:12:13 +0100 Subject: [PATCH 007/968] Read the inline instrument through horizon_client_core::scoreboard #2215 moved items.rs into horizon-items, where scoreboard is not in the crate root. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-items/src/items.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/horizon-items/src/items.rs b/crates/horizon-items/src/items.rs index 9f779a5559..e0b6d5bf4b 100644 --- a/crates/horizon-items/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -839,7 +839,9 @@ impl Items { /// registry entry or inline instrument. pub fn instrument(&self, stack: &ItemStack) -> Option { if let Some(tag) = inline_instrument(stack) { - return Some(parse_instrument(&crate::scoreboard::tag_to_json(tag))); + return Some(parse_instrument( + &horizon_client_core::scoreboard::tag_to_json(tag), + )); } self.instruments.get(&instrument_id(stack)?).cloned() } From e204d3c028022e70f31a01f5093ef2e5c12bcfba Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:18:00 +0100 Subject: [PATCH 008/968] Drink a potion's custom effects at its duration scale A stack's potion_contents lost custom_effects and custom_color on load, and drinking used scale 1. Vanilla (PotionContents.onConsume) applies the potion and custom effects at potion_duration_scale. --- crates/horizon-client/src/brewing.rs | 16 +++++++ crates/horizon-client/src/effects.rs | 37 +++++++++++----- crates/horizon-client/src/game/potions.rs | 7 ++- crates/horizon-items/src/item_data.rs | 54 ++++++++++++++++++++++- 4 files changed, 101 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/brewing.rs b/crates/horizon-client/src/brewing.rs index e058f0fe7a..ac39f563fc 100644 --- a/crates/horizon-client/src/brewing.rs +++ b/crates/horizon-client/src/brewing.rs @@ -30,6 +30,22 @@ pub fn potion_name_key(item: &str, potion: Option<&str>) -> String { let suffix = potion.and_then(self::potion).map_or("empty", |p| p.name); format!("item.minecraft.{prefix}.effect.{suffix}") } +/// `PotionContents.getAllEffects`: the potion's effects, then the stack's +/// `custom_effects` (id, duration, amplifier). Unknown effect ids are dropped. +pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> { + let potion = stack.potion().and_then(self::potion); + let custom = stack.custom_potion_effects(); + potion + .map_or(&[][..], |d| d.effects) + .iter() + .copied() + .chain( + custom + .iter() + .filter_map(|(id, duration, amp)| Some((effect(id)?.id, *duration, *amp))), + ) + .collect() +} /// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion. pub fn tooltip( potion: Option<&str>, diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index 5365a9d5c5..bc698d5302 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -144,6 +144,15 @@ pub fn instant(id: &str, amp: i32, scale: f64) -> Option { _ => None, } } +/// `MobEffectInstance.withScaledDuration`: max(floor(d * scale), 1), leaving +/// an infinite (-1) or zero duration alone. +pub fn scaled_duration(duration: i32, scale: f32) -> i32 { + if duration == -1 || duration == 0 { + duration + } else { + ((duration as f32 * scale).floor() as i32).max(1) + } +} /// What an omen's `applyEffectTick` decided. #[derive(Clone, Debug, PartialEq)] pub enum OmenTick { @@ -304,19 +313,27 @@ impl PlayerEffects { pub fn potion_effects( potion: Option<&str>, scale: f32, + ) -> (Vec, Vec) { + let effects = potion.and_then(potions::potion).map_or(&[][..], |d| d.effects); + Self::contents_effects(effects, scale) + } + /// `PotionContents.forEachEffect`'s effects, as `potion_effects`. + pub fn contents_effects( + effects: &[(&'static str, i32, i32)], + scale: f32, ) -> (Vec, Vec) { let mut lasting = Vec::new(); let mut now = Vec::new(); - if let Some(def) = potion.and_then(potions::potion) { - for (id, duration, amp) in def.effects { - let id = potions::effect(id).map_or("speed", |e| e.id); - if potions::effect(id).is_some_and(|e| e.instant) { - now.extend(instant(id, *amp, 1.0)); - } else { - // withScaledDuration: max(floor(d * scale), 1). - let d = ((*duration as f32) * scale).floor() as i32; - lasting.push(EffectInstance::new(id, d.max(1), *amp)); - } + for (id, duration, amp) in effects { + let id = potions::effect(id).map_or("speed", |e| e.id); + if potions::effect(id).is_some_and(|e| e.instant) { + now.extend(instant(id, *amp, 1.0)); + } else { + lasting.push(EffectInstance::new( + id, + scaled_duration(*duration, scale), + *amp, + )); } } (lasting, now) diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index affd68782e..4b57cf25a2 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -553,7 +553,12 @@ impl Game<'_> { self.drink_ominous_bottle(slot, stack); return true; } - let (lasting, now) = PlayerEffects::potion_effects(stack.potion(), 1.0); + // PotionContents.onConsume: the potion's and the custom effects, at the + // stack's POTION_DURATION_SCALE. + let (lasting, now) = PlayerEffects::contents_effects( + &brewing::contents_effects(stack), + stack.potion_duration_scale(), + ); for e in lasting { self.add_player_effect(e); } diff --git a/crates/horizon-items/src/item_data.rs b/crates/horizon-items/src/item_data.rs index cfcbae869a..4095c879a4 100644 --- a/crates/horizon-items/src/item_data.rs +++ b/crates/horizon-items/src/item_data.rs @@ -20,6 +20,9 @@ pub struct StackData { pub custom_name: Option, /// `minecraft:potion_contents.potion`. pub potion: Option>, + /// The rest of `minecraft:potion_contents` (`custom_color`, `custom_effects`, + /// `custom_name`), kept as saved. + pub potion_extra: Vec<(String, Tag)>, /// `minecraft:trim`: (material, pattern). pub trim: Option<(Arc, Arc)>, /// `minecraft:container` (shulker boxes): the non-empty slots. @@ -257,6 +260,39 @@ impl ItemStack { pub fn potion(&self) -> Option<&str> { self.data().and_then(|d| d.potion.as_deref()) } + /// `PotionContents.customEffects` as saved: (id, duration, amplifier). Both + /// default to 0 like `MobEffectInstance.CODEC`. + pub fn custom_potion_effects(&self) -> Vec<(String, i32, i32)> { + let Some((_, Tag::List(list))) = self + .data() + .and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects")) + else { + return Vec::new(); + }; + list.iter() + .filter_map(|e| { + Some(( + string(e.get("id")?)?, + e.get("duration").and_then(int).unwrap_or(0) as i32, + e.get("amplifier").and_then(int).unwrap_or(0).clamp(0, 255) as i32, + )) + }) + .collect() + } + /// `DataComponents.POTION_DURATION_SCALE` with the item's default: 0.125 + /// for a tipped arrow, 0.25 for a lingering potion, else 1. + pub fn potion_duration_scale(&self) -> f32 { + match self.data().and_then(|d| d.other("minecraft:potion_duration_scale")) { + Some(Tag::Float(bits)) => f32::from_bits(*bits), + Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32, + Some(t) if int(t).is_some() => int(t).unwrap_or(1) as f32, + _ => match &*self.item { + "minecraft:tipped_arrow" => 0.125, + "minecraft:lingering_potion" => 0.25, + _ => 1.0, + }, + } + } pub fn with_potion(mut self, potion: &str) -> Self { self.data_mut().potion = Some(Arc::from(potion)); self @@ -547,10 +583,15 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { if let Some(name) = &d.custom_name { entries.push(("minecraft:custom_name", Tag::String(name.as_str().into()))); } - if let Some(potion) = &d.potion { + if d.potion.is_some() || !d.potion_extra.is_empty() { + let potion = d + .potion + .as_ref() + .map(|p| ("potion", Tag::String((&**p).into()))); + let extra = d.potion_extra.iter().map(|(k, v)| (k.as_str(), v.clone())); entries.push(( "minecraft:potion_contents", - Tag::compound([("potion", Tag::String((&**potion).into()))]), + Tag::compound(potion.into_iter().chain(extra)), )); } if let Some((material, pattern)) = &d.trim { @@ -743,6 +784,14 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option None, }; + let potion_extra = match c.get("minecraft:potion_contents") { + Some(Tag::Compound(map)) => map + .iter() + .filter(|(k, _)| k.to_string_lossy() != "potion") + .map(|(k, v)| (k.to_string_lossy(), v.clone())) + .collect(), + _ => Vec::new(), + }; let data = StackData { enchantments: read_levels(c.get("minecraft:enchantments")), stored_enchantments: read_levels(c.get("minecraft:stored_enchantments")), @@ -753,6 +802,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option Date: Tue, 27 Oct 2026 22:19:06 +0100 Subject: [PATCH 009/968] Apply tipped and spectral arrow effects to the mobs they hit Arrows built from a bow or crossbow carried no effect and mobs never got one. Vanilla (Arrow, SpectralArrow doPostHurtEffects) applies the pickup item's potion contents at its duration scale, or Glowing for 200 ticks. --- crates/horizon-client/src/game/creatures.rs | 8 +-- crates/horizon-client/src/game/potions.rs | 79 ++++++++++++++++++++- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3145c2f759..4583bd5a71 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2806,7 +2806,7 @@ impl Game<'_> { } // AbstractArrow.onHitBlock plays its sound itself (ArrowEvent). (ProjectileKind::Trident, Hit::Block { .. }) => {} - (ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => { + (ProjectileKind::Arrow { .. }, Hit::Entity { who, .. }) => { let mut arrow = p.clone(); let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage; let player_fire = self.player.as_ref().map(|pl| pl.fire_ticks); @@ -2824,9 +2824,6 @@ impl Game<'_> { [p.previous.x, p.previous.y, p.previous.z], "minecraft:arrow", ); - if hurt && let Some((e, t)) = effect { - self.give_player_effect(e, *t, 0); - } hurt } Who::Mob(id) => { @@ -2893,6 +2890,9 @@ impl Game<'_> { velocity: [p.velocity.x * k, 0.1, p.velocity.z * k], }])?; } + if hurt { + self.arrow_post_hurt_effects(p, who); + } match (hurt, who) { (true, _) => self.finish_arrow_hit(arrow, EntityHit::Hurt), // What the player took no damage from (a raised shield) diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 4b57cf25a2..b285800f02 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -17,7 +17,9 @@ use horizon_client::{ hud_layout, hud_overlays, inventory_effects, items::ItemStack, mesher::Vertex, + mobs::{Damage, DamageKind, Who}, particles::{ParticleOptions, ParticleType}, + projectiles::{Projectile, ProjectileKind}, text::Text, }; use horizon_core::{ @@ -90,7 +92,7 @@ pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> { /// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison, /// `Parched` weakness): the immune-to-infested and immune-to-oozing tags /// decide first, then the tag of mobs that ignore poison and regeneration. -fn mob_can_be_affected( +pub(super) fn mob_can_be_affected( tags: &std::collections::HashMap>, kind: &str, effect: &str, @@ -581,6 +583,81 @@ impl Game<'_> { } true } + /// `Arrow.doPostHurtEffects` and `SpectralArrow.doPostHurtEffects`: the + /// arrow's item gives its target Glowing (spectral) or its potion contents + /// at the stack's duration scale (a tipped arrow's default is an eighth); + /// a mob's arrow without such an item (a stray's) carries its own effect. + /// Instant effects act at once: harming hurts and healing heals, the + /// other way round for the undead. + pub(super) fn arrow_post_hurt_effects(&mut self, p: &Projectile, who: Who) { + let from_item = p.arrow.as_ref().map(|a| &a.pickup_item); + let mut effects: Vec<(&'static str, i32, i32)> = match from_item { + Some(s) if &*s.item == "minecraft:spectral_arrow" => vec![("glowing", 200, 0)], + Some(s) => { + let scale = s.potion_duration_scale(); + brewing::contents_effects(s) + .into_iter() + .map(|(id, d, amp)| (id, horizon_client::effects::scaled_duration(d, scale), amp)) + .collect() + } + None => Vec::new(), + }; + if effects.is_empty() + && let ProjectileKind::Arrow { + effect: Some((id, ticks)), + .. + } = p.kind + && let Some(e) = brewing::effect(id) + { + effects.push((e.id, ticks, 0)); + } + if effects.is_empty() { + return; + } + match who { + Who::Player => { + for (id, duration, amp) in effects { + if brewing::effect(id).is_some_and(|e| e.instant) { + if let Some(a) = horizon_client::effects::instant(id, amp, 1.0) { + self.apply_effect_action(a); + } + } else { + self.add_player_effect(EffectInstance::new(id, duration, amp)); + } + } + } + Who::Mob(id) => { + let tags = &self.res.stations().enchantments.entity_tags; + let Some(m) = self.mobs.iter_mut().find(|m| m.id == id && m.alive()) else { + return; + }; + let inverted = tags + .get("minecraft:inverted_healing_and_harm") + .is_some_and(|t| t.contains(&m.kind.id)); + for (effect, duration, amp) in effects { + match effect { + "instant_damage" | "instant_health" => { + // `LivingEntity.addEffect` refuses nothing here; the + // tick hurts with magic (6 << amp) or heals (4 << amp). + if (effect == "instant_damage") != inverted { + m.damage((6 << amp.min(30)) as f32, Damage::of(DamageKind::Magic)); + } else { + m.health = + (m.health + (4 << amp.min(30)) as f32).min(m.kind.max_health); + } + } + _ => { + if mob_can_be_affected(tags, &m.kind.id, effect) + && let Some(name) = mob_effect_id(effect) + { + m.add_effect(name, duration, amp); + } + } + } + } + } + } + } pub(super) fn apply_effect_action(&mut self, action: EffectAction) { let creative = self.creative; let rules = self.world.health_rules; From 1b0b0a814953d836ba35b46d6b000fe6585fd001 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:21:18 +0100 Subject: [PATCH 010/968] Hurt crowded mobs with cramming damage The cramming roll was drawn but nothing hurt the mob. Vanilla (LivingEntity.pushEntities) deals 6 cramming damage, 1 in 4 ticks, above max_entity_cramming - 1 pushers who are not riding. --- .../examples/probe/mob_ai_probe.rs | 1 + .../src/game/commands/world_cmds.rs | 1 + crates/horizon-client/src/game/creatures.rs | 2 ++ crates/horizon-client/src/mobs.rs | 8 +++++++ crates/horizon-client/src/mobs/ai.rs | 22 ++++++++++++------- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 3aae262117..14c780ddf8 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1128,6 +1128,7 @@ pub fn main() -> horizon_client::Result<()> { sea_level: -63, items: Some(&items), lid_openers: &lid_openers, + max_cramming: 24, } }; } diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 952e7d806f..6f17aa7c11 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -124,6 +124,7 @@ impl Game<'_> { "freeze" => DamageKind::Freeze, "in_wall" => DamageKind::InWall, "thorns" => DamageKind::Thorns, + "cramming" => DamageKind::Cramming, _ => DamageKind::Generic, } } diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4583bd5a71..d043e934df 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -454,6 +454,7 @@ impl Game<'_> { nether: self.dim == Dim::Nether, block_tags: &self.res.items().block_tags, biomes: self.res.biomes(), + max_cramming: self.ui.rules.int("max_entity_cramming"), mob_griefing: self.ui.rules.bool("mob_griefing"), mob_drops: self.ui.rules.bool("mob_drops"), end: self.dim == Dim::End, @@ -4546,6 +4547,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::SonicBoom => "minecraft:sonic_boom", K::Freeze => "minecraft:freeze", K::OutOfWorld => "minecraft:out_of_world", + K::Cramming => "minecraft:cramming", } } /// A Horizon item stack as a loot stack (id, count, damage, enchantments). diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index db617e7c9c..7a1db67706 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -538,6 +538,8 @@ pub enum DamageKind { Freeze, /// `fell_out_of_world`: below the world's floor. OutOfWorld, + /// `cramming`: too many entities in one block (`maxEntityCramming`). + Cramming, } /// A damage source: its type, the attacking entity and where it came from. #[derive(Clone, Copy, Debug)] @@ -601,6 +603,7 @@ impl Damage { | DamageKind::Generic | DamageKind::InWall | DamageKind::SweetBerryBush + | DamageKind::Cramming ) } /// DamageTypeTags.BYPASSES_ARMOR @@ -616,6 +619,7 @@ impl Damage { | DamageKind::SonicBoom | DamageKind::Freeze | DamageKind::OutOfWorld + | DamageKind::Cramming ) } pub fn is_fire(&self) -> bool { @@ -642,6 +646,7 @@ impl Damage { | DamageKind::InWall | DamageKind::Freeze | DamageKind::OutOfWorld + | DamageKind::Cramming ) } pub fn is_projectile(&self) -> bool { @@ -1805,6 +1810,8 @@ pub struct MobWorld<'a> { pub nether: bool, pub block_tags: &'a HashMap>, pub biomes: &'a crate::biome::Biomes, + /// The `max_entity_cramming` game rule. + pub max_cramming: i32, /// The `mob_griefing` game rule. pub mob_griefing: bool, /// The `mob_drops` game rule (`shouldDropLoot`). @@ -2181,6 +2188,7 @@ impl Mob { | DamageKind::Thorns | DamageKind::OutOfWorld | DamageKind::Generic + | DamageKind::Cramming ) } /// `Wolf.actuallyHurt`: the armor loses `ceil(damage)` durability, cracks diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index eaadf14a48..837dc83754 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -1058,7 +1058,8 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec) { && m.species() != Species::Shulker && !on_climbable(m, w) && (m.species() != Species::Creaking || super::creaking::can_move(m)); - let mut targets: Vec<(Who, Vec3)> = Vec::new(); + // Each pusher with whether it is a passenger (`isPassenger`). + let mut targets: Vec<(Who, Vec3, bool)> = Vec::new(); if let Some(p) = &w.player // Entity.push: passengers of the same vehicle never push each other. && m.rider.is_none() @@ -1066,21 +1067,26 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec) { && !p.spectator && p.bounds.intersects(bounds) { - targets.push((Who::Player, p.position)); + targets.push((Who::Player, p.position, p.passenger)); } for o in w.others_near(bounds) { if o.id != m.id && o.alive && o.pushable && o.bounds.intersects(bounds) { - targets.push((Who::Mob(o.id), o.position)); + targets.push((Who::Mob(o.id), o.position, o.passenger)); } } // EntitySelector.pushableBy(this): the teams' collision rules. let own = w.teams.of(Who::Mob(m.id)); - targets.retain(|(who, _)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true)); - // Cramming (maxEntityCramming 24). - if targets.len() > 23 { - let _ = m.random.next_int_bound(4); + targets.retain(|(who, ..)| crate::scoreboard::pushable_by(own, w.teams.of(*who), true)); + // Cramming: in one in four ticks, with `maxEntityCramming` or more + // pushers around and that many not riding, 6 damage. + if w.max_cramming > 0 + && targets.len() as i32 > w.max_cramming - 1 + && m.random.next_int_bound(4).ok() == Some(0) + && targets.iter().filter(|t| !t.2).count() as i32 > w.max_cramming - 1 + { + m.damage(6.0, Damage::of(DamageKind::Cramming)); } - for (who, at) in targets { + for (who, at, _) in targets { // Warden.doPush: touching it angers it, then the push itself. if m.species() == Species::Warden { super::warden::do_push(m, w, who, events); From 0e18c68b4d5c3c98beb097e10f8b26843eec270e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:21:37 +0100 Subject: [PATCH 011/968] Hurt mobs with the lightning_bolt damage type A strike hurt mobs as generic damage, which bypasses armour and wolf armour and loses the is_lightning loot condition (a struck turtle dropped no bowl). Vanilla (Entity.thunderHit) uses lightning_bolt. --- crates/horizon-client/src/game/commands/world_cmds.rs | 1 + crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/game/lightning.rs | 7 ++----- crates/horizon-client/src/mobs.rs | 3 +++ crates/horizon-client/src/mobs/armadillo.rs | 6 +++++- crates/horizon-client/src/mobs/panda.rs | 6 +++++- crates/horizon-client/src/mobs/polar_bear.rs | 6 +++++- 7 files changed, 22 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 6f17aa7c11..59fa873247 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -125,6 +125,7 @@ impl Game<'_> { "in_wall" => DamageKind::InWall, "thorns" => DamageKind::Thorns, "cramming" => DamageKind::Cramming, + "lightning_bolt" => DamageKind::LightningBolt, _ => DamageKind::Generic, } } diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index d043e934df..09c8b1678d 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -4548,6 +4548,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::Freeze => "minecraft:freeze", K::OutOfWorld => "minecraft:out_of_world", K::Cramming => "minecraft:cramming", + K::LightningBolt => "minecraft:lightning_bolt", } } /// A Horizon item stack as a loot stack (id, count, damage, enchantments). diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 6caf4458a1..ab50e6202c 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -364,11 +364,8 @@ impl Game<'_> { && p[1] >= at[1] - 3.0 && p[1] <= at[1] + 9.0 }; - let damage = Damage { - kind: DamageKind::Generic, - attacker: None, - from: Some(b.at), - }; + // DamageSources.lightningBolt: its own type, with no position. + let damage = Damage::of(DamageKind::LightningBolt); let mut events = Vec::new(); let peaceful = self.ui.difficulty == 0; for m in self.mobs.iter_mut().filter(|m| m.alive()) { diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 7a1db67706..d09b46c2ff 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -540,6 +540,8 @@ pub enum DamageKind { OutOfWorld, /// `cramming`: too many entities in one block (`maxEntityCramming`). Cramming, + /// `lightning_bolt`: a strike (`Entity.thunderHit`). + LightningBolt, } /// A damage source: its type, the attacking entity and where it came from. #[derive(Clone, Copy, Debug)] @@ -647,6 +649,7 @@ impl Damage { | DamageKind::Freeze | DamageKind::OutOfWorld | DamageKind::Cramming + | DamageKind::LightningBolt ) } pub fn is_projectile(&self) -> bool { diff --git a/crates/horizon-client/src/mobs/armadillo.rs b/crates/horizon-client/src/mobs/armadillo.rs index 8837d8cf85..cbd4b9bf1d 100644 --- a/crates/horizon-client/src/mobs/armadillo.rs +++ b/crates/horizon-client/src/mobs/armadillo.rs @@ -158,7 +158,11 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) { pub fn environmental(kind: DamageKind) -> bool { matches!( kind, - DamageKind::Cactus | DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava + DamageKind::Cactus + | DamageKind::OnFire + | DamageKind::InFire + | DamageKind::Lava + | DamageKind::LightningBolt ) } /// The hurt sound: a ball's is muffled, and it has no ambient sound diff --git a/crates/horizon-client/src/mobs/panda.rs b/crates/horizon-client/src/mobs/panda.rs index a5b8e29447..3e9a18ccb3 100644 --- a/crates/horizon-client/src/mobs/panda.rs +++ b/crates/horizon-client/src/mobs/panda.rs @@ -713,7 +713,11 @@ fn environmental(m: &Mob) -> bool { || m.last_damage.is_some_and(|(d, t)| { matches!( d.kind, - DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Cactus + DamageKind::OnFire + | DamageKind::InFire + | DamageKind::Lava + | DamageKind::Cactus + | DamageKind::LightningBolt ) && i64::from(m.age) - t <= 40 }) } diff --git a/crates/horizon-client/src/mobs/polar_bear.rs b/crates/horizon-client/src/mobs/polar_bear.rs index bfd464a93d..f76db4d2bf 100644 --- a/crates/horizon-client/src/mobs/polar_bear.rs +++ b/crates/horizon-client/src/mobs/polar_bear.rs @@ -95,7 +95,11 @@ fn environmental_panic(m: &Mob) -> bool { || m.last_damage.is_some_and(|(d, t)| { matches!( d.kind, - DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Cactus + DamageKind::OnFire + | DamageKind::InFire + | DamageKind::Lava + | DamageKind::Cactus + | DamageKind::LightningBolt ) && i64::from(m.age) - t <= 40 }) } From 08947cd9223cc31a93e1fcb7e955ab9ca393c230 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:22:36 +0100 Subject: [PATCH 012/968] Burn mobs standing in a lit campfire Only the player took campfire damage. Vanilla (CampfireBlock.entityInside) hurts any living entity in a lit campfire for 1, or 2 for a soul campfire. --- crates/horizon-client/src/game/commands/world_cmds.rs | 3 ++- crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs.rs | 10 +++++++++- crates/horizon-client/src/mobs/ai.rs | 11 ++++++++++- crates/horizon-client/src/mobs/gear.rs | 6 +++++- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 59fa873247..bda05c63dc 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -113,7 +113,8 @@ impl Game<'_> { "explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion, "fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall, "on_fire" => DamageKind::OnFire, - "in_fire" | "campfire" | "hot_floor" => DamageKind::InFire, + "in_fire" | "hot_floor" => DamageKind::InFire, + "campfire" => DamageKind::Campfire, "lava" => DamageKind::Lava, "drown" => DamageKind::Drown, "dry_out" => DamageKind::DryOut, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 09c8b1678d..b07683099e 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -4549,6 +4549,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::OutOfWorld => "minecraft:out_of_world", K::Cramming => "minecraft:cramming", K::LightningBolt => "minecraft:lightning_bolt", + K::Campfire => "minecraft:campfire", } } /// A Horizon item stack as a loot stack (id, count, damage, enchantments). diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index d09b46c2ff..cd505dc6ca 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -542,6 +542,8 @@ pub enum DamageKind { Cramming, /// `lightning_bolt`: a strike (`Entity.thunderHit`). LightningBolt, + /// `campfire`: standing in a lit campfire. + Campfire, } /// A damage source: its type, the attacking entity and where it came from. #[derive(Clone, Copy, Debug)] @@ -606,6 +608,7 @@ impl Damage { | DamageKind::InWall | DamageKind::SweetBerryBush | DamageKind::Cramming + | DamageKind::Campfire ) } /// DamageTypeTags.BYPASSES_ARMOR @@ -627,7 +630,11 @@ impl Damage { pub fn is_fire(&self) -> bool { matches!( self.kind, - DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball + DamageKind::OnFire + | DamageKind::InFire + | DamageKind::Campfire + | DamageKind::Lava + | DamageKind::Fireball ) } /// DamageTypeTags.NO_KNOCKBACK @@ -650,6 +657,7 @@ impl Damage { | DamageKind::OutOfWorld | DamageKind::Cramming | DamageKind::LightningBolt + | DamageKind::Campfire ) } pub fn is_projectile(&self) -> bool { diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 837dc83754..84a8807aa6 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -533,7 +533,8 @@ pub fn tick( true } /// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks -/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1) and the +/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1), a lit +/// campfire (`CampfireBlock.entityInside`) and the /// sweet berry bush (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75, /// 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X /// or Z this tick). The blocks are those the mob's box touches anywhere along @@ -562,6 +563,14 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { "minecraft:cactus" => { m.damage(1.0, Damage::of(DamageKind::Cactus)); } + // CampfireBlock.entityInside: a lit one hurts by its + // fireDamage (1, 2 for a soul campfire). + name @ ("minecraft:campfire" | "minecraft:soul_campfire") => { + if w.prop(x, y, z, "lit") == Some("true") { + let fire = if name == "minecraft:campfire" { 1.0 } else { 2.0 }; + m.damage(fire, Damage::of(DamageKind::Campfire)); + } + } "minecraft:sweet_berry_bush" if !matches!(m.species(), Species::Fox | Species::Bee) => { diff --git a/crates/horizon-client/src/mobs/gear.rs b/crates/horizon-client/src/mobs/gear.rs index 4cb8ed94e7..9339cedc29 100644 --- a/crates/horizon-client/src/mobs/gear.rs +++ b/crates/horizon-client/src/mobs/gear.rs @@ -164,7 +164,11 @@ pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 { } let fire = matches!( kind, - DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Fireball + DamageKind::OnFire + | DamageKind::InFire + | DamageKind::Campfire + | DamageKind::Lava + | DamageKind::Fireball ); let projectile = matches!( kind, From c20247d708eeebd29565ec9ce864e898183e2f76 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:22:51 +0100 Subject: [PATCH 013/968] Hurt mobs walking on magma blocks Only the player took hot floor damage. Vanilla (MagmaBlock.stepOn) deals 1 hot_floor damage to any living entity on the block that is not sneaking. --- .../horizon-client/src/game/commands/world_cmds.rs | 3 ++- crates/horizon-client/src/game/creatures.rs | 1 + crates/horizon-client/src/mobs.rs | 4 ++++ crates/horizon-client/src/mobs/ai.rs | 14 +++++++++++++- crates/horizon-client/src/mobs/armadillo.rs | 1 + crates/horizon-client/src/mobs/gear.rs | 1 + crates/horizon-client/src/mobs/panda.rs | 1 + crates/horizon-client/src/mobs/polar_bear.rs | 1 + crates/horizon-client/src/mobs/sulfur_cube.rs | 1 + 9 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index bda05c63dc..64428cc7f6 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -113,7 +113,8 @@ impl Game<'_> { "explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion, "fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall, "on_fire" => DamageKind::OnFire, - "in_fire" | "hot_floor" => DamageKind::InFire, + "in_fire" => DamageKind::InFire, + "hot_floor" => DamageKind::HotFloor, "campfire" => DamageKind::Campfire, "lava" => DamageKind::Lava, "drown" => DamageKind::Drown, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index b07683099e..4a4d92ebfe 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -4550,6 +4550,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::Cramming => "minecraft:cramming", K::LightningBolt => "minecraft:lightning_bolt", K::Campfire => "minecraft:campfire", + K::HotFloor => "minecraft:hot_floor", } } /// A Horizon item stack as a loot stack (id, count, damage, enchantments). diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index cd505dc6ca..7f9d778195 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -544,6 +544,8 @@ pub enum DamageKind { LightningBolt, /// `campfire`: standing in a lit campfire. Campfire, + /// `hot_floor`: stepping on a magma block. + HotFloor, } /// A damage source: its type, the attacking entity and where it came from. #[derive(Clone, Copy, Debug)] @@ -633,6 +635,7 @@ impl Damage { DamageKind::OnFire | DamageKind::InFire | DamageKind::Campfire + | DamageKind::HotFloor | DamageKind::Lava | DamageKind::Fireball ) @@ -658,6 +661,7 @@ impl Damage { | DamageKind::Cramming | DamageKind::LightningBolt | DamageKind::Campfire + | DamageKind::HotFloor ) } pub fn is_projectile(&self) -> bool { diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 84a8807aa6..28b635f64e 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -533,13 +533,25 @@ pub fn tick( true } /// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks -/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1), a lit +/// that hurt or hold a mob (and the magma block it stands on): the cactus (`CactusBlock.entityInside`, 1), a lit /// campfire (`CampfireBlock.entityInside`) and the /// sweet berry bush (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75, /// 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X /// or Z this tick). The blocks are those the mob's box touches anywhere along /// its move this tick, from where it started to where it ended. fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { + // Block.stepOn for the block under a mob on the ground (`getOnPosLegacy`): + // MagmaBlock.stepOn's hot floor, 1 damage (a mob never sneaks). + if m.on_ground && m.alive() { + let (x, y, z) = ( + m.position.x.floor() as i32, + (m.position.y - f64::from(0.2_f32)).floor() as i32, + m.position.z.floor() as i32, + ); + if w.block_name(x, y, z) == "minecraft:magma_block" { + m.damage(1.0, Damage::of(DamageKind::HotFloor)); + } + } let mut visited: Vec<[i32; 3]> = Vec::new(); let travel = m.position.subtract(m.previous); let longest = travel.x.abs().max(travel.y.abs()).max(travel.z.abs()); diff --git a/crates/horizon-client/src/mobs/armadillo.rs b/crates/horizon-client/src/mobs/armadillo.rs index cbd4b9bf1d..5d7a8de1f1 100644 --- a/crates/horizon-client/src/mobs/armadillo.rs +++ b/crates/horizon-client/src/mobs/armadillo.rs @@ -163,6 +163,7 @@ pub fn environmental(kind: DamageKind) -> bool { | DamageKind::InFire | DamageKind::Lava | DamageKind::LightningBolt + | DamageKind::HotFloor ) } /// The hurt sound: a ball's is muffled, and it has no ambient sound diff --git a/crates/horizon-client/src/mobs/gear.rs b/crates/horizon-client/src/mobs/gear.rs index 9339cedc29..d7374bd27f 100644 --- a/crates/horizon-client/src/mobs/gear.rs +++ b/crates/horizon-client/src/mobs/gear.rs @@ -167,6 +167,7 @@ pub fn protection_points(g: &Gear, kind: DamageKind) -> f32 { DamageKind::OnFire | DamageKind::InFire | DamageKind::Campfire + | DamageKind::HotFloor | DamageKind::Lava | DamageKind::Fireball ); diff --git a/crates/horizon-client/src/mobs/panda.rs b/crates/horizon-client/src/mobs/panda.rs index 3e9a18ccb3..5833203b29 100644 --- a/crates/horizon-client/src/mobs/panda.rs +++ b/crates/horizon-client/src/mobs/panda.rs @@ -718,6 +718,7 @@ fn environmental(m: &Mob) -> bool { | DamageKind::Lava | DamageKind::Cactus | DamageKind::LightningBolt + | DamageKind::HotFloor ) && i64::from(m.age) - t <= 40 }) } diff --git a/crates/horizon-client/src/mobs/polar_bear.rs b/crates/horizon-client/src/mobs/polar_bear.rs index f76db4d2bf..dfb794fd57 100644 --- a/crates/horizon-client/src/mobs/polar_bear.rs +++ b/crates/horizon-client/src/mobs/polar_bear.rs @@ -100,6 +100,7 @@ fn environmental_panic(m: &Mob) -> bool { | DamageKind::Lava | DamageKind::Cactus | DamageKind::LightningBolt + | DamageKind::HotFloor ) && i64::from(m.age) - t <= 40 }) } diff --git a/crates/horizon-client/src/mobs/sulfur_cube.rs b/crates/horizon-client/src/mobs/sulfur_cube.rs index f0d4611a58..4272ae8902 100644 --- a/crates/horizon-client/src/mobs/sulfur_cube.rs +++ b/crates/horizon-client/src/mobs/sulfur_cube.rs @@ -380,6 +380,7 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool { | DamageKind::Cactus | DamageKind::Freeze | DamageKind::SweetBerryBush + | DamageKind::HotFloor ); if immune { if let Some(from) = source.from { From d5ae9945faaa03d08c92f615bcd64616b501b92d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:25:25 +0100 Subject: [PATCH 014/968] Honour can_break and can_place_on in adventure mode Adventure players could never place or break. Vanilla (Player.blockActionRestricted, ItemStack.useOn) allows it when the held item's can_break matches the block, or can_place_on matches the clicked one. --- crates/horizon-client/src/game.rs | 26 ++++-- crates/horizon-client/src/game/game_mode.rs | 93 +++++++++++++++++++ crates/horizon-client/src/game/hand.rs | 4 +- crates/horizon-client/src/game/lan_actions.rs | 2 +- 4 files changed, 116 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8e591cf3b8..0047096f2c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3948,6 +3948,10 @@ impl Game<'_> { self.with_player_actor(|game| game.break_block_as_player(pos)) } fn break_block_as_player(&mut self, pos: [i32; 3]) -> Result<()> { + // ServerPlayerGameMode.destroyBlock. + if self.block_action_restricted(pos) { + return Ok(()); + } if !self .res .items() @@ -4384,7 +4388,7 @@ impl Game<'_> { if info.block.is_none() { return Ok(false); } - if !self.may_build() { + if !self.may_build() && !self.can_place_on_in_adventure(hit.pos) { // ItemStack.useOn passes in adventure mode; useItem eats. return Ok(!on_block && self.start_eating(&info)); } @@ -4738,8 +4742,9 @@ impl Game<'_> { self.mining = None; return Ok(()); }; - // GameType.isBlockPlacingRestricted: adventure and spectator. - if !self.may_build() + // Player.blockActionRestricted: spectators, and adventure players + // without a matching can_break item. + if self.block_action_restricted(hit.pos) || !self .res .items() @@ -11584,10 +11589,19 @@ impl Game<'_> { let mut outline_after_terrain = false; // GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a // player who may build. Adventure players only get one with a CAN_BREAK or - // CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the - // components nor opens menus for spectators, so those never show it. - let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() { + // CAN_PLACE_ON item matching the block, and spectators with a menu block; Horizon + // does not open menus for spectators, so those never show it. + let adventure = self.mode == horizon_client::command::args::GameMode::Adventure; + let outline = if self.screen == Screen::Playing + && !self.ui.hide_gui + && (self.may_build() || adventure) + { self.pick() + .filter(|h| { + self.may_build() + || !self.block_action_restricted(h.pos) + || self.can_place_on_in_adventure(h.pos) + }) .map(|h| { let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]); let f = self.res.facts().get(state); diff --git a/crates/horizon-client/src/game/game_mode.rs b/crates/horizon-client/src/game/game_mode.rs index 1da06b0891..9315a1a449 100644 --- a/crates/horizon-client/src/game/game_mode.rs +++ b/crates/horizon-client/src/game/game_mode.rs @@ -68,6 +68,99 @@ impl Game<'_> { } } impl Game<'_> { + /// `ItemStack.canBreakBlockInAdventureMode` / `canPlaceOnBlockInAdventureMode` + /// for the main-hand stack: some predicate of its `can_break` or + /// `can_place_on` component (`AdventureModePredicate`: one block predicate + /// or a list) matches the block at `pos`. Predicates with `nbt` or + /// `components` need the block entity's data and never match. + fn adventure_predicate(&self, component: &str, pos: [i32; 3]) -> bool { + use horizon_world::nbt::Tag; + let Some(tag) = self + .held() + .and_then(|s| s.data()) + .and_then(|d| d.other(component)) + else { + return false; + }; + let state = self.world.block(pos[0], pos[1], pos[2]); + let name = &self.res.facts().get(state).name; + let properties = self.pack.assets.registry.state(state).map(|s| &s.properties); + let matches = |p: &Tag| { + if p.get("nbt").is_some() || p.get("components").is_some() { + return false; + } + let block_ok = match p.get("blocks") { + None => true, + Some(Tag::String(id)) => self.block_in_holder_set(&id.to_string_lossy(), name), + Some(Tag::List(ids)) => ids.iter().any(|id| match id { + Tag::String(id) => self.block_in_holder_set(&id.to_string_lossy(), name), + _ => false, + }), + Some(_) => false, + }; + // StatePropertiesPredicate: every property exact, or in a min/max range. + let state_ok = match p.get("state") { + None => true, + Some(Tag::Compound(want)) => want.iter().all(|(key, matcher)| { + let Some(have) = properties.and_then(|p| p.get(&key.to_string_lossy())) else { + return false; + }; + match matcher { + Tag::String(value) => *have == value.to_string_lossy(), + Tag::Compound(_) => { + let bound = |k: &str| match matcher.get(k) { + Some(Tag::String(v)) => Some(v.to_string_lossy()), + _ => None, + }; + let in_range = |have: &str, v: Option, above: bool| { + v.is_none_or(|v| match (have.parse::(), v.parse::()) { + (Ok(h), Ok(b)) if above => h >= b, + (Ok(h), Ok(b)) => h <= b, + _ => false, + }) + }; + in_range(have, bound("min"), true) && in_range(have, bound("max"), false) + } + _ => false, + } + }), + Some(_) => false, + }; + block_ok && state_ok + }; + match tag { + Tag::List(list) => list.iter().any(matches), + single => matches(single), + } + } + /// A `HolderSet` entry: a block id or a `#tag`. + fn block_in_holder_set(&self, entry: &str, block: &str) -> bool { + let namespaced = |id: &str| { + if id.contains(':') { + id.to_owned() + } else { + format!("minecraft:{id}") + } + }; + match entry.strip_prefix('#') { + Some(tag) => self.res.items().block_in_tag(&namespaced(tag), block), + None => namespaced(entry) == block, + } + } + /// `Player.blockActionRestricted` for the block at `pos`: spectators never + /// act on blocks, adventure players only with a main-hand item whose + /// `can_break` matches it. + pub(super) fn block_action_restricted(&self, pos: [i32; 3]) -> bool { + if self.spectator() { + return true; + } + !self.may_build() && !self.adventure_predicate("minecraft:can_break", pos) + } + /// `ItemStack.useOn` in adventure mode: the used item's `can_place_on` + /// matches the clicked block (`Player.mayUseItemAt`). + pub(super) fn can_place_on_in_adventure(&self, clicked: [i32; 3]) -> bool { + self.adventure_predicate("minecraft:can_place_on", clicked) + } /// `ServerPlayer.setGameMode(SPECTATOR)`: `stopRiding`. fn dismount_for_spectator(&mut self) { let Some(id) = self.riding else { diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index 40ca9d5596..e7fedcd1f5 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -500,8 +500,8 @@ impl HandWorld for GameHands<'_, '_> { fn attack_entity(&mut self) -> bool { self.game.attack() } - fn block_action_restricted(&mut self, _: [i32; 3]) -> bool { - !self.game.may_build() + fn block_action_restricted(&mut self, pos: [i32; 3]) -> bool { + self.game.block_action_restricted(pos) } fn within_border(&mut self, _: [i32; 3]) -> bool { true diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index da4b6a1a87..e0f4c6c91e 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1057,7 +1057,7 @@ impl Game<'_> { // still plays a note. The dig is marked begun for tick_mining. let pos = a.pos; self.as_guest(host, conn, |g| { - if g.may_build() && !g.creative { + if !g.block_action_restricted(pos) && !g.creative { let _ = g.attack_block(pos); g.mining = Some(Mining { pos, From ae43424882b3a34a845989d4c7ba5d3b9c4150d3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:26:13 +0100 Subject: [PATCH 015/968] Place lily pads and frogspawn on the water aimed at The aim ray skips water, so these items could not be placed on the surface. Vanilla (PlaceOnWaterBlockItem.use) uses a source-only fluid ray and places on the position above the block it meets. --- crates/horizon-client/src/game.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 0047096f2c..c8b8e2dff2 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4382,8 +4382,24 @@ impl Game<'_> { if self.use_ender_eye(&stack, block_hit.is_some())? { return Ok(true); } - let Some(hit) = block_hit else { - return Ok(false); + // PlaceOnWaterBlockItem.use: the lily pad and frogspawn aim at the block + // or water source the SOURCE_ONLY ray meets and are placed on the + // position above it. + let water_hit; + let hit = if matches!(&*stack.item, "minecraft:lily_pad" | "minecraft:frogspawn") { + let Some(h) = self.pick_fluid() else { + return Ok(false); + }; + water_hit = Hit { + pos: [h.pos[0], h.pos[1] + 1, h.pos[2]], + ..h + }; + &water_hit + } else { + let Some(hit) = block_hit else { + return Ok(false); + }; + hit }; if info.block.is_none() { return Ok(false); From 295871fcab9e5b9b0d8b3315c928430592d411c0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:26:57 +0100 Subject: [PATCH 016/968] Turn dirt to mud with a water bottle Right-clicking a water bottle on mud-convertible blocks started a drink, only dispensers swapped. Vanilla (PotionItem.useOn) pours it out onto any side but the underside and sets mud. --- crates/horizon-client/src/game.rs | 6 ++++ crates/horizon-client/src/game/potions.rs | 41 +++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c8b8e2dff2..a67de7243e 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4309,6 +4309,12 @@ impl Game<'_> { UseResult::Fail => return Ok(true), UseResult::Pass => {} } + // PotionItem.useOn comes before PotionItem.use's drink. + if let Some(hit) = block_hit + && self.use_water_bottle_on_block(hit.pos, hit.face, &stack)? + { + return Ok(true); + } if (!on_block && self.use_potion_item(&stack)?) || ((block_hit.is_some() || !spawn_egg) && (!on_block || spawn_egg) diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index b285800f02..1d823b4df2 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -513,6 +513,47 @@ impl Game<'_> { _ => Ok(false), } } + /// `PotionItem.useOn`: a water bottle used on a block that turns to mud + /// (`#convertible_to_mud`), not on its underside, is poured out and the + /// block becomes mud. + pub(super) fn use_water_bottle_on_block( + &mut self, + pos: [i32; 3], + face: [i32; 3], + stack: &ItemStack, + ) -> horizon_client::Result { + if &*stack.item != "minecraft:potion" + || stack.potion() != Some("minecraft:water") + || face == [0, -1, 0] + || !self.block_in_tag("minecraft:convertible_to_mud", &self.block_name(pos)) + { + return Ok(false); + } + let centre = pos.map(|v| f64::from(v) + 0.5); + self.audio + .play("minecraft:entity.generic.splash", Some(centre), 1.0, 1.0); + self.fill_held(ItemStack::new("minecraft:glass_bottle", 1)); + // ServerLevel.sendParticles: five splashes above the block. + for _ in 0..5 { + let r = &mut self.world.random; + let at = [ + f64::from(pos[0]) + r.next_double(), + f64::from(pos[1] + 1), + f64::from(pos[2]) + r.next_double(), + ]; + self.particles + .add(&ParticleOptions::simple(ParticleType::Splash), at, [0.0; 3]); + } + self.audio + .play("minecraft:item.bottle.empty", Some(centre), 1.0, 1.0); + self.post_block_game_event("minecraft:fluid_place", pos, None, None); + if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") { + self.level_set(pos, mud)?; + } + self.stats.add("used", &stack.item, 1); + self.swing_arm(); + Ok(true) + } /// `ItemUtils.createFilledResult`: replace one of the held stack. fn fill_held(&mut self, filled: ItemStack) { if self.creative { From eea99bb586f10385f8d3929fe2dd770a8c23f567 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:36:18 +0100 Subject: [PATCH 017/968] Fix clippy Return the arrow hit result directly. --- crates/horizon-client/src/game/creatures.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4a4d92ebfe..18546cbed5 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2819,14 +2819,11 @@ impl Game<'_> { }])?; } let hurt = match who { - Who::Player => { - let hurt = self.mob_hits_player( - amount, - [p.previous.x, p.previous.y, p.previous.z], - "minecraft:arrow", - ); - hurt - } + Who::Player => self.mob_hits_player( + amount, + [p.previous.x, p.previous.y, p.previous.z], + "minecraft:arrow", + ), Who::Mob(id) => { let hurt = self .mobs From cb5743dcc83e689e9424c38cf5ac0457d7203933 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Tue, 27 Oct 2026 22:36:44 +0100 Subject: [PATCH 018/968] Format cargo fmt. --- crates/horizon-client/src/effects.rs | 4 +++- crates/horizon-client/src/game/game_mode.rs | 10 ++++++++-- crates/horizon-client/src/game/potions.rs | 4 +++- crates/horizon-client/src/mobs/ai.rs | 6 +++++- crates/horizon-items/src/item_data.rs | 5 ++++- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index bc698d5302..8e63b79246 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -314,7 +314,9 @@ impl PlayerEffects { potion: Option<&str>, scale: f32, ) -> (Vec, Vec) { - let effects = potion.and_then(potions::potion).map_or(&[][..], |d| d.effects); + let effects = potion + .and_then(potions::potion) + .map_or(&[][..], |d| d.effects); Self::contents_effects(effects, scale) } /// `PotionContents.forEachEffect`'s effects, as `potion_effects`. diff --git a/crates/horizon-client/src/game/game_mode.rs b/crates/horizon-client/src/game/game_mode.rs index 9315a1a449..0b568bc6db 100644 --- a/crates/horizon-client/src/game/game_mode.rs +++ b/crates/horizon-client/src/game/game_mode.rs @@ -84,7 +84,12 @@ impl Game<'_> { }; let state = self.world.block(pos[0], pos[1], pos[2]); let name = &self.res.facts().get(state).name; - let properties = self.pack.assets.registry.state(state).map(|s| &s.properties); + let properties = self + .pack + .assets + .registry + .state(state) + .map(|s| &s.properties); let matches = |p: &Tag| { if p.get("nbt").is_some() || p.get("components").is_some() { return false; @@ -119,7 +124,8 @@ impl Game<'_> { _ => false, }) }; - in_range(have, bound("min"), true) && in_range(have, bound("max"), false) + in_range(have, bound("min"), true) + && in_range(have, bound("max"), false) } _ => false, } diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 1d823b4df2..3ef4190dd6 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -638,7 +638,9 @@ impl Game<'_> { let scale = s.potion_duration_scale(); brewing::contents_effects(s) .into_iter() - .map(|(id, d, amp)| (id, horizon_client::effects::scaled_duration(d, scale), amp)) + .map(|(id, d, amp)| { + (id, horizon_client::effects::scaled_duration(d, scale), amp) + }) .collect() } None => Vec::new(), diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 28b635f64e..6650b561f8 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -579,7 +579,11 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { // fireDamage (1, 2 for a soul campfire). name @ ("minecraft:campfire" | "minecraft:soul_campfire") => { if w.prop(x, y, z, "lit") == Some("true") { - let fire = if name == "minecraft:campfire" { 1.0 } else { 2.0 }; + let fire = if name == "minecraft:campfire" { + 1.0 + } else { + 2.0 + }; m.damage(fire, Damage::of(DamageKind::Campfire)); } } diff --git a/crates/horizon-items/src/item_data.rs b/crates/horizon-items/src/item_data.rs index 4095c879a4..0994435aad 100644 --- a/crates/horizon-items/src/item_data.rs +++ b/crates/horizon-items/src/item_data.rs @@ -282,7 +282,10 @@ impl ItemStack { /// `DataComponents.POTION_DURATION_SCALE` with the item's default: 0.125 /// for a tipped arrow, 0.25 for a lingering potion, else 1. pub fn potion_duration_scale(&self) -> f32 { - match self.data().and_then(|d| d.other("minecraft:potion_duration_scale")) { + match self + .data() + .and_then(|d| d.other("minecraft:potion_duration_scale")) + { Some(Tag::Float(bits)) => f32::from_bits(*bits), Some(Tag::Double(bits)) => f64::from_bits(*bits) as f32, Some(t) if int(t).is_some() => int(t).unwrap_or(1) as f32, From 43f2497f1fef1a19b046da6c5c247af48e55621e Mon Sep 17 00:00:00 2001 From: Shreyas Date: Wed, 28 Oct 2026 17:13:45 +0530 Subject: [PATCH 019/968] Sulfur cubes: synchronize size metadata and adult egg spawns Include sulfur cubes in AbstractCubeMob size metadata serialization and remote data handling so adult cubes retain their size across the network. Adult sulfur cubes spawned from spawn eggs now correctly render and act as adults rather than defaulting to size 1 babies. Vanilla reference: AbstractCubeMob.ID_SIZE, SulfurCube.setSpawnSize. Fixes #3151 --- crates/horizon-client/src/game/lan.rs | 2 +- .../src/game/remote_mob_data.rs | 23 +++++++++++++++++-- crates/horizon-client/src/mobs/finalize.rs | 5 +++- crates/horizon-client/src/mobs/sulfur_cube.rs | 2 ++ crates/horizon-server/src/lists.rs | 17 +++++++++++--- 5 files changed, 42 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 83fdaf4bee..6995b05320 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -602,7 +602,7 @@ fn mob_meta( } } // AbstractCubeMob.ID_SIZE. - "minecraft:slime" | "minecraft:magma_cube" => { + "minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => { if e.size > 1 { meta.push((18, serializer::INT, Value::Int(e.size))); } diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 1e3f8887c2..04f1cd8bad 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -136,8 +136,12 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti m.extra.color = (*c & 15) as u8; } // AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions). - (Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => { - m.extra.size = (*size).clamp(1, 127); + (Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => { + let size = (*size).clamp(1, 127); + m.extra.size = size; + if m.species() == Species::SulfurCube { + m.extra.age = if size == 1 { -24000 } else { 0 }; + } } // Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth // and raised head of an angry enderman. @@ -532,6 +536,21 @@ mod tests { assert!(!calf.is_baby()); } + #[test] + fn cube_mobs_receive_size_from_metadata() { + for kind in ["slime", "magma_cube", "sulfur_cube"] { + let Some(mut m) = mob(&format!("minecraft:{kind}")) else { + return; + }; + assert_eq!(m.extra.size, 1); + apply(&mut m, &[(18, s::INT, Value::Int(2))]); + assert_eq!(m.extra.size, 2); + if kind == "sulfur_cube" { + assert!(!m.is_baby()); + } + } + } + #[test] fn bat_flags_select_continuous_client_clocks_without_changing_motion() { use horizon_client::mobs::{MobTypes, anim, keyframes::Animation}; diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 52f3d575f7..6a9574b247 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -817,7 +817,7 @@ fn age_boundary_reached(m: &mut Mob) { match m.species() { Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }), Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }), - Species::SulfurCube if !baby => sulfur_size(m, 2), + Species::SulfurCube => sulfur_size(m, if baby { 1 } else { 2 }), _ => {} } } @@ -826,6 +826,9 @@ pub fn set_baby(m: &mut Mob, baby: bool) { let s = m.species(); if ageable(s) { set_age(m, if baby { baby_start_age(s) } else { 0 }); + if s == Species::SulfurCube { + sulfur_size(m, if baby { 1 } else { 2 }); + } return; } match s { diff --git a/crates/horizon-client/src/mobs/sulfur_cube.rs b/crates/horizon-client/src/mobs/sulfur_cube.rs index f0d4611a58..70d3f17dbc 100644 --- a/crates/horizon-client/src/mobs/sulfur_cube.rs +++ b/crates/horizon-client/src/mobs/sulfur_cube.rs @@ -292,6 +292,8 @@ pub fn set_size(m: &mut Mob, size: i32, update_health: bool) { m.health = m.attrs.max_health; if size == 1 && !m.is_baby() { m.extra.age = -24000; + } else if size > 1 && m.is_baby() { + m.extra.age = 0; } } } diff --git a/crates/horizon-server/src/lists.rs b/crates/horizon-server/src/lists.rs index 1a44251d3d..4e5bd78343 100644 --- a/crates/horizon-server/src/lists.rs +++ b/crates/horizon-server/src/lists.rs @@ -169,9 +169,20 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(config::FILE), config::TEMPLATE).unwrap(); let s = |v: &[&str]| v.iter().map(|a| (*a).to_owned()).collect::>(); - run(&dir, "op", &s(&["Alex"])).unwrap(); - run(&dir, "whitelist", &s(&["add", "Steve"])).unwrap(); - run(&dir, "whitelist", &s(&["off"])).unwrap(); + let run_cmd = |dir: &Path, cmd: &str, args: &[&str]| { + let res = run(dir, cmd, &s(args)); + if let Err(e) = &res + && e.contains("services didn't answer") + { + config::update(dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap(); + run(dir, cmd, &s(args)) + } else { + res + } + }; + run_cmd(&dir, "op", &["Alex"]).unwrap(); + run_cmd(&dir, "whitelist", &["add", "Steve"]).unwrap(); + run_cmd(&dir, "whitelist", &["off"]).unwrap(); assert!( std::fs::read_to_string(dir.join("ops.json")) .unwrap() From 2ac85c50eb462c88438ca1a514daaa8ecdcf2973 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Thu, 29 Oct 2026 00:11:11 +0530 Subject: [PATCH 020/968] lan: register default size 1 for AbstractCubeMob metadata --- crates/horizon-client/src/game/lan.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 6995b05320..2cac214e12 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -606,6 +606,7 @@ fn mob_meta( if e.size > 1 { meta.push((18, serializer::INT, Value::Int(e.size))); } + defaults.push((18, serializer::INT, Value::Int(1))); } // Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the // guest swells the creeper from them. The direction always goes in, From 72317bb0c65d0e8486fc539af1a5d8d629ecb84b Mon Sep 17 00:00:00 2001 From: ThatCakeIsAPie Date: Wed, 28 Oct 2026 14:00:58 -0500 Subject: [PATCH 021/968] Worldgen: a level's build range from its dimension type, a data pack's over vanilla's A pack's dimension_type/.json joins the overrides (DIMENSION_TYPES). LevelHeight is a dimension type's build range, logical height and skylight, checked as its codec checks them; WorldgenOverrides::level_height gives a vanilla level's, from the type its level stem names. The generator takes the level's range from the same data instead of the fixed table (dimension_type_height goes), so a pack's taller dimension type no longer clamps the noise, and datapack::worldgen_overrides no longer reports dimension types as left out. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/datapack.rs | 16 +- .../horizon-world/src/worldgen/generator.rs | 22 ++- .../horizon-world/src/worldgen/overrides.rs | 139 +++++++++++++++--- 3 files changed, 138 insertions(+), 39 deletions(-) diff --git a/crates/horizon-client/src/datapack.rs b/crates/horizon-client/src/datapack.rs index ee32145afd..300e245b3f 100644 --- a/crates/horizon-client/src/datapack.rs +++ b/crates/horizon-client/src/datapack.rs @@ -473,11 +473,6 @@ pub fn worldgen_overrides( let read = || String::from_utf8(pack.read(&file)?).ok(); match out.add_file(&pack.id, &file, read) { PackFile::Read | PackFile::Other => {} - PackFile::DimensionType { id, min_y, height } => warnings.push(format!( - "{}: dimension type {id} (min_y {min_y}, height {height}) is not used: \ - the dimension keeps its vanilla build height", - pack.id - )), PackFile::NotRead(kind) => { *not_read.entry(format!("{}: {kind}", pack.id)).or_default() += 1 } @@ -550,10 +545,13 @@ mod tests { ); let tag_packs: Vec<&str> = o.tags("biome_tags").map(|(_, p, _)| p).collect(); assert_eq!(tag_packs, ["file/b", "file/a"]); - assert_eq!(warnings.len(), 3, "{warnings:?}"); - assert!(warnings[0].contains("height 2096")); - assert!(warnings[1].contains("minecraft:new_peaks")); - assert!(warnings[2].contains("worldgen/world_preset")); + assert_eq!(warnings.len(), 2, "{warnings:?}"); + assert!(warnings[0].contains("minecraft:new_peaks")); + assert!(warnings[1].contains("worldgen/world_preset")); + assert_eq!( + o.level_height("minecraft:overworld").map(|h| h.height), + Some(2096) + ); let _ = std::fs::remove_dir_all(&dir); } use horizon_world::nbt::parse_snbt; diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index 16d9065352..ffb0c55019 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -173,6 +173,9 @@ pub struct GeneratorData { /// Data packs' level stems (`dimension/.json`), by dimension id: /// they replace the world preset's. dimensions: BTreeMap, + /// Data packs' dimension types (`dimension_type/.json`), by id: + /// they replace the vanilla ones' build ranges. + dimension_types: BTreeMap, /// What loading the data packs' entries left out, for the log. warnings: Vec, conditions: MaterialConditionRegistry, @@ -542,6 +545,7 @@ impl GeneratorData { settings: read_bundle(dir, ov, "settings")?, world_presets: read_bundle(dir, ov, "world_presets")?, dimensions: ov.apply(overrides::DIMENSIONS, BTreeMap::new()), + dimension_types: ov.apply(overrides::DIMENSION_TYPES, BTreeMap::new()), warnings, presets, conditions, @@ -764,15 +768,6 @@ impl ProtoChunk { } } -/// Build range (minimum Y, height) of the vanilla dimension types. -fn dimension_type_height(name: &str) -> Option<(i32, i32)> { - match name { - "minecraft:overworld" | "minecraft:overworld_caves" => Some((-64, 384)), - "minecraft:the_nether" | "minecraft:the_end" => Some((0, 256)), - _ => None, - } -} - /// Per-noise-settings values resolved from the dimension's generator JSON. struct Settings { noise_min_y: i32, @@ -2546,7 +2541,10 @@ impl OverworldGenerator { None => preset_json["dimensions"][dimension].clone(), }; let generator = stem["generator"].clone(); - let level = stem["type"].as_str().and_then(dimension_type_height); + // The level's build range: its dimension type's, a data pack's + // over the vanilla one. + let level = overrides::LevelHeight::of_type(&stem["type"], &data.dimension_types) + .map(|h| (h.min_y, h.height)); if generator["type"].as_str() != Some("minecraft:noise") { return Err(resource(format!("{dimension} is not a noise generator"))); } @@ -2559,8 +2557,8 @@ impl OverworldGenerator { inline => inline.clone(), }; // NoiseSettings.clampToHeightAccessor: noise past the build height - // (a data pack's taller terrain in a dimension Horizon keeps at its - // vanilla height) fills only the part inside it. + // (a data pack's taller terrain over a dimension type it left as it + // was) fills only the part inside it. let mut noise_clamped = None; if let Some((level_min_y, level_height)) = level && let (Some(min_y), Some(height)) = ( diff --git a/crates/horizon-world/src/worldgen/overrides.rs b/crates/horizon-world/src/worldgen/overrides.rs index be88895ee9..e602eef89b 100644 --- a/crates/horizon-world/src/worldgen/overrides.rs +++ b/crates/horizon-world/src/worldgen/overrides.rs @@ -12,11 +12,15 @@ use std::collections::BTreeMap; /// The bundle of `dimension/.json` level stems. Nothing prepared has /// it: a pack's level stem stands in for the world preset's. pub const DIMENSIONS: &str = "dimensions"; +/// The bundle of `dimension_type/.json` dimension types. Nothing +/// prepared has it: the vanilla types' are `LevelHeight::vanilla`. +pub const DIMENSION_TYPES: &str = "dimension_types"; /// Pack folders (`data///`) the chunk generator reads, and the /// bundle each joins. -const KINDS: [(&str, &str); 14] = [ +const KINDS: [(&str, &str); 15] = [ ("dimension", DIMENSIONS), + ("dimension_type", DIMENSION_TYPES), ("worldgen/biome", "biomes"), // 26.3's names: 26.2 called them configured_carver and configured_feature. ("worldgen/carver", "carvers"), @@ -56,9 +60,6 @@ fn resource_id(file: &str, kind: &str) -> Option { pub enum PackFile { /// An entry or tag file the generator reads. Read, - /// A dimension type: Horizon keeps each dimension's vanilla build - /// height, so it is not read. - DimensionType { id: String, min_y: i64, height: i64 }, /// A worldgen file of a kind the generator does not read (its folder). NotRead(String), /// A generator file whose text could not be read. @@ -67,6 +68,66 @@ pub enum PackFile { Other, } +/// A level's build range (`LevelHeightAccessor`: `min_y`, `height`) and the +/// rest of its dimension type the game reads: `logical_height` and +/// `has_skylight`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct LevelHeight { + pub min_y: i32, + pub height: i32, + pub logical_height: i32, + pub has_skylight: bool, +} +impl LevelHeight { + /// The vanilla dimension types'. + pub fn vanilla(dimension_type: &str) -> Option { + let (min_y, height, logical_height, has_skylight) = match dimension_type { + "minecraft:overworld" | "minecraft:overworld_caves" => (-64, 384, 384, true), + "minecraft:the_nether" => (0, 256, 128, false), + "minecraft:the_end" => (0, 256, 256, true), + _ => return None, + }; + Some(Self { + min_y, + height, + logical_height, + has_skylight, + }) + } + /// A dimension type's JSON, if its codec accepts the range: multiples + /// of 16, at least 16 high, within `DimensionType.MIN_Y` (-2032) and + /// `MAX_Y` (2031), and a logical height within the height. + pub fn of_json(v: &Value) -> Option { + let int = |k: &str| i32::try_from(v[k].as_i64()?).ok(); + let (min_y, height) = (int("min_y")?, int("height")?); + let logical_height = int("logical_height").unwrap_or(height); + let valid = min_y % 16 == 0 + && height % 16 == 0 + && height >= 16 + && min_y >= -2032 + && min_y + height <= 2032 + && (0..=height).contains(&logical_height); + valid.then_some(Self { + min_y, + height, + logical_height, + has_skylight: v["has_skylight"].as_bool().unwrap_or(true), + }) + } + /// A level stem's `type` (a dimension type id, or one inline), with the + /// packs' `types` over the vanilla ones. + pub fn of_type(ty: &Value, types: &BTreeMap) -> Option { + match ty { + Value::String(id) => match types.get(id) { + Some(json) => Self::of_json(&serde_json::from_str(json).ok()?), + None => Self::vanilla(id), + }, + inline @ Value::Object(_) => Self::of_json(inline), + _ => None, + } + } +} + /// Replacement and added bundle entries, and tag files, from data packs. #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] pub struct WorldgenOverrides { @@ -126,15 +187,6 @@ impl WorldgenOverrides { }; self.add_tag(bundle, &id, pack, json); PackFile::Read - } else if let Some(id) = resource_id(file, "dimension_type") { - let v: Value = read() - .and_then(|t| serde_json::from_str(&t).ok()) - .unwrap_or_default(); - PackFile::DimensionType { - id, - min_y: v["min_y"].as_i64().unwrap_or_default(), - height: v["height"].as_i64().unwrap_or_default(), - } } else if rest.starts_with("worldgen/") || rest.starts_with("tags/worldgen/") { PackFile::NotRead(rest.rsplit_once('/').map_or(rest, |(k, _)| k).to_owned()) } else { @@ -198,6 +250,21 @@ impl WorldgenOverrides { } warnings } + /// The build range of the vanilla level `level` (`minecraft:overworld`, + /// `minecraft:the_nether`, `minecraft:the_end`) with these packs: the + /// dimension type its level stem names (a pack's stem, else the + /// preset's, whose type has the level's name), a pack's file for that + /// type over the vanilla one. `None` when the packs give it a range the + /// codec refuses. + pub fn level_height(&self, level: &str) -> Option { + let none = BTreeMap::new(); + let types = self.entries.get(DIMENSION_TYPES).unwrap_or(&none); + let ty = match self.entries.get(DIMENSIONS).and_then(|d| d.get(level)) { + Some(stem) => serde_json::from_str::(stem).ok()?["type"].clone(), + None => Value::String(level.to_owned()), + }; + LevelHeight::of_type(&ty, types) + } /// The entries packs give `bundle`. pub fn entries(&self, bundle: &str) -> impl Iterator { self.entries @@ -300,11 +367,7 @@ mod tests { "data/minecraft/dimension_type/overworld.json", r#"{"min_y":-64,"height":2096}"# ), - PackFile::DimensionType { - id: "minecraft:overworld".into(), - min_y: -64, - height: 2096 - } + PackFile::Read ); assert_eq!( add("data/minecraft/worldgen/carver/cave.json", "6"), @@ -339,6 +402,46 @@ mod tests { assert_eq!(o.tags("biome_tags").count(), 1); } + #[test] + fn levels_take_their_height_from_the_packs_dimension_types() { + let mut o = WorldgenOverrides::default(); + let overworld = o.level_height("minecraft:overworld"); + assert_eq!(overworld, LevelHeight::vanilla("minecraft:overworld")); + let nether = o.level_height("minecraft:the_nether").unwrap(); + assert_eq!((nether.logical_height, nether.has_skylight), (128, false)); + o.set( + DIMENSION_TYPES, + "minecraft:overworld", + r#"{"min_y":-64,"height":2096,"logical_height":384,"has_skylight":true}"#.into(), + ); + let tall = LevelHeight { + min_y: -64, + height: 2096, + logical_height: 384, + has_skylight: true, + }; + assert_eq!(o.level_height("minecraft:overworld"), Some(tall)); + let end = o.level_height("minecraft:the_end"); + assert_eq!(end, LevelHeight::vanilla("minecraft:the_end")); + // A pack's level stem names another type. + o.set( + DIMENSIONS, + "minecraft:the_end", + r#"{"type":"minecraft:overworld"}"#.into(), + ); + assert_eq!(o.level_height("minecraft:the_end"), Some(tall)); + // Ranges the codec refuses. + for bad in [ + r#"{"min_y":-60,"height":384}"#, + r#"{"min_y":-64,"height":2112}"#, + r#"{"min_y":0,"height":0}"#, + r#"{"min_y":0,"height":256,"logical_height":300}"#, + ] { + o.set(DIMENSION_TYPES, "minecraft:overworld", bad.into()); + assert_eq!(o.level_height("minecraft:overworld"), None, "{bad}"); + } + } + #[test] fn new_biomes_are_left_out_of_tags_and_climate_points() { let mut o = WorldgenOverrides::default(); From 0a72ffbf5c4dbf61aa32ca6f0db1ee329bab77b4 Mon Sep 17 00:00:00 2001 From: ThatCakeIsAPie Date: Wed, 28 Oct 2026 14:26:13 -0500 Subject: [PATCH 022/968] Each level's shape from the world's dimension types, local and remote Opening a world reads its levels' shapes from its dimension types, the enabled data packs' over vanilla's (world_loading::level_shapes), and the game keeps them (Game.level_shapes): each level's World, SaveDir, streaming and flat terrain get its shape, and the teleport and portal searches read a level's logical height from them. A host encodes each level at its shape (HubConfig.levels) and sends the data of a dimension type the packs changed even to a client that knows the vanilla pack (NetConfig.levels, SyncData::registry_packets), so the integrated server of single player and LAN guests see the same heights. A remote client takes each level's shape and its chunks' column from the server's dimension type, which now carries its logical height; the vanilla End's fallback has sky light, as 26.3's the_end.json does. A world with To The Max's dimension type is 2032 blocks high from here. Vanilla worlds read vanilla's types: nothing changes there. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/chunk.rs | 37 ++++++++++ crates/horizon-client/src/game.rs | 24 +++--- crates/horizon-client/src/game/consume.rs | 6 +- crates/horizon-client/src/game/dedicated.rs | 2 + .../horizon-client/src/game/initial_spawn.rs | 2 +- crates/horizon-client/src/game/integrated.rs | 2 + crates/horizon-client/src/game/lan.rs | 6 +- crates/horizon-client/src/game/levels.rs | 3 +- crates/horizon-client/src/game/minecarts.rs | 2 +- crates/horizon-client/src/game/remote.rs | 20 ++++- .../horizon-client/src/game/remote_chunks.rs | 8 ++ crates/horizon-client/src/game/render_tick.rs | 2 +- .../horizon-client/src/game/world_loading.rs | 57 +++++++++++++-- crates/horizon-client/src/lan/conn.rs | 2 +- crates/horizon-client/src/lan/data.rs | 73 +++++++++++++++++-- crates/horizon-client/src/lan/hub.rs | 19 ++--- crates/horizon-client/src/lan/mod.rs | 4 + crates/horizon-client/tests/lan_bot.rs | 1 + crates/horizon-protocol/src/registry.rs | 13 +++- 19 files changed, 225 insertions(+), 58 deletions(-) diff --git a/crates/horizon-client/src/chunk.rs b/crates/horizon-client/src/chunk.rs index 89508cfc78..98bbc27b6c 100644 --- a/crates/horizon-client/src/chunk.rs +++ b/crates/horizon-client/src/chunk.rs @@ -86,6 +86,43 @@ impl LevelShape { } } +/// The shapes of a world's three levels, from its dimension types. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LevelShapes { + pub overworld: LevelShape, + pub nether: LevelShape, + pub end: LevelShape, +} +impl LevelShapes { + pub const VANILLA: Self = Self { + overworld: LevelShape::OVERWORLD, + nether: LevelShape::NETHER, + end: LevelShape::END, + }; + /// The shape of the level `id` (`minecraft:overworld`, + /// `minecraft:the_nether`, `minecraft:the_end`); others get the + /// Overworld's. + pub fn get(&self, id: &str) -> LevelShape { + match id { + "minecraft:the_nether" => self.nether, + "minecraft:the_end" => self.end, + _ => self.overworld, + } + } + pub fn get_mut(&mut self, id: &str) -> &mut LevelShape { + match id { + "minecraft:the_nether" => &mut self.nether, + "minecraft:the_end" => &mut self.end, + _ => &mut self.overworld, + } + } +} +impl Default for LevelShapes { + fn default() -> Self { + Self::VANILLA + } +} + /// The span of block Y a chunk holds: from `min_y`, `sections` sections up /// (`LevelShape::column`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c63b5915cc..a5df948e27 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1843,6 +1843,8 @@ struct Game<'a> { lan: Option, /// Single player's server thread, which `remote` is connected to. integrated: Option, + /// The shapes of the open world's levels, from its dimension types. + level_shapes: horizon_client::chunk::LevelShapes, /// This game is that server thread's (game/integrated.rs). serving: Option, /// The open world is shared behind an invite code (game/friends.rs). @@ -1982,7 +1984,7 @@ impl Dim { _ => Self::Overworld, } } - /// The level's shape, from vanilla's dimension types. + /// The level's shape in a world of vanilla's dimension types. fn shape(self) -> horizon_client::save::LevelShape { use horizon_client::save::LevelShape; match self { @@ -1991,18 +1993,6 @@ impl Dim { Self::End => LevelShape::END, } } - /// `Level.getMinY`: the dimension type's minimum y. - fn min_y(self) -> i32 { - self.shape().min_y - } - /// `DimensionType.logicalHeight`. - fn logical_height(self) -> i32 { - self.shape().logical_height - } - /// `Level.getMaxY`: the dimension type's minimum y plus height, less one. - fn max_y(self) -> i32 { - self.shape().max_y() - 1 - } } fn facing_offset(facing: &str) -> [i32; 3] { match facing { @@ -8166,7 +8156,7 @@ impl Game<'_> { texts: None, partial: alpha as f32, game_time: self.world.game_time, - max_y: self.dim.max_y(), + max_y: self.world.shape.max_y() - 1, world: &self.world, sprites: &self.gui.atlas.sprites, nether: self.dim == Dim::Nether, @@ -9067,6 +9057,7 @@ impl Game<'_> { fn finish_open_world(&mut self, prepared: world_loading::PreparedWorld) -> Result<()> { let world_loading::PreparedWorld { save_root, + shapes, level, world, streaming, @@ -9076,6 +9067,7 @@ impl Game<'_> { locator, } = prepared; let dim = Dim::from_id(&level.dimension); + self.level_shapes = shapes; horizon_client::crash::set_detail( "World", format!("{} ({})", level.name, save_root.display()), @@ -9220,6 +9212,7 @@ impl Game<'_> { &self.settings, world_root, dim, + self.level_shapes.get(dim.id()), seed, )?; self.end.set_locator(locator); @@ -9266,7 +9259,7 @@ impl Game<'_> { let (streaming, save) = self.make_streaming(&self.world_root.clone(), target, self.level.seed)?; let mut world = World::new(self.res.facts().air, self.level.seed); - world.shape = target.shape(); + world.shape = self.level_shapes.get(target.id()); world.fast_lava = target == Dim::Nether; world.game_time = self.world.game_time; world.day_time = self.world.day_time; @@ -14089,6 +14082,7 @@ impl<'a> Game<'a> { remote: None, lan: None, integrated: None, + level_shapes: Default::default(), serving: None, world_options: None, options_to_world_options: false, diff --git a/crates/horizon-client/src/game/consume.rs b/crates/horizon-client/src/game/consume.rs index e5fae08caf..b178a2773d 100644 --- a/crates/horizon-client/src/game/consume.rs +++ b/crates/horizon-client/src/game/consume.rs @@ -128,7 +128,7 @@ impl Game<'_> { if !self.world.loaded(pos.x, pos.z) { return false; } - while pos.y > self.dim.min_y() { + while pos.y > self.world.shape.min_y { pos.y -= 1; let state = self.world.block(pos.x, pos.y, pos.z); if self.state_in_tag("minecraft:entities_can_teleport_to", state) { @@ -285,10 +285,10 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> { &mut self.0.world.random } fn min_y(&mut self, level: Dim) -> Result { - Ok(level.min_y()) + Ok(self.0.level_shapes.get(level.id()).min_y) } fn logical_height(&mut self, level: Dim) -> Result { - Ok(level.logical_height()) + Ok(self.0.level_shapes.get(level.id()).logical_height) } fn is_passenger(&mut self) -> Result { Ok(self.0.riding.is_some()) diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 07f3d6c210..0dac27e9a8 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -633,6 +633,7 @@ impl Game<'_> { resource_pack: props.resource_pack.clone(), // DedicatedServer.serverLinks: `bug-report-link` as the only link // (ServerLinks.KnownLinkType.BUG_REPORT is id 0). + levels: self.level_shapes, server_links: if props.bug_report_link.is_empty() { Vec::new() } else if super::server_links::valid_untrusted_uri(&props.bug_report_link) { @@ -656,6 +657,7 @@ impl Game<'_> { view_distance: props.view_distance.clamp(2, 32), simulation_distance: props.simulation_distance.clamp(2, 32), dimension: self.dim.id().to_owned(), + levels: self.level_shapes, seed: self.level.seed, max_players: props.max_players.max(0) as usize, dedicated: true, diff --git a/crates/horizon-client/src/game/initial_spawn.rs b/crates/horizon-client/src/game/initial_spawn.rs index d9eca3832a..dd1ba954f0 100644 --- a/crates/horizon-client/src/game/initial_spawn.rs +++ b/crates/horizon-client/src/game/initial_spawn.rs @@ -84,7 +84,7 @@ impl Game<'_> { &s, self.pack.assets.registry, self.res.biomes(), - super::Dim::Overworld.shape(), + self.level_shapes.overworld, ) }) { diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index dfa016b9e6..69a0934548 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -361,6 +361,7 @@ impl Game<'_> { view_distance, simulation_distance: self.settings.simulation_distance.max(2), dimension: self.dim.id().to_owned(), + levels: self.level_shapes, seed: self.level.seed, max_players: MAX_PLAYERS, // No player of its own: the hub runs without a host. @@ -377,6 +378,7 @@ impl Game<'_> { (uuid != owner && published.published.load(Ordering::Relaxed) == 0) .then(|| translatable("multiplayer.disconnect.not_whitelisted", &[])) })), + levels: self.level_shapes, // IntegratedServer.initServer: the MOTD names the owner and the world. ..NetConfig::lan( 0, diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 1bb9a853e1..de93a43788 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1096,12 +1096,16 @@ impl Game<'_> { view_distance: self.settings.render_distance.clamp(2, 32), simulation_distance: self.settings.render_distance.clamp(2, 32), dimension: self.dim.id().to_owned(), + levels: self.level_shapes, seed: self.level.seed, max_players: MAX_PLAYERS, dedicated: false, integrated: false, }; - let net = NetConfig::lan(settings.port, motd, MAX_PLAYERS); + let net = NetConfig { + levels: self.level_shapes, + ..NetConfig::lan(settings.port, motd, MAX_PLAYERS) + }; self.lan_host(settings, net, config, Some(host_uuid)) } /// Start the network side and the play-state hub: for the LAN (with the diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 918ac41c09..06b26a77b5 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -144,10 +144,11 @@ impl Game<'_> { &self.settings, &self.world_root, dim, + self.level_shapes.get(dim.id()), self.level.seed, )?; let mut world = World::new(self.res.facts().air, self.level.seed); - world.shape = dim.shape(); + world.shape = self.level_shapes.get(dim.id()); world.fast_lava = dim == Dim::Nether; Ok(Parked { world, diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 6b01ae76ac..460cf93812 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -1450,7 +1450,7 @@ impl Game<'_> { texts: None, partial: 0.0, game_time: self.world.game_time, - max_y: self.dim.max_y(), + max_y: self.world.shape.max_y() - 1, world: &self.world, sprites: &self.gui.atlas.sprites, nether: self.dim == super::Dim::Nether, diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 13f98f4376..ca6ea11b2d 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1031,7 +1031,9 @@ impl Game<'_> { .unwrap_or_default(); if let Some(r) = self.remote.as_mut() { let bits = ceil_log2(biomes.len()); - r.chunk_format = Arc::new(ChunkFormat::new(&r.dimension, biomes, bits)); + let column = r.chunk_format.layout(); + r.chunk_format = + Arc::new(ChunkFormat::new(&r.dimension, biomes, bits).in_column(column)); r.registries = registries; } } @@ -1041,9 +1043,21 @@ impl Game<'_> { return; }; let dimension = r.registries.dimension(spawn.dimension_type); - let shape = super::Dim::from_id(&dimension.name).shape(); + // The server's dimension type gives the level's shape, and its + // chunks the shape's column. + let shape = horizon_client::chunk::LevelShape::new( + dimension.min_y, + dimension.height, + dimension.logical_height, + dimension.has_skylight, + ) + .unwrap_or_else(|_| super::Dim::from_id(&dimension.name).shape()); let same = r.dimension.name == dimension.name && keep_world; - r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension)); + r.chunk_format = Arc::new( + r.chunk_format + .with_dimension(&dimension) + .in_column(shape.column()), + ); r.dimension = dimension; r.game_mode = spawn.game_type; r.flat = spawn.flat; diff --git a/crates/horizon-client/src/game/remote_chunks.rs b/crates/horizon-client/src/game/remote_chunks.rs index ea781b7915..68de82d248 100644 --- a/crates/horizon-client/src/game/remote_chunks.rs +++ b/crates/horizon-client/src/game/remote_chunks.rs @@ -44,6 +44,14 @@ impl ChunkFormat { biome_bits, } } + /// Decodes into chunks of `column`. + pub(super) fn in_column(self, column: ColumnLayout) -> Self { + Self { column, ..self } + } + /// The column decoded chunks hold. + pub(super) fn layout(&self) -> ColumnLayout { + self.column + } pub(super) fn with_dimension(&self, dimension: &DimensionType) -> Self { Self { column: self.column, diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index b388fd7ce5..86adb9a227 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -131,7 +131,7 @@ impl Game<'_> { fn level_min_y(&self) -> i32 { match self.remote.as_ref() { Some(r) => r.dimension.min_y, - None => self.dim.min_y(), + None => self.world.shape.min_y, } } /// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index b3ad7aca1f..f48f6f47ee 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -12,6 +12,8 @@ pub(super) struct WorldRequest { pub(super) struct PreparedWorld { pub save_root: PathBuf, + /// The levels' shapes, from the world's dimension types. + pub shapes: horizon_client::chunk::LevelShapes, pub level: Level, pub world: World, pub streaming: Streaming, @@ -112,11 +114,14 @@ impl WorldRequest { level.position = None; } let dim = Dim::from_id(&level.dimension); - let (mut streaming, save, locator) = - make_streaming(res, pack, options, settings, &save_root, dim, level.seed)?; + let shapes = level_shapes(&save_root, res.biomes()); + let shape = shapes.get(dim.id()); + let (mut streaming, save, locator) = make_streaming( + res, pack, options, settings, &save_root, dim, shape, level.seed, + )?; streaming.radius = settings.render_distance; let mut world = World::new(res.facts().air, level.seed); - world.shape = dim.shape(); + world.shape = shape; world.fast_lava = dim == Dim::Nether; world.game_time = level.time; world.day_time = options.time.unwrap_or(level.day_time); @@ -143,6 +148,7 @@ impl WorldRequest { } Ok(PreparedWorld { save_root, + shapes, level, world, streaming, @@ -325,6 +331,46 @@ fn world_worldgen_overrides( overrides } +/// The shapes of the world's levels: their dimension types, the world's +/// enabled data packs' over vanilla's. A type the codec refuses keeps the +/// vanilla shape, with a warning. +pub(super) fn level_shapes( + world_root: &std::path::Path, + biomes: &horizon_client::biome::Biomes, +) -> horizon_client::chunk::LevelShapes { + use horizon_client::chunk::{LevelShape, LevelShapes}; + let packs = horizon_client::datapack::discover(world_root); + let enabled = horizon_client::datapack::enabled_ids(world_root, &packs); + let enabled: Vec<_> = enabled + .iter() + .filter_map(|id| packs.iter().find(|p| &p.id == id)) + .collect(); + let mut shapes = LevelShapes::VANILLA; + if enabled.is_empty() { + return shapes; + } + // What the packs leave out is logged when the generator reads them. + let (overrides, _) = + horizon_client::datapack::worldgen_overrides(&enabled, |b| biomes.contains(b)); + for level in [ + "minecraft:overworld", + "minecraft:the_nether", + "minecraft:the_end", + ] { + let shape = overrides.level_height(level).and_then(|h| { + LevelShape::new(h.min_y, h.height, h.logical_height, h.has_skylight).ok() + }); + match shape { + Some(shape) => *shapes.get_mut(level) = shape, + None => log::warn!( + "Horizon: data pack worldgen: the dimension type of {level} is not one the \ + game accepts; the level keeps the vanilla build height" + ), + } + } + shapes +} + pub(super) fn make_streaming( res: &Resources, pack: &PackResources, @@ -332,11 +378,12 @@ pub(super) fn make_streaming( settings: &Settings, world_root: &std::path::Path, dim: Dim, + shape: horizon_client::chunk::LevelShape, seed: i64, ) -> Result<(Streaming, Arc, Option>)> { let save = Arc::new(SaveDir::new( horizon_client::save::dimension_dir(world_root, dim.id()), - dim.shape(), + shape, )); let mut locator = None; let flat = (dim == Dim::Overworld) @@ -347,7 +394,7 @@ pub(super) fn make_streaming( &s, pack.assets.registry, res.biomes(), - dim.shape(), + shape, ) }); let mut streaming = if let Some(inner) = flat { diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index 0100b8f232..57d2979ac9 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -844,7 +844,7 @@ fn login_and_configure( }), )?; } - for packet in sync.registry_packets(known_pack) { + for packet in sync.registry_packets(known_pack, &shared.config.levels) { send(c, cfg::Clientbound::RegistryData(packet))?; } send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?; diff --git a/crates/horizon-client/src/lan/data.rs b/crates/horizon-client/src/lan/data.rs index 8de0e53d3a..93081733a4 100644 --- a/crates/horizon-client/src/lan/data.rs +++ b/crates/horizon-client/src/lan/data.rs @@ -2,6 +2,7 @@ //! network order, with their data for clients that do not know the vanilla //! pack) and the tags of every registry, loaded from the prepared resources //! (`data/minecraft/...`) and the registries report. +use crate::chunk::LevelShapes; use horizon_protocol::{ packets::configuration::{RegistryData, RegistryEntry, RegistryTags, TagEntry, UpdateTags}, registry::{Builtin, Dynamic}, @@ -174,7 +175,10 @@ impl SyncData { /// ClientboundRegistryDataPacket per registry. With `known_pack` the /// client rebuilds the entries from its own data, so none is sent. - pub fn registry_packets(&self, known_pack: bool) -> Vec { + /// The registries for a client, with the data of a dimension type the + /// world's data packs changed (`levels`) even when it knows the vanilla + /// pack. + pub fn registry_packets(&self, known_pack: bool, levels: &LevelShapes) -> Vec { self.registries .iter() .map(|(key, e)| RegistryData { @@ -183,13 +187,18 @@ impl SyncData { .names .iter() .zip(&e.json) - .map(|(name, json)| RegistryEntry { - id: name.clone(), - data: if known_pack { - None - } else { - Some(json_to_nbt(json)) - }, + .map(|(name, json)| { + let changed = (key == "minecraft:dimension_type") + .then(|| changed_dimension_type(name, json, levels)) + .flatten(); + RegistryEntry { + id: name.clone(), + data: match changed { + Some(json) => Some(json_to_nbt(&json)), + None if known_pack => None, + None => Some(json_to_nbt(json)), + }, + } }) .collect(), }) @@ -290,6 +299,27 @@ impl SyncData { } } +/// The vanilla dimension type `name` (its JSON `json`) with the shape the +/// world gives the level of that name, if its data packs changed it. +fn changed_dimension_type(name: &str, json: &Value, levels: &LevelShapes) -> Option { + if !matches!( + name, + "minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" + ) { + return None; + } + let shape = levels.get(name); + if shape == LevelShapes::VANILLA.get(name) { + return None; + } + let mut json = json.clone(); + json["min_y"] = shape.min_y.into(); + json["height"] = shape.height.into(); + json["logical_height"] = shape.logical_height.into(); + json["has_skylight"] = shape.sky_light.into(); + Some(json) +} + #[cfg(test)] mod tests { use super::*; @@ -302,6 +332,33 @@ mod tests { assert!(matches!(t.get("c"), Some(Tag::String(_)))); assert!(matches!(t.get("b"), Some(Tag::List(l)) if l.len() == 2)); } + + #[test] + fn a_changed_dimension_type_is_sent_with_its_shape() { + let vanilla: Value = + serde_json::from_str(r#"{"min_y":-64,"height":384,"logical_height":384,"has_skylight":true,"ultrawarm":false}"#) + .unwrap(); + let mut levels = LevelShapes::VANILLA; + assert_eq!( + changed_dimension_type("minecraft:overworld", &vanilla, &levels), + None + ); + levels.overworld = crate::chunk::LevelShape::new(-64, 2096, 2096, true).unwrap(); + let sent = changed_dimension_type("minecraft:overworld", &vanilla, &levels).unwrap(); + assert_eq!( + (sent["min_y"].as_i64(), sent["height"].as_i64()), + (Some(-64), Some(2096)) + ); + assert_eq!(sent["ultrawarm"], false, "the rest of the type is kept"); + assert_eq!( + changed_dimension_type("minecraft:the_nether", &vanilla, &levels), + None + ); + assert_eq!( + changed_dimension_type("minecraft:overworld_caves", &vanilla, &levels), + None + ); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index d89d8b689a..0df1f28846 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -64,6 +64,8 @@ pub struct HubConfig { /// the owner's level from `ops`, the others' from Allow Commands and /// the guests' command access, and the integrated-only commands. pub integrated: bool, + /// The shapes of the world's levels (their dimension types). + pub levels: crate::chunk::LevelShapes, } /// The host player as the other players see them. @@ -352,8 +354,7 @@ impl Hub { .unwrap_or(0); let clock_ids = (0..sync.dynamic.len("minecraft:world_clock") as i32).collect(); let mut encoder = encoder; - let d = horizon_protocol::registry::DimensionType::vanilla(&config.dimension); - encoder.set_dimension(level_shape(&d)); + encoder.set_dimension(config.levels.get(&config.dimension)); Self { lan, config, @@ -2643,8 +2644,8 @@ impl Hub { .sync .id("minecraft:dimension_type", dimension) .unwrap_or(0); - let d = horizon_protocol::registry::DimensionType::vanilla(dimension); - self.encoder.set_dimension(level_shape(&d)); + self.encoder + .set_dimension(self.config.levels.get(dimension)); } /// ServerPlayer.teleport(TeleportTransition) into the level the hub now @@ -2799,16 +2800,6 @@ fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 { shared } -/// The shape a level of the dimension type `d` has. -fn level_shape(d: &horizon_protocol::registry::DimensionType) -> crate::chunk::LevelShape { - crate::chunk::LevelShape { - sky_light: d.has_skylight, - min_y: d.min_y, - height: d.height, - logical_height: d.height, - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 34c465ffaf..997cff01d3 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -146,6 +146,9 @@ pub struct NetConfig { /// sent in configuration when non-empty /// (ServerConfigurationPacketListenerImpl.startConfiguration). pub server_links: Vec, + /// The shapes of the world's levels: their dimension types go to + /// clients when the world's data packs changed them. + pub levels: crate::chunk::LevelShapes, } impl NetConfig { /// MinecraftServer.enforceSecureProfile. @@ -173,6 +176,7 @@ impl NetConfig { log: None, resource_pack: None, server_links: Vec::new(), + levels: crate::chunk::LevelShapes::VANILLA, } } } diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 5eff0b91e8..5e567c4ef8 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -254,6 +254,7 @@ fn server(resources: &Path) -> Server { dimension: "minecraft:overworld".into(), seed: 1234, max_players: 8, + levels: Default::default(), }, ); Server { diff --git a/crates/horizon-protocol/src/registry.rs b/crates/horizon-protocol/src/registry.rs index 400c21a378..9406bd5026 100644 --- a/crates/horizon-protocol/src/registry.rs +++ b/crates/horizon-protocol/src/registry.rs @@ -136,6 +136,9 @@ impl Dynamic { if let Some(v) = int("height") { d.height = v; } + if let Some(v) = int("logical_height") { + d.logical_height = v; + } if let Some(Tag::Byte(v)) = map.get(&JavaString::from("has_skylight")) { d.has_skylight = *v != 0; } @@ -149,19 +152,21 @@ pub struct DimensionType { pub name: String, pub min_y: i32, pub height: i32, + pub logical_height: i32, pub has_skylight: bool, } impl DimensionType { pub fn vanilla(name: &str) -> Self { - let (min_y, height, has_skylight) = match name { - "minecraft:the_nether" => (0, 256, false), - "minecraft:the_end" => (0, 256, false), - _ => (-64, 384, true), + let (min_y, height, logical_height, has_skylight) = match name { + "minecraft:the_nether" => (0, 256, 128, false), + "minecraft:the_end" => (0, 256, 256, true), + _ => (-64, 384, 384, true), }; Self { name: name.to_owned(), min_y, height, + logical_height, has_skylight, } } From 148d59256761c04a7ed86a9c450b11f002710b92 Mon Sep 17 00:00:00 2001 From: ThatCakeIsAPie Date: Wed, 28 Oct 2026 14:57:47 -0500 Subject: [PATCH 023/968] Uniform sections are shared, and the sky-source scan skips them Sections of one block, one biome and one light value are one allocation per kind, shared by every column (new chunks, lighting, saved light); edits copy them, as Arc::make_mut already does. A tall level is mostly such sections: air with full sky light above the terrain. The relighter's lowest_sky_source passes over a uniform section that light goes through instead of testing each of its 16 blocks. Memory only: every value read is the same. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/chunk.rs | 48 ++++++++++++++++++++++++++++-- crates/horizon-client/src/light.rs | 36 ++++++++++++++++++++-- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/chunk.rs b/crates/horizon-client/src/chunk.rs index 98bbc27b6c..9251bb2c44 100644 --- a/crates/horizon-client/src/chunk.rs +++ b/crates/horizon-client/src/chunk.rs @@ -243,6 +243,47 @@ pub fn free_columns(columns: T, count: usize) { pub fn set_block_states(states: usize) { let bits = horizon_core::paletted::ceil_log2(states).max(16); BLOCK_BITS.store(bits, std::sync::atomic::Ordering::Relaxed); + forget_shared(); +} + +/// One allocation per kind of uniform section (a single block, a single +/// biome and one light value), shared by every column that has it, keyed by +/// (block, biome, light). A tall column is mostly such sections: air with +/// full sky light above the terrain. Edits copy them (`Arc::make_mut`). +type SharedSections = std::collections::HashMap<(u32, u32, u8), Arc
>; +static SHARED: std::sync::Mutex> = std::sync::Mutex::new(None); + +/// Replaces the uniform sections of `sections` with the shared ones. +pub fn share_uniform(sections: &mut [Arc
]) { + let key = |s: &Section| -> Option<(u32, u32, u8)> { + let Light::Uniform(light) = s.light else { + return None; + }; + if s.blocks.bits() != 0 || s.biomes.bits() != 0 { + return None; + } + Some((s.blocks.uniform()?, s.biomes.uniform()?, light)) + }; + let Ok(mut shared) = SHARED.lock() else { + return; + }; + let shared = shared.get_or_insert_with(Default::default); + for section in sections { + let Some(k) = key(section) else { + continue; + }; + let one = shared.entry(k).or_insert_with(|| section.clone()); + if !Arc::ptr_eq(one, section) { + *section = one.clone(); + } + } +} + +/// Forgets the shared sections, when the palette changes. +fn forget_shared() { + if let Ok(mut shared) = SHARED.lock() { + *shared = None; + } } /// Packed light per block: sky light in the high nibble, block light in the low one. #[derive(Clone)] @@ -374,13 +415,14 @@ impl Chunk { Self::with_layout(x, z, ColumnLayout::VANILLA) } pub fn with_layout(x: i32, z: i32, layout: ColumnLayout) -> Self { + let mut one = [Arc::new(Section::default())]; + share_uniform(&mut one); + let [empty] = one; Self { x, z, min_y: layout.min_y(), - sections: (0..layout.sections()) - .map(|_| Arc::new(Section::default())) - .collect(), + sections: (0..layout.sections()).map(|_| empty.clone()).collect(), surface: OnceLock::new(), motion_blocking: OnceLock::new(), lit: false, diff --git a/crates/horizon-client/src/light.rs b/crates/horizon-client/src/light.rs index 36461fef8f..268767a02e 100644 --- a/crates/horizon-client/src/light.rs +++ b/crates/horizon-client/src/light.rs @@ -467,8 +467,15 @@ fn pack_lighting( /// edits and immutable snapshots retained by other workers. pub fn apply_lighting(chunk: &mut Chunk, lights: Vec) { for (section, light) in chunk.sections.iter_mut().zip(lights) { + // A shared section already lit so stays as it is. + if let (Light::Uniform(a), Light::Uniform(b)) = (§ion.light, &light) + && a == b + { + continue; + } Arc::make_mut(section).light = light; } + crate::chunk::share_uniform(&mut chunk.sections); } /// `apply_lighting` for a chunk lit before: writes only the sections whose @@ -535,6 +542,7 @@ pub fn light_from_layers>( section.light = Light::Full(data); section.compact(); } + crate::chunk::share_uniform(&mut chunk.sections); chunk.lit = true; chunk.light_loaded = true; } @@ -658,6 +666,12 @@ impl<'w> Region<'w> { let slot = self.slot(x, y, z)?; Some(self.slots[slot].section.get(index(x, y, z))) } + /// The one state of the section holding `y`, when it has a single one. + fn uniform_state(&mut self, x: i32, y: i32, z: i32) -> Option { + let slot = self.slot(x, y, z)?; + let section = &self.slots[slot].section; + (section.blocks.bits() == 0).then(|| section.get(0)) + } /// Packed light (sky << 4 | block), None outside loaded, lit chunks. fn light(&mut self, x: i32, y: i32, z: i32) -> Option { let slot = self.slot(x, y, z)?; @@ -826,6 +840,15 @@ fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) - return min_y; }; while y > min_y { + // A section of one state that lets light through itself (the air of + // a tall column) holds no source edge: go to its bottom block. + if let Some(state) = world.uniform_state(x, y - 1, z) + && state == top + && !tables.edge_occluded(top as usize, top as usize) + { + y = ((y - 1 - min_y) & !15) + min_y; + continue; + } let Some(bottom) = world.state(x, y - 1, z) else { break; }; @@ -1210,6 +1233,14 @@ mod tests { assert_eq!(chunk.light(8, 1601, 8) >> 4, 15); assert_eq!(chunk.light(8, 1599, 8) >> 4, 0, "under the roof"); assert_eq!(chunk.light(8, 2031, 8), 0xF0); + // The empty sections above are one shared allocation. + let top = chunk.sections.len() - 1; + assert!(Arc::ptr_eq(&chunk.sections[top], &chunk.sections[top - 10])); + let other = Chunk::with_layout(1, 0, layout); + apply_lighting(&mut other.clone(), vec![Light::Uniform(0xF0); 131]); + let mut lit = other.clone(); + apply_lighting(&mut lit, vec![Light::Uniform(0xF0); 131]); + assert!(Arc::ptr_eq(&chunk.sections[top], &lit.sections[top])); } #[test] fn a_tall_column_relights_as_its_full_light_and_keys_its_sections() { @@ -1484,9 +1515,10 @@ mod tests { .iter() .all(|v| matches!(v, Light::Uniform(n) if *n == if sky { 0xF0 } else { 0 })) ); - let section = Arc::as_ptr(&chunk.sections[0]); apply_lighting(&mut chunk, lights); - assert_eq!(section, Arc::as_ptr(&chunk.sections[0])); + // Uniform sections are one shared allocation (`share_uniform`). + let first = &chunk.sections[0]; + assert!(chunk.sections.iter().all(|s| Arc::ptr_eq(s, first))); } } From 83b958a824c4530fd7973875d18046255f79fe71 Mon Sep 17 00:00:00 2001 From: ThatCakeIsAPie Date: Wed, 28 Oct 2026 15:33:28 -0500 Subject: [PATCH 024/968] docs/world-height.md, a 2096-high fixture pack, and the tall parity run tools/fixtures/tall_datapacks/horizon_tall_test is Horizon's own pack (MIT): an Overworld dimension type of min_y -64, height 2096 (To The Max's, the highest top vanilla allows) and noise settings that raise terrain to about y 1900. VanillaChunkGenerationOracle builds each level at its dimension type's range instead of a fixed -64..320, so HORIZON_PARITY_DATAPACKS=tools/fixtures/tall_datapacks tools/test_datapack_worldgen_parity.py compares Horizon's tall generation with vanilla's: 16 chunks over two seeds, terrain and decorated, the same. docs/world-height.md describes the model (one LevelShape per level, from its dimension type, read everywhere), the network path, the checks and what is left; world-generation.md and the README say packs' dimension types now apply. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/README.md | 1 + docs/world-generation.md | 4 +- docs/world-height.md | 75 +++++++++++++++++++ .../parity/VanillaChunkGenerationOracle.java | 3 +- .../density_function/final_density.json | 5 ++ .../worldgen/density_function/peaks.json | 17 +++++ .../horizon_tall/worldgen/noise/peaks.json | 5 ++ .../minecraft/dimension_type/overworld.json | 63 ++++++++++++++++ .../worldgen/noise_settings/overworld.json | 20 +++++ .../horizon_tall_test/pack.mcmeta | 7 ++ tools/test_datapack_worldgen_parity.py | 3 +- 12 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 docs/world-height.md create mode 100644 tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/final_density.json create mode 100644 tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/peaks.json create mode 100644 tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/noise/peaks.json create mode 100644 tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/dimension_type/overworld.json create mode 100644 tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/worldgen/noise_settings/overworld.json create mode 100644 tools/fixtures/tall_datapacks/horizon_tall_test/pack.mcmeta diff --git a/README.md b/README.md index 786a1396d8..d8be7f57ac 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ 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). +- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's 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 mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods). diff --git a/docs/README.md b/docs/README.md index 1f2d2fefac..b92ae7e75e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor ## Architecture: world generation - [World generation](world-generation.md): the pipeline from noise to features. +- [World height](world-height.md): each level's shape from its dimension type, data packs' taller levels. - [Pregeneration](pregeneration.md): `/pregen`, generating, lighting and saving an area ahead of the players. - [Noise generation](noise-generation.md), [compiled density functions](density-functions.md), [structure density](structure-density.md), [terrain filling](terrain-filling.md). - Biomes: [lookup](biome-lookup.md), [temperatures](biome-temperatures.md). diff --git a/docs/world-generation.md b/docs/world-generation.md index 65e82cf65d..68ffddf6e9 100644 --- a/docs/world-generation.md +++ b/docs/world-generation.md @@ -209,8 +209,8 @@ conditions, carvers, configured and placed features, structures, structure sets, higher pack's file for an id replacing a lower one's, and its block, fluid and biome tag files to the tag bundles in pack order. `GeneratorData::load_with` reads the bundles with them; the client caches the result per set of pack entries (`vanillagen::shared_data_with`). What Horizon leaves out is logged: a pack's new biomes (the game has no id, colours or sounds for them; -their tag entries and climate points go with them), dimension types (every dimension keeps its vanilla build height, and noise settings -taller than it generate only inside it, as `clampToHeightAccessor`), and folders the generator does not read. Sapling growth and bone +their tag entries and climate points go with them) and folders the generator does not read. A pack's dimension types set each level's +height ([world-height.md](world-height.md)); noise settings taller than the level generate only inside it, as `clampToHeightAccessor`. Sapling growth and bone meal still place vanilla's features. `tools/test_datapack_worldgen_parity.py` runs the whole-chunk harness with `HORIZON_DATAPACKS=tools/fixtures/datapacks` on both sides diff --git a/docs/world-height.md b/docs/world-height.md new file mode 100644 index 0000000000..31d87f9f30 --- /dev/null +++ b/docs/world-height.md @@ -0,0 +1,75 @@ +# World height: each level's shape from its dimension type + +Vanilla takes each level's build range from its dimension type: `min_y` and `height`, multiples of 16, +with `height >= 16` and `min_y + height <= 2032` (`DimensionType.MIN_Y = -2032`, `Y_SIZE = 4064`: `BlockPos` +packs Y in 12 bits), plus `logical_height` (how high portals and chorus fruit reach) and `has_skylight`. +`LevelHeightAccessor` derives everything else from them. Data packs that reshape the Overworld make it taller: +JJThunder's To The Max sets `min_y -64, height 2096`, the largest top vanilla allows, which is 131 sections +against vanilla's 24. + +## The model + +**`chunk::LevelShape`** is one level's shape: `min_y`, `height`, `logical_height`, `sky_light`, checked as the +dimension type codec checks them (`LevelShape::new`). **`LevelShapes`** holds a world's three. + +- **Where it comes from.** A local world's shapes are its dimension types, its enabled data packs' over + vanilla's, through the level stems that name them (`worldgen::overrides::WorldgenOverrides::level_height`, + read by `game/world_loading.rs`'s `level_shapes` when the world opens; the game keeps them in + `Game.level_shapes`). A remote client takes a level's shape from the server's `minecraft:dimension_type` + registry (`protocol::registry::DimensionType`, with its logical height). A type the codec refuses keeps the + vanilla shape, with a warning. +- **Where it lives.** Each level's `World` holds its `shape`, `SaveDir::new(root, shape)` takes it, and the + generator reads the same dimension type for the level's build range (`OverworldGenerator::level`). +- **The column.** `LevelShape::column()` is the span of sections the level's chunks hold (`ColumnLayout`): + the level's own range, widened to the vanilla Overworld's `-64..320`. So vanilla's three levels keep the + 24 sections they have always had, and a taller Overworld gets its own column while its Nether and End stay + at 24. Every `Chunk` records its own `min_y` and section count, and the hot accessors read the chunk. +- **No global.** The shape travels with the data (`World`, `SaveDir`, `Chunk`, the light and mesh jobs, the + renderer's `clear_sections(layout)`, the LAN encoder's `set_dimension(shape)`, the remote `ChunkFormat`). + Test binaries run tests in parallel in one process, so a process-wide height would leak between them. + +| Subsystem | Reads | +| --- | --- | +| Chunk storage (`chunk.rs`) | the chunk's own `min_y` and sections | +| Light (`light.rs`) | the chunk's sections; the relighter the world's column (`LightWorld::layout`) | +| World, streaming, meshing, renderer | `World::layout()`, the chunk's `min_y`, the renderer's layout | +| Saves (`save.rs`) | `SaveDir.shape` (sections, `yPos`, heightmaps, light layers, far tiles) | +| Network (`lan/`, `game/remote_chunks.rs`) | the level's shape on the host, the server's dimension type on the client | +| Section caches, generation glue, far terrain, minimap, flat worlds | the chunk's column, the generator's level range | +| Gameplay (bounds, searches, commands, mobs) | `World::bottom_y`/`top_y` (the column), `World.shape`, `Game.level_shapes` | + +Section sets are `SectionMask`s (256 bits: vanilla's tallest level has 254 sections, light data one more +below and above). Uniform sections (one block, one biome, one light value) are one shared allocation per kind +(`chunk::share_uniform`), and the sky-source scan passes over a uniform section light goes through: a tall +column is mostly such air. + +## A world of data packs + +The game, single player and LAN alike, runs through a host: the integrated server thread of single player +reads the world's shapes, encodes each level at its shape (`HubConfig.levels`) and sends the data of a +dimension type the packs changed even to a client that knows the vanilla pack (`NetConfig.levels`, +`SyncData::registry_packets`), and its client takes the heights from that registry data, as a vanilla client +of a vanilla server does. + +## Checks + +- Unit tests: a 131-section chunk stores, lights, relights, saves, loads, encodes and random-ticks its whole + height; `tests/tall_column.rs` streams, lights and meshes a 131-section level to its top and remeshes after an + edit at y 1800. +- `HORIZON_PARITY_DATAPACKS=tools/fixtures/tall_datapacks python3 tools/test_datapack_worldgen_parity.py` + generates the fixture's 2096-high Overworld (`horizon_tall_test`, Horizon's own MIT pack, terrain from about + y 475 to 1900) with the vanilla oracle, which takes each level's range from its dimension type, and with + Horizon, and compares every block, biome and block entity. +- Vanilla worlds are unchanged at every step: `chunkgen_bench --snapshot`, `test_light_parity.py` and the save + and LAN tests give the same results as before. + +## What is left + +- **Nether and End at their own height.** They keep the vanilla column (`-64..320`). Holding exactly their 16 + sections would save memory and match vanilla's `LevelChunk`, but Horizon's light only spreads inside a + chunk's column while vanilla's spreads one section past the level, so it needs that border in the flood fill + and the relighter first, checked with `test_light_parity.py`. +- The vanilla constants `MIN_Y`, `HEIGHT` and `SECTIONS` remain as the default column and in tests. +- Far terrain's detail for a camera far above the world uses vanilla's top (`lod.rs`). +- Looking down from far above the terrain shows fog: vanilla's render-distance fog uses `max(xz, |y|)`, so + terrain more than the render distance below the camera is fogged in vanilla too. diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java index f5cda463f2..6baecabc41 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaChunkGenerationOracle.java @@ -262,7 +262,8 @@ public final class VanillaChunkGenerationOracle { case "the_end" -> net.minecraft.world.level.dimension.BuiltinDimensionTypes.END; default -> net.minecraft.world.level.dimension.BuiltinDimensionTypes.OVERWORLD; }).value(); - LevelHeightAccessor height = dimension.equals("overworld") ? LevelHeightAccessor.create(-64, 384) : LevelHeightAccessor.create(0, 256); + // The level's build range is its dimension type's: a data pack's when one changes it. + LevelHeightAccessor height = LevelHeightAccessor.create(dimensionType.minY(), dimensionType.height()); var factory = PalettedContainerFactory.create(access); var unsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe"); unsafeField.setAccessible(true); diff --git a/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/final_density.json b/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/final_density.json new file mode 100644 index 0000000000..274bba3b07 --- /dev/null +++ b/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/final_density.json @@ -0,0 +1,5 @@ +{ + "type": "minecraft:max", + "left": "minecraft:overworld/final_density", + "right": "horizon_tall:peaks" +} diff --git a/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/peaks.json b/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/peaks.json new file mode 100644 index 0000000000..fc40440182 --- /dev/null +++ b/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/density_function/peaks.json @@ -0,0 +1,17 @@ +{ + "type": "minecraft:add", + "left": { + "type": "minecraft:noise", + "noise": "horizon_tall:peaks", + "xz_scale": 1.0, + "y_scale": 0.0 + }, + "right": { + "type": "minecraft:gradient", + "axis": "y", + "from_coordinate": 0, + "from_value": 1.0, + "to_coordinate": 1900, + "to_value": -1.0 + } +} diff --git a/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/noise/peaks.json b/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/noise/peaks.json new file mode 100644 index 0000000000..cf42886914 --- /dev/null +++ b/tools/fixtures/tall_datapacks/horizon_tall_test/data/horizon_tall/worldgen/noise/peaks.json @@ -0,0 +1,5 @@ +{ + "base_amplitude": 1.0, + "base_octave": -9, + "octave_count": 3 +} diff --git a/tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/dimension_type/overworld.json b/tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/dimension_type/overworld.json new file mode 100644 index 0000000000..0c37860790 --- /dev/null +++ b/tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/dimension_type/overworld.json @@ -0,0 +1,63 @@ +{ + "ambient_light": 0.0, + "attributes": { + "minecraft:audio/ambient_sounds": { + "mood": { + "block_search_extent": 8, + "offset": 2.0, + "sound": "minecraft:ambient.cave", + "tick_delay": 6000 + } + }, + "minecraft:audio/background_music": { + "creative": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.creative" + }, + "default": { + "max_delay": 24000, + "min_delay": 12000, + "sound": "minecraft:music.game" + } + }, + "minecraft:gameplay/bed_rule": { + "can_set_spawn": "always", + "can_sleep": "when_dark", + "error_message": { + "translate": "block.minecraft.bed.no_sleep" + } + }, + "minecraft:gameplay/nether_portal_spawns_piglin": true, + "minecraft:gameplay/respawn_anchor_works": false, + "minecraft:gameplay/straw_bed_rule": { + "can_set_spawn": "never", + "can_sleep": "when_dark", + "destroy_on_leave": true, + "error_message": { + "translate": "block.minecraft.bed.no_sleep" + } + }, + "minecraft:visual/ambient_light_color": "#0a0a0a", + "minecraft:visual/cloud_color": "#ccffffff", + "minecraft:visual/cloud_height": 192.33, + "minecraft:visual/fog_color": "#c0d8ff", + "minecraft:visual/sky_color": "#78a7ff" + }, + "coordinate_scale": 1.0, + "default_clock": "minecraft:overworld", + "has_ceiling": false, + "has_ender_dragon_fight": false, + "has_skylight": true, + "height": 2096, + "infiniburn": "#minecraft:infiniburn_overworld", + "logical_height": 384, + "min_y": -64, + "monster_spawn_block_light_limit": 0, + "monster_spawn_light_level": { + "type": "minecraft:uniform", + "max_inclusive": 7, + "min_inclusive": 0 + }, + "timelines": "#minecraft:in_overworld" +} diff --git a/tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/worldgen/noise_settings/overworld.json b/tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/worldgen/noise_settings/overworld.json new file mode 100644 index 0000000000..181dc5ddce --- /dev/null +++ b/tools/fixtures/tall_datapacks/horizon_tall_test/data/minecraft/worldgen/noise_settings/overworld.json @@ -0,0 +1,20 @@ +{ + "noise": {"min_y": -64, "height": 2096}, + "default_block": "minecraft:stone", + "default_fluid": "minecraft:water", + "noise_router": { + "chunk_surface_level": "minecraft:overworld/chunk_surface_level", + "continents": "minecraft:overworld/continents", + "depth": "minecraft:overworld/depth", + "erosion": "minecraft:overworld/erosion", + "final_density": "horizon_tall:final_density", + "ridges": "minecraft:overworld/ridges", + "temperature": "minecraft:overworld/temperature", + "vegetation": "minecraft:overworld/vegetation" + }, + "material_rule": "minecraft:overworld", + "spawn_target": [], + "sea_level": 63, + "disable_mob_generation": false, + "legacy_random_source": false +} diff --git a/tools/fixtures/tall_datapacks/horizon_tall_test/pack.mcmeta b/tools/fixtures/tall_datapacks/horizon_tall_test/pack.mcmeta new file mode 100644 index 0000000000..57b5d7eb8f --- /dev/null +++ b/tools/fixtures/tall_datapacks/horizon_tall_test/pack.mcmeta @@ -0,0 +1,7 @@ +{ + "pack": { + "description": "Horizon's tall-world fixture: a 2096-high Overworld with peaks past y 1000", + "min_format": 121, + "max_format": 121 + } +} diff --git a/tools/test_datapack_worldgen_parity.py b/tools/test_datapack_worldgen_parity.py index 8433fbb2e6..939b236026 100644 --- a/tools/test_datapack_worldgen_parity.py +++ b/tools/test_datapack_worldgen_parity.py @@ -26,7 +26,8 @@ except ImportError: import lab import test_chunk_generation_parity as chunks -# HORIZON_PARITY_DATAPACKS: another folder of packs to compare. +# HORIZON_PARITY_DATAPACKS: another folder of packs (tools/fixtures/tall_datapacks: a 2096-high +# Overworld). DATAPACKS = Path(os.environ.get('HORIZON_PARITY_DATAPACKS', lab.ROOT / 'tools/fixtures/datapacks')) SEEDS = [12345, 4242] From a7771347e6910989639a404bb7fc2049a730ea3b Mon Sep 17 00:00:00 2001 From: Lyle Cole Date: Fri, 23 Oct 2026 15:04:40 -0500 Subject: [PATCH 025/968] Biomes: a world's data-pack biome files over the built-in table with_packs replaces a built-in biome in place and adds new ones after the built-in ids, which never move; registry_order gives vanilla's network order (Identifier: path, then namespace, as RegistryDataLoader sorts). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/biome.rs | 284 +++++++++++++++++++++++------ 1 file changed, 229 insertions(+), 55 deletions(-) diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index 4538166fa0..567261deb5 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -3,7 +3,12 @@ use crate::Result; use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom}; use serde_json::Value; -use std::{collections::HashMap, fs, path::Path}; +use std::{ + collections::{BTreeMap, HashMap}, + fs, + path::Path, + sync::Arc, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum GrassModifier { @@ -130,10 +135,27 @@ impl BiomeInfo { .then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15) } } +/// The game's biomes, by id. The built-in (vanilla) biomes come first, in +/// file order, and keep their ids whatever a world's data packs do; a pack's +/// new biomes follow them (`with_packs`). Those are the client's own ids: +/// the network registry's are `registry_order`'s. +#[derive(Clone)] pub struct Biomes { pub list: Vec, pub index: HashMap, info_noise: SimplexNoise, + colormaps: Arc, + /// The built-in biomes, the first of `list` unless packs replaced them. + builtin: Arc>, + /// The data packs' biome files (new biomes and replaced built-in ones), + /// for the tables that read more of a biome than `BiomeInfo` keeps. + packs: BTreeMap>, +} +/// The grass, foliage and dry foliage colormaps. +struct Colormaps { + grass: Vec, + foliage: Vec, + dry: Vec, } fn color(v: &Value) -> Option { match v { @@ -166,11 +188,77 @@ fn sample(map: &[u32], temperature: f32, downfall: f32, fallback: u32) -> u32 { let index = (y << 8 | x) as usize; map.get(index).copied().unwrap_or(fallback) } +impl BiomeInfo { + /// A biome file's (`worldgen/biome/.json`) values, the colours it + /// leaves out sampled from the colormaps. + fn from_json(name: String, v: &Value, maps: &Colormaps) -> Self { + let effects = &v["effects"]; + let attributes = &v["attributes"]; + let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32; + let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32; + let modifier = match effects["grass_color_modifier"].as_str() { + Some("dark_forest") => GrassModifier::DarkForest, + Some("swamp") => GrassModifier::Swamp, + _ => GrassModifier::None, + }; + let mut spawns: HashMap> = HashMap::new(); + let natural = &attributes["minecraft:gameplay/natural_mob_spawns"]; + // A modifier object ({argument, modifier: overlay}) or the plain value; + // the overlay over the dimension's empty default is the biome's value. + let natural = if natural["argument"].is_object() { + &natural["argument"] + } else { + natural + }; + let categories = &natural["spawns_by_category"]; + for (category, entries) in categories.as_object().into_iter().flatten() { + let list = parse_entries(entries); + spawns.insert(category.clone(), list); + } + BiomeInfo { + spawns, + spawn_costs: parse_costs(&natural["spawn_costs"]), + creature_probability: + attributes["minecraft:gameplay/creature_world_gen_spawn_probability"] + .as_f64() + .unwrap_or(0.1) as f32, + increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"] + .as_bool() + .unwrap_or(false), + temperature, + frozen: v["temperature_modifier"].as_str() == Some("frozen"), + downfall, + precipitation: v["has_precipitation"].as_bool().unwrap_or(true), + grass: color(&effects["grass_color"]) + .unwrap_or_else(|| sample(&maps.grass, temperature, downfall, 0xFF00FF)), + modifier, + foliage: color(&effects["foliage_color"]) + .unwrap_or_else(|| sample(&maps.foliage, temperature, downfall, 0x48B518)), + dry_foliage: color(&effects["dry_foliage_color"]) + .unwrap_or_else(|| sample(&maps.dry, temperature, downfall, 0x5D4930)), + water: color(&effects["water_color"]).unwrap_or(0x3F76E4), + sky: color(&attributes["minecraft:visual/sky_color"]), + fog: color(&attributes["minecraft:visual/fog_color"]), + water_fog: color(&attributes["minecraft:visual/water_fog_color"]).unwrap_or(0x050533), + name, + } + } +} +/// `Identifier.compareTo`: the path first, then the namespace. +pub fn identifier_order(a: &str, b: &str) -> std::cmp::Ordering { + fn split(id: &str) -> (&str, &str) { + let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id)); + (path, ns) + } + split(a).cmp(&split(b)) +} impl Biomes { pub fn load(root: &Path) -> Result { - let grass = colormap(root, "grass")?; - let foliage = colormap(root, "foliage")?; - let dry = colormap(root, "dry_foliage")?; + let maps = Colormaps { + grass: colormap(root, "grass")?, + foliage: colormap(root, "foliage")?, + dry: colormap(root, "dry_foliage")?, + }; let mut list = Vec::new(); let mut names: Vec<_> = fs::read_dir(root.join("data/minecraft/worldgen/biome"))? .filter_map(|e| e.ok()) @@ -186,57 +274,7 @@ impl Biomes { .ok_or("bad biome")? ); let v: Value = serde_json::from_slice(&fs::read(&path)?)?; - let effects = &v["effects"]; - let attributes = &v["attributes"]; - let temperature = v["temperature"].as_f64().unwrap_or(0.5) as f32; - let downfall = v["downfall"].as_f64().unwrap_or(0.5) as f32; - let modifier = match effects["grass_color_modifier"].as_str() { - Some("dark_forest") => GrassModifier::DarkForest, - Some("swamp") => GrassModifier::Swamp, - _ => GrassModifier::None, - }; - let mut spawns: HashMap> = HashMap::new(); - let natural = &attributes["minecraft:gameplay/natural_mob_spawns"]; - // A modifier object ({argument, modifier: overlay}) or the plain value; - // the overlay over the dimension's empty default is the biome's value. - let natural = if natural["argument"].is_object() { - &natural["argument"] - } else { - natural - }; - let categories = &natural["spawns_by_category"]; - for (category, entries) in categories.as_object().into_iter().flatten() { - let list = parse_entries(entries); - spawns.insert(category.clone(), list); - } - list.push(BiomeInfo { - spawns, - spawn_costs: parse_costs(&natural["spawn_costs"]), - creature_probability: - attributes["minecraft:gameplay/creature_world_gen_spawn_probability"] - .as_f64() - .unwrap_or(0.1) as f32, - increased_fire_burnout: attributes["minecraft:gameplay/increased_fire_burnout"] - .as_bool() - .unwrap_or(false), - temperature, - frozen: v["temperature_modifier"].as_str() == Some("frozen"), - downfall, - precipitation: v["has_precipitation"].as_bool().unwrap_or(true), - grass: color(&effects["grass_color"]) - .unwrap_or_else(|| sample(&grass, temperature, downfall, 0xFF00FF)), - modifier, - foliage: color(&effects["foliage_color"]) - .unwrap_or_else(|| sample(&foliage, temperature, downfall, 0x48B518)), - dry_foliage: color(&effects["dry_foliage_color"]) - .unwrap_or_else(|| sample(&dry, temperature, downfall, 0x5D4930)), - water: color(&effects["water_color"]).unwrap_or(0x3F76E4), - sky: color(&attributes["minecraft:visual/sky_color"]), - fog: color(&attributes["minecraft:visual/fog_color"]), - water_fog: color(&attributes["minecraft:visual/water_fog_color"]) - .unwrap_or(0x050533), - name, - }); + list.push(BiomeInfo::from_json(name, &v, &maps)); } let index = list .iter() @@ -244,11 +282,65 @@ impl Biomes { .map(|(i, b)| (b.name.clone(), i as u16)) .collect(); Ok(Self { + builtin: Arc::new(list.clone()), list, index, + colormaps: Arc::new(maps), + packs: BTreeMap::new(), info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true), }) } + /// The built-in biomes with a world's data packs' biome files (id → + /// JSON) over them, as `RegistryDataLoader` builds the registry: a file + /// for a built-in biome replaces it, keeping its id, and new biomes join + /// after the built-in ones, in registry order. Starts from the built-in + /// table whatever packs `self` has. + pub fn with_packs<'a>(&self, files: impl IntoIterator) -> Self { + let mut out = self.builtin_only(); + let mut files: Vec<(&str, &Value)> = files.into_iter().collect(); + files.sort_by(|a, b| identifier_order(a.0, b.0)); + for (name, json) in files { + let info = BiomeInfo::from_json(name.to_owned(), json, &out.colormaps); + match out.index.get(name) { + Some(&id) => out.list[usize::from(id)] = info, + None if out.list.len() < usize::from(u16::MAX) => { + out.index.insert(name.to_owned(), out.list.len() as u16); + out.list.push(info); + } + None => continue, + } + out.packs.insert(name.to_owned(), Arc::new(json.clone())); + } + out + } + /// The built-in biomes alone: this table without what packs gave it. + pub fn builtin_only(&self) -> Self { + let mut out = self.clone(); + if self.packs.is_empty() { + return out; + } + out.list = self.builtin.to_vec(); + let builtin = out.list.len(); + out.index.retain(|_, id| usize::from(*id) < builtin); + out.packs.clear(); + out + } + /// The data pack file a world's biome `name` came from, if a pack added + /// or replaced it. + pub fn pack_file(&self, name: &str) -> Option<&Value> { + self.packs.get(name).map(|v| &**v) + } + /// Whether data packs added or replaced any biome. + pub fn has_packs(&self) -> bool { + !self.packs.is_empty() + } + /// The biome names in the network registry's order: `RegistryDataLoader` + /// sorts a registry's files by `Identifier` (path, then namespace). + pub fn registry_order(&self) -> Vec<&str> { + let mut names: Vec<&str> = self.list.iter().map(|b| b.name.as_str()).collect(); + names.sort_by(|a, b| identifier_order(a, b)); + names + } pub fn id(&self, name: &str) -> u16 { self.index .get(name) @@ -344,6 +436,88 @@ pub fn blend_rgb(weights: &[(u16, f64)], mut value: impl FnMut(u16) -> [f32; 3]) result.unwrap_or([0.0; 3]) } +#[cfg(test)] +mod pack_tests { + use super::*; + use serde_json::json; + + /// A built-in table of `names` (no colormaps: sampled colours fall back). + fn builtin(names: &[&str]) -> Biomes { + let maps = Colormaps { + grass: Vec::new(), + foliage: Vec::new(), + dry: Vec::new(), + }; + let list: Vec = names + .iter() + .map(|n| BiomeInfo::from_json((*n).to_owned(), &json!({}), &maps)) + .collect(); + Biomes { + builtin: Arc::new(list.clone()), + index: list + .iter() + .enumerate() + .map(|(i, b)| (b.name.clone(), i as u16)) + .collect(), + list, + info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true), + colormaps: Arc::new(maps), + packs: BTreeMap::new(), + } + } + + #[test] + fn registry_order_is_path_then_namespace() { + use std::cmp::Ordering::*; + assert_eq!(identifier_order("minecraft:a", "minecraft:b"), Less); + assert_eq!(identifier_order("z:a", "a:b"), Less); + assert_eq!(identifier_order("b:same", "a:same"), Greater); + assert_eq!(identifier_order("minecraft:a", "minecraft:a_b"), Less); + assert_eq!(identifier_order("minecraft:a-b", "minecraft:a"), Greater); + } + + #[test] + fn packs_replace_in_place_and_add_after_the_builtin_biomes() { + let base = builtin(&["minecraft:meadow", "minecraft:plains", "minecraft:river"]); + let red = json!({"temperature": 2.0, "effects": {"water_color": "#ff0000"}}); + let hot = json!({"effects": {"grass_color": 0x123456}}); + let files = [ + ("pack:zeta", &hot), + ("minecraft:molten_peaks", &hot), + ("minecraft:river", &red), + ]; + let world = base.with_packs(files); + // The built-in ids stay; the river takes the pack's values. + assert_eq!(world.id("minecraft:plains"), 1); + assert_eq!(world.id("minecraft:river"), 2); + assert_eq!(world.get(2).water, 0xFF0000); + assert_eq!(world.get(2).temperature, 2.0); + // New biomes follow, in registry order. + assert_eq!(world.id("minecraft:molten_peaks"), 3); + assert_eq!(world.id("pack:zeta"), 4); + assert_eq!(world.get(3).grass, 0x123456); + assert!(world.pack_file("minecraft:river").is_some()); + assert!(world.pack_file("minecraft:plains").is_none()); + // The network registry sorts every name by path, then namespace. + assert_eq!( + world.registry_order(), + [ + "minecraft:meadow", + "minecraft:molten_peaks", + "minecraft:plains", + "minecraft:river", + "pack:zeta" + ] + ); + // Another world starts again from the built-in biomes. + let other = world.with_packs([]); + assert_eq!(other.list.len(), 3); + assert!(!other.contains("minecraft:molten_peaks")); + assert_eq!(other.get(2).water, 0x3F76E4); + assert!(!other.has_packs()); + } +} + #[cfg(test)] mod blend_tests { use super::*; From 32f99ed5f7a9a29248ecd39c05470b9babcea362 Mon Sep 17 00:00:00 2001 From: AHHhHHHHH-H <149083296+AHHhHHHHH-H@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:50:15 -0300 Subject: [PATCH 026/968] LOD Distance: a slider up to the whole world, with heightmap far tiles The LOD Distance button becomes a slider from 64 chunks to 4194304 (the whole world). Far terrain plans up to 22 levels with larger cells past 1024 chunks, meshes tiles from level 6 up as heightmaps, makes a very wide face one scaled quad instead of thousands, and bounds the clip subdivision of coarse tiles around full-detail chunks. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game.rs | 2 +- crates/horizon-client/src/game/control.rs | 9 +- crates/horizon-client/src/gfx.rs | 13 ++ crates/horizon-client/src/graphics.rs | 13 +- crates/horizon-client/src/graphics_ui.rs | 59 +++++++- crates/horizon-client/src/lod.rs | 56 +++++++- crates/horizon-client/src/lod/mesh.rs | 157 +++++++++++++++++++++- docs/graphics.md | 2 +- 8 files changed, 288 insertions(+), 23 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index e78163f738..e52b3c3c2d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -12463,7 +12463,7 @@ impl Game<'_> { Some(renderer) => { horizon_client::graphics::lod_distance_capped(distance, &renderer.info) } - None => distance.min(1024), + None => distance.min(horizon_client::lod::MAX_CHUNKS), } } /// Only enabled LOD worlds extend the renderer's range, never simulation. diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 6026086544..54b77ac53d 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -525,8 +525,12 @@ impl Game<'_> { } "lod-distance" => { let n: u32 = arg(1)?.parse()?; - if n > 1024 { - return Err("LOD distance must be 0..1024".into()); + if n > horizon_client::lod::MAX_CHUNKS { + return Err(format!( + "LOD distance must be 0..{}", + horizon_client::lod::MAX_CHUNKS + ) + .into()); } self.settings.horizon.lod_distance = n; self.apply_settings(); @@ -1004,6 +1008,7 @@ impl Game<'_> { "cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1), "far_detail": self.settings.horizon.far_detail.name(), "resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()), + "levels": self.renderer.as_ref().map_or_else(Vec::new, |r| r.lod_levels()), "pending_observations": self.lod_observe.len(), "refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()), }); diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 7f14c77a6c..ef3122177f 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -1022,6 +1022,19 @@ impl Renderer { .map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent)) .sum() } + /// Tiles and quads of the far terrain tiles with GPU buffers, by level. + pub fn lod_levels(&self) -> Vec<[u64; 2]> { + let mut levels = Vec::new(); + for (key, tile) in &self.lods { + let level = usize::from(key.level); + if levels.len() <= level { + levels.resize(level + 1, [0, 0]); + } + levels[level][0] += 1; + levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent); + } + levels + } /// Bytes of the section vertex arena's buffers, and of the vertices /// resident sections hold in them. pub fn section_vertex_bytes(&self) -> [u64; 2] { diff --git a/crates/horizon-client/src/graphics.rs b/crates/horizon-client/src/graphics.rs index f3e4c94872..0dc7695c4d 100644 --- a/crates/horizon-client/src/graphics.rs +++ b/crates/horizon-client/src/graphics.rs @@ -221,7 +221,7 @@ pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool { } /// A LOD distance as far terrain uses it on this adapter. pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 { - let distance = distance.min(1024); + let distance = distance.min(crate::lod::MAX_CHUNKS); if lod_capped(info) { distance.min(INTEGRATED_LOD_DISTANCE) } else { @@ -340,7 +340,9 @@ impl Graphics { let number = |d: u32| value.parse().unwrap_or(d); match key.trim() { "preset" => g.preset = Preset::parse(value), - "lodDistance" => g.lod_distance = number(g.lod_distance).min(1024), + "lodDistance" => { + g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS) + } "farDetail" => g.far_detail = FarDetail::parse(value), "caveCulling" => g.cave_culling = flag(g.cave_culling), "entityCulling" => g.entity_culling = flag(g.entity_culling), @@ -571,12 +573,15 @@ mod tests { #[test] fn lod_distance_is_opt_in_and_bounded() { assert_eq!(Graphics::default().lod_distance, 0); - for distance in [0, 64, 128, 256, 512, 1024] { + for distance in [0, 64, 128, 256, 512, 1024, 65_536, crate::lod::MAX_CHUNKS] { let g = Graphics::parse(&format!("lodDistance:{distance}\n")); assert_eq!(g.lod_distance, distance); assert_eq!(Graphics::parse(&g.serialize()), g); } - assert_eq!(Graphics::parse("lodDistance:10000\n").lod_distance, 1024); + assert_eq!( + Graphics::parse("lodDistance:99999999\n").lod_distance, + crate::lod::MAX_CHUNKS + ); assert_eq!(Graphics::parse("lodDistance:-1\n").lod_distance, 0); assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0); } diff --git a/crates/horizon-client/src/graphics_ui.rs b/crates/horizon-client/src/graphics_ui.rs index cb097c114a..5f49d61447 100644 --- a/crates/horizon-client/src/graphics_ui.rs +++ b/crates/horizon-client/src/graphics_ui.rs @@ -301,6 +301,28 @@ pub fn rows(level: Level) -> Vec { } const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384]; +/// What the LOD Distance slider stops at, in chunks: off, then each doubling +/// up to the whole world. +const LOD_STEPS: [u32; 18] = [ + 0, + 64, + 128, + 256, + 512, + 1024, + 2048, + 4096, + 8192, + 16384, + 32768, + 65536, + 131072, + 262144, + 524288, + 1048576, + 2097152, + crate::lod::MAX_CHUNKS, +]; /// Horizon's graphics screens supplement the vanilla options on Video Settings. fn extra_rows(level: Level) -> Vec { @@ -339,6 +361,13 @@ impl H { .unwrap_or(UPLOAD_STEPS.len() - 1); Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32) } + H::LodDistance => { + let i = LOD_STEPS + .iter() + .position(|&d| d >= g.lod_distance) + .unwrap_or(LOD_STEPS.len() - 1); + Some(i as f32 / (LOD_STEPS.len() - 1) as f32) + } H::Workers => Some(g.workers as f32 / max_workers() as f32), H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0), H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0), @@ -407,6 +436,9 @@ impl H { g.upload_budget_kib = UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize] } + H::LodDistance => { + g.lod_distance = LOD_STEPS[(v * (LOD_STEPS.len() - 1) as f32).round() as usize] + } H::Workers => g.workers = (v * max_workers() as f32).round() as u32, H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32, H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32, @@ -428,9 +460,6 @@ impl H { } let g = &mut s.horizon; match self { - H::LodDistance => { - g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back) - } H::FarDetail => { g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back) } @@ -992,11 +1021,12 @@ mod tests { } #[test] - fn lod_distance_cycles_in_both_levels_without_changing_near_distance() { + fn lod_distance_slides_in_both_levels_without_changing_near_distance() { let mut s = Settings::default(); let item = Item::Horizon(H::LodDistance); let near = s.render_distance; assert_eq!(item.label(&s).plain(), "LOD Distance: Off"); + assert_eq!(item.slider(&s), Some(0.0)); for level in [Level::Simple, Level::Advanced] { let items: Vec<_> = rows(level) .into_iter() @@ -1011,14 +1041,29 @@ mod tests { .unwrap(); assert_eq!(items[render + 1], item); } - for distance in [64, 128, 256, 512, 1024, 0] { - item.cycle(&mut s, false, [854, 480]); + // Every stop is reachable and reads back as the same position. + for (i, &distance) in LOD_STEPS.iter().enumerate() { + let at = i as f32 / (LOD_STEPS.len() - 1) as f32; + item.set_slider(&mut s, at); assert_eq!(s.horizon.lod_distance, distance); + assert_eq!(item.slider(&s), Some(at)); assert_eq!(s.render_distance, near); } - item.cycle(&mut s, true, [854, 480]); + // The ends are off and the whole world, which the far terrain plans. + item.set_slider(&mut s, 0.0); + assert_eq!(s.horizon.lod_distance, 0); + item.set_slider(&mut s, 1.0); + assert_eq!(s.horizon.lod_distance, crate::lod::MAX_CHUNKS); + assert_eq!( + item.label(&s).plain(), + format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS) + ); + item.set_slider(&mut s, 5.0 / 17.0); assert_eq!(s.horizon.lod_distance, 1024); assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks"); + // A distance between stops (from the file) sits on the next one. + s.horizon.lod_distance = 3000; + assert_eq!(item.slider(&s), Some(7.0 / 17.0)); assert_eq!( Item::Vanilla(Opt::RenderDistance).label(&s).plain(), format!("Full Detail Distance: {near} chunks") diff --git a/crates/horizon-client/src/lod.rs b/crates/horizon-client/src/lod.rs index 1ee61cdf4b..334be3923a 100644 --- a/crates/horizon-client/src/lod.rs +++ b/crates/horizon-client/src/lod.rs @@ -29,8 +29,17 @@ const SAMPLE_JOBS: usize = 4; /// terrain starts one sample job at most this often and no detail: it would /// otherwise take the cores the near chunks and the frame need. const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500); -/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around. -const MAX_LEVEL: u8 = 10; +/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks +/// around (a level-22 tile is 67 million blocks wide: the whole world). +const MAX_LEVEL: u8 = 22; +/// Tiles this far from the camera (1024 chunks, as far as far terrain went +/// before it covered the world) are planned with `WORLD_SPLIT`. +const WORLD_START: i32 = 1024 * 16; +/// How many tile widths away from the camera a tile of the world-wide terrain +/// splits (`Plan::visit`: cells then span about 1/48 of the distance). +const WORLD_SPLIT: i32 = 3; +/// The farthest `Plan` reaches, in chunks. +pub const MAX_CHUNKS: u32 = 1 << 22; /// Top solid face and optional fluid face, in absolute block coordinates. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Surface { @@ -338,7 +347,7 @@ impl Plan { elevation: i32, reduced: bool, ) -> Self { - let range = distance.min(1024) as i32 * 16; + let range = distance.min(MAX_CHUNKS) as i32 * 16; let mut level = 0; while (16 << level) < range { level += 1; @@ -373,7 +382,16 @@ impl Plan { // Keep distant cells small on screen; finest tiles retain individual // blocks. Reduced keeps them twice as large (a quarter of the cells). let split = if previous.split.contains(&key) { 10 } else { 8 }; - let threshold = key.size() * if reduced { split / 2 } else { split }; + // Beyond the old 1024-chunk limit the cells are as large as `reduced`'s: + // the whole world is 22 levels, not all of them worth full cells. + let threshold = key.size() + * if key.distance(camera) >= WORLD_START { + WORLD_SPLIT + } else if reduced { + split / 2 + } else { + split + }; if key.level > 0 && (key.distance(camera) < detail * 16 + 16 || key.distance(camera).max(self.elevation) < threshold) @@ -1529,6 +1547,36 @@ mod tests { ); } #[test] + fn world_wide_plans_stay_small_and_cover_every_level() { + for (camera, elevation) in [ + ([0, 0], 0), + ([115, 135], 49_000_000), + ([-30_000_000, 29_999_000], 90), + ] { + let plan = + Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false); + let leaves = plan + .nodes + .iter() + .filter(|k| !plan.split.contains(k)) + .count(); + let top = plan.nodes.iter().map(|k| k.level).max().unwrap(); + eprintln!( + "{camera:?} y={elevation}: {} nodes, {leaves} leaves, top level {top}", + plan.nodes.len() + ); + let reduced = + Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true); + eprintln!(" reduced: {} nodes", reduced.nodes.len()); + let _ = top; + let mut per = [0usize; 23]; + for k in &plan.nodes { + per[k.level as usize] += 1; + } + eprintln!(" per level {per:?}"); + } + } + #[test] fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() { let ground = Plan::new([0, 0], 12, 1024); let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false); diff --git a/crates/horizon-client/src/lod/mesh.rs b/crates/horizon-client/src/lod/mesh.rs index 852f45e5b4..6c24cdee93 100644 --- a/crates/horizon-client/src/lod/mesh.rs +++ b/crates/horizon-client/src/lod/mesh.rs @@ -286,6 +286,34 @@ fn mesh_columns_with_neighbors( }); mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material) } +/// From this tile level up a cell is 64 blocks wide or more: the strata +/// inside the ground cannot be told apart, so a column is its surface. +const HEIGHTMAP_LEVEL: u8 = 6; +/// `column` as a heightmap: its top solid span reaching down to the bottom of +/// the world (so the walls toward lower neighbours are one face of the +/// surface's material, merged along the cliff), and the fluid over it. +fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column { + let Some((top, below)) = column.spans.split_last() else { + return column.clone(); + }; + let ground = |span: &Span| Span { + min: crate::chunk::MIN_Y as i16, + ..*span + }; + let mut spans = Vec::with_capacity(2); + match below.last() { + Some(under) if fluid(top) => { + spans.push(ground(under)); + spans.push(*top); + } + _ if fluid(top) => spans.push(*top), + _ => spans.push(ground(top)), + } + Column { + surface: column.surface, + spans, + } +} fn mesh_columns_with_edges( key: TileKey, columns: &[Column], @@ -296,6 +324,27 @@ fn mesh_columns_with_edges( mut material: impl FnMut(&Span, Direction) -> Material, ) -> SectionMesh { assert_eq!(columns.len(), CELLS * CELLS); + let heightmap_columns; + let heightmap_neighbors; + let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL { + let mut fluid = |span: &Span| material(span, Direction::Up).fluid != 0; + heightmap_columns = columns + .iter() + .map(|column| heightmap(column, &mut fluid)) + .collect::>(); + heightmap_neighbors = neighbors.each_ref().map(|side| { + side.iter() + .map(|edge| EdgeColumn { + start: edge.start, + end: edge.end, + column: heightmap(&edge.column, &mut fluid), + }) + .collect::>() + }); + (&heightmap_columns[..], &heightmap_neighbors) + } else { + (columns, neighbors) + }; let mut count = CELLS * CELLS; let edge_offsets = std::array::from_fn::<_, 4, _>(|side| { let start = count; @@ -318,7 +367,10 @@ fn mesh_columns_with_edges( step = step.min(other.size()); } } - step + // The cells of the coarsest tiles are thousands of blocks wide: a + // few steps each keep the work bounded, the cut around finer terrain + // coarse for as long as such a tile stands in for its children. + step.max(stride / MAX_STEPS) }; let filled = fill_buried_gaps( columns, @@ -841,20 +893,35 @@ fn merge(rects: &mut Vec) { /// The widest face `emit` writes, in blocks: its UVs, counted from the face's /// corner, must stay within `TerrainVertex::tiled`'s range. const MAX_FACE: f32 = 1024.0; +/// A face that would be more than this many `MAX_FACE` pieces is one quad +/// whose UVs are scaled to fit: a cell of the coarsest tiles is thousands to +/// millions of blocks wide, and the texture repeats within it are far below a +/// pixel. +const MAX_PIECES: f32 = 4.0; +/// Steps along a cell of a tile cut around finer terrain, at most. +const MAX_STEPS: i32 = 32; fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) { + let pieces = ((rect.u1 - rect.u0) / MAX_FACE).ceil() * ((rect.v1 - rect.v0) / MAX_FACE).ceil(); + if pieces > MAX_PIECES { + let extent = (rect.u1 - rect.u0).max(rect.v1 - rect.v0); + emit_quad(mesh, key, rect, (MAX_FACE / extent).min(1.0)); + return; + } let mut u0 = rect.u0; while u0 < rect.u1 { let u1 = rect.u1.min(u0 + MAX_FACE); let mut v0 = rect.v0; while v0 < rect.v1 { let v1 = rect.v1.min(v0 + MAX_FACE); - emit_quad(mesh, key, Rect { u0, v0, u1, v1 }); + emit_quad(mesh, key, Rect { u0, v0, u1, v1 }, 1.0); v0 = v1; } u0 = u1; } } -fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) { +/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face +/// too wide for them. +fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) { let Rect { u0, v0, u1, v1 } = rect; let p = f32::from_bits(key.plane); let mut points = match key.direction { @@ -891,7 +958,10 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) { out.extend(points.into_iter().zip(uvs).map(|(pos, uv)| { TerrainVertex::tiled( pos, - [uv[0] - corner[0], uv[1] - corner[1]], + [ + (uv[0] - corner[0]) * uv_scale, + (uv[1] - corner[1]) * uv_scale, + ], key.layer, key.color, key.light, @@ -990,6 +1060,85 @@ mod tests { } assert_eq!(area, 2500.0 * 15.5); } + #[test] + fn far_columns_are_heightmaps_of_their_surface_and_water() { + let fluid = |s: &Span| s.state.0 == 2; + let strata = Column { + surface: None, + spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)], + }; + let flat = heightmap(&strata, fluid); + assert_eq!(flat.spans, [span(-64, 64, 4)]); + let sea = Column { + surface: None, + spans: vec![span(0, 50, 1), span(50, 62, 2)], + }; + let flat = heightmap(&sea, fluid); + assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]); + assert!(heightmap(&columns()[0], fluid).spans.is_empty()); + // A rolling landscape of strata: far tiles need a fraction of the quads. + let rolling: Vec = (0..CELLS * CELLS) + .map(|i| { + let top = 64 + (i * 7 % 5) as i16; + Column { + surface: None, + spans: vec![ + span(0, top - 4, 1), + span(top - 4, top - 1, 3), + span(top - 1, top, 4), + ], + } + }) + .collect(); + let quads = |level| { + let key = TileKey { x: 0, z: 0, level }; + let mesh = mesh_columns(key, &rolling, &FxHashSet::default(), None, material); + mesh.solid.len() / 4 + }; + let (voxel, height) = (quads(HEIGHTMAP_LEVEL - 1), quads(HEIGHTMAP_LEVEL)); + assert!(height * 3 < voxel * 2, "{height} quads against {voxel}"); + } + #[test] + fn world_sized_faces_are_one_quad_with_uvs_that_fit() { + let mut mesh = SectionMesh::default(); + let key = FaceKey { + direction: Direction::Up, + plane: 64.0f32.to_bits(), + overlay: 0, + layer: 1, + light: 0, + color: 0, + translucent: false, + }; + let size = 4_194_304.0; + emit( + &mut mesh, + key, + Rect { + u0: 0.0, + v0: 0.0, + u1: size, + v1: size / 2.0, + }, + ); + assert_eq!(mesh.solid.len(), 4); + let corners: Vec<[f32; 3]> = mesh.solid.iter().map(|v| v.pos).collect(); + assert!( + corners + .iter() + .any(|p| p[0] == size && p[2] == size / 2.0 || p[2] == 0.0) + ); + let top = mesh + .solid + .iter() + .map(tiled_uv) + .fold(0.0f32, |m, uv| m.max(uv[0]).max(uv[1])); + assert!(top <= MAX_FACE + 1.0, "UV reaches {top}"); + assert!( + top > MAX_FACE / 4.0, + "the texture still repeats across the face" + ); + } fn normal(q: &[TerrainVertex; 4]) -> glam::Vec3 { let p = |i: usize| glam::Vec3::from(q[i].pos); (p(1) - p(0)).cross(p(2) - p(0)) diff --git a/docs/graphics.md b/docs/graphics.md index f7deed8f6f..b6a4be2aaa 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -35,7 +35,7 @@ Which settings each screen shows, their descriptions and costs are in `crates/ho ## Distant terrain -**LOD Distance** is off by default. Enable it in Simple or Advanced graphics settings and choose 64, 128, 256, 512 or 1024 chunks. With LODs enabled, **Full Detail Distance** controls the nearby normal chunks; LOD Distance controls the outer view. Keep the nearby range modest, for example 12 chunks with a 256-chunk LOD distance. Increasing the distant range does not increase simulation distance. +**LOD Distance** is off by default. Enable it in Simple or Advanced graphics settings with the slider, from 64 chunks up to 4194304, which covers the whole world. With LODs enabled, **Full Detail Distance** controls the nearby normal chunks; LOD Distance controls the outer view. Keep the nearby range modest, for example 12 chunks with a 256-chunk LOD distance. Increasing the distant range does not increase simulation distance. The world is planned in up to 22 levels, and from level 6 up (cells of 64 blocks or more) a tile is a heightmap of its surface and water instead of full strata. Past 1024 chunks the cells are also larger, about 1/48 of their distance. On a 2 GiB GPU the whole world costs about twice the 1024-chunk quads (5.5 M against 2.8 M at reduced far detail), so keep the slider lower on weak GPUs. `lod-distance` over the control socket takes the same range. Distant terrain uses progressively coarser voxel columns. Columns retain separate material layers and air gaps; matching exposed faces are merged, with block-scale texture repeats. Complete chunk data is reduced once on the terrain worker and cached at each supported scale, across each cell’s area instead of enlarging its corner column. Material votes combine different light levels of the same block; reduced cells retain the brightest sampled sky and block light separately. Finer tiles replace only their own footprints in a loading fallback, and a movement margin limits rapid resolution changes at tile boundaries. Local Overworld, Nether and End worlds and a LAN world you host can supply their generator. Remote servers do not supply the client's world-generation seed or generator configuration. From 2228a7456e52ae6c4b69e38c302753db4668a69b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 00:55:35 +1000 Subject: [PATCH 027/968] Apply the attacker's 0.6 speed cut after a knockback hit on another player MultiPlayerGameMode.attack runs Player.attack on the client too. For a mob the client's hurtOrSimulate fails, so the hit has no attacker side, but RemotePlayer.hurtClient is true: a sprinting (or knockback-enchanted) hit on another player runs causeExtraKnockback's attacker side on the client, cutting horizontal speed to 0.6 and stopping the sprint. The remote attack path only sent the packet, so after such a hit the client kept its speed while the server's player slowed, and the server pulled the player back (seen on Hypixel when sprint-hitting a nearby player). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf --- .../src/game/enchant_effects.rs | 8 ++-- crates/horizon-client/src/game/remote.rs | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 3714063339..4d173a1660 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -147,9 +147,11 @@ impl Game<'_> { (f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw))) } /// The attacker's side of `causeExtraKnockback` as the attacking client - /// sees it: the server's player stops sprinting, and its flag reaches the - /// client. (The server's 0.6 speed cut never does: the client's own - /// `Player.attack` simulates no hurt, so it skips the knockback.) + /// sees it after a hit on a mob: the server's player stops sprinting, and + /// its flag reaches the client. (The server's 0.6 speed cut never does: + /// the client's own `Player.attack` simulates no hurt on a mob, so it + /// skips the knockback. A hit on another player does simulate one: + /// `remote_player_attack`.) pub(super) fn stop_sprint_after_extra_knockback(&mut self) { if let Some(p) = self.player.as_mut() { let _ = p.set_sprinting(false); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 0e5f69fa0c..e9eb9ca277 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5412,6 +5412,7 @@ impl Game<'_> { self.send(S::Attack(play::Attack { id: self.mobs[i].id as i32, })); + self.remote_player_attack(i); self.reset_attack_strength(); } else if !self.entity_targeted() && let Some(hit) = self.pick() @@ -5432,6 +5433,46 @@ impl Game<'_> { instant } + /// `MultiPlayerGameMode.attack` runs `Player.attack` on this client too. + /// For a mob `hurtOrSimulate` fails here (`Entity.hurtClient`), so the + /// hit has no attacker side; but `RemotePlayer.hurtClient` is true, so a + /// hit on another player with extra knockback runs the attacker's side of + /// `causeExtraKnockback`: the 0.6 horizontal speed cut and the sprint + /// stop. Without it the next move outruns the server's player. + fn remote_player_attack(&mut self, mob: usize) { + let id = self.mobs[mob].id as i32; + if !self + .remote + .as_ref() + .is_some_and(|r| r.level_players.contains_key(&id)) + { + return; + } + // `getAttackStrengthScale(0.5)`: here the ticker is `hand_strength`'s + // (`attack_ticks` only counts in the local world), and the attack + // speed is the player's synced attribute, which a server may change. + let speed = self + .player + .as_mut() + .and_then(|p| { + let key = p.attributes.key("minecraft:attack_speed")?; + p.attributes.lookup(Some(&key))?; + p.attributes + .value(Some(key), horizon_world::attribute_map::ValueKind::Computed) + .ok() + }) + .unwrap_or_else(|| self.main_hand_attack_speed()); + let scale = self.hand_strength.scale(0.5, speed); + let (knockback, _) = self.extra_knockback("minecraft:player", scale); + if knockback <= 0.0 { + return; + } + if let Some(p) = self.player.as_mut() { + p.velocity = Vec3::new(p.velocity.x * 0.6, p.velocity.y, p.velocity.z * 0.6); + } + self.stop_sprint_after_extra_knockback(); + } + fn remote_continue_attack(&mut self, down: bool) { if !down { self.clicks.miss_time = 0; From 0e4c964d0ac2bd41e1d9e4a698315d0bef95635a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:06:14 +1000 Subject: [PATCH 028/968] Keep Player.attack's no-damage guard before the attacker's sprint-hit cut Player.attack ends before hurtOrSimulate and causeExtraKnockback when neither the scaled ATTACK_DAMAGE (clamped to [0, 2048], so Weakness can't push it below 0) nor the scaled enchantment damage is left. The remote path cut the attacker's speed on knockback alone, so an empty-hand sprint hit under Weakness I still slowed the client. The decision moves to attack_strength::attacker_slows_after_hit, with the knockback formula shared with the local path as attack_strength::extra_knockback, and unit tests for a charged and an uncharged sprint hit, a knockback weapon, no sprint, a no-damage hit under Weakness, enchantment damage alone, and a server-raised attack speed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf --- crates/horizon-client/src/attack_strength.rs | 78 +++++++++++++++++++ .../src/game/enchant_effects.rs | 5 +- crates/horizon-client/src/game/remote.rs | 14 +++- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/attack_strength.rs b/crates/horizon-client/src/attack_strength.rs index 43454c0a82..704e85cbd4 100644 --- a/crates/horizon-client/src/attack_strength.rs +++ b/crates/horizon-client/src/attack_strength.rs @@ -68,6 +68,36 @@ impl AttackStrength { } } +/// `Player.attack`'s extra knockback from the weapon's knockback enchantment +/// (`getKnockback`, halved) and a sprinting full-strength hit (0.5), and +/// whether the hit is that sprinting knockback attack. +pub fn extra_knockback(enchanted: f32, strength: f32, sprinting: bool) -> (f32, bool) { + let knockback_attack = strength > 0.9 && sprinting; + let bonus = if knockback_attack { 0.5 } else { 0.0 }; + (enchanted / 2.0 + bonus, knockback_attack) +} + +/// What the attacking client's own `Player.attack` does to itself on a hit +/// at another player, whose `RemotePlayer.hurtClient` lets the hit through: +/// whether `causeExtraKnockback` cuts its horizontal speed to 0.6 and stops +/// its sprint. `attack_damage` is the ATTACK_DAMAGE value before clamping +/// (the hand and weapon plus strength and weakness, or a riptide spin's), +/// `enchant_damage` the weapon's enchantment damage, `strength` +/// `getAttackStrengthScale(0.5)` and `knockback` from [`extra_knockback`]. +pub fn attacker_slows_after_hit( + attack_damage: f32, + enchant_damage: f32, + strength: f32, + knockback: f32, +) -> bool { + // The attribute's range is [0, 2048]: weakness can't make it negative. + let base = attack_damage.clamp(0.0, 2048.0) * (0.2 + strength * strength * 0.8); + let enchant = enchant_damage * strength; + // Player.attack stops before the hurt and the knockback when neither + // part of the damage is left. + (base > 0.0 || enchant > 0.0) && knockback > 0.0 +} + #[cfg(test)] mod tests { use super::*; @@ -107,4 +137,52 @@ mod tests { s.reset_attack(); assert_eq!((s.ticker, s.swap_ticker), (0, 1)); } + /// The attacker's side of a hit on another player, as the client sees it. + fn slows(damage: f32, enchant: f32, strength: f32, sprinting: bool, kb_enchant: f32) -> bool { + let (knockback, _) = extra_knockback(kb_enchant, strength, sprinting); + attacker_slows_after_hit(damage, enchant, strength, knockback) + } + #[test] + fn a_charged_sprint_hit_slows_the_attacker() { + // A diamond sword (1 + 6), full strength, sprinting. + assert!(slows(7.0, 0.0, 1.0, true, 0.0)); + assert_eq!(extra_knockback(0.0, 1.0, true), (0.5, true)); + } + #[test] + fn an_uncharged_sprint_hit_does_not() { + assert!(!slows(7.0, 0.0, 0.5, true, 0.0)); + assert!( + !slows(7.0, 0.0, 0.9, true, 0.0), + "the bonus needs more than 0.9" + ); + } + #[test] + fn a_hit_without_sprinting_or_knockback_does_not() { + assert!(!slows(7.0, 0.0, 1.0, false, 0.0)); + } + #[test] + fn a_knockback_weapon_slows_even_uncharged() { + // Knockback I: getKnockback 1, halved; no sprint needed. + assert!(slows(7.0, 0.0, 0.2, false, 1.0)); + } + #[test] + fn a_hit_that_does_no_damage_stops_first() { + // An empty hand (1) under Weakness I (-4): the attribute clamps to 0, + // so Player.attack ends before causeExtraKnockback. + assert!(!slows(1.0 - 4.0, 0.0, 1.0, true, 0.0)); + assert!(!slows(0.0, 0.0, 1.0, true, 1.0)); + // Enchantment damage alone still lands the hit. + assert!(slows(0.0, 1.25, 1.0, true, 0.0)); + } + #[test] + fn a_server_attack_speed_charges_at_once() { + // 1.8-style servers raise ATTACK_SPEED: with 1024 the delay is under + // a tick, so getAttackStrengthScale(0.5) is full right after a hit. + let mut s = AttackStrength::default(); + s.reset_attack(); + assert_eq!(s.scale(0.5, 1024.0), 1.0); + assert!(slows(7.0, 0.0, s.scale(0.5, 1024.0), true, 0.0)); + // A sword's own 1.6 leaves it uncharged. + assert!(!slows(7.0, 0.0, s.scale(0.5, 1.6), true, 0.0)); + } } diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 4d173a1660..7a4a595907 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -114,7 +114,7 @@ impl Game<'_> { /// enchantment, halved) plus 0.5 for a sprinting full-strength hit, and /// whether the hit is that knockback attack. pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) { - let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting); + let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting); let enchanted = match self.held().cloned() { Some(held) => { let ctx = EffectContext { @@ -135,8 +135,7 @@ impl Game<'_> { } None => 0.0, }; - let bonus = if knockback_attack { 0.5 } else { 0.0 }; - (enchanted / 2.0 + bonus, knockback_attack) + horizon_client::attack_strength::extra_knockback(enchanted, scale, sprinting) } /// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's /// sine table: the target flies where the attacker looks, not away from diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e9eb9ca277..62fc03b660 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5463,8 +5463,20 @@ impl Game<'_> { }) .unwrap_or_else(|| self.main_hand_attack_speed()); let scale = self.hand_strength.scale(0.5, speed); + // Player.attack's damage, in its order: the ATTACK_DAMAGE value (or a + // riptide spin's), the enchantment damage, then the knockback. + let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg); + let damage = if spin > 0.0 { + spin + } else { + 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage) + + self.effect_attack_bonus() + }; + let enchant = self.enchant_damage_bonus("minecraft:player"); let (knockback, _) = self.extra_knockback("minecraft:player", scale); - if knockback <= 0.0 { + if !horizon_client::attack_strength::attacker_slows_after_hit( + damage, enchant, scale, knockback, + ) { return; } if let Some(p) = self.player.as_mut() { From c4f7b4f964e04bf525e2b4543394f870bd5dafa1 Mon Sep 17 00:00:00 2001 From: v3nn7 <147159855+v3nn7@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:23:48 +0100 Subject: [PATCH 029/968] Fix teleport commands without explicit targets Co-Authored-By: Codex --- crates/horizon-client/src/game/commands.rs | 5 ++++- tools/command_exec_corpus.py | 4 ++++ tools/test_server_dimension_players_parity.py | 14 +++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 4efea42d90..0291442773 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1430,7 +1430,10 @@ impl Game<'_> { let targets = if id.starts_with("teleport.targets") { self.entities(ctx, "targets", false)? } else { - vec![self.implied_player()] + vec![ + self.source_entity() + .ok_or_else(|| err("permissions.requires.entity", &[]))?, + ] }; if id.ends_with("destination") { let dest = self.entities(ctx, "destination", false)?[0]; diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index fd527fc766..91f003d7b2 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -463,6 +463,10 @@ def execute(): cmd('/execute positioned 4 210 4 if block ~ ~ ~ minecraft:stone'), cmd('/execute positioned as @s run tp @s ~ ~ ~'), cmd('/execute align xyz run summon minecraft:armor_stand ~ ~-20 ~ {NoGravity:1b,Tags:["ex"],UUID:[I;1,2,3,4]}'), + cmd('/summon minecraft:armor_stand 3 210 3 {NoGravity:1b,Tags:["implicit_tp"]}'), + cmd('/execute as @e[tag=implicit_tp,limit=1] run tp 4 210 4'), + cmd('/data get entity @e[tag=implicit_tp,limit=1] Pos'), + cmd('/kill @e[tag=implicit_tp]'), cmd('/execute if entity @e[tag=ex]'), cmd('/execute as @e[tag=ex] run data get entity @s Pos'), cmd('/data get entity @e[tag=ex,limit=1]'), diff --git a/tools/test_server_dimension_players_parity.py b/tools/test_server_dimension_players_parity.py index 887ebb2d7d..17e6d8d337 100644 --- a/tools/test_server_dimension_players_parity.py +++ b/tools/test_server_dimension_players_parity.py @@ -37,6 +37,15 @@ SCRIPT = [ ('bob-end', [f'cmd Bob execute in {END} run tp @s 2.5 100 0.5']), ('alice-back', [f'cmd Alice execute in {OVERWORLD} run tp @s 0.5 100 0.5']), ('bob-back-again', [f'cmd Bob execute in {OVERWORLD} run tp @s 2.5 100 0.5']), + # The dedicated server console has no player as its command source. + ('console-bob-nether', [f'console execute in {NETHER} run tp Bob 2.5 100 0.5']), + ('console-bob-end', [f'console execute in {END} run tp Bob 2.5 100 0.5']), + ('console-bob-back', [f'console execute in {OVERWORLD} run tp Bob 2.5 100 0.5']), + # A destination entity supplies its own dimension; a selector can move + # more than one player in the same command. + ('console-alice-end', [f'console execute in {END} run tp Alice 0.5 100 0.5']), + ('console-bob-to-alice', ['console tp Bob Alice']), + ('console-both-back', [f'console execute in {OVERWORLD} run tp @a 2.5 100 0.5']), # Through an End portal (Entity.handlePortal), and back by command. ('bob-portal', ['cmd Bob setblock 2 101 3 minecraft:end_portal', 'cmd Bob tp @s 2.5 101 3.5']), ('bob-portal-back', [f'cmd Bob execute in {OVERWORLD} run tp @s 2.5 100 0.5']), @@ -72,7 +81,10 @@ def run_side(label, server_command, client_command, build, world): for step, actions in SCRIPT: client.tell(f'step {step}') for action in actions: - client.tell(action) + if action.startswith('console '): + server.console(action.removeprefix('console ')) + else: + client.tell(action) # Standing still, a client still sends its position every 20 ticks # (LocalPlayer.sendPosition): the server then tracks to it what it has sent the # chunks of since (ChunkMap.move). From 09a9735630d48e1daa62a01c65dcf02b5ad922d0 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:04:46 +0500 Subject: [PATCH 030/968] Keep unsupported saved vehicle types pending without respawning them Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/game/persist.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index d132c32462..3320b7f6fa 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -568,6 +568,13 @@ impl Game<'_> { } let tag = raw?.get("RootVehicle")?.get("Entity")?.clone(); let root_uuid = entity_nbt::uuid(&tag)?; + let kind = entity_nbt::kind(&tag)?; + if !self.res.mob_types().contains_key(&kind) + && super::boats::any_boat_wood(&kind).is_none() + && super::minecarts::CartKind::from_id(&kind).is_none() + { + return None; + } if self.vehicle_by_uuid(root_uuid).is_none() { let at = entity_nbt::position(&tag)?; let key = ( From 164cfd6b11e87328838124367c24c7142c42461a Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:12:19 +0500 Subject: [PATCH 031/968] Restore server riders after their chunk is sent and refresh entity pairing Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/game/lan.rs | 11 ++++++++++- crates/horizon-client/src/game/persist.rs | 6 +++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 811ccb4699..1824424966 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1256,7 +1256,14 @@ impl Game<'_> { .filter_map(|(i, g)| Some((i, g.saved_vehicle?))) .filter(|(i, _)| { let g = &host.hub.guests[*i]; - g.placed && g.vehicle.is_none() && g.dimension == self.dim.id() + let chunk = ( + (g.position[0].floor() as i32).div_euclid(16), + (g.position[2].floor() as i32).div_euclid(16), + ); + g.placed + && g.vehicle.is_none() + && g.dimension == self.dim.id() + && g.sent_chunks.contains(&chunk) }) .collect(); for (i, uuid) in waiting { @@ -1270,6 +1277,8 @@ impl Game<'_> { } g.vehicle = Some(id); g.saved_vehicle = None; + // Pair the stationary restored entities before their passenger packet. + g.moved = true; } } /// The file name of a player's advancements and statistics: an diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 3320b7f6fa..8be9ce016a 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -589,7 +589,11 @@ impl Game<'_> { /// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file /// names, once the vehicle is in the world. pub(super) fn restore_root_vehicle(&mut self) { - if self.riding.is_some() || self.player.is_none() { + if self.serving.is_some() + || self.options.dedicated.is_some() + || self.riding.is_some() + || self.player.is_none() + { return; } let Some(uuid) = self.pending_vehicle else { From ae72e1f6ca159fb8a08efec4860ea7a1693eca0a Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:12:50 +0500 Subject: [PATCH 032/968] Add a two-path saved vehicle tree and passenger restoration audit Co-Authored-By: OpenAI Codex --- tools/singleplayer_vehicle_audit.py | 73 +++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tools/singleplayer_vehicle_audit.py diff --git a/tools/singleplayer_vehicle_audit.py b/tools/singleplayer_vehicle_audit.py new file mode 100644 index 0000000000..c86948f83e --- /dev/null +++ b/tools/singleplayer_vehicle_audit.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Restore vanilla RootVehicle.Entity, attach to its passenger UUID, and save/reopen. + +A fresh world's player file is given a horse with a pig passenger, present only +in RootVehicle.Entity, as ServerPlayer.saveParentVehicle writes it. Attach names +the pig rather than the root horse. Both loading this fixture and reopening its +Horizon save must retain one horse, one pig, and the correct mounted player. +Every process uses fresh scratch homes; only the fixture's saves are copied. +""" +import argparse +import gzip +import os +import shutil +import struct +import subprocess +import tempfile +import time +from pathlib import Path +import lab, anvil_dump +from agent_control_e2e import Cli +def text(v): + b=v.encode();return struct.pack('>H',len(b))+b +def tag(t,n,b):return bytes([t])+text(n)+b +def uuid(n,v):return tag(11,n,struct.pack('>i4i',4,*v)) +def entity(kind,u,pos,passengers=b''): + return tag(8,'id',text('minecraft:'+kind))+uuid('UUID',u)+tag(9,'Pos',struct.pack('>bi3d',6,3,*pos))+tag(9,'Rotation',struct.pack('>bi2f',5,2,0,0))+tag(1,'NoAI',b'\1')+tag(1,'Tame',b'\1')+tag(5,'Health',struct.pack('>f',20))+passengers+b'\0' +def run(mode, bins, resources): + with tempfile.TemporaryDirectory(prefix='vehicle-proof-') as tmp: + work=Path(tmp); previous=None + for stage in range(3): + env=lab.game_env({k:v for k,v in os.environ.items() if k not in ('HORIZON_HOME','HORIZON_LAUNCHER_HOME','XDG_DATA_HOME','XDG_CONFIG_HOME')}) + home=Path(env['HORIZON_HOME']) + if previous:shutil.copytree(previous,home/'saves') + sock=work/f'{stage}.sock';cli=Cli(bins/'horizon-ctl',sock,home,env) + args=[str(bins/'horizon-client'),'--resources',str(resources),'--world','VehicleProof','--creative','--offscreen','--render-distance','2','--control','--control-socket',str(sock)] + if mode=='in-frame':args+=['--in-frame'] + with (work/f'{stage}.log').open('w') as out: + p=subprocess.Popen(args,cwd=home,env=env,stdout=out,stderr=out) + try: + deadline=time.monotonic()+120 + while not sock.exists(): + if p.poll() is not None or time.monotonic()>deadline:raise RuntimeError((work/f'{stage}.log').read_text()[-2500:]) + time.sleep(.1) + cli('wait_ready',seconds=240) + if stage: + for _ in range(30): + cli('wait',ticks=5) + status=cli('status');riding=status.get('riding') + if riding is not None:break + pigs=cli('entities',radius=32,type='pig')['entities'];horses=cli('entities',radius=32,type='horse')['entities'] + assert len(pigs)==len(horses)==1,(mode,stage,pigs,horses) + assert riding==pigs[0]['id'],(mode,stage,riding,pigs,horses) + print('PASS',mode,stage,'rides passenger pig; one pig and one root horse',flush=True) + cli('quit');assert p.wait(timeout=120)==0 + finally: + if p.poll() is None:p.terminate();p.wait(timeout=30) + if stage==0: + player=next((home/'saves'/'VehicleProof'/'players'/'data').glob('*.dat')) + saved=anvil_dump.read_dat(player);pos=tuple(v[1] for v in saved['Pos']) + child=entity('pig',[55,66,77,88],pos) + root=entity('horse',[11,22,33,44],pos,tag(9,'Passengers',struct.pack('>bi',10,1)+child)) + raw=gzip.decompress(player.read_bytes());assert raw[-1:]==b'\0' + wrapper=tag(10,'RootVehicle',uuid('Attach',[55,66,77,88])+tag(10,'Entity',root)+b'\0') + player.write_bytes(gzip.compress(raw[:-1]+wrapper+b'\0')) + previous=home/'saves' +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--bin-dir', type=Path, required=True) + parser.add_argument('--resources', type=Path, default=lab.RESOURCES) + parser.add_argument('--mode', choices=['server', 'in-frame', 'both'], default='both') + args = parser.parse_args() + for mode in (('server', 'in-frame') if args.mode == 'both' else (args.mode,)): + run(mode, args.bin_dir.resolve(), args.resources.resolve()) From 88e7afbedf26d35ce80971390ce071bb2302bb92 Mon Sep 17 00:00:00 2001 From: martipops Date: Thu, 29 Oct 2026 07:57:08 -0400 Subject: [PATCH 033/968] Controller movement on servers: the stick moves as the keys it sets do A server only hears the movement keys (ServerboundPlayerInputPacket), and vanilla's client moves exactly as they do (KeyboardInput.tick: 8 directions, full speed). With analog movement a player walked at angles and speeds the keys can't produce, and a server that predicts movement from them could pull it back. On a server that isn't this game's own server thread the stick now moves as its keys; single player keeps analog movement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JLHp82HU35NWsN14K4taHT --- crates/horizon-client/src/game/extras.rs | 4 ++++ crates/horizon-client/src/game/remote.rs | 9 ++++++++- crates/horizon-client/src/reconnect.rs | 4 ++++ docs/controllers.md | 5 ++++- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/extras.rs b/crates/horizon-client/src/game/extras.rs index 268d38d462..54e51a522b 100644 --- a/crates/horizon-client/src/game/extras.rs +++ b/crates/horizon-client/src/game/extras.rs @@ -79,6 +79,10 @@ impl Game<'_> { pub(super) fn extras_connecting(&mut self, address: &str, name: &str) { self.extras.reconnect.connecting(address, name); } + /// The server this game connected to last. + pub(super) fn server_address(&self) -> Option<&str> { + self.extras.reconnect.address() + } /// Auto-reconnect: the server let the player in. pub(super) fn extras_joined(&mut self) { self.extras.reconnect.joined(std::time::Instant::now()); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 66daf4a63a..9a85e0cbac 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3783,7 +3783,7 @@ impl Game<'_> { } let playing = self.screen == Screen::Playing; let held = |k| playing && self.keys.contains(&k); - let keys = Keys { + let mut keys = Keys { forward: held(KeyCode::KeyW), backward: held(KeyCode::KeyS), left: held(KeyCode::KeyA), @@ -3794,6 +3794,13 @@ impl Game<'_> { analog: None, } .with_stick(stick.filter(|_| playing)); + // On a server (not this game's own server thread), the stick moves as the keys it sets do + // (`KeyboardInput.tick`: 8 directions, full speed). The server only hears those keys + // (`ServerboundPlayerInputPacket`), and an anticheat that predicts from them pulls back + // a player moving at the stick's own angle and speed. + if self.integrated.is_none() && self.server_address().is_some() { + keys.analog = None; + } let input = u8::from(keys.forward) | u8::from(keys.backward) << 1 | u8::from(keys.left) << 2 diff --git a/crates/horizon-client/src/reconnect.rs b/crates/horizon-client/src/reconnect.rs index c5184d7adf..e500d70cbd 100644 --- a/crates/horizon-client/src/reconnect.rs +++ b/crates/horizon-client/src/reconnect.rs @@ -90,6 +90,10 @@ impl Retry { } } impl Tracker { + /// The server connected to last. + pub fn address(&self) -> Option<&str> { + self.address.as_deref() + } /// A connection to `address` as `name` starts: a new address starts /// counting anew. pub fn connecting(&mut self, address: &str, name: &str) { diff --git a/docs/controllers.md b/docs/controllers.md index b94e07b9c5..d85d027e19 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -36,7 +36,10 @@ loop iteration next to winit's events. move vector directly (a half-tilted stick walks slowly, like Bedrock), instead of being rounded to WASD. The forward/back/left/right flags other code reads (sprint start, boats, minecarts, the server's input packet) are - set when the stick passes a threshold. + set when the stick passes a threshold. On a server (not this game's own + server thread) the stick moves as those keys do, in 8 directions at full + speed, as the server only hears the keys: an anticheat predicting from them + pulls back a player moving at the stick's own angle and speed. - The pad last touched is the active one. Several pads are not merged. - `horizon-ctl pad ...` injects a virtual pad (buttons, sticks, triggers) into a running game, for tests without hardware. From d27a7cbce3c498fa38675e9d3760c4aabdea4c9d Mon Sep 17 00:00:00 2001 From: Shreyas Date: Thu, 29 Oct 2026 18:00:54 +0530 Subject: [PATCH 034/968] Sulfur cubes: align babyhood and egg offspring with vanilla 26.3 - Route sulfur cube baby flag through baby_index (entry 16) in lan.rs - Remove client-side baby state derivation from size in remote_mob_data.rs - Revert finalize.rs age_boundary_reached and set_baby to vanilla behavior - Ensure spawn_egg_offspring sets size 1 on new sulfur cube offspring - Drop lists.rs change from this branch --- crates/horizon-client/src/game/creatures.rs | 3 +++ crates/horizon-client/src/game/lan.rs | 1 + .../horizon-client/src/game/remote_mob_data.rs | 6 +----- crates/horizon-client/src/mobs/finalize.rs | 5 +---- crates/horizon-client/src/mobs/sulfur_cube.rs | 2 -- crates/horizon-server/src/lists.rs | 17 +++-------------- 6 files changed, 9 insertions(+), 25 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index f7d4828f1c..31c268f008 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -4084,6 +4084,9 @@ impl Game<'_> { if species == Species::Sheep { child.extra.color = color; } + if species == Species::SulfurCube { + horizon_client::mobs::sulfur_cube::set_size(child, 1, true); + } } self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target); self.consume_held(); diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 2cac214e12..4d3600f6fc 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -178,6 +178,7 @@ pub(super) fn baby_index(kind: &str) -> Option { | "minecraft:dolphin" | "minecraft:squid" | "minecraft:glow_squid" => Some(16), + | "minecraft:sulfur_cube" => Some(16), _ => None, } } diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 04f1cd8bad..fa205d6d14 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -137,11 +137,7 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti } // AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions). (Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => { - let size = (*size).clamp(1, 127); - m.extra.size = size; - if m.species() == Species::SulfurCube { - m.extra.age = if size == 1 { -24000 } else { 0 }; - } + m.extra.size = (*size).clamp(1, 127); } // Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth // and raised head of an angry enderman. diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 6a9574b247..52f3d575f7 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -817,7 +817,7 @@ fn age_boundary_reached(m: &mut Mob) { match m.species() { Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }), Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }), - Species::SulfurCube => sulfur_size(m, if baby { 1 } else { 2 }), + Species::SulfurCube if !baby => sulfur_size(m, 2), _ => {} } } @@ -826,9 +826,6 @@ pub fn set_baby(m: &mut Mob, baby: bool) { let s = m.species(); if ageable(s) { set_age(m, if baby { baby_start_age(s) } else { 0 }); - if s == Species::SulfurCube { - sulfur_size(m, if baby { 1 } else { 2 }); - } return; } match s { diff --git a/crates/horizon-client/src/mobs/sulfur_cube.rs b/crates/horizon-client/src/mobs/sulfur_cube.rs index 70d3f17dbc..f0d4611a58 100644 --- a/crates/horizon-client/src/mobs/sulfur_cube.rs +++ b/crates/horizon-client/src/mobs/sulfur_cube.rs @@ -292,8 +292,6 @@ pub fn set_size(m: &mut Mob, size: i32, update_health: bool) { m.health = m.attrs.max_health; if size == 1 && !m.is_baby() { m.extra.age = -24000; - } else if size > 1 && m.is_baby() { - m.extra.age = 0; } } } diff --git a/crates/horizon-server/src/lists.rs b/crates/horizon-server/src/lists.rs index 4e5bd78343..1a44251d3d 100644 --- a/crates/horizon-server/src/lists.rs +++ b/crates/horizon-server/src/lists.rs @@ -169,20 +169,9 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(config::FILE), config::TEMPLATE).unwrap(); let s = |v: &[&str]| v.iter().map(|a| (*a).to_owned()).collect::>(); - let run_cmd = |dir: &Path, cmd: &str, args: &[&str]| { - let res = run(dir, cmd, &s(args)); - if let Err(e) = &res - && e.contains("services didn't answer") - { - config::update(dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap(); - run(dir, cmd, &s(args)) - } else { - res - } - }; - run_cmd(&dir, "op", &["Alex"]).unwrap(); - run_cmd(&dir, "whitelist", &["add", "Steve"]).unwrap(); - run_cmd(&dir, "whitelist", &["off"]).unwrap(); + run(&dir, "op", &s(&["Alex"])).unwrap(); + run(&dir, "whitelist", &s(&["add", "Steve"])).unwrap(); + run(&dir, "whitelist", &s(&["off"])).unwrap(); assert!( std::fs::read_to_string(dir.join("ops.json")) .unwrap() From 7daca92c4a1377c31d0cbb2a5c2177cf4a33ccd3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:53:01 +0100 Subject: [PATCH 035/968] rustfmt: sulfur cubes join the baby-flag group Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 4d3600f6fc..0495e143db 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -177,7 +177,7 @@ pub(super) fn baby_index(kind: &str) -> Option { | "minecraft:axolotl" | "minecraft:dolphin" | "minecraft:squid" - | "minecraft:glow_squid" => Some(16), + | "minecraft:glow_squid" | "minecraft:sulfur_cube" => Some(16), _ => None, } From 26fa54369c3e52aecfbc9faeb0f58dca5aec50f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 12:55:49 +0000 Subject: [PATCH 036/968] Show your own skin: offline skins from the launcher, the host's skin on LAN - The game now reads HORIZON_OFFLINE_SKIN / HORIZON_OFFLINE_SKIN_MODEL, which the launcher already sets for an offline name, and draws that skin (slim or classic) on the local player where neither the server nor a signed-in account gives one. It was read for capes only. - On the window's own server thread (single player, and the world opened to LAN), the owner's memory connection now logs in with the signed-in account's profile properties (IntegratedServer.getSingleplayerProfile), so the player list carries the owner's signed skin to LAN guests instead of the default one. The profile is fetched while the world loads; the login waits for it at most 10 s. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CxQvrTahuwEpRxJpTvWpjG --- crates/horizon-client-render/src/skins.rs | 70 +++++++++++++++ crates/horizon-client/src/game/dedicated.rs | 1 + crates/horizon-client/src/game/integrated.rs | 20 ++++- crates/horizon-client/src/game/remote.rs | 14 +++ crates/horizon-client/src/lan/conn.rs | 92 +++++++++++++++++++- crates/horizon-client/src/lan/data.rs | 16 ++++ crates/horizon-client/src/lan/mod.rs | 45 ++++++++++ launcher/src/skins.rs | 2 +- 8 files changed, 257 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client-render/src/skins.rs b/crates/horizon-client-render/src/skins.rs index 53f6c8ed30..1e9a8fa0c3 100644 --- a/crates/horizon-client-render/src/skins.rs +++ b/crates/horizon-client-render/src/skins.rs @@ -554,6 +554,39 @@ pub fn offline_cape() -> Option<&'static [u8]> { .as_deref() } +/// The skin the launcher gives an offline name (`HORIZON_OFFLINE_SKIN`, a +/// PNG's path, and `HORIZON_OFFLINE_SKIN_MODEL`, `classic` or `slim`; +/// launcher/src/skins.rs): its 64x64 RGBA as `process_legacy_skin` lays it +/// out, and whether it is slim. Read once. +pub fn offline_skin() -> Option<&'static (Vec, bool)> { + static SKIN: std::sync::OnceLock, bool)>> = std::sync::OnceLock::new(); + SKIN.get_or_init(|| { + let path = std::env::var_os("HORIZON_OFFLINE_SKIN").filter(|p| !p.is_empty())?; + let model = std::env::var("HORIZON_OFFLINE_SKIN_MODEL").unwrap_or_default(); + read_offline_skin(std::path::Path::new(&path), &model) + .inspect_err(|e| eprintln!("Horizon: no offline skin ({}): {e}", path.display())) + .ok() + }) + .as_ref() +} +fn read_offline_skin(path: &std::path::Path, model: &str) -> Result<(Vec, bool), String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let (w, h, rgba) = crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string())?; + let skin = process_legacy_skin(w, h, &rgba).ok_or("not a 64x64 or 64x32 skin")?; + Ok((skin, model == "slim")) +} + +/// The signed-in account's own profile properties (`Minecraft.profileFuture`: +/// `fetchProfile(id, true)`, so its `textures` property is signed), or empty +/// without an answer. Blocks on the network. +pub fn fetch_own_properties(id: u128) -> Vec<(String, String, Option)> { + crate::auth::discovery() + .ok() + .and_then(|doc| resolve_profile(&NameOrId::Id(id), &doc)) + .map(|p| p.properties) + .unwrap_or_default() +} + /// Where a texture download stands. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Download { @@ -879,6 +912,43 @@ pub fn sha1(data: &[u8]) -> [u8; 20] { #[cfg(test)] mod tests { + /// The launcher's offline skin: a 64x64 or legacy 64x32 PNG laid out as + /// a downloaded skin, slim only for the `slim` model; anything else is + /// refused and the player keeps their default skin. + #[test] + fn offline_skins_read_like_downloaded_ones() { + let png = |w: u32, h: u32| { + let rgba: Vec = (0..w * h * 4).map(|i| (i % 251) as u8).collect(); + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, w, h); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder + .write_header() + .unwrap() + .write_image_data(&rgba) + .unwrap(); + (out, rgba) + }; + let dir = std::env::temp_dir().join(format!("horizon-offline-skin-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("steve.png"); + for (h, model, slim) in [(64, "slim", true), (32, "classic", false), (64, "", false)] { + let (bytes, rgba) = png(64, h); + std::fs::write(&file, bytes).unwrap(); + let (skin, is_slim) = super::read_offline_skin(&file, model).unwrap(); + assert_eq!(skin, super::process_legacy_skin(64, h, &rgba).unwrap()); + assert_eq!(skin.len(), 64 * 64 * 4); + assert_eq!(is_slim, slim); + } + std::fs::write(&file, png(32, 32).0).unwrap(); + assert!(super::read_offline_skin(&file, "slim").is_err()); + std::fs::write(&file, b"not a png").unwrap(); + assert!(super::read_offline_skin(&file, "slim").is_err()); + assert!(super::read_offline_skin(&dir.join("missing.png"), "").is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + /// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim /// skin; no metadata is the wide (classic) model. #[test] diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 7284b6135f..9b56e7f43a 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -648,6 +648,7 @@ impl Game<'_> { ); Vec::new() }, + owner_profile: None, }; let config = HubConfig { default_game_mode: game_mode, diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index e12309990b..c3a8631914 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -20,7 +20,7 @@ use horizon_client::{ Result, audio::Audio, hud::HudAtlas, - lan::{Lan, MAX_PLAYERS, NetConfig, hub::HubConfig}, + lan::{Lan, MAX_PLAYERS, NetConfig, OwnerProfile, hub::HubConfig}, settings::Settings, title::WorldOptions, }; @@ -191,6 +191,8 @@ struct Launch { owner: u128, /// Its name, for the LAN's MOTD. player: String, + /// The signed-in account's profile, fetched while the world loads. + owner_profile: Option>, } impl Game<'_> { @@ -230,6 +232,21 @@ impl Game<'_> { // An offline-mode server names its players by their offline profile. owner: offline_uuid(&player), player: player.clone(), + owner_profile: auth.as_ref().map(|a| { + let id = a.uuid; + Arc::new(OwnerProfile::new(move || { + horizon_client::skins::fetch_own_properties(id) + .into_iter() + .map(|(name, value, signature)| { + horizon_protocol::packets::login::Property { + name, + value, + signature, + } + }) + .collect() + })) + }), }; let mut options = self.options.clone(); // A server without a console: no local player, no window, no control socket. @@ -377,6 +394,7 @@ impl Game<'_> { (uuid != owner && published.published.load(Ordering::Relaxed) == 0) .then(|| translatable("multiplayer.disconnect.not_whitelisted", &[])) })), + owner_profile: launch.owner_profile.clone(), // IntegratedServer.initServer: the MOTD names the owner and the world. ..NetConfig::lan( 0, diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e71cd9fed0..d979f18ec8 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3267,6 +3267,20 @@ impl Game<'_> { } } resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin)); + // An offline name's skin from the launcher, where neither the server + // nor an account gives the local player one. + if !resolved.iter().any(|(u, _)| *u == local) + && let Some((rgba, is_slim)) = horizon_client::skins::offline_skin() + { + const OFFLINE: &str = "horizon/offline_skin"; + if !self.show_skin(OFFLINE) { + self.store_skin(OFFLINE.into(), rgba.clone()); + } + if *is_slim { + slim.insert(local); + } + resolved.push((local, OFFLINE.into())); + } // An offline name's cape from the launcher, where the profile has none. if !capes.contains_key(&local) && let Some(rgba) = horizon_client::skins::offline_cape() diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index 0100b8f232..9ba32e13f7 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -703,7 +703,15 @@ fn serve_login( return Err(err); } }, - None => (offline_uuid(&hello.name), Vec::new()), + // handleHello: the singleplayer owner (the window's memory connection) + // logs in with its own profile, other players with no properties. + None => { + let properties = match &shared.config.owner_profile { + Some(owner) if c.is_memory() => owner.properties().to_vec(), + _ => Vec::new(), + }; + (offline_uuid(&hello.name), properties) + } }; // verifyLoginAndFinishConnectionSetup: PlayerList.canPlayerLogin, then // duplicates and a full server. @@ -1194,6 +1202,88 @@ fn write_loop( mod tests { use super::*; + /// Logs in through `c` and answers the `LoginFinished` profile. + fn login_profile(mut c: Connection, name: &str) -> login::GameProfile { + c.send( + &handshake::Serverbound::Intention(handshake::Intention { + protocol: horizon_protocol::PROTOCOL_VERSION, + host: "127.0.0.1".into(), + port: 0, + intent: handshake::LOGIN, + }) + .encode(), + ) + .unwrap(); + c.send( + &login::Serverbound::Hello(login::ServerHello { + name: name.into(), + uuid: offline_uuid(name), + }) + .encode(), + ) + .unwrap(); + loop { + let (id, payload) = c.recv().unwrap(); + match login::Clientbound::decode(id, &payload).unwrap() { + login::Clientbound::Compression(p) => c.set_compression(p.threshold), + login::Clientbound::LoginFinished(p) => return p.profile, + other => panic!("unexpected login packet {other:?}"), + } + } + } + + /// handleHello on an integrated server: the window's own (memory) + /// connection logs in with the signed-in account's properties, its skin + /// among them, so the player list gives everyone the owner's skin; a + /// LAN guest on the offline-mode world gets none. + #[test] + fn the_owner_logs_in_with_its_own_profile_properties() { + let textures = login::Property { + name: "textures".into(), + value: "e30=".into(), + signature: Some("c2lnbmVk".into()), + }; + let fetched = textures.clone(); + let config = crate::lan::NetConfig { + announce: false, + owner_profile: Some(Arc::new(crate::lan::OwnerProfile::new(move || { + vec![fetched] + }))), + ..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8) + }; + let lan = + crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config) + .unwrap(); + let owner = login_profile(Connection::local(lan.connect_local().unwrap()), "Owner"); + assert_eq!(owner.uuid, offline_uuid("Owner")); + assert_eq!(owner.properties, vec![textures]); + let guest = Connection::connect("127.0.0.1", lan.port, Duration::from_secs(5)).unwrap(); + guest + .reader + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let guest = login_profile(guest, "Guest"); + assert_eq!(guest.uuid, offline_uuid("Guest")); + assert!(guest.properties.is_empty()); + } + + /// Without a signed-in account (or an answer from the services) the + /// owner logs in with no properties, as before. + #[test] + fn an_owner_without_an_answer_logs_in_without_properties() { + let config = crate::lan::NetConfig { + announce: false, + listen: false, + owner_profile: Some(Arc::new(crate::lan::OwnerProfile::new(Vec::new))), + ..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8) + }; + let lan = + crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config) + .unwrap(); + let owner = login_profile(Connection::local(lan.connect_local().unwrap()), "Owner"); + assert!(owner.properties.is_empty()); + } + #[test] fn prompts_become_the_components_vanilla_sends() { let tag = |json: &str| { diff --git a/crates/horizon-client/src/lan/data.rs b/crates/horizon-client/src/lan/data.rs index 8de0e53d3a..18215dad24 100644 --- a/crates/horizon-client/src/lan/data.rs +++ b/crates/horizon-client/src/lan/data.rs @@ -123,6 +123,22 @@ pub fn component_nbt(v: &Value) -> Tag { } impl SyncData { + /// No registries, tags or recipes: enough for tests of the login phase. + #[cfg(test)] + pub(crate) fn empty() -> Self { + Self { + builtin: Builtin::default(), + dynamic: Dynamic::default(), + registries: Vec::new(), + tags: UpdateTags { + registries: Vec::new(), + }, + features: Vec::new(), + commands: None, + recipes: Vec::new(), + advancements: super::advancements::ServerAdvancements::load(Path::new("")), + } + } pub fn load(resources: &Path) -> Result { let builtin = Builtin::find(resources) .and_then(|p| Builtin::load(&p).ok()) diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 34c465ffaf..5fb344704b 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -146,6 +146,9 @@ pub struct NetConfig { /// sent in configuration when non-empty /// (ServerConfigurationPacketListenerImpl.startConfiguration). pub server_links: Vec, + /// An integrated server's owner profile: the properties the window's own + /// (memory) connection logs in with. + pub owner_profile: Option>, } impl NetConfig { /// MinecraftServer.enforceSecureProfile. @@ -173,10 +176,52 @@ impl NetConfig { log: None, resource_pack: None, server_links: Vec::new(), + owner_profile: None, } } } +/// `IntegratedServer.getSingleplayerProfile` (`Minecraft.getGameProfile`): +/// the signed-in account's profile properties, its signed `textures` among +/// them, which the owner logs in with so the player list gives everyone on +/// the world, LAN guests included, the owner's skin. Fetched in the +/// background from startup and waited for by the owner's login, as +/// `Minecraft.profileFuture` is, but never longer than `OWNER_PROFILE_WAIT`: +/// past that the owner plays without properties (the default skin to others). +pub struct OwnerProfile { + properties: std::sync::OnceLock>, + fetch: Mutex>>>, +} + +const OWNER_PROFILE_WAIT: std::time::Duration = std::time::Duration::from_secs(10); + +impl OwnerProfile { + pub fn new( + fetch: impl FnOnce() -> Vec + Send + 'static, + ) -> Self { + let (send, receive) = channel(); + let _ = std::thread::Builder::new() + .name("Profile fetcher".into()) + .spawn(move || { + let _ = send.send(fetch()); + }); + Self { + properties: std::sync::OnceLock::new(), + fetch: Mutex::new(Some(receive)), + } + } + /// The owner's properties (none when the services couldn't be reached + /// in time). + pub fn properties(&self) -> &[horizon_protocol::packets::login::Property] { + self.properties.get_or_init(|| { + let receive = self.fetch.lock().ok().and_then(|mut r| r.take()); + receive + .and_then(|r| r.recv_timeout(OWNER_PROFILE_WAIT).ok()) + .unwrap_or_default() + }) + } +} + /// A dedicated server's secure chat (DedicatedServer.enforceSecureProfile, /// Services.profileKeySignatureValidator): the services' profile-key keys, /// fetched in the background from startup and waited for by their first diff --git a/launcher/src/skins.rs b/launcher/src/skins.rs index 55f1a72ac7..a70933aa86 100644 --- a/launcher/src/skins.rs +++ b/launcher/src/skins.rs @@ -1,7 +1,7 @@ //! Offline skins and capes: a PNG the player picks for an offline name, kept in the launcher's //! `skins/` folder, with its model (classic arms or slim). The game gets them in //! `HORIZON_OFFLINE_SKIN`, `HORIZON_OFFLINE_SKIN_MODEL` and `HORIZON_OFFLINE_CAPE` when that -//! name plays; a game that doesn't read them yet draws the default skin, as before. +//! name plays, and draws them on the local player (`skins::offline_skin`, `skins::offline_cape`). //! //! Skins are the layout every Java Edition skin uses: 64×64, or the older 64×32 (whose left arm //! and leg are the right ones mirrored). Capes are 64×32 (or the older 22×17). From 296603342f78d4f0fb691ee54af0442a7bd89f80 Mon Sep 17 00:00:00 2001 From: martipops Date: Thu, 29 Oct 2026 09:34:42 -0400 Subject: [PATCH 037/968] Allow sound engines to share one dimension-aware audio output Select the nearest listener in each world, suppress duplicate world sounds, and clean up shared listeners and channels. Inaudible sources continue advancing. Part 2 of the splitscreen series; game integration follows separately. Co-Authored-By: Codex --- crates/horizon-client-render/src/audio.rs | 405 ++++++++++++++++++++-- docs/design/shared-audio.md | 24 ++ 2 files changed, 397 insertions(+), 32 deletions(-) create mode 100644 docs/design/shared-audio.md diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs index e45362e190..e2a0a646c5 100644 --- a/crates/horizon-client-render/src/audio.rs +++ b/crates/horizon-client-render/src/audio.rs @@ -39,6 +39,11 @@ struct Channel { looping: bool, at: [f32; 3], relative: bool, + /// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`). + owner: u8, + world: String, + /// The sound file it started and when, to tell another player's copy of it. + started: Option<(String, std::time::Instant)>, } #[derive(Default)] struct Mixer { @@ -46,6 +51,10 @@ struct Mixer { listener: [f64; 3], listener_yaw: f32, listener_pitch: f32, + /// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is + /// heard from the nearest camera, and panned half as wide while several share the speakers. + listeners: HashMap, + worlds: HashMap, limiter_peak: f32, } /// A sound the player could have heard (`SoundEventListener.onPlaySound`): @@ -69,7 +78,29 @@ pub struct MixerBackend { decoder: Option>, next: ChannelId, live: bool, + /// Whose channels these are (`Channel::owner`). + owner: u8, + world: String, } +/// A splitscreen player's backend leaving: its camera and sounds go with it. +impl Drop for MixerBackend { + fn drop(&mut self) { + if self.owner != 0 + && let Ok(mut m) = self.mixer.lock() + { + m.listeners.remove(&self.owner); + m.worlds.remove(&self.owner); + let owner = self.owner; + m.channels.retain(|_, c| c.owner != owner); + } + } +} +/// Splitscreen: each shared backend's channel ids start at a block of their own, never handed out +/// again, so a player in a seat used before never reuses an id a departed one's decoder may +/// still attach to. The game's own backend counts from 0, as before. +static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +/// Two players' copies of one sound: started within this of each other. +const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150); /// Decoded samples and their rate (`None`: the file did not load). type Samples = Option<(Arc>, u32)>; /// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least @@ -207,7 +238,49 @@ fn decode(path: &Path) -> Option<(Vec, u32)> { } Some((mono, rate)) } +/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each +/// player's game picks its own variant of the same event. +// ponytail: variants named by a trailing number, as vanilla's are; the event name if a pack breaks it. +fn variantless(key: &str) -> &str { + key.trim_end_matches(".ogg") + .trim_end_matches(|c: char| c.is_ascii_digit()) +} impl MixerBackend { + /// Splitscreen: another player's game started this same sound here just now (both clients + /// heard it); this copy stays stopped, the first is heard from the nearest camera. + fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool { + let Ok(mut m) = self.mixer.lock() else { + return false; + }; + // Alone (no other player's camera), or a sound by the listener (a click, music): its own. + let Some(c) = m.channels.get(&channel).filter(|c| !c.relative) else { + return false; + }; + if m.listeners.is_empty() { + return false; + } + let key = format!("{namespace}:{path}"); + let key = variantless(&key); + let at = c.at; + let world = c.world.clone(); + let twin = m.channels.values().any(|o| { + o.owner != self.owner + && o.world == world + && !o.relative + && o.started + .as_ref() + .is_some_and(|(k, t)| k == key && t.elapsed() < SAME_SOUND) + && (0..3).map(|i| (o.at[i] - at[i]).powi(2)).sum::() < 4.0 + }); + if let Some(c) = m.channels.get_mut(&channel) { + if twin { + c.state = State::Stopped; + } else { + c.started = Some((key.to_owned(), std::time::Instant::now())); + } + } + twin + } fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) { if let Ok(mut m) = self.mixer.lock() && let Some(c) = m.channels.get_mut(&channel) @@ -232,6 +305,9 @@ impl Backend for MixerBackend { looping: false, at: [0.0; 3], relative: false, + owner: self.owner, + world: self.world.clone(), + started: None, }, ); Some(id) @@ -269,6 +345,10 @@ impl Backend for MixerBackend { } let (namespace, path) = sound.path(); let file = self.root.join("assets").join(namespace).join(&path); + // A looping sound follows its entity in one client's tracking only: each keeps its own. + if !looping && self.duplicate(channel, (namespace, &path)) { + return; + } if sound.stream { // A streamed sound loops by restarting its stream. let mixer = self.mixer.clone(); @@ -377,28 +457,47 @@ fn distance_gain(linear: Option, distance: f32) -> f32 { #[cfg_attr(not(feature = "audio"), allow(dead_code))] fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { data.fill(0.0); - let listener = mixer.listener; // The camera's forward and up vectors (`Camera.forwardVector`/`upVector`) // and OpenAL's right = forward x up. - let (yaw, pitch) = ( - f64::from(mixer.listener_yaw.to_radians()), - f64::from(mixer.listener_pitch.to_radians()), - ); - let forward = [ - -yaw.sin() * pitch.cos(), - -pitch.sin(), - yaw.cos() * pitch.cos(), - ]; - let up = [ - -yaw.sin() * pitch.sin(), - pitch.cos(), - yaw.cos() * pitch.sin(), - ]; - let right = [ - forward[1] * up[2] - forward[2] * up[1], - forward[2] * up[0] - forward[0] * up[2], - forward[0] * up[1] - forward[1] * up[0], - ]; + let basis = |yaw: f32, pitch: f32| { + let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians())); + let forward = [ + -yaw.sin() * pitch.cos(), + -pitch.sin(), + yaw.cos() * pitch.cos(), + ]; + let up = [ + -yaw.sin() * pitch.sin(), + pitch.cos(), + yaw.cos() * pitch.sin(), + ]; + let right = [ + forward[1] * up[2] - forward[2] * up[1], + forward[2] * up[0] - forward[0] * up[2], + forward[0] * up[1] - forward[1] * up[0], + ]; + (forward, up, right) + }; + // Every camera hearing (splitscreen: the other players' too). + let cameras: Vec<_> = std::iter::once(( + 0, + (mixer.listener, mixer.listener_yaw, mixer.listener_pitch), + )) + .chain( + mixer + .listeners + .iter() + .map(|(&owner, &camera)| (owner, camera)), + ) + .map(|(owner, (at, yaw, pitch))| { + ( + at, + basis(yaw, pitch), + mixer.worlds.get(&owner).cloned().unwrap_or_default(), + ) + }) + .collect(); + let shared = cameras.len() > 1; let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32; // Gains and pans per source for this buffer. let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new(); @@ -409,24 +508,44 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { if c.state != State::Playing { continue; } - let d = if c.relative { - [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])] - } else { + let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]; + let away = |listener: [f64; 3]| { [ - f64::from(c.at[0]) - listener[0], - f64::from(c.at[1]) - listener[1], - f64::from(c.at[2]) - listener[2], + at[0] - listener[0], + at[1] - listener[1], + at[2] - listener[2], ] }; + let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; + // The nearest camera hears it (one camera but in splitscreen). + let camera = cameras + .iter() + .filter(|camera| c.relative || camera.2 == c.world) + .min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0)))) + .map(|camera| (camera.0, camera.1)); + // Unheard sources still advance: returning to a world must not resume stale sounds. + let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0))); + let d = if c.relative { at } else { away(listener) }; let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32; - let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance); + let gain = if camera.is_some() { + c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance) + } else { + 0.0 + }; // Relative sources are already in OpenAL's listener space (-Z ahead). - let [left, right] = if c.relative { + let [mut left, mut right] = if c.relative { stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32) } else { stereo_gains(dot(d, right), dot(d, up), dot(d, forward)) + }; + if shared { + // Several players on the speakers: half the stereo width, as left and right are + // nobody's in particular. + let middle = (left + right) / 2.0; + left = middle + (left - middle) / 2.0; + right = middle + (right - middle) / 2.0; } - .map(|pan| gain * pan); + let [left, right] = [left, right].map(|pan| gain * pan); let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0)); voices.push((*id, [left, right, gain], step)); } @@ -589,6 +708,8 @@ impl Audio { decoder: None, next: 0, live, + owner: 0, + world: String::new(), }, record_heard: false, listener: [0.0; 3], @@ -648,15 +769,61 @@ impl Audio { backend: &mut self.backend, } } + /// World identity for this player's sources and camera. Existing channels keep their world. + pub fn set_world(&mut self, world: &str) { + if self.backend.world != world { + self.backend.world = world.to_owned(); + } + if let Ok(mut mixer) = self.backend.mixer.lock() { + mixer.worlds.insert(self.backend.owner, world.to_owned()); + } + } + /// `SoundManager.updateSource`: the camera position and rotation /// (`Listener.setTransform` takes its forward and up vectors). pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) { self.listener = position; self.listener_yaw = yaw; if let Ok(mut m) = self.backend.mixer.lock() { - m.listener = position; - m.listener_yaw = yaw; - m.listener_pitch = pitch; + if self.backend.owner == 0 { + m.listener = position; + m.listener_yaw = yaw; + m.listener_pitch = pitch; + } else { + m.listeners + .insert(self.backend.owner, (position, yaw, pitch)); + } + } + } + /// Splitscreen: another player's sounds on this output, heard from its camera too + /// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go. + pub fn share(&self, owner: u8) -> Self { + assert_ne!(owner, 0, "shared audio owners must be nonzero"); + let mut engine = SoundEngine::new(self.engine.registry.clone()); + engine.loaded = self.backend.live; + Self { + _stream: None, + engine, + backend: MixerBackend { + mixer: self.backend.mixer.clone(), + root: self.backend.root.clone(), + cache: self.backend.cache.clone(), + decoder: None, + next: (SHARED_CHANNELS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1) + << 40, + live: self.backend.live, + owner, + world: self.backend.world.clone(), + }, + record_heard: false, + listener: [0.0; 3], + listener_yaw: 0.0, + master: self.master, + random: LegacyRandom::new(crate::sound_engine::unique_seed()), + source_override: None, + capture: None, + heard: Default::default(), + stopped: Default::default(), } } /// Sync the volume options, refreshing the categories that changed @@ -917,6 +1084,9 @@ mod tests { looping, at, relative: false, + owner: 0, + world: String::new(), + started: None, } } @@ -1074,6 +1244,177 @@ mod tests { assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5])); } + #[test] + fn splitscreen_sounds_are_heard_from_the_nearest_camera() { + // P1 far away, a second player beside the source: heard at full gain, centred. + let mut m = Mixer { + listener: [100.0, 0.0, 0.0], + ..Mixer::default() + }; + m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0)); + m.channels + .insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true)); + let mut data = [0.0; 2]; + mix(&mut m, &mut data, 2, 48_000); + assert!( + data[0] > 0.5 && (data[0] - data[1]).abs() < 1e-4, + "{data:?}" + ); + // Alone, P1 would not hear it: 100 blocks past its 16. + m.listeners.clear(); + let mut data = [0.0; 2]; + mix(&mut m, &mut data, 2, 48_000); + assert_eq!(data, [0.0; 2]); + } + + #[test] + fn a_shared_listener_registers_even_in_the_hosts_world() { + let mut host = Audio::silent(Path::new("/nonexistent/horizon-test-audio")); + host.set_world("minecraft:overworld"); + let mut guest = host.share(1); + guest.set_world("minecraft:overworld"); + guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0); + let id = guest.backend.create(false).unwrap(); + let mixer = host.backend.mixer.lock().unwrap(); + assert_eq!( + mixer.worlds.get(&1).map(String::as_str), + Some("minecraft:overworld") + ); + assert_eq!(mixer.channels[&id].world, "minecraft:overworld"); + } + + #[test] + fn cameras_in_other_dimensions_cannot_hear_a_source() { + let mut m = Mixer { + listener: [100.0, 0.0, 0.0], + ..Default::default() + }; + m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0)); + m.worlds.insert(1, "nether".into()); + m.channels + .insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true)); + let mut data = [0.0; 2]; + mix(&mut m, &mut data, 2, 48_000); + assert_eq!( + data, [0.0; 2], + "nearby Nether camera must not hear Overworld source" + ); + m.channels.get_mut(&1).unwrap().world = "nether".into(); + mix(&mut m, &mut data, 2, 48_000); + assert!(data[0] > 0.5, "same-world camera must hear source"); + m.worlds.insert(1, "end".into()); + data.fill(0.0); + mix(&mut m, &mut data, 2, 48_000); + assert_eq!(data, [0.0; 2], "no listener remains in source's world"); + } + + #[test] + fn sounds_in_a_world_without_listeners_finish_while_inaudible() { + let mut mixer = Mixer::default(); + let mut source = channel(vec![1.0; 4], None, [0.0; 3], false); + source.world = "nether".into(); + mixer.channels.insert(1, source); + let mut data = [0.0; 4]; + mix(&mut mixer, &mut data, 2, 48_000); + assert_eq!(data, [0.0; 4]); + assert!(mixer.channels[&1].position > 0.0); + mix(&mut mixer, &mut data, 2, 48_000); + assert_eq!(mixer.channels[&1].state, State::Stopped); + mixer.worlds.insert(0, "nether".into()); + mix(&mut mixer, &mut data, 2, 48_000); + assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot"); + } + + #[test] + fn variants_of_one_sound_are_the_same_sound() { + assert_eq!( + variantless("minecraft:mob/creeper/say1.ogg"), + "minecraft:mob/creeper/say" + ); + assert_eq!( + variantless("minecraft:step/grass6.ogg"), + "minecraft:step/grass" + ); + assert_eq!( + variantless("minecraft:random/click.ogg"), + "minecraft:random/click" + ); + } + + #[test] + fn another_players_copy_of_a_sound_is_dropped() { + let mixer = Arc::new(Mutex::new(Mixer::default())); + let backend = |owner| MixerBackend { + mixer: mixer.clone(), + root: PathBuf::new(), + cache: Arc::default(), + decoder: None, + next: (u64::from(owner)) << 40, + live: true, + owner, + world: String::new(), + }; + let (mut p1, mut p2) = (backend(0), backend(1)); + // P2 is listening (its camera is in the mixer), so two players share the speakers. + mixer + .lock() + .unwrap() + .listeners + .insert(1, ([0.0; 3], 0.0, 0.0)); + let started = |b: &mut MixerBackend, at: [f64; 3]| { + let id = b.create(false).unwrap(); + b.set_position(id, at); + !b.duplicate(id, ("minecraft", "mob/creeper/say1.ogg")) + }; + assert!(started(&mut p1, [5.0, 64.0, 5.0])); + // P2's client got the same sound at the same place: one copy plays. + assert!(!started(&mut p2, [5.2, 64.0, 5.0])); + // Elsewhere, or P1's own second one, it is a sound of its own. + assert!(started(&mut p2, [40.0, 64.0, 5.0])); + assert!(started(&mut p1, [5.0, 64.0, 5.0])); + // Equal coordinates in another dimension describe a different sound. + p2.world = "nether".into(); + assert!(started(&mut p2, [5.0, 64.0, 5.0])); + // P2 leaving takes its sounds. + drop(p2); + assert!( + mixer + .lock() + .unwrap() + .channels + .values() + .all(|c| c.owner == 0) + ); + } + + #[test] + fn reusing_a_seat_does_not_reuse_its_channels_or_leave_its_camera() { + let host = Audio::silent(Path::new("/nonexistent/horizon-test-audio")); + let mut guest = host.share(1); + guest.set_world("overworld"); + guest.set_listener([1.0, 0.0, 0.0], 0.0, 0.0); + let old = guest.backend.create(false).unwrap(); + drop(guest); + { + let mixer = host.backend.mixer.lock().unwrap(); + assert!(!mixer.channels.contains_key(&old)); + assert!(!mixer.listeners.contains_key(&1)); + assert!(!mixer.worlds.contains_key(&1)); + } + let mut replacement = host.share(1); + let new = replacement.backend.create(false).unwrap(); + assert_ne!(old, new); + assert!( + replacement + .backend + .mixer + .lock() + .unwrap() + .channels + .contains_key(&new) + ); + } + #[test] fn a_mono_output_plays_sources_unpanned() { let mut m = Mixer::default(); diff --git a/docs/design/shared-audio.md b/docs/design/shared-audio.md new file mode 100644 index 0000000000..9d4dc50090 --- /dev/null +++ b/docs/design/shared-audio.md @@ -0,0 +1,24 @@ +# Shared audio output + +Part 2 of the splitscreen series (#3541). This adds the mixer API; the game still uses one +listener until the player-session integration lands. No launcher changes or settings are added. + +`Audio::share(owner)` creates another sound engine on the existing output and decoded sample +cache. Give each live engine its own nonzero owner ID. Call `set_world` with the same world or +dimension identity for engines that can hear each other's world sounds, then update each one's +camera with `set_listener`. Existing channels keep their original world when a listener travels. + +A world sound is heard from the nearest camera in its world. Copies heard by different engines +at nearly the same position within 150 ms are played once; relative sounds and looping sources +keep their own channels. Numeric suffixes identify vanilla's sound-file variants. Shared stereo +panning is reduced while multiple listeners share the speakers. + +Dropping a shared engine removes its sources and listener. Replacement engines get fresh channel +IDs even when they reuse an owner ID, so a late decoder reply cannot attach to a replacement's +sound. Keep the original `Audio` alive: it owns the output stream. + +Run the deterministic mixer tests without an audio device or game resources: + +```sh +cargo test --profile fast -p horizon-client-render --lib audio::tests +``` From 3b3560d71dfb1ba7a731e570f4a7e43f780473e7 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Thu, 29 Oct 2026 16:45:47 +0300 Subject: [PATCH 038/968] Launcher: remove several versions at once App::removing held one version, and remove() returned early while it was set, so a second row's Remove did nothing until the first delete ended. It becomes the list of versions in flight: each row starts its own delete, Msg::Removed drops its own tag, and the rows, Play and Install ask whether that tag is in the list. The gate is removable(), so the two-at-once case has a test. Fix #3719. Co-Authored-By: Hermes Agent (Nous Research) --- launcher/src/main.rs | 55 ++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 476306414e..0d5ee34830 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -167,9 +167,10 @@ struct Launcher { releases: Load>, installed: Vec, installing: Option<(String, f32)>, - /// The version being deleted on a background thread. It is already out of `installed`, so + /// The versions being deleted on background threads. Each is already out of `installed`, so /// nothing can launch, select or prepare it, and its row can't install or remove it again. - removing: Option, + /// More than one may run at once: a row's Remove stays usable while another version goes. + removing: Vec, /// What each installed version takes on disk, measured on a thread for the Versions page, and /// the installed list it was measured for. disk: Vec<(String, u64)>, @@ -395,7 +396,7 @@ impl Launcher { releases: Load::Loading, installed, installing: None, - removing: None, + removing: Vec::new(), disk: Vec::new(), disk_for: Vec::new(), install_error: None, @@ -563,7 +564,7 @@ impl Launcher { let Some(release) = self.updates.horizon.clone() else { return; }; - if self.installing.is_some() || self.removing.as_ref() == Some(&release.tag) { + if self.installing.is_some() || self.removing.contains(&release.tag) { return; } let select = @@ -812,9 +813,7 @@ impl Launcher { } } Msg::Removed(tag, result) => { - if self.removing.as_ref() == Some(&tag) { - self.removing = None; - } + self.removing.retain(|t| *t != tag); self.installed = backend::installed(); self.settings.selected = selection_after_removal(&self.installed, Settings::load().selected); @@ -1040,7 +1039,7 @@ impl Launcher { } fn install(&mut self, release: &Release) { - if release.asset.is_none() || self.removing.as_ref() == Some(&release.tag) { + if release.asset.is_none() || self.removing.contains(&release.tag) { return; } let (release, tx) = (release.clone(), self.tx.clone()); @@ -1062,14 +1061,12 @@ impl Launcher { /// the window must keep drawing meanwhile. `Msg::Removed` ends it. fn remove(&mut self, tag: &str) { self.confirm_remove = None; - if self.removing.is_some() - || self.installing.as_ref().is_some_and(|(t, _)| t == tag) - || !self.installed.iter().any(|t| t == tag) - { + let installing = self.installing.as_ref().map(|(t, _)| t.as_str()); + if !removable(&self.installed, installing, &self.removing, tag) { return; } self.install_error = None; - self.removing = Some(tag.to_string()); + self.removing.push(tag.to_string()); self.installed.retain(|t| t != tag); self.settings.selected = selection_after_removal(&self.installed, self.settings.selected.take()); @@ -1647,6 +1644,19 @@ fn starts_now(prepared: bool, preparing: bool, running: bool) -> bool { /// The selected version once one has been removed: the saved choice if it is still installed, /// else the newest left. +/// Whether `tag` can start being deleted now: it is installed, no install is writing it, and it +/// is not already going. Another version deleting at the same time does not stop it. +fn removable( + installed: &[String], + installing: Option<&str>, + removing: &[String], + tag: &str, +) -> bool { + installing != Some(tag) + && installed.iter().any(|t| t == tag) + && !removing.iter().any(|t| t == tag) +} + fn selection_after_removal(installed: &[String], saved: Option) -> Option { saved .filter(|s| installed.contains(s)) @@ -3895,7 +3905,7 @@ impl Launcher { self.installing.as_ref().is_some_and(|(t, _)| t == tag) || self.install_error.as_ref().is_some_and(|(t, _)| t == tag) || self.confirm_remove.as_ref() == Some(tag) - || self.removing.as_ref() == Some(tag) + || self.removing.contains(tag) }; let list = self.long_list.clone().column( &self.content_scroll.handle, @@ -3935,7 +3945,7 @@ impl Launcher { .child(format!("{:.0}%", p * 100.)), ) .into_any_element() - } else if self.removing.as_ref() == Some(&tag) { + } else if self.removing.contains(&tag) { div() .flex() .items_center() @@ -4596,6 +4606,21 @@ mod tests { assert!(hit.iter().any(|b| b.action().partial_eq(&CloseWindow))); } + #[test] + fn a_second_version_can_be_removed_while_another_deletes() { + let installed = ["v0.2.97".to_string(), "v0.2.76".to_string()]; + let removing = ["v0.2.97".to_string()]; + assert!(super::removable(&installed, None, &removing, "v0.2.76")); + assert!(!super::removable(&installed, None, &removing, "v0.2.97")); + assert!(!super::removable( + &installed, + Some("v0.2.76"), + &[], + "v0.2.76" + )); + assert!(!super::removable(&installed, None, &[], "v0.2.85")); + } + #[test] fn the_selection_survives_a_removal_or_moves_to_the_newest_left() { let left = ["v0.2.49".to_string(), "v0.2.48".to_string()]; From 51005638e053af772e84032a2912bc3d4248040f Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Thu, 29 Oct 2026 14:46:18 +0100 Subject: [PATCH 039/968] Skeletons: a path out of the shade stops at the first node the sky sees GroundPathNavigation.trimPath, with avoidSun set by RestrictSunGoal, cuts a path that starts out of the sun at its first sunlit node, so a skeleton chasing a player by day waits in the shade. Horizon never trimmed a path. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/mobs/nav.rs | 30 +++++++++++++++++++++++++++ tools/test_mob_ai_parity.py | 4 ++++ 2 files changed, 34 insertions(+) diff --git a/crates/horizon-client/src/mobs/nav.rs b/crates/horizon-client/src/mobs/nav.rs index d63035a614..8f3f1584a6 100644 --- a/crates/horizon-client/src/mobs/nav.rs +++ b/crates/horizon-client/src/mobs/nav.rs @@ -50,6 +50,9 @@ pub struct Navigation { pub below_surface: bool, /// WallClimberNavigation.pathToPosition climb_to: Option<[i32; 3]>, + /// `PathNavigation.moveTo` stored a path that `trimPath` has not seen + /// yet: it runs on the navigation's next tick, which has the world. + trim_pending: bool, } impl Default for Navigation { fn default() -> Self { @@ -80,6 +83,7 @@ impl Default for Navigation { avoid_sun: false, below_surface: false, climb_to: None, + trim_pending: false, } } } @@ -321,6 +325,7 @@ impl Mob { if self.ai.nav.path.as_ref().is_none_or(|p| p.nodes.is_empty()) { return false; } + self.ai.nav.trim_pending = true; self.ai.nav.speed_modifier = speed; self.ai.nav.last_stuck_check = self.ai.nav.tick; // getTempMobPos needs the world for ground navigation; position is close enough @@ -372,9 +377,34 @@ impl Mob { self.ai.nav.delayed_recompute = false; } } + /// `GroundPathNavigation.trimPath` with `avoidSun`: a mob that is out of + /// the sun keeps only the nodes before the first one the sky sees. + fn trim_path(&mut self, w: &MobWorld) { + if !self.ai.nav.avoid_sun + || !matches!(self.ai.nav.kind, NavKind::Ground | NavKind::WallClimber) + { + return; + } + let at = [ + self.position.x.floor() as i32, + (self.position.y + 0.5).floor() as i32, + self.position.z.floor() as i32, + ]; + if w.can_see_sky(at[0], at[1], at[2]) { + return; + } + if let Some(path) = &mut self.ai.nav.path + && let Some(i) = path.nodes.iter().position(|n| w.can_see_sky(n.x, n.y, n.z)) + { + path.nodes.truncate(i); + } + } /// PathNavigation.tick. pub fn nav_tick(&mut self, w: &MobWorld) { self.ai.nav.tick += 1; + if std::mem::take(&mut self.ai.nav.trim_pending) { + self.trim_path(w); + } if self.ai.nav.delayed_recompute { self.recompute_path(w); } diff --git a/tools/test_mob_ai_parity.py b/tools/test_mob_ai_parity.py index bb9476b1e5..4ded6b6a63 100644 --- a/tools/test_mob_ai_parity.py +++ b/tools/test_mob_ai_parity.py @@ -294,6 +294,10 @@ def scenarios(): ('witch', 176), ('vindicator', 177), ('zombie', 178)]: add(f'{species}_deep_water', 160, ['fill -4 -64 -4 4 -61 4 minecraft:water', f'mob minecraft:{species} 0.5 -62 0.5 0 {seed} persistent']) + # A skeleton by day chases a player past the edge of a roof (GroundPathNavigation.trimPath with + # avoidSun: the path stops at the first node the sky sees). + add('skeleton_sun_path', 120, ['fill -5 -57 -5 5 -57 5 minecraft:stone', 'fill -5 -56 -5 5 -56 5 minecraft:air', + 'player 12.5 -60 0.5', 'mob minecraft:skeleton 0.5 -60 0.5 0 211 persistent'], 'time=1000') # Axolotls (#64): idle swimming, hunting a cod, and playing dead after hits, in a pool sunk into # the ground so no water flows out of it (the probe doesn't tick fluids). POOL = 'fill -5 -63 -5 5 -61 5 minecraft:water' From f904f4404751d355b00787fe5edd170da82c71c4 Mon Sep 17 00:00:00 2001 From: umceko Date: Thu, 29 Oct 2026 16:44:39 +0300 Subject: [PATCH 040/968] Mod port harness: compare items' stats with the NeoForge oracle The items check only saw an item exist with its stack size, so a ported sword carried as a stick passed. The probe now dumps what Horizon's loaders make of each item (durability, attribute modifiers, tool rules, enchantability, repair items, equipment slot, food) and the harness checks them against the oracle's components, as `item_stats`, with 1.21.1's `generic.` attribute ids read as 26.3's and vanilla's newer tool rules allowed beside the mod's own. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/examples/mod_port_probe.rs | 32 ++++++++++++- tools/mod_port_harness.py | 47 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/examples/mod_port_probe.rs b/crates/horizon-client/examples/mod_port_probe.rs index 13dc7f6857..463e621de5 100644 --- a/crates/horizon-client/examples/mod_port_probe.rs +++ b/crates/horizon-client/examples/mod_port_probe.rs @@ -12,6 +12,7 @@ use horizon_client::{ assets::Assets, blocks::BlockFacts, crafting::{Recipe, Recipes}, + enchant::Enchantments, items::{ItemStack, Items}, mod_content::{self, ContentMod}, stations::StationRecipes, @@ -80,6 +81,7 @@ fn main() -> Result<()> { let items = Items::load(&root, registry, &facts).map_err(step("items"))?; let recipes = Recipes::load(&root, &items).map_err(step("recipes"))?; let stations = StationRecipes::load(&root, &items); + let enchantments = Enchantments::load(&root); let collisions = horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?; let loaded = began.elapsed().as_secs_f64() - built; @@ -197,7 +199,35 @@ fn main() -> Result<()> { item_rows.insert( name.clone(), match items.items.get(name.as_str()) { - Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}), + Some(i) => { + let props = enchantments.props(name); + let repairable: Vec = props + .into_iter() + .flat_map(|p| &p.repairable) + .flat_map(|r| match r.strip_prefix('#') { + Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(), + None => vec![r.clone()], + }) + .collect(); + json!({ + "max_stack_size": i.max_stack, + "block": i.block.map(|b| b.0), + "max_damage": i.max_damage, + "modifiers": i.modifiers.iter().map(|(attribute, id, amount, operation, slot)| { + json!({"type": attribute, "id": id, "amount": amount, "operation": operation, "slot": slot}) + }).collect::>(), + "tool": i.tool_damage_per_block.map(|per_block| json!({ + "rules": i.tool.iter().map(|r| json!({"blocks": r.blocks, "speed": r.speed, "correct_for_drops": r.correct})).collect::>(), + "default_mining_speed": i.tool_default_speed, + "damage_per_block": per_block, + })), + "enchantable": props.map_or(0, |p| p.enchantable), + "repairable": repairable, + "equip_slot": i.equip_slot, + "equip_asset": i.equip_asset, + "food": i.food.map(|(nutrition, saturation)| json!({"nutrition": nutrition, "saturation": saturation})), + }) + } None => Value::Null, }, ); diff --git a/tools/mod_port_harness.py b/tools/mod_port_harness.py index 181e6533b0..c245f29035 100644 --- a/tools/mod_port_harness.py +++ b/tools/mod_port_harness.py @@ -15,7 +15,9 @@ and what the game's loaders make of it. For every block and item the mod registe (Horizon places the template as vanilla does, which the vanilla harnesses check); - tags: the vanilla block tags (tool and mining tags) holding the mod's blocks; - recipes: each recipe's result, count and ingredients; -- items: present, and their stack size. +- items: present, and their stack size; +- item stats: durability, attribute modifiers (damage, speed, armour), tool rules, enchantability, repair + items, armour slot and food, from the oracle's 1.21.1 components with ids upgraded to 26.3's. Each mod is probed on its own, against vanilla. Writes WORK/mods//harness/{horizon,result}.json per mod and prints a table. Jars, facts and results stay @@ -29,7 +31,7 @@ import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -CHECKS = ['blocks', 'properties', 'sounds', 'shapes', 'drops', 'placement', 'tags', 'recipes', 'items'] +CHECKS = ['blocks', 'properties', 'sounds', 'shapes', 'drops', 'placement', 'tags', 'recipes', 'items', 'item_stats'] EXAMPLES = 8 @@ -87,6 +89,44 @@ def sound_upgrades(oracle, horizon): return out +def modifiers(entries): + """Attribute modifiers as comparable tuples, 1.21.1's `generic.` attribute prefix dropped.""" + return sorted((m['type'].replace(':generic.', ':'), m['id'], round(m['amount'], 4), m['operation'], m['slot']) + for m in entries) + + +def tool_rules(rules): + blocks = lambda b: tuple(sorted([b] if isinstance(b, str) else b)) + return {blocks(r['blocks']): (r.get('speed'), r.get('correct_for_drops')) for r in rules} + + +def item_stats(oi, hi): + """(ok, what) per stat of an item the oracle has components for.""" + c = oi['components'] + yield hi['max_damage'] == c.get('minecraft:max_damage', 0), f'durability {hi["max_damage"]}' + want = modifiers(oi.get('attribute_modifiers', {}).get('modifiers', [])) + yield modifiers(hi['modifiers']) == want, f'modifiers {modifiers(hi["modifiers"])}, want {want}' + tool = c.get('minecraft:tool') + if tool is None: + yield hi['tool'] is None, 'a tool' + elif hi['tool'] is None: + yield False, 'no tool' + else: + have = tool_rules(hi['tool']['rules']) + # 26.3 templates may add vanilla's newer rules; the mod's own must all be there. + for blocks, rule in tool_rules(tool['rules']).items(): + yield have.get(blocks) == rule, f'tool rule {blocks}: {have.get(blocks)}, want {rule}' + yield hi['tool']['default_mining_speed'] == tool.get('default_mining_speed', 1.0), 'default mining speed' + yield hi['tool']['damage_per_block'] == tool.get('damage_per_block', 1), 'damage per block' + yield hi['enchantable'] == oi.get('enchantment_value', 0), f'enchantability {hi["enchantable"]}' + if 'repair_items' in oi: + yield sorted(hi['repairable']) == sorted(oi['repair_items']), f'repair items {hi["repairable"]}' + if 'armor' in oi: + yield hi['equip_slot'] == oi['armor']['slot'], f'armour slot {hi["equip_slot"]}' + if 'food' in oi: + yield (hi['food'] or {}).get('nutrition') == oi['food']['nutrition'], f'food {hi["food"]}' + + def compare_mod(mod, oracle, horizon, carry, upgrades, old_items): checks = {c: Check() for c in CHECKS} prefix = mod + ':' @@ -156,6 +196,9 @@ def compare_mod(mod, oracle, horizon, carry, upgrades, old_items): hi = horizon['items'].get(iid) checks['items'].record(hi is not None and hi['max_stack_size'] == oi['max_stack_size'], f'{iid}: {"missing" if hi is None else "stack " + str(hi["max_stack_size"])}') + if hi is not None and 'components' in oi and oi.get('block') is None: + for ok, what in item_stats(oi, hi): + checks['item_stats'].record(ok, f'{iid}: {what}') result = {c: v.json() for c, v in checks.items()} failed = sum(v.failed for v in checks.values()) total = sum(v.passed + v.failed for v in checks.values()) From d39715cca0db7637598449a67deb57cdcc6d9362 Mon Sep 17 00:00:00 2001 From: umceko Date: Thu, 29 Oct 2026 16:47:45 +0300 Subject: [PATCH 041/968] Port pack: items of vanilla tool and armour classes keep their stats With the oracle's facts, every item came over as a stick: a ported sword had no damage, no durability and no mining rules. An item of a vanilla tool or armour class now gets the iron item of its class and slot as its template, and the oracle's durability, attribute modifiers (1.21.1's ids upgraded), tool rules, enchantability and repair items as a component patch, minus what the template already has. The mod's tool rules replace the template's whole (its tier names its own incorrect-for-drops tag); what 1.21.1 kept in the class, a sword sparing blocks in creative, stays the template's. Simplest Emerald Gear's item stats: 4/49 -> 64/64 against the mod on NeoForge. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-port-pack/src/oracle.rs | 194 ++++++++++++++++++++++- crates/horizon-port-pack/src/pipeline.rs | 5 +- docs/modpacks.md | 6 +- 3 files changed, 200 insertions(+), 5 deletions(-) diff --git a/crates/horizon-port-pack/src/oracle.rs b/crates/horizon-port-pack/src/oracle.rs index 7e04aa9ef9..24749d7643 100644 --- a/crates/horizon-port-pack/src/oracle.rs +++ b/crates/horizon-port-pack/src/oracle.rs @@ -17,6 +17,9 @@ pub struct Facts { pub items: BTreeMap, #[serde(default)] pub tags: Tags, + /// The game's items' default components (`items.json`), so item ports leave out what their template has. + #[serde(skip)] + pub vanilla_items: BTreeMap, } #[derive(Debug, Default, Deserialize)] @@ -75,6 +78,56 @@ pub struct Item { pub block: Option, #[serde(default)] pub food: Option, + /// The default data components in 1.21.1's form; empty in facts from before they were exported. + #[serde(default)] + pub components: Map, + #[serde(default)] + pub attribute_modifiers: Option, + #[serde(default)] + pub enchantment_value: Option, + #[serde(default)] + pub repair_items: Option>, + #[serde(default)] + pub armor: Option, +} + +#[derive(Debug, Deserialize)] +pub struct Armor { + /// `head`, `chest`, `legs` or `feet`. + pub slot: String, +} + +/// The vanilla item standing in for an item of a vanilla class, for the behaviour the class gives. +fn item_template(item: &Item) -> &'static str { + let class = item.class.strip_prefix("net.minecraft.world.item."); + match (class, item.armor.as_ref().map(|a| a.slot.as_str())) { + (Some("SwordItem"), _) => "minecraft:iron_sword", + (Some("PickaxeItem"), _) => "minecraft:iron_pickaxe", + (Some("AxeItem"), _) => "minecraft:iron_axe", + (Some("ShovelItem"), _) => "minecraft:iron_shovel", + (Some("HoeItem"), _) => "minecraft:iron_hoe", + (Some("ArmorItem"), Some("head")) => "minecraft:iron_helmet", + (Some("ArmorItem"), Some("chest")) => "minecraft:iron_chestplate", + (Some("ArmorItem"), Some("legs")) => "minecraft:iron_leggings", + (Some("ArmorItem"), Some("feet")) => "minecraft:iron_boots", + _ => "minecraft:stick", + } +} + +/// 1.21.1's `{"modifiers": [...]}` as 26.3's list, with attribute ids' `generic.` and `player.` dropped. +fn modifiers_26_3(v: &Value) -> Value { + let list = v["modifiers"].as_array().cloned().unwrap_or_default(); + Value::Array( + list.into_iter() + .map(|mut m| { + if let Some(t) = m["type"].as_str() { + let t = t.replacen(":generic.", ":", 1).replacen(":player.", ":", 1); + m["type"] = json!(t); + } + m + }) + .collect(), + ) } const VANILLA_PACKAGE: &str = "net.minecraft."; @@ -184,6 +237,20 @@ pub struct Choice { pub differences: Vec, } +/// Equal JSON, numbers compared as f64 (the oracle writes `6.0` where the game's table has `6`). +fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), + (Value::Array(x), Value::Array(y)) => { + x.len() == y.len() && x.iter().zip(y).all(|(x, y)| same(x, y)) + } + (Value::Object(x), Value::Object(y)) => { + x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))) + } + _ => a == b, + } +} + impl Facts { pub fn read(path: &Path) -> Result { let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -323,8 +390,8 @@ impl Facts { } /// An item declaration for a mod item that is not a declared block's: - /// a vanilla template item with its stack size and rarity, and what it - /// does not reproduce. + /// the vanilla item of its class with its stack size, rarity and numbers, + /// and what it does not reproduce. pub fn item(&self, id: &str) -> Result<(Value, Vec), String> { let item = self.items.get(id).ok_or("not in the oracle")?; if item.block.is_some() { @@ -337,16 +404,81 @@ impl Facts { if item.food.is_some() { return Err("food (no food template yet)".into()); } - let mut decl = json!({"template": "minecraft:stick"}); + let template = item_template(item); + let mut decl = json!({"template": template}); if item.max_stack_size != 64 { decl["max_stack_size"] = json!(item.max_stack_size); } if item.rarity != "common" { decl["rarity"] = json!(item.rarity); } + let components = self.item_components(item, template); + if !components.is_empty() { + decl["components"] = Value::Object(components); + } Ok((decl, differences)) } + /// The item's numbers as a 26.3 component patch over `template`'s, leaving out what already matches. + fn item_components(&self, item: &Item, template: &str) -> Map { + if item.components.is_empty() { + return Map::new(); + } + let base = self.vanilla_items.get(template); + let mut want = Map::new(); + want.insert( + "minecraft:max_damage".into(), + item.components + .get("minecraft:max_damage") + .cloned() + .unwrap_or(Value::Null), + ); + if let Some(m) = &item.attribute_modifiers { + want.insert("minecraft:attribute_modifiers".into(), modifiers_26_3(m)); + } + // The mod's rules as they are; what 1.21.1 kept in the class (can_destroy_blocks_in_creative) stays the template's. + let tool = item.components.get("minecraft:tool").map(|t| { + let mut tool = base + .and_then(|b| b.get("minecraft:tool")) + .cloned() + .unwrap_or_else(|| json!({})); + for key in ["rules", "default_mining_speed", "damage_per_block"] { + match t.get(key) { + Some(v) => tool[key] = v.clone(), + None => _ = tool.as_object_mut().map(|o| o.remove(key)), + } + } + tool + }); + want.insert("minecraft:tool".into(), tool.unwrap_or(Value::Null)); + if let Some(value) = item.enchantment_value { + let enchantable = (value > 0).then(|| json!({"value": value})); + want.insert( + "minecraft:enchantable".into(), + enchantable.unwrap_or(Value::Null), + ); + } + if let Some(repair) = &item.repair_items { + let items = match repair.as_slice() { + [] => Value::Null, + [one] => json!({"items": one}), + many => json!({"items": many}), + }; + want.insert("minecraft:repairable".into(), items); + } + let mut patch = Map::new(); + for (key, value) in want { + let had = base.and_then(|b| b.get(&key)); + match (value, had) { + (Value::Null, None) => {} + (Value::Null, Some(_)) => _ = patch.insert(format!("!{key}"), json!({})), + (v, Some(h)) if same(&v, h) => {} + (v, _) => _ = patch.insert(key, v), + } + } + patch + } + /// A tag's members the content mod may name: vanilla's and its own. pub fn tag_members(&self, kind: &str, tag: &str, own: &str) -> Option> { let map = if kind == "block" { @@ -492,6 +624,62 @@ mod tests { assert_eq!(door(&json!({"hand": {"m:other": 1}})), None); } + #[test] + fn items_of_vanilla_classes_take_their_template_and_numbers() { + let mut facts: Facts = serde_json::from_value(json!({ + "blocks": {}, + "items": { + "m:sword": {"class": "net.minecraft.world.item.SwordItem", "max_stack_size": 1, "rarity": "common", + "components": {"minecraft:max_damage": 905, "minecraft:tool": {"rules": [ + {"blocks": "minecraft:cobweb", "speed": 15.0, "correct_for_drops": true}], "damage_per_block": 2}}, + "attribute_modifiers": {"modifiers": [{"type": "minecraft:generic.attack_damage", + "id": "minecraft:base_attack_damage", "amount": 5.5, "operation": "add_value", "slot": "mainhand"}]}, + "enchantment_value": 14, "repair_items": ["minecraft:emerald"]}, + "m:boots": {"class": "net.minecraft.world.item.ArmorItem", "max_stack_size": 1, "rarity": "common", + "components": {"minecraft:max_damage": 1}, "enchantment_value": 0, "repair_items": [], + "armor": {"slot": "feet"}}, + "m:gem": {"class": "net.minecraft.world.item.Item", "max_stack_size": 64, "rarity": "common"}, + } + })) + .unwrap(); + facts.vanilla_items = serde_json::from_value(json!({ + "minecraft:iron_sword": {"minecraft:max_damage": 250, "minecraft:enchantable": {"value": 14}, + "minecraft:repairable": {"items": "#minecraft:iron_tool_materials"}, + "minecraft:tool": {"can_destroy_blocks_in_creative": false, "damage_per_block": 2, + "rules": [{"blocks": "#minecraft:sword_efficient", "speed": 1.5}]}}, + "minecraft:iron_boots": {"minecraft:max_damage": 195, "minecraft:enchantable": {"value": 9}, + "minecraft:repairable": {"items": "#minecraft:repairs_iron_armor"}}, + })) + .unwrap(); + let (sword, _) = facts.item("m:sword").unwrap(); + assert_eq!(sword["template"], "minecraft:iron_sword"); + let c = &sword["components"]; + assert_eq!(c["minecraft:max_damage"], 905); + assert_eq!( + c["minecraft:attribute_modifiers"][0]["type"], + "minecraft:attack_damage" + ); + assert_eq!( + c["minecraft:repairable"], + json!({"items": "minecraft:emerald"}) + ); + // The mod's rules replace the template's; the creative flag the class gave stays. + assert_eq!( + c["minecraft:tool"], + json!({"can_destroy_blocks_in_creative": false, "damage_per_block": 2, + "rules": [{"blocks": "minecraft:cobweb", "speed": 15.0, "correct_for_drops": true}]}) + ); + // Equal to the template's: left out. + assert!(c.get("minecraft:enchantable").is_none()); + let (boots, _) = facts.item("m:boots").unwrap(); + assert_eq!(boots["template"], "minecraft:iron_boots"); + assert_eq!(boots["components"]["!minecraft:enchantable"], json!({})); + assert_eq!(boots["components"]["!minecraft:repairable"], json!({})); + // Facts without components (exported before they were) keep the plain template. + let (gem, _) = facts.item("m:gem").unwrap(); + assert_eq!(gem, json!({"template": "minecraft:stick"})); + } + #[test] fn state_keys_sort() { assert_eq!( diff --git a/crates/horizon-port-pack/src/pipeline.rs b/crates/horizon-port-pack/src/pipeline.rs index c34f584112..d2053a3b52 100644 --- a/crates/horizon-port-pack/src/pipeline.rs +++ b/crates/horizon-port-pack/src/pipeline.rs @@ -102,7 +102,10 @@ pub fn carry( let records: Vec = read_json(&work.join("inventory.json"))?; let facts_path = work.join("oracle/facts.json"); let facts = if facts_path.exists() { - let f = oracle::Facts::read(&facts_path)?; + let mut f = oracle::Facts::read(&facts_path)?; + if let Some(r) = resources { + f.vanilla_items = read_json(&r.join("items.json"))?; + } log(format!( "oracle facts: {} blocks, {} items", f.blocks.len(), diff --git a/docs/modpacks.md b/docs/modpacks.md index 02d3aa3282..971ccb022d 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -365,7 +365,11 @@ resistance, light, tool need, friction, map colour and sounds as overrides. `--r ` limits templates to blocks Horizon's tables have. `carry-report.json` lists, per block, what the template does not reproduce (other shapes, sounds no vanilla block has, a class of its own), and the blocks left out: those whose vanilla class has no vanilla block with the -same states (Macaw's garage doors, multi-part gates). Recipes get the convention tags they use +same states (Macaw's garage doors, multi-part gates). An item of a vanilla tool or armour class +(`SwordItem`, `PickaxeItem`, `ArmorItem`, ...) gets the iron item of that class and slot as its +template, and the oracle's durability, attribute modifiers, tool rules, enchantability and repair +items as a component patch over it, minus what the template already has (`--resources` gives the +template's components); other items keep `minecraft:stick`. Recipes get the convention tags they use (`c:dyes/red`), filled from the oracle with vanilla's and the mod's own members. Without the oracle, templates are guessed from the blockstate's properties: the template whose properties include them with the fewest left over (models often ignore `powered` or `waterlogged`), the From a9d14449e858d65fa94fe269c2d2ab54e64c2077 Mon Sep 17 00:00:00 2001 From: umceko Date: Thu, 29 Oct 2026 16:51:29 +0300 Subject: [PATCH 042/968] Equipment assets and their textures load from content mods' namespaces Worn armour found its equipment asset only under minecraft, and the atlas packed only minecraft's entity textures, so a content mod's armour could not draw as itself. EquipmentAssetManager reads `equipment/` in every namespace: the armour layers now load from the content mods' namespaces too, under `:`, and their textures pack as `:entity/equipment//`. A root without content mods reads exactly what it did. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/hud.rs | 53 ++++++++++++++++--- .../horizon-client/src/game/armor_stands.rs | 30 +++++++---- .../src/game/entity_model_loading_tests.rs | 28 ++++++++++ 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index d083fcdc5c..45c69d4af9 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -393,18 +393,31 @@ impl HudAtlas { for folder in folders { walk(&textures.join(folder), &mut files); } + // Content mods' equipment textures, which their equipment assets name as `:`. + if folders.contains(&"entity") { + for namespace in horizon_items::content_namespaces::namespaces(root) { + walk( + &root.join(format!("assets/{namespace}/textures/entity/equipment")), + &mut files, + ); + } + } + let assets = root.join("assets"); files.retain(|p| { let s = p.to_string_lossy(); !s.contains("title/background") && !s.contains("realms") && !s.contains("presets") }); type Decoded = (PackedImage, Option, Option<(u32, u32)>, bool); let decode_one = |path: &PathBuf| -> Option { - let name = path - .strip_prefix(&textures) - .ok()? - .with_extension("") - .to_string_lossy() - .replace('\\', "/"); + let name = match path.strip_prefix(&textures) { + Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"), + Err(_) => { + let rel = path.strip_prefix(&assets).ok()?.with_extension(""); + let rel = rel.to_string_lossy().replace('\\', "/"); + let (namespace, rest) = rel.split_once("/textures/")?; + format!("{namespace}:{rest}") + } + }; let (mut w, mut h, pixels) = decode(path).ok()?; let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok(); let animation = mcmeta @@ -1710,6 +1723,34 @@ mod tests { atlas.pixels[at..at + 4].try_into().unwrap() } #[test] + fn content_mod_equipment_textures_pack_under_their_namespace() { + let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let equipment = root.join("assets/gems/textures/entity/equipment/humanoid"); + fs::create_dir_all(&equipment).unwrap(); + write_strip( + &equipment.join("ruby.png"), + 64, + 32, + &[[200, 0, 0, 255]], + None, + ); + let other = root.join("assets/gems/textures/item"); + fs::create_dir_all(&other).unwrap(); + write_strip(&other.join("ruby.png"), 16, 16, &[[0, 200, 0, 255]], None); + fs::write( + root.join(horizon_items::content_namespaces::MARKER), + br#"["gems"]"#, + ) + .unwrap(); + let atlas = HudAtlas::build(&root).unwrap(); + let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"]; + assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]); + // Only equipment: the mods' other textures reach the atlas through atlas definitions. + assert!(!atlas.sprites.contains_key("gems:item/ruby")); + fs::remove_dir_all(&root).unwrap(); + } + #[test] fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() { let root = animated_root(); let atlas = HudAtlas::build(&root).unwrap(); diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 4d8a9fd31a..376a0237f1 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -70,7 +70,14 @@ impl ArmorStandAssets { } let mut equipment = HashMap::new(); let mut trim_overrides = HashMap::new(); - if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) { + // EquipmentAssetManager reads every namespace: vanilla's and the content mods'. + let namespaces = std::iter::once("minecraft".to_owned()) + .chain(horizon_client::mod_content::namespaces(root)); + for namespace in namespaces { + let Ok(entries) = std::fs::read_dir(root.join(format!("assets/{namespace}/equipment"))) + else { + continue; + }; for e in entries.flatten() { let path = e.path(); let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { @@ -82,24 +89,25 @@ impl ArmorStandAssets { continue; }; let Ok(v) = v else { continue }; + let id = format!("{namespace}:{name}"); let mut kinds = HashMap::new(); for kind in ["humanoid", "humanoid_leggings", "wings"] { if let Some(layer) = v["layers"][kind].get(0) { - let tex = layer["texture"] - .as_str() - .unwrap_or(name) - .trim_start_matches("minecraft:"); + let tex = layer["texture"].as_str().unwrap_or(&id); + let (tex_namespace, tex) = + tex.split_once(':').unwrap_or(("minecraft", tex)); + let sprite = match tex_namespace { + "minecraft" => format!("entity/equipment/{kind}/{tex}"), + other => format!("{other}:entity/equipment/{kind}/{tex}"), + }; let undyed = layer["dyeable"]["color_when_undyed"] .as_i64() .map(|c| c as u32 & 0xFF_FFFF); - kinds.insert( - kind.to_owned(), - (format!("entity/equipment/{kind}/{tex}"), undyed), - ); + kinds.insert(kind.to_owned(), (sprite, undyed)); } } - trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v)); - equipment.insert(format!("minecraft:{name}"), kinds); + trim_overrides.insert(id.clone(), TrimOverride::list(&v)); + equipment.insert(id, kinds); } } Self { diff --git a/crates/horizon-client/src/game/entity_model_loading_tests.rs b/crates/horizon-client/src/game/entity_model_loading_tests.rs index 61ed814912..2012a8c0cb 100644 --- a/crates/horizon-client/src/game/entity_model_loading_tests.rs +++ b/crates/horizon-client/src/game/entity_model_loading_tests.rs @@ -224,6 +224,34 @@ fn armor_equipment_loads_without_an_entity_model_document() { assert!(!assets.equipment.contains_key("minecraft:invalid")); } +#[test] +fn content_mods_equipment_loads_under_its_namespace() { + let root = FixtureRoot::new(); + let equipment = root.0.join("assets/gems/equipment"); + fs::create_dir_all(&equipment).unwrap(); + fs::write( + equipment.join("ruby.json"), + serde_json::to_vec(&json!({"layers": { + "humanoid": [{"texture": "gems:ruby"}], + "humanoid_leggings": [{"texture": "gems:ruby"}] + }})) + .unwrap(), + ) + .unwrap(); + // Not a content root: other namespaces stay unread, as before. + assert!(ArmorStandAssets::load(&root.0, None).equipment.is_empty()); + fs::write( + root.0.join(horizon_items::content_namespaces::MARKER), + br#"["gems"]"#, + ) + .unwrap(); + let assets = ArmorStandAssets::load(&root.0, None); + assert_eq!( + assets.equipment["gems:ruby"]["humanoid_leggings"], + ("gems:entity/equipment/humanoid_leggings/ruby".into(), None) + ); +} + #[test] #[ignore = "opt-in benchmark; requires ENTITY_MODEL_BENCH_DOCUMENT"] fn entity_model_document_benchmark() { From a91763379f4c26106576028061306fd057f52500 Mon Sep 17 00:00:00 2001 From: spider <51022611+spidercraft@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:30:01 +0100 Subject: [PATCH 043/968] FSR 3.1 optical flow, ported to WGSL (frame generation 1/3) AMD FidelityFX SDK 1.1.4's optical flow (MIT): luma pyramids, scene change detection, and a 7-level block search with filter and scale, as WGSL compute passes that run on WebGPU's default limits. Not used by the game yet; the interpolation and the setting follow. Co-Authored-By: Claude Opus 5.5 --- THIRD_PARTY_NOTICES.md | 8 + crates/horizon-client-render/src/gfx.rs | 1 + .../src/gfx/frame_gen.rs | 7 + .../src/gfx/frame_gen/optical_flow.rs | 631 ++++++++++++++++++ .../src/gfx/frame_gen/optical_flow.wgsl | 537 +++++++++++++++ 5 files changed, 1184 insertions(+) create mode 100644 crates/horizon-client-render/src/gfx/frame_gen.rs create mode 100644 crates/horizon-client-render/src/gfx/frame_gen/optical_flow.rs create mode 100644 crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index e83ccd8403..4473c22fda 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -19,3 +19,11 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1 [Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on the same grid. + +## AMD FidelityFX SDK (FSR 3.1 frame generation) + +`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the +optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) +1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`), Copyright (C) 2024 Advanced Micro Devices, +Inc., under the MIT license, whose text heads the file. AMD and FidelityFX are AMD's +trademarks; Horizon is not affiliated with AMD. diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 1d7cba6308..9546885e95 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -99,6 +99,7 @@ mod timing; pub mod translucency; pub(crate) mod upload; mod wgsl_prefix; +mod frame_gen; mod world_post; /// A column whose every section has its mesh (`Renderer::lod_detail_columns`). diff --git a/crates/horizon-client-render/src/gfx/frame_gen.rs b/crates/horizon-client-render/src/gfx/frame_gen.rs new file mode 100644 index 0000000000..d2c2d2be2d --- /dev/null +++ b/crates/horizon-client-render/src/gfx/frame_gen.rs @@ -0,0 +1,7 @@ +//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each +//! two real ones. So far this holds its optical flow; the interpolation and the setting +//! that runs them come next. + +// Unused until the interpolation and the renderer use it. +#[allow(dead_code)] +mod optical_flow; diff --git a/crates/horizon-client-render/src/gfx/frame_gen/optical_flow.rs b/crates/horizon-client-render/src/gfx/frame_gen/optical_flow.rs new file mode 100644 index 0000000000..40d9cdfead --- /dev/null +++ b/crates/horizon-client-render/src/gfx/frame_gen/optical_flow.rs @@ -0,0 +1,631 @@ +//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to +//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this +//! one's, and scene change detection, for frame interpolation (`interpolation.rs`). +//! +//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged +//! between frames; a histogram of the luma for scene change detection; then from the +//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and +//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma +//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup +//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD). +//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a +//! scene change and no motion, and rewrite them. +//! +//! The vectors point from a block of this frame to where it was in the previous one, in +//! display pixels: content moving right by 4 pixels gives (-4, 0). + +/// The size in pixels of the square blocks the flow has one vector for. +pub const BLOCK_SIZE: u32 = 8; +/// `OpticalFlowMaxPyramidLevels`: the search's levels. +const LEVELS: usize = 7; +/// `HistogramBins * HistogramsPerDim²`: the scene change histograms. +const HISTOGRAM_WIDTH: u64 = 256 * 9; + +/// `optical_flow.wgsl`'s `Params`. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct Params { + display: [i32; 2], + level: u32, + level_count: u32, + frame_index: u32, + transfer_function: u32, + min_max_luminance: [f32; 2], +} + +struct Pipelines { + prepare_luma: wgpu::ComputePipeline, + luma_pyramid: wgpu::ComputePipeline, + scd_histogram: wgpu::ComputePipeline, + scd_divergence: wgpu::ComputePipeline, + search: wgpu::ComputePipeline, + filter: wgpu::ComputePipeline, + scale: wgpu::ComputePipeline, +} + +/// One frame parity's bindings: which luma pyramid is this frame's. +struct Frame { + /// This frame's luma at the display size, where `prepare_luma` writes. + luma0: wgpu::TextureView, + luma_pyramid: [wgpu::BindGroup; LEVELS - 1], + scd_histogram: wgpu::BindGroup, + search: [wgpu::BindGroup; LEVELS], + scale: [wgpu::BindGroup; LEVELS - 1], +} + +pub struct OpticalFlow { + display: [u32; 2], + pipelines: Pipelines, + /// One per level, the level's constants (the passes outside the search use level 0's). + params: Vec, + /// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1. + frames: [Frame; 2], + filter: [wgpu::BindGroup; LEVELS], + scd_divergence: wgpu::BindGroup, + scd_histogram: wgpu::Buffer, + scd_previous_histogram: wgpu::Buffer, + scd_temp: wgpu::Buffer, + scd_output: wgpu::Buffer, + vectors: wgpu::TextureView, + flow_sizes: [[u32; 2]; LEVELS], + /// `resourceFrameIndex` and `frameIndex`. + resource_frame: u32, + frame_index: u32, + first: bool, +} + +fn texture( + device: &wgpu::Device, + label: &str, + size: [u32; 2], + format: wgpu::TextureFormat, +) -> wgpu::TextureView { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: size[0].max(1), + height: size[1].max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::STORAGE_BINDING + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }) + .create_view(&Default::default()) +} + +fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) +} + +/// Bind `entries` (binding number, resource) for `pipeline`'s own layout. +fn bind( + device: &wgpu::Device, + pipeline: &wgpu::ComputePipeline, + entries: &[(u32, wgpu::BindingResource)], +) -> wgpu::BindGroup { + let entries: Vec<_> = entries + .iter() + .map(|(binding, resource)| wgpu::BindGroupEntry { + binding: *binding, + resource: resource.clone(), + }) + .collect(); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Optical flow"), + layout: &pipeline.get_bind_group_layout(0), + entries: &entries, + }) +} + +fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> { + wgpu::BindingResource::TextureView(v) +} + +fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> { + b.as_entire_binding() +} + +impl OpticalFlow { + /// For frames of `display` size. + pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Optical flow"), + source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()), + }); + let pipeline = |entry: &str| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: None, + module: &module, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) + }; + let pipelines = Pipelines { + prepare_luma: pipeline("prepare_luma"), + luma_pyramid: pipeline("luma_pyramid"), + scd_histogram: pipeline("scd_histogram_pass"), + scd_divergence: pipeline("scd_divergence_pass"), + search: pipeline("search_pass"), + filter: pipeline("filter_flow"), + scale: pipeline("scale_pass"), + }; + let params: Vec<_> = (0..LEVELS) + .map(|_| { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Optical flow constants"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + }) + .collect(); + let luma: [Vec; 2] = [0, 1].map(|_| { + (0..LEVELS) + .map(|k| { + let size = display.map(|v| (v >> k).max(1)); + texture( + device, + "Optical flow luma", + size, + wgpu::TextureFormat::R32Float, + ) + }) + .collect() + }); + let mut flow_sizes = [[0; 2]; LEVELS]; + flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE)); + for l in 1..LEVELS { + flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2)); + } + let flow = |label| -> Vec { + flow_sizes + .iter() + .map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint)) + .collect() + }; + // Per level: the scaled prediction from the level above, the search's vectors and + // the filtered ones (level 0's are the output). + let (predicted, searched, filtered) = ( + flow("Optical flow prediction"), + flow("Optical flow search"), + flow("Optical flow"), + ); + let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4); + let scd_previous_histogram = buffer( + device, + "Scene change previous histogram", + HISTOGRAM_WIDTH * 4, + ); + let scd_temp = buffer(device, "Scene change temp", 12); + let scd_output = buffer(device, "Scene change detection", 12); + let frame = |current: usize| { + let (cur, prev) = (&luma[current], &luma[1 - current]); + Frame { + luma0: cur[0].clone(), + luma_pyramid: std::array::from_fn(|k| { + bind( + device, + &pipelines.luma_pyramid, + &[(2, view(&cur[k + 1])), (3, view(&cur[k]))], + ) + }), + scd_histogram: bind( + device, + &pipelines.scd_histogram, + &[ + (0, whole(¶ms[0])), + (3, view(&cur[0])), + (7, whole(&scd_histogram)), + ], + ), + search: std::array::from_fn(|l| { + bind( + device, + &pipelines.search, + &[ + (0, whole(¶ms[l])), + (3, view(&cur[l])), + (4, view(&prev[l])), + (5, view(&predicted[l])), + (6, view(&searched[l])), + (10, whole(&scd_output)), + ], + ) + }), + scale: std::array::from_fn(|i| { + let l = i + 1; + bind( + device, + &pipelines.scale, + &[ + (0, whole(¶ms[l])), + (3, view(&cur[l])), + (4, view(&prev[l])), + (5, view(&filtered[l])), + (6, view(&predicted[l - 1])), + (10, whole(&scd_output)), + ], + ) + }), + } + }; + let frames = [frame(0), frame(1)]; + let filter = std::array::from_fn(|l| { + bind( + device, + &pipelines.filter, + &[(5, view(&searched[l])), (6, view(&filtered[l]))], + ) + }); + let scd_divergence = bind( + device, + &pipelines.scd_divergence, + &[ + (7, whole(&scd_histogram)), + (8, whole(&scd_previous_histogram)), + (9, whole(&scd_temp)), + (10, whole(&scd_output)), + ], + ); + Self { + display, + pipelines, + params, + frames, + filter, + scd_divergence, + scd_histogram, + scd_previous_histogram, + scd_temp, + scd_output, + vectors: filtered[0].clone(), + flow_sizes, + resource_frame: 0, + frame_index: 0, + first: true, + } + } + + /// The flow from the previous call's `color` to this one's. `color`: `display` size, + /// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's + /// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first + /// frame, resize, teleport). + pub fn dispatch( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + color: &wgpu::TextureView, + reset: bool, + ) { + let reset = reset || self.first; + self.first = false; + self.frame_index = if reset { 0 } else { self.frame_index + 1 }; + if reset { + for b in [ + &self.scd_temp, + &self.scd_output, + &self.scd_histogram, + &self.scd_previous_histogram, + ] { + encoder.clear_buffer(b, 0, None); + } + } + for (level, buffer) in self.params.iter().enumerate() { + let params = Params { + display: self.display.map(|v| v as i32), + level: level as u32, + level_count: LEVELS as u32, + frame_index: self.frame_index, + transfer_function: 0, + min_max_luminance: [0.0, 1.0], + }; + queue.write_buffer(buffer, 0, bytemuck::bytes_of(¶ms)); + } + let frame = &self.frames[(self.resource_frame & 1) as usize]; + let prepare = bind( + device, + &self.pipelines.prepare_luma, + &[ + (0, whole(&self.params[0])), + (1, view(color)), + (2, view(&frame.luma0)), + ], + ); + let p = &self.pipelines; + let [w, h] = self.display; + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("Optical flow"), + timestamp_writes: None, + }); + let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, group, &[]); + pass.dispatch_workgroups(x, y, z); + }; + run( + &p.prepare_luma, + &prepare, + (w.div_ceil(2)).div_ceil(16), + (h.div_ceil(2)).div_ceil(16), + 1, + ); + for (k, group) in frame.luma_pyramid.iter().enumerate() { + let size = self.display.map(|v| (v >> (k + 1)).max(1)); + run( + &p.luma_pyramid, + group, + size[0].div_ceil(8), + size[1].div_ceil(8), + 1, + ); + } + run( + &p.scd_histogram, + &frame.scd_histogram, + ((w / 4) / 3).div_ceil(32), + 16, + 9, + ); + run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1); + for level in (0..LEVELS).rev() { + let luma = self.display.map(|v| (v >> level).max(1)); + run( + &p.search, + &frame.search[level], + (luma[0].div_ceil(4) * 16).div_ceil(64), + luma[1].div_ceil(16), + 1, + ); + let size = self.flow_sizes[level]; + run( + &p.filter, + &self.filter[level], + size[0].div_ceil(16), + size[1].div_ceil(4), + 1, + ); + if level > 0 { + let next = self.flow_sizes[level - 1]; + run( + &p.scale, + &frame.scale[level - 1], + next[0].div_ceil(4), + next[1].div_ceil(4), + 1, + ); + } + } + drop(pass); + self.resource_frame = (self.resource_frame + 1) % 16; + } + + /// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size + /// over `BLOCK_SIZE` rounded up, + /// holding the values FSR's `R16G16_SINT` optical flow vector texture holds. + pub fn vectors(&self) -> &wgpu::TextureView { + &self.vectors + } + + /// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage + /// buffer of 3 `u32`: element `i` holds texel `i`'s value. + pub fn scene_change(&self) -> &wgpu::Buffer { + &self.scd_output + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + #[test] + fn shader_validates() { + let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::empty(), + ) + .validate(&module) + .unwrap(); + } + + /// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None` + /// without a GPU. + pub 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() + } + + /// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`. + /// A `seed` of 100 or more gives cells of one primary each. + pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec { + let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize); + for y in 0..size[1] as i32 { + for x in 0..size[0] as i32 { + let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4)); + let mut h = (cx as u32).wrapping_mul(0x9e37_79b9) + ^ (cy as u32).wrapping_mul(0x85eb_ca6b) + ^ seed.wrapping_mul(0xc2b2_ae35); + h ^= h >> 15; + h = h.wrapping_mul(0x2c1b_3c6d); + h ^= h >> 12; + let v = (h & 0xff) as u8; + if seed >= 100 { + // Primaries: no two neighbours' colours point the same way. + let mut c = [0, 0, 0, 255]; + c[(h >> 8) as usize % 3] = 128 + v / 2; + pixels.extend_from_slice(&c); + } else { + pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]); + } + } + } + pixels + } + + pub fn upload( + device: &wgpu::Device, + queue: &wgpu::Queue, + size: [u32; 2], + pixels: &[u8], + ) -> wgpu::TextureView { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: None, + 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: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + texture.as_image_copy(), + pixels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(size[0] * 4), + rows_per_image: None, + }, + texture.size(), + ); + texture.create_view(&Default::default()) + } + + /// `texture`'s texels (`texel` bytes each), row by row. + pub fn read( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + texel: u32, + ) -> Vec { + let size = texture.size(); + let row = (size.width * texel).next_multiple_of(256); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(row * size.height), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(row), + rows_per_image: None, + }, + }, + size, + ); + queue.submit([encoder.finish()]); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let mapped = buffer.slice(..).get_mapped_range().unwrap(); + mapped + .chunks(row as usize) + .flat_map(|r| r[..(size.width * texel) as usize].to_vec()) + .collect() + } + + fn run( + device: &wgpu::Device, + queue: &wgpu::Queue, + flow: &mut OpticalFlow, + color: &wgpu::TextureView, + reset: bool, + ) { + let mut encoder = device.create_command_encoder(&Default::default()); + flow.dispatch(device, queue, &mut encoder, color, reset); + queue.submit([encoder.finish()]); + } + + fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> { + read(device, queue, flow.vectors.texture(), 8) + .chunks(8) + .map(|c| { + [ + i32::from_le_bytes(c[0..4].try_into().unwrap()), + i32::from_le_bytes(c[4..8].try_into().unwrap()), + ] + }) + .collect() + } + + /// A frame moved right by 16 and down by 8: the blocks inside report where they were + /// (-16, -8), and nothing reads as a scene change. A much darker frame then does. + #[test] + fn finds_a_shift_and_a_scene_change() { + let Some((device, queue)) = device() else { + return; + }; + let size = [512, 288]; + let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0])); + let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8])); + // Scene changes are told by the luma's histogram: a much darker frame. + let dark: Vec = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect(); + let other = upload(&device, &queue, size, &dark); + let mut flow = OpticalFlow::new(&device, size); + // The first 6 frames after a reset are scene changes. + run(&device, &queue, &mut flow, &still, true); + for _ in 0..6 { + run(&device, &queue, &mut flow, &still, false); + } + run(&device, &queue, &mut flow, &moved, false); + let [w, h] = flow.flow_sizes[0]; + let found = vectors(&device, &queue, &flow); + let inside: Vec<_> = (4..h - 4) + .flat_map(|y| (4..w - 4).map(move |x| (x, y))) + .map(|(x, y)| found[(y * w + x) as usize]) + .collect(); + let right = inside.iter().filter(|v| **v == [-16, -8]).count(); + assert!( + right * 10 >= inside.len() * 9, + "{right} of {} blocks: {:?}", + inside.len(), + &inside[..8] + ); + let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| { + let mut encoder = device.create_command_encoder(&Default::default()); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 12, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12); + queue.submit([encoder.finish()]); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec(); + u32::from_le_bytes(bytes[4..8].try_into().unwrap()) + }; + assert_eq!(scd(&device, &queue, &flow) & 0xf, 0); + run(&device, &queue, &mut flow, &other, false); + assert_eq!(scd(&device, &queue, &flow) & 1, 1); + assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0])); + } +} diff --git a/crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl b/crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl new file mode 100644 index 0000000000..ab52714b34 --- /dev/null +++ b/crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl @@ -0,0 +1,537 @@ +// This file is part of the FidelityFX SDK. +// +// Copyright (C) 2024 Advanced Micro Devices, Inc. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL +// for Horizon. What differs from AMD's (optical_flow.rs lists why): +// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers; +// - the luma pyramid is one pass per level instead of SPD's single pass; +// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a +// wave's lockstep have barriers; +// - the scene change textures are storage buffers; +// - the search reads its prediction from one texture and writes another (rg32sint can't +// be read_write everywhere). + +struct Params { + // Display size (cbOF.iInputLumaResolution). + display: vec2, + level: u32, + level_count: u32, + frame_index: u32, + transfer_function: u32, + min_max_luminance: vec2, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var input_color: texture_2d; +@group(0) @binding(2) var luma_out: texture_storage_2d; +// The current frame's luma at this level, and the previous frame's. +@group(0) @binding(3) var luma_in: texture_2d; +@group(0) @binding(4) var luma_previous: texture_2d; +// Search: the prediction from the level above; filter: the search; scale: the filtered. +@group(0) @binding(5) var flow_in: texture_2d; +@group(0) @binding(6) var flow_out: texture_storage_2d; +@group(0) @binding(7) var scd_histogram: array>; +@group(0) @binding(8) var scd_previous_histogram: array; +@group(0) @binding(9) var scd_temp: array, 3>; +@group(0) @binding(10) var scd_output: array, 3>; + +const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u; +const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u; +const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u; + +fn load_luma(t: texture_2d, p: vec2) -> u32 { + let size = vec2(textureDimensions(t)); + if any(p < vec2(0)) || any(p >= size) { + return 0u; + } + return u32(textureLoad(t, p, 0).x); +} + +fn load_flow(p: vec2) -> vec2 { + let size = vec2(textureDimensions(flow_in)); + if any(p < vec2(0)) || any(p >= size) { + return vec2(0); + } + return textureLoad(flow_in, p, 0).xy; +} + +fn store_flow(p: vec2, v: vec2) { + let size = vec2(textureDimensions(flow_out)); + if all(p >= vec2(0)) && all(p < size) { + textureStore(flow_out, p, vec4(v, 0, 0)); + } +} + +fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 { + var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u); + if x < 0 { + let filler = packed & 0xffu; + if x <= -1 { + packed = (packed << 8u) | filler; + } + if x <= -2 { + packed = (packed << 8u) | filler; + } + if x <= -3 { + packed = (packed << 8u) | filler; + } + } else if x > width - 4 { + let filler = packed & 0xff000000u; + if x >= width - 3 { + packed = (packed >> 8u) | filler; + } + if x >= width - 2 { + packed = (packed >> 8u) | filler; + } + if x >= width - 1 { + packed = (packed >> 8u) | filler; + } + } + return packed; +} + +fn load_packed(t: texture_2d, p: vec2) -> u32 { + let width = params.display.x >> params.level; + let height = params.display.y >> params.level; + let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1)); + return packed_luma( + width, + p.x, + load_luma(t, q), + load_luma(t, q + vec2(1, 0)), + load_luma(t, q + vec2(2, 0)), + load_luma(t, q + vec2(3, 0)), + ); +} + +fn sad(a: u32, b: u32) -> u32 { + return u32(abs(i32(a & 0xffu) - i32(b & 0xffu)) + + abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu)) + + abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu)) + + abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu))); +} + +fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4 { + var a0 = a0_in; + var a1 = a1_in; + var s: vec4; + s.x = sad(a0, b); + a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u); + a1 = a1 >> 8u; + s.y = sad(a0, b); + a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u); + a1 = a1 >> 8u; + s.z = sad(a0, b); + a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u); + s.w = sad(a0, b); + return s; +} + +fn is_scene_changed() -> bool { + if params.frame_index <= 5u { + return true; + } + return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u; +} + +// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's +// frames); the HDR transfer functions follow AMD's. +fn luminance_to_perceived(luminance: f32) -> f32 { + var perceived: f32; + if luminance <= 216.0 / 24389.0 { + perceived = luminance * (24389.0 / 27.0); + } else { + perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0; + } + return perceived * 0.01; +} + +fn linear_from_pq(x: vec3) -> vec3 { + let p = pow(x, vec3(0.0126833)); + return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739)); +} + +@compute @workgroup_size(16, 16) +fn prepare_luma(@builtin(global_invocation_id) gid: vec3) { + for (var y = 0; y < 2; y++) { + for (var x = 0; x < 2; x++) { + let pos = vec2(gid.xy) * 2 + vec2(x, y); + if any(pos >= params.display) { + continue; + } + let c = textureLoad(input_color, pos, 0).rgb; + var y_lum = 0.0; + if params.transfer_function == 0u { + y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722)); + } else if params.transfer_function == 1u { + let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y); + y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593))); + } else if params.transfer_function == 2u { + let mn = params.min_max_luminance.x; + let mx = params.min_max_luminance.y; + let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0); + y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722))); + } + textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0)); + } + } +} + +// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass. +@compute @workgroup_size(8, 8) +fn luma_pyramid(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + if any(p >= vec2(textureDimensions(luma_out))) { + return; + } + let s = p * 2; + let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x + + textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25; + textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0)); +} + +// ffx_opticalflow_generate_scd_histogram.h +const LBASE: u32 = 10u; +var scd_buffer: array, 2560>; + +@compute @workgroup_size(32, 8) +fn scd_histogram_pass( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_index) local_index: u32, +) { + let histograms_per_dim = 3u; + let div = vec2(params.display) / histograms_per_dim; + let nx = gid.z % histograms_per_dim; + let ny = gid.z / histograms_per_dim; + let start = div * vec2(nx, ny); + let stop = start + div; + let offset = local_index * LBASE; + for (var i = 0u; i < LBASE; i++) { + atomicStore(&scd_buffer[offset + i], 0u); + } + workgroupBarrier(); + var coord = vec2(i32(start.x + 4u * gid.x), i32(start.y + gid.y)); + if u32(coord.x) < stop.x { + for (; u32(coord.y) < stop.y; coord.y += 128) { + let color = vec4( + load_luma(luma_in, coord), + load_luma(luma_in, coord + vec2(1, 0)), + load_luma(luma_in, coord + vec2(2, 0)), + load_luma(luma_in, coord + vec2(3, 0)), + ) * LBASE; + let scrambling = local_index % LBASE; + atomicAdd(&scd_buffer[color.x + scrambling], 1u); + atomicAdd(&scd_buffer[color.y + scrambling], 1u); + atomicAdd(&scd_buffer[color.z + scrambling], 1u); + atomicAdd(&scd_buffer[color.w + scrambling], 1u); + } + } + workgroupBarrier(); + var value = 0u; + for (var i = 0u; i < LBASE; i++) { + value += atomicLoad(&scd_buffer[offset + i]); + } + let histogram_start = 256u * gid.z; + atomicAdd(&scd_histogram[histogram_start + local_index], value); +} + +// ffx_opticalflow_compute_scd_divergence.h +var source_histogram: array; +var filtered_histogram: array; +var temp_buffer: array; +var temp_buffer2: array, 256>; + +@compute @workgroup_size(256) +fn scd_divergence_pass( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_index) local_index: u32, +) { + let factor = 1000000.0; + let where_to_stop = 3u * 9u - 1u; + let histogram_count = 9.0; + let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541); + let li = i32(local_index); + source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x])); + workgroupBarrier(); + let read = li - 5; + var val = 0.0; + val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)]; + val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)]; + val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)]; + val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)]; + val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)]; + val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)]; + val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)]; + val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)]; + val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)]; + val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)]; + val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)]; + val += 1.0; + if gid.y == 0u { + if li == 0 { + filtered_histogram[255] = 1.0; + } else { + filtered_histogram[li - 1] = val; + } + } else if gid.y == 1u { + filtered_histogram[li] = val; + } else if gid.y == 2u { + if li == 255 { + filtered_histogram[0] = 1.0; + } else { + filtered_histogram[li + 1] = val; + } + } + workgroupBarrier(); + temp_buffer[li] = filtered_histogram[li]; + workgroupBarrier(); + for (var stride = 128; stride > 0; stride >>= 1u) { + if li < stride { + temp_buffer[li] += temp_buffer[li + stride]; + } + workgroupBarrier(); + } + filtered_histogram[li] /= temp_buffer[0]; + let current = filtered_histogram[li]; + let previous = scd_previous_histogram[gid.x]; + temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current)); + workgroupBarrier(); + for (var stride = 128; stride > 0; stride >>= 1u) { + if li < stride { + temp_buffer2[li] += temp_buffer2[li + stride]; + } + workgroupBarrier(); + } + if li == 0 { + let sum = temp_buffer2[0]; + let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y))); + let res_uint = u32((res_float / histogram_count) * factor); + atomicAdd(&scd_temp[gid.y], res_uint); + let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u); + if finished == where_to_stop { + let res0 = atomicLoad(&scd_temp[0]); + let res1 = atomicLoad(&scd_temp[1]); + let res2 = atomicLoad(&scd_temp[2]); + let scene_change = f32(min(res0, min(res1, res2))) / factor; + var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u; + if scene_change > 0.45 { + history |= 1u; + } + atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast(scene_change)); + atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history); + atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u); + atomicStore(&scd_temp[0], 0u); + atomicStore(&scd_temp[1], 0u); + atomicStore(&scd_temp[2], 0u); + } + } + if gid.y == 1u { + scd_previous_histogram[gid.x] = current; + atomicStore(&scd_histogram[gid.x], 0u); + } +} + +// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels, +// 2x2 blocks per workgroup of 64. +var pixels: array, 8>; +// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas. +var search_buffer: array; +var sad_map: array, 16>, 4>; +var wave_min: atomic; +var wave_sum: atomic; +var scene_changed: u32; + +fn abs_2(v: vec2) -> vec2 { + let mask = v >> vec2(31u); + return vec2((v + mask) ^ mask); +} + +// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins. +fn encode_search_coord(coord: vec2) -> u32 { + let a = abs_2(coord - 8); + return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x); +} + +fn decode_search_coord(bits: u32) -> vec2 { + return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8); +} + +fn calculate_qsads2(search_id: vec2) -> vec4 { + var s = vec4(0u); + for (var dy = 0; dy < 8; dy++) { + let row = (search_id.y + dy) * 6; + let a0 = search_buffer[row + search_id.x]; + let a1 = search_buffer[row + search_id.x + 1]; + let a2 = search_buffer[row + search_id.x + 2]; + s += qsad(a0, a1, pixels[dy][0]); + s += qsad(a1, a2, pixels[dy][1]); + } + return s; +} + +@compute @workgroup_size(64) +fn search_pass( + @builtin(workgroup_id) group_id: vec3, + @builtin(local_invocation_index) local_index: u32, +) { + let li = i32(local_index); + let search_id = vec2(li & 3, (li >> 2u) & 15); + let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u); + let group = vec2(group_id.xy); + let px = (group << vec2(4u)) + search_id * vec2(4, 1); + if local_index == 0u { + scene_changed = u32(is_scene_changed()); + } + if workgroupUniformLoad(&scene_changed) != 0u { + if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 { + store_flow(px >> vec2(3u), vec2(0)); + } + return; + } + let use_prediction = params.level != params.level_count - 1u; + let packed = load_packed(luma_in, px); + let sad_4blocks = sad(packed, load_packed(luma_previous, px)); + let of_group = group << vec2(1u); + let pixel_group = group << vec2(4u); + for (var by = 0; by < 2; by++) { + for (var bx = 0; bx < 2; bx++) { + let block = vec2(bx, by); + var current = vec2(0); + if use_prediction { + current = load_flow(of_group + block); + } + if lane_block == by * 2 + bx { + pixels[search_id.y & 7][search_id.x & 1] = packed; + } + // LoadSearchBuffer + let base = pixel_group + block * 8 + current - vec2(8, 8); + for (var id = li; id < 144; id += 64) { + search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6)); + } + if local_index == 0u { + atomicStore(&wave_min, 0xffffffffu); + atomicStore(&wave_sum, 0u); + } + workgroupBarrier(); + let q = calculate_qsads2(search_id); + // PrepareSadMap + let sx = search_id.x; + let sy = search_id.y; + sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy)); + sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy)); + sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy)); + sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy)); + workgroupBarrier(); + // SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK) + let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]); + let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]); + atomicMin(&wave_min, min(min01, min23)); + atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2)); + workgroupBarrier(); + let min_sad = atomicLoad(&wave_min); + let block_sad_sum = atomicLoad(&wave_sum); + var v = current + decode_search_coord(min_sad); + if params.level == 0u && block_sad_sum <= (min_sad >> 16u) { + v = vec2(0); + } + if local_index == 0u { + store_flow(of_group + block, v); + } + // The next block reuses the shared buffers and counters. + workgroupBarrier(); + } + } +} + +// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others. +@compute @workgroup_size(16, 4) +fn filter_flow(@builtin(global_invocation_id) gid: vec3) { + let p = vec2(gid.xy); + var mv: array, 9>; + var idx = 0; + for (var xx = -1; xx < 2; xx++) { + for (var yy = -1; yy < 2; yy++) { + mv[idx] = load_flow(p + vec2(xx, yy)); + idx++; + } + } + var ret = 0xffffffffu; + for (var i = 0; i < 9; i++) { + var tmp = 0u; + for (var j = 0; j < 9; j++) { + let delta = mv[i] - mv[j]; + tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp); + } + ret = min((tmp << 4u) | u32(i), ret); + } + store_flow(p, mv[ret & 0xfu]); +} + +// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the +// best of its 4 nearest at this level. +var nearest_vectors: array, 4>, 4>, 4>; +var local_region: array, 4>, 4>; +var sads: array, 4>, 4>; + +@compute @workgroup_size(4, 4, 4) +fn scale_pass( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3, + @builtin(local_invocation_index) local_index: u32, +) { + if local_index == 0u { + scene_changed = u32(is_scene_changed()); + } + if workgroupUniformLoad(&scene_changed) != 0u { + store_flow(vec2(gid.xy), vec2(0)); + return; + } + let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u); + let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u); + let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset); + let nearest = load_flow(src); + nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2; + let max_y = 4u; + for (var n = lid.z; n < max_y; n += 4u) { + let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)); + local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos); + } + workgroupBarrier(); + var s = 0u; + for (var n = 0u; n < max_y; n++) { + let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest; + s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos)); + } + sads[lid.z][lid.y][lid.x] = s; + workgroupBarrier(); + if lid.z == 0u { + var best_sad = 0xffffffffu; + var best = 0u; + for (var n = 0u; n < 4u; n++) { + if sads[n][lid.y][lid.x] < best_sad { + best_sad = sads[n][lid.y][lid.x]; + best = n; + } + } + store_flow(vec2(gid.xy), nearest_vectors[best][lid.y][lid.x]); + } +} From c3daaa876206b9dfbec7666b1c80f5a5679c31bf Mon Sep 17 00:00:00 2001 From: ThatCakeIsAPie Date: Thu, 29 Oct 2026 10:31:26 -0500 Subject: [PATCH 044/968] Worldgen: data packs' block state providers join the state_providers bundle worldgen/block_state_provider was missing from overrides::KINDS, so a pack's own providers (WWOO's wythers:stony_cliffs) never reached the state_providers bundle the generator already reads, and every feature using one was skipped. pack_files_go_to_their_bundles covers the folder. Reported by AdachiReiYay's agent on #3476. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit bd92abce3b0ba8f209f8a613acd5741cb9e4b825) --- crates/horizon-world/src/worldgen/overrides.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/horizon-world/src/worldgen/overrides.rs b/crates/horizon-world/src/worldgen/overrides.rs index e602eef89b..0a20987a32 100644 --- a/crates/horizon-world/src/worldgen/overrides.rs +++ b/crates/horizon-world/src/worldgen/overrides.rs @@ -18,10 +18,11 @@ pub const DIMENSION_TYPES: &str = "dimension_types"; /// Pack folders (`data///`) the chunk generator reads, and the /// bundle each joins. -const KINDS: [(&str, &str); 15] = [ +const KINDS: [(&str, &str); 16] = [ ("dimension", DIMENSIONS), ("dimension_type", DIMENSION_TYPES), ("worldgen/biome", "biomes"), + ("worldgen/block_state_provider", "state_providers"), // 26.3's names: 26.2 called them configured_carver and configured_feature. ("worldgen/carver", "carvers"), ("worldgen/feature", "features"), @@ -377,6 +378,13 @@ mod tests { add("data/minecraft/worldgen/feature/water_delta.json", "7"), PackFile::Read ); + assert_eq!( + add( + "data/p/worldgen/block_state_provider/stony_cliffs.json", + "8" + ), + PackFile::Read + ); assert_eq!( add("data/minecraft/worldgen/configured_carver/cave.json", "{}"), PackFile::NotRead("worldgen/configured_carver".into()) @@ -398,6 +406,7 @@ mod tests { assert_eq!(get(DIMENSIONS), [("minecraft:overworld", "5")]); assert_eq!(get("carvers"), [("minecraft:cave", "6")]); assert_eq!(get("features"), [("minecraft:water_delta", "7")]); + assert_eq!(get("state_providers"), [("p:stony_cliffs", "8")]); assert_eq!(get("structures"), []); assert_eq!(o.tags("biome_tags").count(), 1); } From 0503ee0cbf23a42865ca4a712c2cf950f4401705 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:40:52 +0500 Subject: [PATCH 045/968] gfx: rustfmt's order for the frame_gen module Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/gfx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 9546885e95..99f691fe7f 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -72,6 +72,7 @@ mod arena; mod cull; mod end_sky; mod flat; +mod frame_gen; mod hdr; pub mod lightning; mod lod; @@ -99,7 +100,6 @@ mod timing; pub mod translucency; pub(crate) mod upload; mod wgsl_prefix; -mod frame_gen; mod world_post; /// A column whose every section has its mesh (`Renderer::lod_detail_columns`). From d36f45e21306b20cfa9fd77893b637586aaa1431 Mon Sep 17 00:00:00 2001 From: TraiNguyenVan Date: Thu, 29 Oct 2026 23:51:10 +0700 Subject: [PATCH 046/968] README: the launcher's Home and Settings pages, freshly captured The two launcher pictures dated from the 0.1.5 UI: the Play page (now Home) and Patch notes, which no longer shows what the launcher is for. Both are replaced in place with 1280x800 captures of 0.2.26, and the alt texts follow; nothing else in the README changes. Taken in a scratch home with the offline name Steve, one instance and v0.2.97 prepared, so no account or server data appears. Co-Authored-By: Qoder --- README.md | 4 ++-- docs/media/launcher-patch-notes.webp | Bin 44896 -> 43646 bytes docs/media/launcher-play.webp | Bin 19542 -> 41060 bytes 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 19594f9d49..e92d5034a8 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.
- - + +
The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and PlayThe launcher's Patch notes pageThe launcher's Home page: the selected instance ready to play, Minecraft's prepared files, the version picker and PlayThe launcher's Settings page: account, updates and appearance
diff --git a/docs/media/launcher-patch-notes.webp b/docs/media/launcher-patch-notes.webp index 5d8da61617821583a76964683b2bedf5c9caf1c0..7cc21b658ffafdaa7b70266633d41acabfe0bfea 100644 GIT binary patch literal 43646 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 DnR;@t!+~r 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 k5SgloMBW@4yP=hu}imZsFkYr8=*$Fn% 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 diff --git a/docs/media/launcher-play.webp b/docs/media/launcher-play.webp index 9db5c64d83c600a30a34edd281b7cec1294cbbbd..3b324014c1bfef48eb16b17e0d4755696bf41223 100644 GIT binary patch literal 41060 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

vK4F&_(9JsYo2B5H42B1Q= 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 z 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 Nj}o8k00000006qmLwEoH From e4c1af1fff461af08b321e50e7e24ab8a0ffb3f2 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:59:12 +0200 Subject: [PATCH 047/968] Launcher: the version and account menus close on a click outside them Both menus only closed from their own button or a row. They now close on a mouse-down anywhere outside them; a press that does so on the menu's own button no longer reopens it when it ends. Fixes #3216. Co-Authored-By: Claude Opus 5.5 --- launcher/src/main.rs | 61 +++++++++++++++++++++++++++++++++---- launcher/src/switch_page.rs | 7 ++++- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 4e1af19af3..79384eb990 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -47,8 +47,8 @@ mod worlds_page; use gpui::{ actions, div, ease_out_quint, prelude::*, px, relative, size, Animation, AnimationExt, AnyElement, App, Application, Bounds, ClickEvent, Context, Div, ElementId, FocusHandle, - FontWeight, Hsla, KeyBinding, KeyDownEvent, Menu, MenuItem, PathPromptOptions, SharedString, - Stateful, Subscription, Window, WindowBounds, WindowOptions, + FontWeight, Hsla, KeyBinding, KeyDownEvent, Menu, MenuItem, MouseDownEvent, PathPromptOptions, + Pixels, Point, SharedString, Stateful, Subscription, Window, WindowBounds, WindowOptions, }; use horizon_launcher::account::{self, Accounts, SignIn, SignInState}; use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Updates}; @@ -184,6 +184,9 @@ struct Launcher { picker_open: bool, /// The top bar's quick switch of who plays (`switch_page.rs`). account_menu: bool, + /// Where the mouse went down to close a menu by pressing outside it, so that the + /// same press, ending on the menu's own button, doesn't open it again. + menu_closed_at: Option>, /// Settings > Account's skin panel for an offline name (`skin_page.rs`). skin: Option, /// Why the last picture for Home's background was refused. @@ -408,6 +411,7 @@ impl Launcher { confirm_remove: None, picker_open: false, account_menu: false, + menu_closed_at: None, skin: None, background_error: None, args_input: line_edit::LineEdit::new(settings.game_args.clone(), 1024, 38), @@ -1955,7 +1959,10 @@ impl Launcher { ) .child(text(theme::CAPTION).text_color(t.muted).child(how)), ) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + .on_click(cx.listener(|this, event: &ClickEvent, _, cx| { + if this.closed_by_this_press(event) { + return; + } this.account_menu = !this.account_menu; if this.account_menu { // Who can play may have changed elsewhere since the window opened. @@ -2288,6 +2295,12 @@ impl Launcher { .child(foot) } + /// Whether `event` is the press that just closed a menu from outside it (on the menu's + /// own button): the button then leaves the menu closed instead of opening it again. + fn closed_by_this_press(&mut self, event: &ClickEvent) -> bool { + closed_by_press(self.menu_closed_at.take(), event) + } + fn picker(&self, cx: &mut Context) -> Div { let t = self.theme; let label = self @@ -2329,8 +2342,10 @@ impl Launcher { ), ) .child(blocks::icon("chevron-down", 20., t.muted)) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.picker_open = !this.picker_open; + .on_click(cx.listener(|this, event: &ClickEvent, _, cx| { + if !this.closed_by_this_press(event) { + this.picker_open = !this.picker_open; + } cx.notify(); })), ); @@ -2345,7 +2360,12 @@ impl Launcher { .border_color(t.outline) .bg(t.surface) .flex() - .flex_col(); + .flex_col() + .on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| { + this.picker_open = false; + this.menu_closed_at = Some(event.position); + cx.notify(); + })); // Picking a version keeps the selected instance on it; the launcher's own version // (newest unless chosen otherwise on Versions) follows updates. let follows = self.launcher_version().cloned(); @@ -4589,8 +4609,37 @@ fn main() { }); } +/// Whether the click `event` began with the mouse-down that closed a menu at `closed_at`. +/// A keyboard click never did. +fn closed_by_press(closed_at: Option>, event: &ClickEvent) -> bool { + matches!(event, ClickEvent::Mouse(click) if closed_at == Some(click.down.position)) +} + #[cfg(test)] mod tests { + #[test] + fn a_menu_button_stays_closed_after_the_press_that_closed_its_menu() { + use super::closed_by_press; + use gpui::{point, px, ClickEvent, KeyboardClickEvent, MouseClickEvent, MouseDownEvent}; + let click = |x: f32| { + ClickEvent::Mouse(MouseClickEvent { + down: MouseDownEvent { + position: point(px(x), px(20.)), + ..MouseDownEvent::default() + }, + ..MouseClickEvent::default() + }) + }; + let closed_at = Some(point(px(10.), px(20.))); + // The press on the button that closed the open menu: it stays closed. + assert!(closed_by_press(closed_at, &click(10.))); + // A later press, or one with no menu closed, toggles as before. + assert!(!closed_by_press(closed_at, &click(11.))); + assert!(!closed_by_press(None, &click(10.))); + let keyboard = ClickEvent::Keyboard(KeyboardClickEvent::default()); + assert!(!closed_by_press(closed_at, &keyboard)); + } + #[cfg(target_os = "macos")] #[test] fn cmd_q_quits_and_cmd_w_closes_the_window() { diff --git a/launcher/src/switch_page.rs b/launcher/src/switch_page.rs index 92de676978..9aff1bb32f 100644 --- a/launcher/src/switch_page.rs +++ b/launcher/src/switch_page.rs @@ -81,7 +81,12 @@ impl Launcher { .bg(t.surface) .flex() .flex_col() - .occlude(); + .occlude() + .on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| { + this.account_menu = false; + this.menu_closed_at = Some(event.position); + cx.notify(); + })); for (i, (who, name, how)) in self.players().into_iter().enumerate() { let chosen = who == now; menu = menu.child( From f4bdc6678153726b253cb3de2dd46f0f42b166f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 17:15:39 +0000 Subject: [PATCH 048/968] Never wait on the owner's profile fetch: list its skin once it lands The owner's login to its server thread is main's again: no properties and no wait. The player list gives the integrated server's owner the signed-in account's properties instead (Hub::player_info), taken from the background fetch only if it has already answered, so a slow, failing or absent network can't delay opening a world. A LAN guest joins after the world is open, well after the fetch. Tests: the owner's login doesn't wait on a fetch that never answers; the player list's owner entry before, after and without the fetch; and in tests/lan_bot.rs (needs the prepared resources) a TCP guest's player list carries the owner's textures and none of its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CxQvrTahuwEpRxJpTvWpjG --- crates/horizon-client/src/lan/conn.rs | 78 +++++------------------- crates/horizon-client/src/lan/hub.rs | 73 ++++++++++++++++++++++- crates/horizon-client/src/lan/mod.rs | 37 ++++++------ crates/horizon-client/tests/lan_bot.rs | 82 +++++++++++++++++++++++++- 4 files changed, 186 insertions(+), 84 deletions(-) diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index 9ba32e13f7..2903ba07da 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -703,15 +703,7 @@ fn serve_login( return Err(err); } }, - // handleHello: the singleplayer owner (the window's memory connection) - // logs in with its own profile, other players with no properties. - None => { - let properties = match &shared.config.owner_profile { - Some(owner) if c.is_memory() => owner.properties().to_vec(), - _ => Vec::new(), - }; - (offline_uuid(&hello.name), properties) - } + None => (offline_uuid(&hello.name), Vec::new()), }; // verifyLoginAndFinishConnectionSetup: PlayerList.canPlayerLogin, then // duplicates and a full server. @@ -1232,76 +1224,34 @@ mod tests { } } - /// handleHello on an integrated server: the window's own (memory) - /// connection logs in with the signed-in account's properties, its skin - /// among them, so the player list gives everyone the owner's skin; a - /// LAN guest on the offline-mode world gets none. + /// The owner's login is main's: it never waits on the profile fetch, + /// even one that never answers, and logs in with no properties. #[test] - fn the_owner_logs_in_with_its_own_profile_properties() { - let textures = login::Property { - name: "textures".into(), - value: "e30=".into(), - signature: Some("c2lnbmVk".into()), - }; - let fetched = textures.clone(); + fn the_owners_login_never_waits_on_the_profile_fetch() { + let (_hold, never) = channel::<()>(); let config = crate::lan::NetConfig { announce: false, + listen: false, owner_profile: Some(Arc::new(crate::lan::OwnerProfile::new(move || { - vec![fetched] + let _ = never.recv(); + Vec::new() }))), ..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8) }; let lan = crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config) .unwrap(); + let start = Instant::now(); let owner = login_profile(Connection::local(lan.connect_local().unwrap()), "Owner"); + assert!( + start.elapsed() < Duration::from_secs(1), + "{:?}", + start.elapsed() + ); assert_eq!(owner.uuid, offline_uuid("Owner")); - assert_eq!(owner.properties, vec![textures]); - let guest = Connection::connect("127.0.0.1", lan.port, Duration::from_secs(5)).unwrap(); - guest - .reader - .set_read_timeout(Some(Duration::from_secs(10))) - .unwrap(); - let guest = login_profile(guest, "Guest"); - assert_eq!(guest.uuid, offline_uuid("Guest")); - assert!(guest.properties.is_empty()); - } - - /// Without a signed-in account (or an answer from the services) the - /// owner logs in with no properties, as before. - #[test] - fn an_owner_without_an_answer_logs_in_without_properties() { - let config = crate::lan::NetConfig { - announce: false, - listen: false, - owner_profile: Some(Arc::new(crate::lan::OwnerProfile::new(Vec::new))), - ..crate::lan::NetConfig::lan(0, "Owner - World".into(), 8) - }; - let lan = - crate::lan::Lan::start_with(Arc::new(crate::lan::data::SyncData::empty()), config) - .unwrap(); - let owner = login_profile(Connection::local(lan.connect_local().unwrap()), "Owner"); assert!(owner.properties.is_empty()); } - #[test] - fn prompts_become_the_components_vanilla_sends() { - let tag = |json: &str| { - let tag = component_tag(&crate::server::json::parse(json, true).unwrap()); - component_json(&tag) - }; - assert_eq!(tag(r#"{"text":"Hi"}"#), r#""Hi""#); - assert_eq!(tag(r#""Hi""#), r#""Hi""#); - assert_eq!( - tag(r#"["Hi ",{"text":"there","bold":true}]"#), - r#"{"text":"Hi ","extra":[{"text":"there","bold":true}]}"# - ); - assert_eq!( - tag(r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#), - r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"# - ); - } - fn unpack(out: Out) -> Vec> { match out { Out::Packet(body) => vec![body], diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index fd8088fcbc..7fea0d55a9 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -602,7 +602,15 @@ impl Hub { play::PlayerInfo { uuid, name: Some(name.to_owned()), - properties: guest.map(|g| g.properties.clone()).unwrap_or_default(), + properties: guest + .map(|g| { + listed_properties( + &g.properties, + self.singleplayer_owner == Some(g.uuid), + self.lan.shared.config.owner_profile.as_deref(), + ) + }) + .unwrap_or_default(), chat_session: guest.and_then(|g| g.chain.session()).map(|s| s.data()), game_mode: Some(mode), listed: Some(true), @@ -2803,11 +2811,74 @@ fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 { shared } +/// The profile properties the player list gives a player: those it logged +/// in with, or for the integrated server's owner (who logs in with none) the +/// signed-in account's, once fetched (`IntegratedServer.getSingleplayerProfile`). +fn listed_properties( + logged_in: &[horizon_protocol::packets::login::Property], + owner: bool, + profile: Option<&super::OwnerProfile>, +) -> Vec { + match profile { + Some(profile) if owner && logged_in.is_empty() => profile.properties().to_vec(), + _ => logged_in.to_vec(), + } +} + #[cfg(test)] mod tests { use super::*; use crate::fast_hash::FxHashSet; + fn textures(value: &str) -> horizon_protocol::packets::login::Property { + horizon_protocol::packets::login::Property { + name: "textures".into(), + value: value.into(), + signature: Some("c2lnbmVk".into()), + } + } + + /// The owner is listed with the account's skin once the fetch answered, + /// with none before (no wait) or when it failed; other players keep the + /// properties they logged in with, and none of the owner's. + #[test] + fn the_owner_is_listed_with_the_accounts_skin_once_fetched() { + let (answer, fetched) = std::sync::mpsc::channel(); + let profile = super::super::OwnerProfile::new(move || fetched.recv().unwrap_or_default()); + let start = std::time::Instant::now(); + assert!(listed_properties(&[], true, Some(&profile)).is_empty()); + assert!(start.elapsed() < std::time::Duration::from_millis(500)); + answer.send(vec![textures("owner")]).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while listed_properties(&[], true, Some(&profile)).is_empty() { + assert!( + std::time::Instant::now() < deadline, + "the fetch never landed" + ); + std::thread::yield_now(); + } + assert_eq!( + listed_properties(&[], true, Some(&profile)), + vec![textures("owner")] + ); + // A guest: its own properties (online mode), or none (offline mode). + assert_eq!( + listed_properties(&[textures("guest")], false, Some(&profile)), + vec![textures("guest")] + ); + assert!(listed_properties(&[], false, Some(&profile)).is_empty()); + // No signed-in account (no fetch): as before. + assert!(listed_properties(&[], true, None).is_empty()); + let failed = super::super::OwnerProfile::new(Vec::new); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while failed.properties.get().is_none() { + let _ = failed.properties(); + assert!(std::time::Instant::now() < deadline); + std::thread::yield_now(); + } + assert!(listed_properties(&[], true, Some(&failed)).is_empty()); + } + /// `requeue` as it was, scanning the queue for each chunk of the view. fn requeue_scan( queue: &mut Vec<(i32, i32)>, diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 5fb344704b..8b6805dfd2 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -183,18 +183,15 @@ impl NetConfig { /// `IntegratedServer.getSingleplayerProfile` (`Minecraft.getGameProfile`): /// the signed-in account's profile properties, its signed `textures` among -/// them, which the owner logs in with so the player list gives everyone on -/// the world, LAN guests included, the owner's skin. Fetched in the -/// background from startup and waited for by the owner's login, as -/// `Minecraft.profileFuture` is, but never longer than `OWNER_PROFILE_WAIT`: -/// past that the owner plays without properties (the default skin to others). +/// them, which the player list gives the owner so that LAN guests see the +/// owner's skin. Fetched in the background from the world's start; nothing +/// waits for it: until it answers (or when it fails) the owner is listed +/// without properties, as before. pub struct OwnerProfile { properties: std::sync::OnceLock>, - fetch: Mutex>>>, + fetch: Mutex>>, } -const OWNER_PROFILE_WAIT: std::time::Duration = std::time::Duration::from_secs(10); - impl OwnerProfile { pub fn new( fetch: impl FnOnce() -> Vec + Send + 'static, @@ -207,18 +204,24 @@ impl OwnerProfile { }); Self { properties: std::sync::OnceLock::new(), - fetch: Mutex::new(Some(receive)), + fetch: Mutex::new(receive), } } - /// The owner's properties (none when the services couldn't be reached - /// in time). + /// The owner's properties once fetched; empty while the fetch is out + /// or when it failed. Never blocks. pub fn properties(&self) -> &[horizon_protocol::packets::login::Property] { - self.properties.get_or_init(|| { - let receive = self.fetch.lock().ok().and_then(|mut r| r.take()); - receive - .and_then(|r| r.recv_timeout(OWNER_PROFILE_WAIT).ok()) - .unwrap_or_default() - }) + if let Some(properties) = self.properties.get() { + return properties; + } + let received = self.fetch.lock().ok().and_then(|r| match r.try_recv() { + Ok(properties) => Some(properties), + Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Vec::new()), + Err(std::sync::mpsc::TryRecvError::Empty) => None, + }); + match received { + Some(properties) => self.properties.get_or_init(|| properties), + None => &[], + } } } diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 15c21414ad..28d26a9306 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -5,7 +5,7 @@ use horizon_client::{ chunk::{Chunk, MIN_Y}, lan::{ - Lan, + Lan, NetConfig, OwnerProfile, chunks::ChunkEncoder, data::SyncData, entity::EntityState, @@ -224,8 +224,12 @@ impl Server { } fn server(resources: &Path) -> Server { + server_with(resources, NetConfig::lan(0, "Test - World".into(), 8)) +} + +fn server_with(resources: &Path, net: NetConfig) -> Server { let sync = Arc::new(SyncData::load(resources).expect("sync data")); - let lan = Lan::start(sync.clone(), 0, "Test - World".into(), 8).unwrap(); + let lan = Lan::start_with(sync.clone(), net).unwrap(); let biomes: Vec = sync .dynamic .registries @@ -279,6 +283,80 @@ fn run( handle.join().unwrap().unwrap() } +/// An integrated server opened to LAN with a signed-in owner: a guest's +/// player list gives the owner the account's skin (its `textures` +/// property), and the guest itself none (an offline-mode world). +#[test] +fn a_lan_guest_sees_the_signed_in_owners_skin() { + let Some(resources) = resources() else { + return; + }; + let textures = login::Property { + name: "textures".into(), + value: "e30=".into(), + signature: Some("c2lnbmVk".into()), + }; + let fetched = textures.clone(); + let profile = Arc::new(OwnerProfile::new(move || vec![fetched])); + let net = NetConfig { + announce: false, + owner_profile: Some(profile.clone()), + ..NetConfig::lan(0, "Test - World".into(), 8) + }; + let mut s = server_with(&resources, net); + s.hub.singleplayer_owner = Some(offline_uuid("Owner")); + let port = s.hub.lan.port; + let deadline = Instant::now() + Duration::from_secs(5); + while profile.properties().is_empty() { + assert!( + Instant::now() < deadline, + "the profile fetch never answered" + ); + thread::yield_now(); + } + // The owner (the window's connection) joins first and stays. + let stop = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let staying = stop.clone(); + let owner = thread::spawn(move || -> Result<(), String> { + let mut bot = connect(port, "Owner", false)?; + bot.c + .reader + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + while !staying.load(std::sync::atomic::Ordering::Relaxed) { + let _ = bot.c.recv(); + } + Ok(()) + }); + let deadline = Instant::now() + Duration::from_secs(10); + while !s.hub.guests.iter().any(|g| g.name == "Owner") { + s.tick(&[]); + assert!(Instant::now() < deadline, "the owner did not join"); + thread::sleep(Duration::from_millis(10)); + } + let listed = run(&mut s, move || { + let mut bot = connect(port, "Guest", false)?; + let (mut owner, mut guest) = (None, None); + let deadline = Instant::now() + Duration::from_secs(20); + while Instant::now() < deadline && (owner.is_none() || guest.is_none()) { + if let C::PlayerInfoUpdate(p) = bot.recv_play()? { + for e in p.entries { + if e.uuid == offline_uuid("Owner") { + owner = Some(e.properties); + } else if e.uuid == offline_uuid("Guest") { + guest = Some(e.properties); + } + } + } + } + Ok((owner, guest)) + }); + stop.store(true, std::sync::atomic::Ordering::Relaxed); + owner.join().unwrap().unwrap(); + assert_eq!(listed.0, Some(vec![textures]), "the owner's entry"); + assert_eq!(listed.1, Some(Vec::new()), "the guest's entry"); +} + #[test] fn recipe_settings_packets_update_only_the_players_book_and_survive_save() { let Some(resources) = resources() else { From 37de786282a6a8270575efc854e1dd8b64328803 Mon Sep 17 00:00:00 2001 From: TraiNguyenVan Date: Fri, 30 Oct 2026 01:11:50 +0700 Subject: [PATCH 049/968] README: the banner is the website's hero, captured from playhorizon.dev The banner was a hand-written HTML page (docs/media/banner.html) imitating the design system. It now shows the real site: playhorizon.dev's hero in its dark theme, captured with Playwright. The navbar, the hero's buttons and the launcher version line are hidden first, so nothing in the picture goes stale, and the crop starts 56 px above the kicker and ends at the hero's bottom edge (1600x654). banner.html is removed and its capture documented in docs/media/brand/README.md; the README alt text follows, and the CI path test uses banner.webp as its content-only example. Co-Authored-By: Qoder --- README.md | 2 +- docs/media/banner.html | 21 --------------------- docs/media/banner.webp | Bin 19988 -> 25456 bytes docs/media/brand/README.md | 12 ++++++++++++ tests/test_ci_rust_changes.py | 2 +- 5 files changed, 14 insertions(+), 23 deletions(-) delete mode 100644 docs/media/banner.html diff --git a/README.md b/README.md index e92d5034a8..21ddb21def 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses + The Horizon website: the game you know, rebuilt in Rust

diff --git a/docs/media/banner.html b/docs/media/banner.html deleted file mode 100644 index 2f727e8bdf..0000000000 --- a/docs/media/banner.html +++ /dev/null @@ -1,21 +0,0 @@ - - -

- -
HorizonMINECRAFT: JAVA EDITION 26.3 · RUST
-

The game you know, rebuilt in Rust.

-

The 26.3 client and its game logic, checked against vanilla by 281 differential harnesses.

-
diff --git a/docs/media/banner.webp b/docs/media/banner.webp index 318c12d0f4fdc8c63043e8a616a01f6275f14f3d..d37430704b56fe23432797c199d6dbe73762d8b2 100644 GIT binary patch literal 25456 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 GkN^Pj4|$yc literal 19988 zcmc$_V~{Sewk_JWZ5yj?+jg(EZQIsr+gNSewr$&Xe|Mj%cd+-_x9a_Q_3~?0DoM?Z z%$YHgRFtK}#o^?EfHcH}71b3viGcoL*Hr-L0MiIU>;dyjk;RLX6cQH@v;Iw2god`X z`%-joranFs3Lz$}h0R{TAN-zbsrmO0Pxw}Cjj`=@CO1suGB8yUJ%9#v=It+wtR%Y>~7Rv_kQq&e_0;` zTKrAFvww!KhyVPP09K!5-bPpAyZjvh-~iZ9j_clSg-!oCfcJL>0Q!7)2=NZk?+x-J z_Eq}{c+K1Lzu9f;xd9x!f?np``0eon`&N7qe#GAaPW&JJgPxMVvmUZu0f62`{WO3J zKBmC|5PQR=7#2)~-`wafx{fxixQ}Pc6c>VysAo~y>@-F?a{ks8?-*F$> zKN>&rAA~RZZ~k-tXb!lqecZk8ed(PARD9Qd!vC0mCw&9n7@h>a{QUu6-;e%)SEkQ0 zz~a{FivS>BbqCFJGJ5LMnSX1xmF4>XKO@;FlUYG&hx0h;5f^k z)E)9GCevf68zPu{d`Ma)sB`KaGF^K$vZ(!cO1Cp7aF2G- zmJ2b_B>bSh$HAXjr)vyvuvaR6IA&(u_rve_Ov&DAfhfM>k}=0IJIEV2kANax9YA~Q zV62;iV79rLtgSY)VBZS~UgR8Ab!LJ=g!wYfSAs;4TnivQwCcXjcByD_3-y z?f(ci+SH-90C0qp-i<6!KYiV3sa8lL;u(k@b!@X^3QpR0lI;o?zOTaMMX!E=Jo|}vgpuEzVhNIHmuY2%I~y78Zx@s`Q*pU@ zWj!5_fh@2t^w^=cPao-`zwbBIqFxTE4IH_oAiC0&$LQf#U85BBC7`neoZ-;qy6ofjKuint0pi*xNXE$F5NM)+=u-9@u!f=NiG|H#g$2bgbr+syJ|n@c z{KQ}4nE7j!aj=y?r8cAX<)NrwR6KD&9wYuX)w7-0gPw*LRd+1E`oc|TJ68pTK<9sb z!J_+msD>h%0I0NL#AyeT|7Q}98=lxm;@YymMhDE1*@O_7HLCL*9VDvCK1!b% zmsrxG&nE; z8?$w}@qp={eeX}ZHUfk-k>*<1S))|gNK}F-8#`x@(>>~m%b}CHyoUsMF&G#H z@D^qIaSt#4rh_&Ovj59|W+6Vy>`Bm$FBc{eK&LwE<3<9V%y}?6pSAIP;2q9=j#5l} z_Y+xlcKL*7Zd|FGVnE1=2tU>vq^Ou{m~DHv5v84g#TM2w>D?9~!-tOKBq`BDU|q7oII9)mZwx@G^1S02$IR_H9u;_ zw{#n}?$Rwzhl~xAFGjsNRyxW)w@<>Ca8wvrQH2G*18hPknl91-iUfTtZNqOZsz$~M zG9D2A)NH32pL0CJ^q0kLD<3|*Mh@`(c-7U>xyIFNgx4#S2+8?xvR*pCX+>XI?E6e| z|EB#JU~(A53}}0lpctIdG5e*r8~=Mo=bwAcPNc^`x9Y0nPI}(2iN4o?pxBGs-PDNv z&@4>NM|3GpdrtEP_j^qxXsmK)ErrQd^}9}Cz(gKA?uA43iMJa4n{As<1eIhFNtO%=QYnv+HniYIZZ>ELh~s3tlsLT zHW*%8z4=<#GJ7Dd3aFwNVUHG5NI3MNZw}y(+`wQPlYApg?$uFx=wY=QP?+e>AiYe6 zB9`g-hSvkS;Sepm2Lh4%`q@#Ip$3Ity4K>1D&g!Tr%6a+7@EAtgPvNXjo&$=MraYk9?J1tj-LNafEOm9?glVu-{|BI-FLY}CmaOlb^3z* zw3WdV)#8k0Tt_crckp;MpVe4~{!_PaESYzofvRcv(!9sR|H`5NmRDj~(Tjs~|I=T1 zQk2io{r~E$N>XoH3Zr!T>;KMhbM3j`Olhq#8lhB=X_y%wDAF(glI8y_Ukvyiv1PPW?R{Mxsd-i!v8}b8SuGG_`>Qhd5DOk zcg*q2MHBRV=UraRJT1=mTzzlw!jypPJM;~Y3=37u&q2&PMz?S`G;$#w>p&R9;P+~N z5`N#e4u)ijJNSS2)-J*FM!fJk(SsP>TJw zYbA`LhHwU6QQ*GG7>BNYml0*1uU_j8U~$bKt>dJ(iat({s0OX}GN&ia>!X-{5->T49F66_nXFWgxw1CM z{wYFEE;^({t#VsDkA+RY_77nHOT%zPG|@s!7MxT9zb6K zqb5XqsB@)+j@V-W^Jlw2A<;0>Cww3S#Ib51%Syal+MsYFwEH;wEw~^!?2aBKt(V}I z{zc8%qeIX#Ily?(HF+{-UG>oyUd~`5XuXi_Ut$wDJwL)P>@Z}FwfLwJprKkEQsh=r zi+b0+o!o@x>Hk(r$cHB6#p=s^|7V|Bxij$$Hyq#+scZH&?$eDWrfWjPC&Rn0^YO4K zuNNCS=>>v+g{EbA1-p+8%9blZp?($=a_eyA}| z*vU^Ll#LKs_2#v3$~Xt1!%1&4TC(0acrcKLcmEowy}V2!xF11pu*p7i;0OQ>6QdlGyY>xkXcHYw>$pM zZx$pzY$l0{jv1%}_`Y)hO)vU&uduV;V2VE-Q;34Wu+DCm__o7f~bn1b!4mnn3-Y7iDwr_ z0Rp62wNm7gdpuoUT~c%o1vk%X?C&+QS1D&Gkc(vlo_4O`2}1x<$V$5db~vs~nGB%I7mr%LNL5&9%<<`@|nOib+ zgX7La!SZR4H7GUg26qqdSKvDJ1kEmFSyF^5jX|5bWiKE{^#;rE*^q-vS>S2oqg}Xy z0m{<{+j;RL?N=4mcdc_;4vqgFeROh`$(GjrWtkmgMq1vO<+^<<8VS0&#=!Zhfx1*c zfsA13_<&=cK(t)_s9nU7{w(}_sN>~? zyuez7VMcAiQt;rs@!tOrtjk}j_Q(MoR4eY7btHia1#X$gX$8w&n93QXSSUP&yq0@Y5 zml(kZ2ns!=CCU7rUN39AYgSGTJ4kU=oxCmHp?|}zHa5(2l+__T`b?bG#+j-+EX2$- zrMEtafT%#9Ye%337dVhS25I4=51jlV84h>hnL6%DX1ZhzO|VOBNZ*2C7(;k|JbxJa zkj8HxWrI_vbi5D{nw#mLJ5ixjUH0rXyHTb)!@OWCbjbWqu%=pykW7?qJ{J6xZ?T!7 z6pBlNkXTQj7Y;ULUkTu(2uP9TgMPk-Hyr47g;>%HZz*Ej*o@{$>!XU;vgPY%V%O~} z;a>EobofnT?re2!zYuS(o7WeDuOP6|^q1bscPtmtv^Xyn)K2B7i&5p{1{;kg~`c_UZJ(i&4 zN0TR|(-|;i2xI`poi`Wd(|f!t!yt>uTBkH(tp+#_nVD#jbE`*8?usTP@er2X-uDKCrd=0+tv1qIpNstJ8ypS1=c(JnzoZG z$pcEvkk%jIdy-+yrAq5OalG6=oOw^nd3bY9M_zh=mco510Jq9a7LmD8({pQxE!-E) z4&@nl|Mq;xH|mVIRD$YSJL7+3Va{XRGeBL*gO38e4~5;YSV_WQ{P%gIVRz_z_4`5C zM*MDcPeg-ZYGjy{oDKp`rt0|V(wq~Z!yys`kTV+{D(;K7$jr_reOcrD?GK+uR>oM1Srej_k4Dt+DzYPo+9j1~^$lpqaQgXz;J_w2LSYHP&;6+Xg8p=vaxD zdLr6R%`fCnS>HUd8r$plky*29tYlLU2H|+j&{AHeiu5OT-=BxKyS+12q-*-4iH>o~ zKSmR8z1~{(Ffrbr=E~Wh+|Ctz@3pE^FVn2VDr0KyvE|G*3-(E21)t3w&ZMbpa{_DU zoHMJxmd4)nmdg9NFzsv}?FSnkMh)+AkkwP41HVe*?^gI!YXUy|4=%=%DeT>9X0GDZ zATZzJ3HID`ZCw-K^4sQ5DZEGboAf|TldK-}cE3V19XC;pbajahWhwa@HT7Hm8e)dd zxGzat1SLAj5@Pqzw?KhG=WQy0`jj0JS7W2Alyprj9Kmo}twYsZ7b#&m$z~P$ER;G@ zxbraIZ~xBd=%HpnOqsi{c_!^*En=OMFrWG>(pJI-8(Djf`AQm6xs_=s%uK;n$MNuz zhMo}E^~Ps&Wzj16lsaq{Ppx`KSSrq&^5rDf4O!d+dl^=~18~lF1bhjA3J}G-5xvn? zD(zYRQePUH)Utq#u=upOiSsTrngt^Wmd9+;7rc*DjXH*bmn@x+YKl!{CsaYTj9=Wmwy@gn*z`^~ z5L3U&2s%LYWVNt|@qco{UG}R8 z4D;dC5~qYuWusV*y^~T)lk+jf{mxgq6mP@&rM#XrnO;chnk}o27wFKBbu(9RqRmd} zo-XS}c9lj>$k(by#uywrfm;G%3s;zqz+<0`oLAlU&DDZRR{QgzNe0`id) z3gO8{3sT`lSt>3}Mw%y><)?r^`Rbbkp#bY;BIsrcuQt|Ny5f99kC$BESpwP4GU5-Z z&ag(tbO51Z&sTjv%A9Yt`$hi|xKGG8@zYgX#N9Vswhd_veZKkE5!qZht+y)Y@G;vL z+gtYLYFZPCS@<#gqJGX#z?|8RsAZd4z!DO zXLD!2FMRxualK+;H|I@+% z9BJ`MW3x`M^dV=Lk8%BZ^M`#Gj(i~ArZ&ItpOC{4q^pj*b+pr0T&~iN5q~rAsYOdr z^>s^JbxZKVSLY?N;gOV#pbs)^Bf4<|3Keu zpW6?R1{asMP{q-$usBkwsWn83PJq7kn!REacP5KPkslHH=SlR)FW9r#W?-$kh zB*o9T-FW%G9me^^^Y4vYFb9I^Au$9`1(#xhDB%P{FbR9zwoqNg1sxGxA znE_t};Q*E&H-RwtE;EIw20wz+4OS+lfe61i*EInZARY1NMXFks5H2Srs&1?a8c}ja z!68S@N+`$Zb9T9C8NUa{)A1_Kt84e(p%go_Bn#Cre}G7_jqAF%Yb-c$+`XgQ7+>SP zn$OvKi6r<%5&uvYll1M6-y+xFP&2@uiq5QIr2WrqZX{iqGN z1VMfPIFvA%rTt9JpL^YtSKbC|W#sj4Rb`mu-V-udU1Wz$rV?wZVzlypLq{Kpi()Sr zOk^Mzwu*-RA@1@FVSd)^gi|1q91z3esP7;0E^_(Ra8yW+Vjw|m~^?4WF zlb#WUmrKgVUX7+CJ`(a=1HCgl?pMKk^vrl0Abua35qf9EQSA4#**mSouAf36(?4NW zE*UOa_A;JJHhR{5{8cZ-aB{phhq$jwD6&tZz;()4f`7?TKNh+unRE?FrP`(^Yun5yWm7aRqONOc@9ngN#*2w^$@l++xyWU6Cuyn^IBx z$xVv#sy9h^LoK1yY3tmRU-;JjAKK^xTISeH&oDJ#@p&t)~_nu@2yxyYHF!tns?p}m6 z^>RL!CPTS2`=70PIms;3;vlfbzOk6O-OP&xLaNG)S95tr82=9KWB&qW!U@9`8FIig zHdPA}keWA>?dh{UoRvotE~24>7#Q|jXC^nWU9{$MogJP`9R(7$UfX5Ux{0@CzVGEa z;(>G{rFLi0EQ!fsZZBaaBc_nT>X1d({2W8AqkbdFc(;I<8OY`kjF~F+AjEAqg_;jM zc}x(MvF8Mw=c>Ac4r3=QWa#U3e9UK4ll%880e&nWs2!kdwEK zsUkY{IUAC710fn(&N^Lr1Fv|ye{G()5==15pGe~b!cu_t-*#&97=vw>588r_q!?jF z-!KQ8C=16{h73F}9iv3%jY_|Y#*OPkVYvB012e|!xfx9y{5-)l8xn_esoBG=^lh(m-9!;2eTrfyIC)!EO_<=22_ZC}qc%t*_ z_@MNspVo%T!otGMeQ?bF_nD0&v`cW0iWBi{YMYgIv9(zB5FbrmO&?Zc`$Y7BZ;&J={ z2rvnr71jOs#C*_evS~ybQ2Bs*!Z3$Ssafqy%9|bmIO*CSNe+=#%SSSboL3xeOiy+K zsj&mcN?OI+>%47AWZxR8xc;WVY*EopT~)ZKNN>v^`FhoIz5Mg>xTg1;UTMquw?LXg zHc-EonGH$&kt_5J4BA-#w!&?v5MU>d(nJiIG~=8iJRO#5m>?x?3TyAoj5sYt*V8j; z8*!iY&z7il*$da#H39is%;~ z8yn)HNih5PQggsd0VHIq@ef}E;0P2&Bv2_^6=e}h(M!@ZNk6Dj1D7GzlBRqPl+PV# zeqxNw^WBriolW2FvHCTDYOX04hc=)8WrkI>si{Q%N(=}^#;v2i*5ji+`VX{!-QU3? zOV&gmZ1l&1vjHEjm4#yM&{D|?!0}3}NO&R)nMYbuB3pY`EU8*4HKNDRbOV8tSOHEE z4mGh;sp&WQa7vT)KQI-vEGIfGWE1UnTd;T92KLkNPviBWbh`8 zEbQ*J(nm_hd5U}!ZrX(0uWaLaC?Cs^Cd(Ic1`#x+m(C%Y35}V!`C4Lbcr0Ic`8s9b zF4|m;y*Dn!jxPr<1#|&&RXgkr1s?IPS>WAhpXT8+4dmzFI85yRGcT#`5IsDEV>eC2 zBn!a^YAb`uBd_cq!Vt~^fdj8|G4Z$#ikepE{SKtyaR&AzMR_J^@OCNYb-+$oJ`0g)<@Fe3Kw7HeL}PAVHK%?u%Tbk zc7p`B3{IWO(+X!)jDNh@Cy2J~V)d?jNWZY)Ew!&TBmUrT;=$mzg&}%OC5+qlC0_(P z>Q&F6QcUCW_0f3=duBBFB-l{E+wc;J)PN}X{|!uvWA2u#A(jlIRhCWQ0knN50hhOcfv^43d6yOpMelK>GcXvO+(x(%$ z29{^N?u9r>woJx{81OJkz`b+h6`R6=Jq#b@t#6uEb#Un7vpO*P(tGtLplPK%NwInc z6ZPHX3fq^jiIK*1sdKW0hr@{6Kg@3{H+Q&109Pfq*Ha@ROb#X{^!}Y}3wV2!b2%fy zY}UwTO& zJF~Y1*Ea&y4j1$giux=W%}()I4qe;*;a$|_hH7_1Z|%!o$(nbieOI$-RdEc8Rq8?Y zCG=5YZ6S=_4mkNc&^fQzxDyHu#Tngl69tUgzZj2I6lRn~TtXl1nF@S&8~WJS6`x@W z(bGp6j3v}%3r$MguL)ly*E?qaN2@v@*K?i#%j zr0|(-eZ=bUt2V{&S&;||>K$aLYcoISdH}7F#U|CN2>?fB|Bj)cd_yJdFoL8_R^7|X zsNREX!kS*#Z>LW*T4!34jZWq3o;Sz;_iOoXb>Z3Jl09X)!-)Ae@ZDxS-2&gf1`x0= zqw}z~-III%>@<3uQtZs&#fGn3o(66{SRs+z8#m&n+o*#ceb0O%4!J<@V&`vQ?Y#+- z^W$W>}>VPE@5uKp6u&c+wfpN>ny3Av`MmY#Xm^+TLPdapiY~w+ArK`d+zlR;U5%oCr*=B1es~>BF@5ZF>mCra z+NtEoEew>l5vao1urswf9h@Lnl3S@QYaYTF-T_xSABW?6GOc27ZB4Q&=vPA9cS7Ah zR*-1xi|LZ3XXB9X4zuE36tM9t*GNo$Gm)U8um>V;8qQSiBpfRBi`Edab|)!$R5KB~ z$!m*CS5X0PZwf8hC2ku{_#$sniE(YtxNy2~NrTR@5JG7dC8lG$?T^#;bhH!?cN()e zD@!JtfdVW}!FwN`ph`d$s`?SoZ`_NWTzzf}S5uDN-T+Ck{`Y66ZEtv%ejs{QTnTG% z6<(JfLZ8;rt@JnXH<0DhPFhAPwv{KBo7a*3YS~TQ+)~Evg|qQm;3y2^hx~)X??VYf zA_yMwxPg5cZ#H|&1~m7&A{u?!~ z-mQ7ExWwe;q{6#+ueyG*3WpKPe1(M01XyRu;(m7?S>R%eI7`$ITNRB!4;+Zmi<~djpgs>q=Zc>A5k$6u{`IH42o4kk@QGm5%0B0>v(`0c zjqUtO-E)C%$?+JOlYP&HZM?5Y6?P!@94r4R_@>8 zbsge5y@UMOCet#wjo^Ib`cMS*MZNw zeZ`1-HP4=q3bM^O>bvd+_h+fRdjT-jVd8ey%aUI;=7f<=KIDsv{Np$RIP=YZS*oZM z4^4|VY!Q21M)~tUQyt8@l|}bxh4&w(c|WhPvZbr!*KnrkEj^sEjE(4TLIzuJYC9CL z@lkYdNgR7_z-fhllJ;Gn6GMLTKC|P5cMXV&CX5ofLP%S~YDj?LboFI_?vhW949o!T zXRgf$cSdr>%+N6#?~0F}Lll1P9UOfb;$GF6qGb} zbQ->&Foyc`Q>NHfSbxs*m7EbV_9|YP0xsKW^j6HP%#*IZ7#|!__37|s-nVi(iuv`p@XTOb)c$KfXA)4@9Sb9^PrB5DN)1c%Y z?y8rW*1{4bVx0*<(a+B-NG#sQTwQxKKOR>@*99d#KZX$Gdl#1rG}h0|iBnCGI-}Ld zCgGDV`13x`XeL_|xD%>ZP%~EDTTCOyaJNG|-S55PEg#Qk{qd3dlB?x1t%dili{EuZ z>nQWj{wo;yiXSHlfeJ#u*P-fk*ws^AMHY98P%t1N#1W6UK0UEPut%Hz$J9NS;G@JDCEZrhO z1WF+;i_S#)4Vz<===gk4u#ns#~{n$hafG(V6>*inKhApGJ+YK@pdHo(A?fUUL6x+#3_1e{;auK7W9-Jko?UyCHWgpVa8FuLE||n zmO*P^kwn_$4x^C@G=N8#{}Ch}v`DnO)+aS_WM1mIH-ihEtocLL!Cxn{jS+`zxP1@L z^8FsC`LhKk;!*d-Qz#P-ek6e&LFSTA8(q-kydi$ILc~`-7V~=ncJuqOQFsDehY5+w zW9E8S1e8ePJm9v_J+3YVPpSCjjiZG#1DkguRQ!aHh_=!Cr63A@zwImbUe~JLIV@-) zD?b>g`6S`{VPB(hxF6#9_Xg0_8cM4iQ4Z7`9cmHd&_ayMMrTS(xKjLi1gcAOBJ(q_ zi5VZ{#+4-xXtsM2e?m#TcqJ~TNj2HeN}ro%b`3}X3N#yAm}|)%K9{$zI<1?wo^;(Yz{ctLyK%WNPVBDQ{ia*xN@-LgYyg#xx#@jJFK4Srql#zdK!+wrGe1k83a zb3Zelx7RNnX(s~yv;OY0=b+k&(oS;v&ut!dKBt?KC3zd7L#(-OZ$#9Sx-FHpy+R8E zhrRaDE!j{SbI5MaSma`y4y9?~4{UPx2-?fWudI-YT*Y3U-BD$&3TPy1N+jgLrJzFW z-(tIX6>MR#5P1sg`felYkCO^-2)`|%#T;DIvNd(K$DJfI&PHGBPeRt(KXn|bMRv?a zy6(Z~u8}6(xH+c8osdrqhU#;Q)~^xkH~=YYI-A%j^F6I_*}8fp-wAYIflp)=6W=GN zxm_)A2XmS_RGp$m(*tK7+&b=4+Ti}_R}of=k$=9mG`D9T-;Ez_1z(XNxZe@A_|yKn zhXD9?x)=3Dg^|wI%B@m5-Bv&wzR%+8Ju2_(r#wwRTJx$ZR>0Rv`Nu2X@!<7&?P&Ph z7bkj4<&pV5s^#)wGh3f+A1x9Vr-aNqaJ2K{ix?n3|?c=IGylgcGe_Os@QI{ zISj5S?kz=zk_r`LIJx*3^RWiHd_A++ghdQ6-gx2;(x(s^-;5|+|2Q^ES}m3+%4%XP z>S4JQKkTqCfsw^Kt0f@tMzZ{M@_dbv7=m(xGcfu^n;Kn;TM_%!*8@i+EJtZ?Zi9#7K5=F23v5T!ha4v1>&Kq0{!%~le+Ew5=g@v z8@cVtAGZayQ2Y6s?%*$}u;F)m7j#l)yX+))`HS#dq^;BfYt8>%x=3xAcPQ>_+a_#0 z;-I*_0`v?uqbp?v;jLn33mn)eDBVVUN3QwyA7Fg3F7g8>UAdd$?RcxMOR;CcvrsMG zpbj7~=l0-+s=0F>aB6P=BGrpsk#`Kri05nfZg3f0uzy!jcBW->ve?V@37$| zYy`eO$o?Gkrdq;z-mwv~w#^Sp>pc8?fnaIKFTqXie;)OtKO+FvfOGL>4j~DH|0xmoYZ-5;&&4$h%$S zWKh}8vgJguoNC0EUgR*4^x!9>oKW$yX&k6hk;7j1OwyT;$XmP92$0(oq29GAL_qJl z3$TMuJ{?Kg>?W-L#hH6=N6O2gE9GiVRhnyFGe-GvfKcQ4 z^h*I9(=Vw<^3dSapy&^O&4Z5Ax^{-y(Yvig?mu19*~5|D;-}@(d_r*TArX}QK}qTU z=g2M5zDDI9an}d?8ol>Y7c;t7WcpzEDfCcaS5Lu9o0J5M>>LJtT@BfkJb18jH|6;` z9{0Lr1jnL$w5FSL<*TvQYj4Y+aWtkI^K@>+L9pY$M|SVwihOO}m0*9Ek3%+@@-~@6 z>s{7+y_k-$sg;ra$e|D6Syl6CjMO3hFj|PQoV<|j$4obwyR2`~1FA73D(i67b$0z4 z@buJB2OOF}ryb=yQL4XE)q1S*sSn4V=ha^2W3>jcjY$sSx($2PZHWA3$b1cQH4NCu zYPRdN+c{=l`(}7LO~I5=c|k=o5m?`cQDU56C8w18eXwWXm;APu1nXyM?5xUq5$uU>i%@)B>@8!tmItX|)}i<|P6iIaY?!oV zM9`javi0NVlil+SG;$5H(EWob)y0*!7hO$o*_{JmN|`q2THyj8_3WtdTkbi|<7$ z!@MG#+}rpTNDJY!X4m}3`xE%*)^%+(>94kG!5At08jjT?tN84$Rpqhk3*`}F7-_e7qpNmkS`Y4G}#;nHQX2?%G;{bWw z!EaWonp}Q1=!4AVSaw99o;v0BvYL7)sJAD6_~&jUlmkski??P&@2I)C$<}!f{My5+ zVd25zzBdX^V!o@Z_Y@14m5-fNVVJOoRH);+#UB06ObqZhAf=@mol(fR$jO~qB5*Ik zbI1YMDym>)*0SQ$#bVG>N?Nky&0WaA`Z>B@&0A%EkXGQ=$yzcV%g(ltDXP%iOC8DM z@S<#h!}U1u&Y!{B^D*O^+3jg~=r5Qp$lYIa*EtCYDJo~xv{5+zWPT6_hOV7;dZpvIfyS&1=yAu@R%bK=HaOseGzKfrbT zF_nN~4n?g}!Otb#T+q`xCH%*4gA>AhrZrCS# z2i7oe-B($gt6nce^+J0wDkKCcLjoK(ils-W99YpUtt9KGCXdx&kiqPe0Wdq2up)ot z2G#zW9R8az0mYlXbK;$z`KBx&5|=whjOrsZGHt{86997J8$z)+}3%gpUj`jQcDXw&w!It5(w`3G6~6;}e9OY{U(yjSm_GO}F zL0z2J)&quZ#ex*tc_;GzTqx9NE7mlBqJ+}?h(XVkudzD8UsU2?I$fucVj0#YqY}S+ zOJNqqXRswAN&;=Zy6o8*g6k$v1LuCPc_(TL3b1q7)mZZW&YQgdJq+U8#Bz)^KVsdG zt=^z|&oy?k4wREjUBwac8M+jFU>fdcHwl?DOhm;9#jady4vszQxtO`eK(&c&-V5|2 zz(Rp-$mEaHKtnh~n6WX_(i_bcpUdnx$BD~eb4BQ#aI~Jj%3^Ua(cd-&l}C7rYKJd| z-|(zqXv>wc0jQW|Rty;j*b1AAIG}rDiO4r_1VzAHn|9K`Q02l__O{9|+IC3P2@+SI zBW);3J?D13OwCA~bLUr z0*FS5c6*mQ;E1kRm`nR~>-$X1N3bHi&tTmaF+7f)E50}xpvZ{dX#=>o!g5&=eZ+95 zY}>rd#K}mwmaRI8JJ&m&^c_)=rK7XviylwOubSY?) zGo4Q&d^z`h9s?>v@vlT`Smd&c$v|9tSmtGyc`5Uo-&l#VltFR+@nV)X@%N%~`*VTU z|BoyVUW2UD?a}_JdO;P%Tg>&az>#C_Op-Jw(Z_;o9@OhtnU18;99Xct$-K@U1ZT$U zU);)<9mNRFt=cMRXQ8m+b;JiyG9p9h%b6L4D0szs0Y!K3wg$Gp~k(lqbnq= z-$-!c%o8KRaOoi5b~jf%@|C;2`e%_O9i$tBud(eb{^8?x=kl@m35dESf97U8usNHq zuD`K7%nk}dsQF=i1qkzJ`ad*vJeyai5hM0$;RF|MM=2RdY6u((%*=?Vd@|U+-MJyt z>GHs%PKZ*E7MFe8Coss5t0%U}+Wdso$Bo1qy4P(ZI6Vj#c~NT*m!ECPcQe^0-!QQ7 zz&-YhP`wtCW5{4!BkvbK5EzVZ4ON0I>{1}0wJu(UmarMS2?hI?g$i;4fGG8aQWS@^ zvYPl$6%J`amDG%huIp%GQJc^dIlcCH_LjHz0z@|)D{EQ~pU&tLb!L85&Gwa!z8PboG>aHw zzjsvTp#(pV)%|m|#Y{F{wyh`-g69=CKuj(D-&uKb~&g4OaZ=WYkz7{iX3)1|$apO>V zcKIUd4@&3f&+?v~wX81q5XJt~(IYeu3IqCe9r$0coD!tY~(?%uhH6Fp-KqXtQV94Q{O=z;BCJos~_EwCqc zBmZJsiLf{cr1u^T<>47xK^OhQwtc%`MZ_0qj*)5&$-fwPB$(y-IDLbTaWVrjD+pXp zqM|X|xH88idE6ewjq=fM%;vVKg{QdByiCjtvShpbbIg^-E5(j;$E?H0VkhQf$GmU_5JM{Gc?y54JoYt>0SBSUgGA zCc5$ln(>Nx^`y0u55Nv|0H?gx(JLu}R_e%1pAqZ)T9~2^I}CJ_(>lvG9#Q-RxaBZ8 zd7QsIko`8x^{Xs&#%^3sgNPmcxB0b4vOmQtmI{`fQQlfD`R(A;eO=+_-HDl2;Jqq~ zRa^Y+?quCFF1ir{Lt|hpi~zo~tubw{P8{t*U7*(yxpGT4t=kS`qN2ZDf6r|W8MkgU z=nu``iOyh3(Fy|ZfAk_k7Z&YkaY1gZBDM1Q-h-f^lk=zcrbXgaPVC2$>uv(eXKel; zZRC|sTWik@;_wT&UUM4FeIhQYwYRp#*khfbar_kIe2mW#7!Y_?6|KTsKdZ_iVRYY( z@|6gO9U5db7TA6Fsa#MMbH7BaEw6s+b~FPi_yx1NI{4U6zy_q=Hu&3kunbeXY48)O zou3-+bWO>QR0A_XpJjezHq99GEg!*lmbVmRLo?W;5(yt%J7Lj~_r}MD8in&+h2g}F zYAl_vd`>NK={p!={ftpVW>~blnYq$M5gtw=m%KQWOR%sxn`#R@A{3=JoS2gf@vm}I z(`ZY7qNrS;ym<8s=6wQtImAmdDFi>}TahDgOti=JJER<82skgmg3RtOpR?o}_>pEh z0{vo{HEAEmbyZ*N)49FfwzT^6mRw8)UTRskKU{vfwWcB|u_U9ZSN7 zIzmHvP`djgO}P&JtAwuMmnWr9i8)I7k>HdKvQA7tw$v&JxA$VqOth1$J5W$-8ZLs2 z2y15Hh&bRAw&BGiP!T4ajx)PU-O$jiScrd$Z8lST(%@Ivw*G<+tw%ckIKJ0hx@cD( zdp{>cQ3aPl(rWbF?o!k6`>&13;mMm7&OoB{6AMNLyo*o8V2iV7RC-n1iCjS^PPQ;d z0Zhf0m>zXJg?wSo#da1(3Wq<9Hs%sDqg2zrz5+taa`iehn6h5*VMszpCu3?*{GvJp z3WnW9teO=#wAIy26tF6CQrMyE`d6d9jKW3O3}*bd&OzT}y5*zTX2QUBy-3G;TJT63-k9~X^F3@C8?)v+VpTvLE|7uy2$ z<&X7rPCLrB^_wEQI~iMVmNA+$H0<-d@#W2c=d_7vW`yRPo`)Kw>lgr>lmeA&+Nn!QAF7vH4jhVlA_r^EtBgV`WJfqba9GClcruW;li= z9m+uFxaLFsp9?ET`nPVZpA}x=Nm%{{1ks^VhHV zMOJ~5J9E&x8u2s?Zp>BCyi0D1w)ndYr^k(KKt4McAcsQR!^O*^HReBjavHA&GZ!k* zbn#_4FQ^QRu7f4MtIICs`hCtwH8y+KcP8jloiL9={|2V@k9qu464%*J2#SyNx2rd( zDjy&utr|$ptpU(8pyjULi@6QfyT)q4>#Ly6&0b>C@ku`hq0?sHSnr^B*)f}(oaBmN zhDP7UEPW7v#VQvNmIN{G&ml_usX8DPezIrGGj+&UWXzgo07Rr#)r{uKB+ruU_{|~o zv<8e3=e(RVUweOVk^G>M2bBRlXHB>TT!(##sGd3gHlwXzMYrX7ewf^ zfe_b*%A`8NmiHLLs3I!Wn9N*iw0TeuU_~2cRrcswZn2I4)xOqEl^6r}?ip8dRzWoF zL9W_B|Ge|w&>+W6rQi^gZh27e&WxCyHHF|wr4Z8b9_fpKhP!U|ZyLAW{ww(raOcm; zi5v{rQfkBYjp@L8aqoJ_DNGaxcu)RJti{P&ZMTNzl+&|nhG4?P1nyQx`XpdHvKL}ZhAqh_4bFx)P3VyMrZ?c=S-BrNh+ zu~mNz89(gdIlA0d!PP#l7b<6K^} zssmE6iI3bml0DL`$w(R-cCgS8WpRKYUP8XOU~btX(E1#fvK!!^G+rqx$tC_(I0xV&9BX3E5mw681+eri)j<^mxF z?4)_I{=Vc=JM)MR4|UuJYcZB1t;YnL+EfoG^|V2Sx0Vf@**C{Cud|(~dwI2fBMk^u z+gSP_(C9+=`nzC~EOQRB*KE$Jz2Chfz!o+JH})9R3)M}QL2nQFx~FAD=Pz|;Ms~#F zuycJw`r>BGyayvk_>XC5uYaaGyQs{s$Gt<+!>?5-ocowmUM7t1?Z0-`-W21##C#LN z2@eW(HgF6i)7K;p3h6mH9R6}@$=nbjAzmBc_H`YWk1KI>J%DJOEMx%C3nGy#ZTPwe z9awdV=cq+BFFO-TB=ZL_6ScM=GGVf?|DbLNYlpglyn_5&UytYjDZ%Kh4fJJ}TOny6 z00002UjPQ89B?er;vxV8fzp_80006o8Pos(0000Bpr&gjy%iZW?r>HC?zEhzWTHKS zwhAgYNEh7#Na>Z;p2>{|Gf>ztL7vqYg;+O#T-@i&(K9QUOza__?ny}kg- z=WmZbtux9<1b{O7K)4e#Q?5SCLqALYobkDMxf3Xk#9gj9ctrE>uFL0#mc64q(h_~d z`x>pmK{yO8+j6S3Tt1eQhgG7oT2ld;&E(yXmOi+_nWJZXV&);9%<8jO&E4K8Qg!c* zoTV-NVE4K1y5?MhOgi*ysbS2e*%mw$aJ;@Zz<^dudDF)mT9f8!#_?7#@RA^~sb7nS zbwt^rHUT?;&XLn8LZ1ikVyn+9i!^6P8XL>Mco&lfbpz0z6b|`dF&pi6^!k!@zd`!I z`N~-SNhDWWdSYC76mmNih=`INE0O}2Zvp=^+keZ#(rF3m8nqVD7b@8n7@n+AG%Grs9FGX2;_8czhO_KZJWcI_NGVHM0wU(In_E$sWL z(=`A1w&U|P`7b93ghfe`Uw0{!<=gG?+a^g?VFoE{0+E;vKZL?5>K%%Bv zE`|!}4i2e5CjVOem$K>wV6AOHl2sI7IN$siU+2nWV!n3P9^6ji%z0oRljYgKCDS-< zT@s!M0RX=+@^Hr`=ZX^G-WV9zIIKwsGW(9Bn2c5MX{$^B%pRDtdEf7y;m4YMNwW~- zY)Ajc>7C1BVS63karkv zvkR9fOj zn|UD_2_2+@C4M5ksswI4D15f|}01(?(H9@K7hF^dH0000009;;n$-{+$4Mvp)=BY@! zT1kxV4$?x;0=lw^MZCB$^d{EeyHpXfUdReS934YvWp#0@ehubhmUTS!{`dHg$N5@| zXw}yU*Y(e9TYfo%a6$O9ihqI_uR&A|H}98I*uGm6(mHHkt}tyVBe&kYSj7UmC^={` z6s1sxwUWYXq3g`JUo8@VI8459@v+s$t@t+g(b7DYH^s9cIa-To)z=8u_0N%6K47Dg zuxks?K@=vRNyZ|VfB*pww5e=WWySVw52A%B1`&W5f~M}+Jco^DV5|pLSklUng%;n_F;GLZxzG| zs79G?M#7B3A8Gn~Jgzlgd-UK@ynuJ9QHRHk4HyPWDl~z(C1@wfFBwJ$r|tahy|@6i zG>Op1l&;MV5AkR#03?kEXJ;4QHmMFc&F-Ja@wfaTNN|xAu;^KY^t^J;YS%@_@GK8U zS$HBQL&-`j8-51;NfsPHD3ZV z!r%aQ3-e-tZFRT+5Zti0RT=li5&!holsE{^_Q%vFGF;b+UqL+Ngu3{R LOya7~TmS$7mzk8H diff --git a/docs/media/brand/README.md b/docs/media/brand/README.md index fd52b82b69..4e5bf70dbc 100644 --- a/docs/media/brand/README.md +++ b/docs/media/brand/README.md @@ -37,6 +37,18 @@ d=$(mktemp -d) && npm install --prefix "$d" playwright && npx --prefix "$d" play NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();for(const[f,w,h,o]of[["card",1280,640,"github-social-1280x640"]]){const p=await b.newPage({viewport:{width:w,height:h}});await p.goto("file://"+require("path").resolve("docs/media/brand/social/"+f+".html"));await p.waitForTimeout(300);await p.screenshot({path:"docs/media/brand/social/"+o+".png"})}await b.close()})()' ``` +## The README banner + +`docs/media/banner.webp` (1600x654) is the website's hero, a screenshot of playhorizon.dev in its +dark theme. The navbar, the hero's buttons and its version line are hidden first: no launcher +version goes stale, and the banner is the message and the landscape. The crop starts 56 px above +the kicker, so no empty sky sits over the text. With Playwright, from the repository's root: + +```sh +d=$(mktemp -d) && npm install --prefix "$d" playwright && npx --prefix "$d" playwright install chromium +NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();const p=await b.newPage({viewport:{width:1600,height:1000}});await p.goto("https://playhorizon.dev/",{waitUntil:"networkidle"});await p.click(".theme-toggle");await p.waitForTimeout(1200);await p.evaluate(()=>{for(const s of ["header",".hero-actions",".download-meta"]){const e=document.querySelector(s);if(e)e.style.display="none"}});await p.waitForTimeout(400);await p.screenshot({path:"docs/media/banner.webp",clip:{x:0,y:136,width:1600,height:654}});await b.close()})()' +``` + ## Drawn in code | Where | What | diff --git a/tests/test_ci_rust_changes.py b/tests/test_ci_rust_changes.py index 1fff955d28..6e2054a46f 100644 --- a/tests/test_ci_rust_changes.py +++ b/tests/test_ci_rust_changes.py @@ -11,7 +11,7 @@ class RustChangesTests(unittest.TestCase): def test_only_content_changes_skip_rust(self): self.assertFalse(needs_rust([])) self.assertFalse(needs_rust([ - "docs/setup.md", "docs/media/banner.html", "README.md", + "docs/setup.md", "docs/media/banner.webp", "README.md", ])) for path in [ "crates/horizon-client/src/game.rs", "xtask/src/main.rs", "Cargo.lock", From 573f398a2a5827954d981a1d83f9421fd451d554 Mon Sep 17 00:00:00 2001 From: TraiNguyenVan Date: Fri, 30 Oct 2026 01:21:09 +0700 Subject: [PATCH 050/968] README: the Settings picture is named launcher-settings.webp The 0.2.26 capture replaced the Patch notes page but kept its file name. Nothing else referenced it, so the name now follows the page it shows. Co-Authored-By: Qoder --- README.md | 2 +- ...cher-patch-notes.webp => launcher-settings.webp} | Bin 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/media/{launcher-patch-notes.webp => launcher-settings.webp} (100%) diff --git a/README.md b/README.md index 21ddb21def..312a0371b1 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Horizon is installed through **the Horizon launcher**. Get it from [playhorizon. - +
The launcher's Home page: the selected instance ready to play, Minecraft's prepared files, the version picker and PlayThe launcher's Settings page: account, updates and appearanceThe launcher's Settings page: account, updates and appearance
diff --git a/docs/media/launcher-patch-notes.webp b/docs/media/launcher-settings.webp similarity index 100% rename from docs/media/launcher-patch-notes.webp rename to docs/media/launcher-settings.webp From d1699635566442ba69dc54652c241dae55e90b35 Mon Sep 17 00:00:00 2001 From: TraiNguyenVan Date: Fri, 30 Oct 2026 01:25:41 +0700 Subject: [PATCH 051/968] README: the banner drops the lede, so it carries no harness count The site's hero says 281 differential harnesses; the README's prose says more than 300. Keeping that line in a screenshot puts the two side by side, so the banner is now the kicker, the headline and the landscape (1600x600) and the README owns the count. Co-Authored-By: Qoder --- docs/media/banner.webp | Bin 25456 -> 17988 bytes docs/media/brand/README.md | 11 ++++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/media/banner.webp b/docs/media/banner.webp index d37430704b56fe23432797c199d6dbe73762d8b2..68b7618cdfe92265d6c10e8f487770835f4f056a 100644 GIT binary patch literal 17988 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 literal 25456 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 GkN^Pj4|$yc diff --git a/docs/media/brand/README.md b/docs/media/brand/README.md index 4e5bf70dbc..5b64530b63 100644 --- a/docs/media/brand/README.md +++ b/docs/media/brand/README.md @@ -39,14 +39,15 @@ NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(asy ## The README banner -`docs/media/banner.webp` (1600x654) is the website's hero, a screenshot of playhorizon.dev in its -dark theme. The navbar, the hero's buttons and its version line are hidden first: no launcher -version goes stale, and the banner is the message and the landscape. The crop starts 56 px above -the kicker, so no empty sky sits over the text. With Playwright, from the repository's root: +`docs/media/banner.webp` (1600x600) is the website's hero, a screenshot of playhorizon.dev in its +dark theme. The navbar, the hero's buttons, its version line and its lede are hidden first, so the +banner carries no launcher version and no harness count (the README's prose owns the count), and +nothing in it goes stale. The crop starts 56 px above the kicker and ends at the hero's bottom +edge. With Playwright, from the repository's root: ```sh d=$(mktemp -d) && npm install --prefix "$d" playwright && npx --prefix "$d" playwright install chromium -NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();const p=await b.newPage({viewport:{width:1600,height:1000}});await p.goto("https://playhorizon.dev/",{waitUntil:"networkidle"});await p.click(".theme-toggle");await p.waitForTimeout(1200);await p.evaluate(()=>{for(const s of ["header",".hero-actions",".download-meta"]){const e=document.querySelector(s);if(e)e.style.display="none"}});await p.waitForTimeout(400);await p.screenshot({path:"docs/media/banner.webp",clip:{x:0,y:136,width:1600,height:654}});await b.close()})()' +NODE_PATH="$d/node_modules" node -e 'const {chromium}=require("playwright");(async()=>{const b=await chromium.launch();const p=await b.newPage({viewport:{width:1600,height:1000}});await p.goto("https://playhorizon.dev/",{waitUntil:"networkidle"});await p.click(".theme-toggle");await p.waitForTimeout(1200);await p.evaluate(()=>{for(const s of ["header",".hero-actions",".download-meta",".lede"]){const e=document.querySelector(s);if(e)e.style.display="none"}});await p.waitForTimeout(400);await p.screenshot({path:"docs/media/banner.webp",clip:{x:0,y:136,width:1600,height:600}});await b.close()})()' ``` ## Drawn in code From 4264d34f1c9b311e7999dc3f24877083d65bb6e5 Mon Sep 17 00:00:00 2001 From: GIGABAIT93 Date: Thu, 29 Oct 2026 20:32:38 +0200 Subject: [PATCH 052/968] --offscreen: hide the window and leave the system pointer alone An offscreen game renders into a texture, so its window only ever showed white, yet entering a world grabbed the system pointer: on Windows it was clipped to one pixel even while the window wasn't in front, until the person alt-tabbed. The window is now created hidden, the grab is the game's own state (agents still turn the view with mouse-move), and the real mouse's raw motion never steers an offscreen game. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 528da54439..e10e98b220 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -311,7 +311,8 @@ struct Options { /// `HORIZON_SPIKES=MS`: print the timed spans of frames slower than MS. spikes: Option, /// Render into a texture instead of the window (`--offscreen`), so a - /// GPU the display cannot present from can still be benchmarked. + /// GPU the display cannot present from can still be benchmarked. The + /// window stays hidden and never grabs the system pointer. offscreen: bool, /// `--width` and `--height` (vanilla's own arguments): the window's /// starting size, 1280x720 by default. @@ -5968,7 +5969,15 @@ impl Game<'_> { }; let scale = HudBatch::new(size).scale; let was_grabbed = self.captured; - if capture { + if self.options.offscreen { + // `--offscreen` shows no window: the system pointer stays where the + // person at the computer has it, and the grab is the game's own (an + // agent turns the view with `mouse-move`). + if capture { + self.mouse.set_ignore_first_move(); + } + self.captured = capture; + } else if capture { self.mouse.set_ignore_first_move(); // macOS warps by re-associating the mouse with the pointer, // which undoes a Locked grab and leaves the pointer free: warp @@ -6015,7 +6024,9 @@ impl Game<'_> { ) { self.cursor = pos; } - window.set_cursor_visible(!self.captured); + if !self.options.offscreen { + window.set_cursor_visible(!self.captured); + } } /// `GuiGraphicsExtractor.applyCursor` -> `Window.selectCursor` at the end /// of a frame: the cursor the frame's widgets asked for, set on the window @@ -13062,10 +13073,12 @@ impl ApplicationHandler for Game<'_> { let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name( attributes, "horizon", "horizon", ); + // `--offscreen` renders into a texture: its window would only show white. let window = match event_loop.create_window( attributes .with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1])) - .with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))), + .with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))) + .with_visible(!self.options.offscreen), ) { Ok(w) => Arc::new(w), Err(e) => { @@ -14091,9 +14104,11 @@ impl ApplicationHandler for Game<'_> { } } fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) { + // The real mouse never steers an offscreen game: its pointer is not grabbed. if self.pending_save.is_none() && self.captured && self.focused + && !self.options.offscreen && self.screen == Screen::Playing && let DeviceEvent::MouseMotion { delta: (x, y) } = event { @@ -15625,10 +15640,12 @@ impl Loading { let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name( attributes, "horizon", "horizon", ); + // `--offscreen` renders into a texture: its window would only show white. let window = match event_loop.create_window( attributes .with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1])) - .with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))), + .with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))) + .with_visible(!self.options.as_ref().is_some_and(|o| o.offscreen)), ) { Ok(w) => Arc::new(w), Err(e) => { From e855ded821c73eba802ec6f5984f3a68d7ecd59d Mon Sep 17 00:00:00 2001 From: GIGABAIT93 Date: Thu, 29 Oct 2026 20:44:20 +0200 Subject: [PATCH 053/968] Test that an offscreen game never grabs, warps or hides the pointer The capture fixture compiles the client's own capture body; it now has the game's options, and an offscreen game must keep its grab as its own state without touching the window's pointer. Co-Authored-By: Claude Opus 5.5 --- tests/test_world_input_capture.py | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/tests/test_world_input_capture.py b/tests/test_world_input_capture.py index 58ce25395a..ecb709dafc 100644 --- a/tests/test_world_input_capture.py +++ b/tests/test_world_input_capture.py @@ -99,13 +99,15 @@ struct Mouse { ignore_first: bool } impl Mouse { fn set_ignore_first_move(&mut self) { self.ignore_first = true; } } struct Remote; #[derive(Default)] +struct Options { offscreen: bool } +#[derive(Default)] struct ServerPacks { prompt: Option<()> } struct Game { focused: bool, agent_pointer: bool, window: Option, mouse: Mouse, keyboard_mouse: bool, view_at: [u32; 2], captured: bool, title: Option<()>, screen: Screen, remote: Option, mining: bool, lan_acting: Option, renderer: Option, cursor: (f32, f32), - server_packs: ServerPacks, + server_packs: ServerPacks, options: Options, } impl Default for Game { fn default() -> Self { @@ -113,7 +115,8 @@ impl Default for Game { keyboard_mouse: true, view_at: [0, 0], mouse: Mouse::default(), captured: false, title: None, screen: Screen::Playing, remote: None, mining: false, lan_acting: None, - renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default() } + renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default(), + options: Options::default() } } } HELPER @@ -212,6 +215,23 @@ fn main() { assert_eq!(game.window.as_ref().unwrap().at.get(), (1700.0, 700.0)); assert_eq!(game.cursor, (250.0, 150.0)); } + // `--offscreen` shows no window: the grab is the game's own, and the system + // pointer is never grabbed, warped or hidden. + { + let mut game = Game::default(); + game.options.offscreen = true; + game.window.as_ref().unwrap().visible.set(true); + game.capture_loaded_world(); + assert!(game.captured); + assert!(game.mouse.ignore_first); + game.capture(false); + assert!(!game.captured); + game.capture(true); + assert!(game.captured); + let window = game.window.as_ref().unwrap(); + assert_eq!((window.grabs.get(), window.warps.get()), (0, 0)); + assert!(window.visible.get()); + } let mut game = Game::default(); // On a server the chat is ChatScreen (#708): loading never grabs over it. game.remote = Some(Remote); From 647ec89bfc39688c0703bd1444c1db57ea3627dc Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:28:38 +0500 Subject: [PATCH 054/968] Launcher: native rebuilds stay on the instance's Horizon and in its instance - A build starts from the version the instance plays; a native build's next one compiles its own recorded revision (native-mods.json) from its own game files. `native-build --update` moves to the newest installed release or Nightly, and `native_mods::update_for` says which one a window could offer. - The build pins the instance the mods are installed in, the default one included; no more hidden "Native mods" instance (old ones keep working). - Toolchain commands drop RUSTFLAGS, CARGO_PROFILE_*, CARGO_BUILD_*, CARGO_TARGET_*, CARGO_INCREMENTAL and friends, and start Cargo from the disk's root with --manifest-path, so the player's ~/.cargo/config.toml can't change the flags. - prune keeps the three newest native builds no instance plays. Co-Authored-By: Claude Opus 5.5 --- docs/launcher-commands.md | 5 +- docs/native-mods.md | 41 +++- launcher/src/cli.rs | 34 ++-- launcher/src/main.rs | 16 +- launcher/src/native_mods.rs | 356 +++++++++++++++++++++------------ launcher/src/rust_toolchain.rs | 89 ++++++++- 6 files changed, 372 insertions(+), 169 deletions(-) diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 9c38e47f2b..bd729c753b 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -73,12 +73,13 @@ MCP: `server`, `server_command`, `server_log`, `server_stop`. ## `horizon-launcher native-build` -Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, with the launcher's own Rust, as the version the "Native mods" instance plays. Not sandboxed: the mods' code runs with your rights. +Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, else the one the instance plays, with the launcher's own Rust, as the version the instance then plays. Not sandboxed: the mods' code runs with your rights. horizon-launcher native-build [FOLDER] - `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance) -- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one; the game's own home builds into the Native mods instance) +- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one) +- `--update`: Move to the newest installed release or Nightly instead of the Horizon the instance plays (a full rebuild) - `--trust-native-code`: You read the mods' source and trust it with full access to this computer MCP: none: the player's switch. diff --git a/docs/native-mods.md b/docs/native-mods.md index b93c3cfc1f..9b6c844758 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -45,8 +45,8 @@ In the window: install a native mod from the Mods page. The first one shows a no Rust and can take 20 to 30 minutes; later builds are faster) and turns on Settings → Game → **Native mods (experimental)**, which stays off for players who never install one; `mods install` does the same. Then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the row shows each step, the error if one fails, and the build the instance plays. From the command line: -`horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked -`.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool: +`horizon-launcher native-build [FOLDER] [--instance NAME] [--update] --trust-native-code`. FOLDER is a bundle, an +unpacked `.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool: trusting native code is the player's decision, never an agent's. 1. **Rust.** The launcher's own toolchain ([`rust_toolchain`](../launcher/src/rust_toolchain.rs)): Rust 1.99.1's @@ -73,11 +73,28 @@ trusting native code is the player's decision, never an agent's. horizon-mixins diagnostic in one line). 6. **Install**: a new version, `native--`, a copy of the starting version's folder (resources included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json` - saying which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it (a - folder built from the command line, or mods of the default home, go to the **Native mods** instance). That - instance gets each mod's settings from the default home the first time it plays it (its - `horizon-mods/.horizon-mod-data//` folder and `key..` lines of `horizon-mods.txt`, where the mod's - WebAssembly version kept them); nothing it already has is overwritten. + saying which release or Nightly it started from (`base`) and which commit, mods, horizon-mixins and Rust made + it. The instance the mods are installed in plays it, the default **Horizon** instance included: the build + pins its version, as picking a version does. Updates of the launcher's version leave a pinned instance where + it is, and their clean-up never removes a native build. An instance built before this (the **Native mods** + instance the default home's mods used to go to) keeps its worlds and its build. + +## Rebuilds stay on the instance's Horizon + +A build starts from the version the instance plays (`native_mods::base_for`). When that is a native build, the +next one compiles the same commit (its `native-mods.json`'s `horizon`) from the same game files, unless a mod pins +another, so a rebuild after a mod changes or is switched off compiles only what changed. A newer Horizon is taken +only when the player asks: `native-build --update` starts from the newest installed release or Nightly (a full +rebuild); `native_mods::update_for` says which one, if any, the window can offer. + +The compiler runs as the release's build does, whatever the player's own Rust setup: the launcher's toolchain +drops the variables that change compiler flags or the target (`RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, +`RUSTDOCFLAGS`, `CARGO_INCREMENTAL`, `CARGO_PROFILE_*`, `CARGO_BUILD_*`, `CARGO_TARGET_*`, rustup's and the +wrappers'), and starts Cargo from the disk's root with `--manifest-path`. Cargo reads `.cargo/config.toml` from +the folder it starts in and every one above it, whatever `CARGO_HOME` says, so from the launcher's data folder it +would read the player's `~/.cargo/config.toml`, whose `[profile]`, `[build]` and `[target]` settings beat the +workspace and the environment; no switch turns that search off. The source's own `.cargo/config.toml`, if it +has one, is passed with `--config`. ## Cache and disk @@ -111,7 +128,9 @@ Measured 2026-10-28 on Linux (an x86_64 server with no Rust of its own), Zoom on ran: `rust install` in 11 s (592 MB), the first build in 12 min 9 s. The built game ran Zoom's proof (under `xvfb-run`: the game needs a display even with `--offscreen`). -Only the native builds an instance plays are kept (`native_mods::prune`); `native/target/` stays for the next one. +The native builds an instance plays are kept, and the three newest no instance plays (`native_mods::prune`, by when +each was built or last used): switching a mod off and on again plays the kept build again without compiling. +`native/target/` stays for the next one. ## Trust @@ -126,7 +145,8 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ A WebAssembly mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the player's `.hmod` (the game runs it until a native build has the mod, then skips it); removing the mod removes both. -- **Isolation of data.** Native mods run only in the instance built for them, with its own worlds, options and logs. +- **Isolation of data.** Native mods run only in the instance they are installed in, with its worlds, options and + logs (the default Horizon instance's, when they are installed there); other instances play their own versions. - What this does not do: sandbox the compiled game. horizon-mixins' checks stop mistakes, not a malicious helper ([security notes](https://github.com/horizon-rust/horizon-mixins/blob/main/docs/security.md)). @@ -139,5 +159,6 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ ## Left to do -- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes). +- Rebuild after the mods change, and offer **Update** when `update_for` finds a newer Horizon (asking first, as it + compiles for minutes). - A prebuilt horizon-mixins from its CI instead of building it on the player's computer. diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index 994ef02ecd..8c439e2f4b 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -707,20 +707,25 @@ enum Cmd { accept_eula: bool, }, /// Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon - /// revision they pin, with the launcher's own Rust, as the version the "Native mods" instance - /// plays. Not sandboxed: the mods' code runs with your rights + /// revision they pin, else the one the instance plays, with the launcher's own Rust, as the + /// version the instance then plays. Not sandboxed: the mods' code runs with your rights NativeBuild { /// A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them /// (default: the native mods installed and turned on in the instance) folder: Option, /// The instance whose installed native mods to build, and which then plays the build - /// (default: the selected one; the game's own home builds into the Native mods instance) + /// (default: the selected one) #[arg(long)] instance: Option, /// The installed version whose game files the build reuses, and whose commit it builds on - /// when no mod pins one (default: the selected one) + /// when no mod pins one (default: the one the instance plays, so a rebuild stays on its + /// Horizon) #[arg(long)] version: Option, + /// Move to the newest installed release or Nightly instead of the Horizon the instance + /// plays (a full rebuild) + #[arg(long, conflicts_with = "version")] + update: bool, /// You read the mods' source and trust it with full access to this computer #[arg(long)] trust_native_code: bool, @@ -1135,25 +1140,30 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> { folder, instance, version, + update, trust_native_code, } => { if !trust_native_code { return Err("Native mods run with full access to this computer. Read their source, then pass --trust-native-code.".into()); } - let base = backend::resolve_version(version.as_deref())?; let started = std::time::Instant::now(); let target = match &instance { Some(name) => instances::find(name)?, None => instances::selected(), }; - let (folder, target) = match folder { - Some(folder) => (folder, None), - None => ( - horizon_launcher::native_mods::installed_mods(&target.home)?, - Some(&target), - ), + let base = match version { + Some(v) => backend::resolve_version(Some(&v))?, + None => { + let plays = target.resolve_version(None).ok(); + native_mods::base_for(plays.as_deref(), &backend::installed(), update) + .ok_or("No version is installed yet.")? + } }; - let built = horizon_launcher::native_mods::build(&base, &folder, target, |step| { + let folder = match folder { + Some(folder) => folder, + None => native_mods::installed_mods(&target.home)?, + }; + let built = native_mods::build(&base, &folder, &target, |step| { eprintln!("{}", step.label()) })?; println!( diff --git a/launcher/src/main.rs b/launcher/src/main.rs index bafe16f83d..dead0a8c33 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -591,7 +591,7 @@ impl Launcher { } /// Builds the selected instance's native mods, once the player accepted the warning. The - /// build reuses the game files of the version the instance plays, else the newest installed. + /// build stays on the Horizon the instance plays (`native_mods::base_for`). fn build_native(&mut self) { if self.native_step.is_some() { return; @@ -599,16 +599,8 @@ impl Launcher { self.native_confirm = None; self.native_error = None; let instance = instances::selected(); - let base = instance - .version - .clone() - .filter(|v| !native_mods::is_native(v)) - .or_else(|| { - self.installed - .iter() - .find(|t| backend::is_game_tag(t) || t.starts_with("nightly-")) - .cloned() - }); + let plays = self.version_of(&instance).cloned(); + let base = native_mods::base_for(plays.as_deref(), &self.installed, false); let Some(base) = base else { self.native_error = Some(tr("Install Horizon first.").into()); return; @@ -623,7 +615,7 @@ impl Launcher { self.native_step = Some(tr("Starting the build").into()); let tx = self.tx.clone(); std::thread::spawn(move || { - let result = native_mods::build(&base, &folder, Some(&instance), |step| { + let result = native_mods::build(&base, &folder, &instance, |step| { let _ = tx.send(Msg::NativeProgress(step.label())); }); let _ = tx.send(Msg::Native(result)); diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 654f53aafc..2f8f784036 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -11,8 +11,9 @@ //! by default, so such mods always build together. The build compiles that revision's source, from //! GitHub, never a checkout on this computer, and installs as its own version, //! `native--` (the key hashes the revision, the mods' files, the tool's commit and -//! the profile, so the same set builds once). It plays in its own instance, "Native mods", so a -//! mod never touches the player's worlds. +//! the profile, so the same set builds once). The instance the mods are installed in plays it, and +//! its next build stays on the same revision ([`base_for`]) until the player asks to update +//! ([`update_for`]), so a rebuild compiles only what the mods changed. use crate::backend; use crate::instances::{self, Instance}; use crate::rust_toolchain::{self, Toolchain}; @@ -22,9 +23,6 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -/// The Native mods instance's id (its folder in `instances/`) and name. -pub const INSTANCE: &str = "native-mods"; -const NAME: &str = "Native mods"; const PREFIX: &str = "native-"; /// horizon-mixins, for bundles that don't name the revision they were tested with. const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins"; @@ -356,6 +354,58 @@ pub fn revision_of(version: &str) -> Result { .ok_or_else(|| format!("GitHub doesn't know the commit of {version}.")) } +/// What a native build was made from: its `native-mods.json`. +#[derive(Clone, Debug, PartialEq)] +pub struct About { + /// The release or Nightly it started from. + pub base: String, + /// The Horizon commit it compiled. + pub horizon: String, +} + +/// What installed native build `version` was made from; None for any other version. +pub fn about(version: &str) -> Option { + if !is_native(version) { + return None; + } + let path = backend::version_dir(version).join("native-mods.json"); + let json: serde_json::Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?; + let field = |name: &str| json[name].as_str().map(str::to_owned); + Some(About { + base: field("base")?, + horizon: field("horizon").filter(|r| is_revision(r))?, + }) +} + +/// The newest installed release or Nightly in `installed` (newest first). +fn newest(installed: &[String]) -> Option<&String> { + installed + .iter() + .find(|t| backend::is_game_tag(t) || t.starts_with("nightly-")) +} + +/// The installed version a build for an instance that plays `current` starts from: `current` +/// itself, so a rebuild stays on the Horizon it plays (a native build's own revision, [`about`]). +/// With `update`, or when it plays nothing installed, the newest installed release or Nightly. +pub fn base_for(current: Option<&str>, installed: &[String], update: bool) -> Option { + match current { + Some(v) if !update && installed.iter().any(|t| t == v) => Some(v.to_owned()), + _ => newest(installed).cloned(), + } +} + +/// The newer Horizon a build with `update` would move native build `version` to: the newest +/// installed release or Nightly, when it is newer than the one the build started from. +pub fn update_for(version: &str, installed: &[String]) -> Option { + newer_than(&about(version)?.base, installed) +} + +fn newer_than(base: &str, installed: &[String]) -> Option { + newest(installed) + .filter(|n| *n != base && backend::compare_versions(n, base).is_gt()) + .cloned() +} + /// Every file under `dir`, by its path relative to `dir` (with `/`), sorted. A symlink is refused: /// horizon-mixins refuses them too. fn files(dir: &Path) -> Result, String> { @@ -522,6 +572,26 @@ fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) cmd } +/// The release's own build of the game in `stage` (game-build.yml), into `target`. It starts +/// outside any Cargo configuration of the player's ([`rust_toolchain::outside_configs`]), so only +/// the workspace's own (`stage/.cargo/config.toml`, when it has one) applies. +fn compile(rust: &Toolchain, stage: &Path, target: &Path) -> Command { + let mut cmd = rust.command(&rust.cargo()); + cmd.env("CARGO_TARGET_DIR", target); + cmd.arg("build"); + let config = stage.join(".cargo").join("config.toml"); + if config.is_file() { + cmd.arg("--config").arg(config); + } + cmd.arg("--manifest-path").arg(stage.join("Cargo.toml")); + cmd.args(["--locked", "--profile", PROFILE]); + if has_mods_feature(stage) { + cmd.args(["--features", FEATURES]); + } + cmd.args(["-p", "horizon-client", "--bin", "horizon-client"]); + cmd +} + /// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in /// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly /// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out: @@ -585,13 +655,13 @@ fn sync(from: &Path, to: &Path) -> Result<(), String> { } /// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version -/// whose game files the build reuses) and points `instance` at the build: the instance the mods -/// were installed in, else (None, or the game's own home) the Native mods instance. Returns the -/// build's version; a build already made is used again. +/// whose game files the build reuses: a native build's own revision when it is one, [`base_for`]) +/// and points `instance` at the build, the default one included. Returns the build's version; a +/// build already made is used again. pub fn build( base: &str, folder: &Path, - instance: Option<&Instance>, + instance: &Instance, progress: impl Fn(Step), ) -> Result { backend::need_prepared(base)?; @@ -602,6 +672,9 @@ pub fn build( let incoming = root().join("mods.incoming"); let _ = fs::remove_dir_all(&incoming); let found = bundles(folder)?; + // A native build starts from the release or Nightly it was made from, on its own revision. + let built_on = about(base); + let origin = built_on.as_ref().map_or(base, |a| a.base.as_str()); for b in &found { backend::copy_dir(&b.dir, &incoming.join(&b.id)).map_err(|e| e.to_string())?; } @@ -619,7 +692,10 @@ pub fn build( &pins, "Horizon", |b| b.horizon.as_ref(), - || revision_of(base), + || match &built_on { + Some(about) => Ok(about.horizon.clone()), + None => revision_of(base), + }, )?; let revision = with_hooks(&mods, pinned, free, hooks_at)?; for b in mods.iter().filter(|b| b.hooks.is_some()) { @@ -649,28 +725,26 @@ pub fn build( "horizon-mixins", |_| {}, )?; - let stage = root().join("stage"); + // Absolute: the compiler runs from elsewhere (`compile`). + let stage = real.join("stage"); sync(&fresh, &stage)?; let _ = fs::remove_dir_all(&fresh); - let target = root().join("target"); - let mut compile = rust.command(&rust.cargo()); - compile.current_dir(&stage).env("CARGO_TARGET_DIR", &target); - compile.args(["build", "--locked", "--profile", PROFILE]); - if has_mods_feature(&stage) { - compile.args(["--features", FEATURES]); - } - compile.args(["-p", "horizon-client", "--bin", "horizon-client"]); + let target = real.join("target"); let mut crates = 0; - run(compile, "Compiling the game", |line| { - if let Some(name) = line.trim_start().strip_prefix("Compiling ") { - crates += 1; - let name = name.split_whitespace().next().unwrap_or(name); - progress(Step::Compile(crates, name.to_owned())); - } - })?; + run( + compile(&rust, &stage, &target), + "Compiling the game", + |line| { + if let Some(name) = line.trim_start().strip_prefix("Compiling ") { + crates += 1; + let name = name.split_whitespace().next().unwrap_or(name); + progress(Step::Compile(crates, name.to_owned())); + } + }, + )?; progress(Step::Install); let about = serde_json::json!({ - "base": base, + "base": origin, "horizon": revision, "horizon_mixins": mixins, "hooks": mods.iter().find_map(|m| m.hooks), @@ -679,16 +753,11 @@ pub fn build( }); let binary = target.join(PROFILE).join(backend::CLIENT); install(base, &version, &binary, &about)?; + } else { + // Used again: it counts as the newest of the builds kept (`prune`). + let _ = fs::write(backend::version_dir(&version).join(".installed"), ""); } - match instance.filter(|i| i.kind != instances::Kind::Default) { - Some(own) => instances::edit(&own.id, |meta| meta.version = Some(version.clone()))?, - None => { - let native = instance_on(&version)?; - let ids: Vec<&str> = mods.iter().map(|m| m.id.as_str()).collect(); - carry_settings(&backend::game_home(), &native.home, &ids)?; - native - } - }; + instances::edit(&instance.id, |meta| meta.version = Some(version.clone()))?; prune(&version); let _ = fs::remove_dir_all(&incoming); Ok(version) @@ -736,64 +805,48 @@ fn install( fs::write(dir.join(".installed"), "").map_err(|e| e.to_string()) } -/// The Native mods instance, made empty the first time, playing `version`. -fn instance_on(version: &str) -> Result { - if instances::find(INSTANCE).is_err() { - instances::create(NAME, None)?; - } - instances::edit(INSTANCE, |meta| meta.version = Some(version.to_owned())) -} +/// How many native builds no instance plays stay installed, the newest first: switching a mod +/// off and on again plays the kept build again, without compiling. +const SPARE_BUILDS: usize = 3; -/// The mods' settings from the game's own home `from` (where their WebAssembly versions kept -/// them) into the Native mods instance's `to`, for each mod `to` has none of yet: its storage -/// folder (`horizon-mods/.horizon-mod-data//`) and its keys (`key..:…` lines of -/// `horizon-mods.txt`). Nothing is overwritten. -fn carry_settings(from: &Path, to: &Path, ids: &[&str]) -> Result<(), String> { - const DATA: &str = "horizon-mods/.horizon-mod-data"; - const LIST: &str = "horizon-mods.txt"; - let read = |home: &Path| fs::read_to_string(home.join(LIST)).unwrap_or_default(); - let (theirs, mut ours) = (read(from), read(to)); - let before = ours.len(); - for id in ids { - let data = from.join(DATA).join(id); - let target = to.join(DATA).join(id); - if data.is_dir() && !target.exists() { - backend::copy_dir(&data, &target).map_err(|e| format!("{}: {e}", data.display()))?; - } - let prefix = format!("key.{id}."); - let has_keys = ours.lines().any(|l| l.starts_with(&prefix)); - for line in theirs - .lines() - .filter(|l| l.starts_with(&prefix) && !has_keys) - { - if !ours.is_empty() && !ours.ends_with('\n') { - ours.push('\n'); - } - ours += line; - ours.push('\n'); - } - } - if ours.len() != before { - fs::create_dir_all(to).map_err(|e| e.to_string())?; - fs::write(to.join(LIST), ours).map_err(|e| format!("{LIST}: {e}"))?; - } - Ok(()) -} - -/// Removes the native builds other than `keep` that no instance plays. The compiled crates -/// (`native/target`) stay, so the next build only compiles what changed. +/// Removes the native builds no instance plays, but `keep` and the [`SPARE_BUILDS`] newest. The +/// compiled crates (`native/target`) stay, so the next build only compiles what changed. pub fn prune(keep: &str) { let played: Vec = instances::list() .into_iter() .filter_map(|i| i.version) .collect(); - for old in backend::installed() { - if is_native(&old) && old != keep && !played.contains(&old) { - let _ = backend::remove(&old); - } + let builds = backend::installed() + .into_iter() + .filter(|v| is_native(v)) + .map(|v| { + let made = fs::metadata(backend::version_dir(&v).join(".installed")) + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH); + (v, made) + }) + .collect(); + for old in stale(builds, keep, &played) { + let _ = backend::remove(&old); } } +/// The native `builds` (with when each was made or last used) to remove: those no instance plays +/// (`played`), other than `keep`, past the [`SPARE_BUILDS`] newest. +fn stale( + mut builds: Vec<(String, std::time::SystemTime)>, + keep: &str, + played: &[String], +) -> Vec { + builds.retain(|(v, _)| v != keep && !played.contains(v)); + builds.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + builds + .into_iter() + .skip(SPARE_BUILDS) + .map(|(v, _)| v) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -841,46 +894,6 @@ mod tests { d } - #[test] - fn the_native_mods_instance_gets_the_mods_settings_once() { - let (from, to) = (dir("carry-from"), dir("carry-to")); - let data = from.join("horizon-mods/.horizon-mod-data"); - fs::create_dir_all(data.join("zoom")).unwrap(); - fs::write(data.join("zoom/level"), "8").unwrap(); - fs::create_dir_all(data.join("other")).unwrap(); - fs::write( - from.join("horizon-mods.txt"), - "mod.zoom:true\nkey.zoom.zoom:key.keyboard.c\nkey.other.x:key.keyboard.x\n", - ) - .unwrap(); - fs::write( - to.join("horizon-mods.txt"), - "key.minimap.map:key.keyboard.m", - ) - .unwrap(); - carry_settings(&from, &to, &["zoom", "minimap"]).unwrap(); - let ours = to.join("horizon-mods/.horizon-mod-data"); - assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "8"); - assert!(!ours.join("other").exists()); - assert_eq!( - fs::read_to_string(to.join("horizon-mods.txt")).unwrap(), - "key.minimap.map:key.keyboard.m\nkey.zoom.zoom:key.keyboard.c\n" - ); - // Changed in the instance since: kept. - fs::write(ours.join("zoom/level"), "4").unwrap(); - fs::write( - from.join("horizon-mods.txt"), - "key.zoom.zoom:key.keyboard.v\n", - ) - .unwrap(); - carry_settings(&from, &to, &["zoom"]).unwrap(); - assert_eq!(fs::read_to_string(ours.join("zoom/level")).unwrap(), "4"); - assert!(fs::read_to_string(to.join("horizon-mods.txt")) - .unwrap() - .contains("key.zoom.zoom:key.keyboard.c")); - let _ = (fs::remove_dir_all(&from), fs::remove_dir_all(&to)); - } - fn write_bundle(at: &Path, id: &str, helper: &str) { fs::create_dir_all(at).unwrap(); let manifest = format!("[mod]\nid = \"{id}\"\nversion = \"0.1.0\"\n"); @@ -1173,6 +1186,99 @@ mod tests { assert!(!to.join("gone.rs").exists()); } + /// levzzz5154's decision (2026-10-29): a rebuild stays on the Horizon the instance plays; + /// only Update moves it to a newer one. + #[test] + fn a_rebuild_stays_on_the_instances_horizon_until_an_update() { + let installed: Vec = [ + "nightly-2026-10-29-4baa571", + "v2026.10.20", + "native-83f15fa-0123456789ab", + "v2026.10.13", + ] + .map(String::from) + .to_vec(); + let base = |current: Option<&str>, update| base_for(current, &installed, update); + let native = "native-83f15fa-0123456789ab"; + assert_eq!(base(Some(native), false).as_deref(), Some(native)); + assert_eq!( + base(Some("v2026.10.13"), false).as_deref(), + Some("v2026.10.13") + ); + let newest = Some("nightly-2026-10-29-4baa571"); + assert_eq!(base(Some(native), true).as_deref(), newest); + assert_eq!(base(None, false).as_deref(), newest); + assert_eq!(base(Some("native-gone"), false).as_deref(), newest); + assert_eq!(base_for(Some(native), &[native.into()], true), None); + // Update offers the newest release or Nightly, when newer than the build's own. + assert_eq!(newer_than("v2026.10.20", &installed).as_deref(), newest); + assert_eq!(newer_than("nightly-2026-10-29-4baa571", &installed), None); + let releases = ["v2026.10.20".to_string(), "v2026.10.13".to_string()]; + assert_eq!(newer_than("v2026.10.20", &releases), None); + assert_eq!( + newer_than("v2026.10.13", &releases).as_deref(), + Some("v2026.10.20") + ); + } + + #[test] + fn the_newest_builds_no_instance_plays_are_kept() { + let at = |s: u64| std::time::UNIX_EPOCH + std::time::Duration::from_secs(s); + let builds: Vec<(String, std::time::SystemTime)> = [ + ("native-a", 1), + ("native-b", 6), + ("native-c", 3), + ("native-d", 5), + ("native-e", 4), + ("native-f", 2), + ("native-g", 7), + ] + .map(|(v, s)| (v.to_string(), at(s))) + .to_vec(); + // g was just built, b another instance plays: d, e and c are the newest spare ones. + let mut gone = stale(builds.clone(), "native-g", &["native-b".into()]); + gone.sort(); + assert_eq!(gone, ["native-a", "native-f"]); + assert!(stale(builds[..4].to_vec(), "native-a", &[]).is_empty()); + } + + #[test] + fn the_game_compiles_outside_the_players_cargo_configuration() { + let rust = Toolchain { + dir: PathBuf::from("/rust/1.99.1-x"), + }; + let stage = dir("stage-config"); + let cmd = compile(&rust, &stage, Path::new("/native/target")); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert_eq!( + args[..3], + [ + "build", + "--manifest-path", + &*stage.join("Cargo.toml").to_string_lossy() + ] + ); + assert!( + args.ends_with(&["-p", "horizon-client", "--bin", "horizon-client"].map(String::from)) + ); + let dir = cmd.get_current_dir().expect("a folder of its own"); + assert!( + dir.has_root() && dir.parent().is_none(), + "{}", + dir.display() + ); + // The workspace's own configuration still applies. + fs::create_dir_all(stage.join(".cargo")).unwrap(); + fs::write(stage.join(".cargo/config.toml"), "").unwrap(); + let cmd = compile(&rust, &stage, Path::new("/native/target")); + let args: Vec<_> = cmd.get_args().collect(); + assert_eq!(args[1], "--config"); + assert_eq!(args[2], stage.join(".cargo/config.toml")); + } + #[test] fn tool_errors_read_in_one_line() { let err = "{\n \"code\": \"E_REVISION\",\n \"message\": \"requires exact Horizon revision a\",\n \"file\": \"m.toml\"\n}"; diff --git a/launcher/src/rust_toolchain.rs b/launcher/src/rust_toolchain.rs index 85f2f91e23..3259a03c8f 100644 --- a/launcher/src/rust_toolchain.rs +++ b/launcher/src/rust_toolchain.rs @@ -142,7 +142,7 @@ impl Toolchain { /// `program` (this toolchain's cargo, or a tool built with it) set up to use only this /// toolchain: its rustc, its MinGW-w64 where it has one, the launcher's Cargo home, and none of - /// the player's rustup or compiler-wrapper settings. + /// the player's rustup, compiler-wrapper or compiler-flag settings ([`scrubbed`]). pub fn command(&self, program: &Path) -> Command { let mut cmd = Command::new(program); let path = std::env::var_os("PATH").unwrap_or_default(); @@ -150,18 +150,20 @@ impl Toolchain { let paths = std::iter::once(self.dir.join("bin")) .chain(mingw) .chain(std::env::split_paths(&path)); + if let Some(root) = outside_configs() { + cmd.current_dir(root); + } cmd.env("PATH", std::env::join_paths(paths).unwrap_or(path)) .env("RUSTC", self.dir.join("bin").join(exe("rustc"))) .env("CARGO_HOME", cargo_home()); - for name in [ - "RUSTUP_TOOLCHAIN", - "RUSTC_WRAPPER", - "RUSTC_WORKSPACE_WRAPPER", - "CARGO_BUILD_RUSTC_WRAPPER", - "CARGO_TARGET_DIR", - ] { + for name in SCRUBBED { cmd.env_remove(name); } + for (name, _) in std::env::vars_os() { + if name.to_str().is_some_and(scrubbed) { + cmd.env_remove(name); + } + } cmd } @@ -182,6 +184,43 @@ impl Toolchain { } } +/// Whether the player's environment variable `name` stays out of a build: it would pick another +/// Rust or wrap it (rustup, `RUSTC_WRAPPER`), or change the compiler's flags (`RUSTFLAGS`, a +/// profile, incremental, the build's target, linker or jobs: `CARGO_BUILD_*`, `CARGO_TARGET_*`), +/// so the build would differ from the release's and recompile everything after any change. +pub fn scrubbed(name: &str) -> bool { + const PREFIXES: [&str; 3] = ["CARGO_PROFILE_", "CARGO_BUILD_", "CARGO_TARGET_"]; + // Windows' variable names ignore case. + let name = name.to_ascii_uppercase(); + SCRUBBED.contains(&name.as_str()) || PREFIXES.iter().any(|p| name.starts_with(p)) +} + +const SCRUBBED: [&str; 8] = [ + "RUSTUP_TOOLCHAIN", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "RUSTDOCFLAGS", + "CARGO_ENCODED_RUSTDOCFLAGS", + "CARGO_INCREMENTAL", +]; + +/// Where commands run so that no player's Cargo configuration reaches them: the root of the disk +/// the launcher's data is on. Cargo reads `.cargo/config.toml` in the folder it starts in and in +/// every folder above it, so from a folder under the player's home it would read +/// `~/.cargo/config.toml` even with another `CARGO_HOME`: its `[profile]`, `[build]` and +/// `[target]` settings (rustflags, linker, incremental) beat both the workspace's profile and +/// the environment, and no command-line switch turns that search off. A build names its workspace +/// with `--manifest-path` instead. +pub fn outside_configs() -> Option { + let data = backend::data_dir(); + data.ancestors() + .last() + .filter(|root| root.has_root()) + .map(Path::to_path_buf) +} + /// Where toolchains live: `/rust/`. pub fn root() -> PathBuf { backend::data_dir().join("rust") @@ -411,6 +450,40 @@ mod tests { } } + #[test] + fn the_players_compiler_settings_stay_out_of_builds() { + for name in [ + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "RUSTDOCFLAGS", + "CARGO_INCREMENTAL", + "CARGO_PROFILE_RELEASE_LTO", + "CARGO_PROFILE_RELEASE_CODEGEN_UNITS", + "CARGO_BUILD_TARGET", + "CARGO_BUILD_RUSTFLAGS", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_TARGET_DIR", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + "RUSTC_WRAPPER", + "RUSTUP_TOOLCHAIN", + "cargo_profile_release_debug", + ] { + assert!(scrubbed(name), "{name}"); + } + for name in [ + "PATH", + "HOME", + "CARGO_HOME", + "CARGO_NET_OFFLINE", + "CARGO_HTTP_PROXY", + "HTTPS_PROXY", + "SSL_CERT_FILE", + ] { + assert!(!scrubbed(name), "{name}"); + } + } + #[test] fn component_files_land_under_the_prefix_and_nothing_else_does() { let at = |p: &str| component_path(Path::new(p)); From aba29be6af0480c0663992641ef32940ea1e938e Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:30:53 +0500 Subject: [PATCH 055/968] Launcher: native mods are on by default; Build moves to the Mods page The "Native mods (experimental)" switch leaves Settings: native mods are always on. Old settings.json files still read (the `native_mods` key is ignored); `native_mods_noticed` now records the one-time trust notice before the first native install, which no longer asks to turn anything on. The Mods page's installed list gets a "Native build" row for the selected instance: the build's step or error, Build when the version it plays doesn't hold the native mods turned on (compared with its native-mods.json), else "Up to date". Build still opens the red trust warning. An installed native mod now says which instance it went into. Co-Authored-By: Claude Opus 5.5 --- docs/launcher-commands.md | 2 +- docs/native-mods.md | 26 ++--- launcher/lang/de.json | 10 +- launcher/lang/es.json | 10 +- launcher/lang/fr.json | 12 +-- launcher/lang/ru.json | 12 +-- launcher/lang/uk.json | 10 +- launcher/lang/zh.json | 10 +- launcher/src/backend.rs | 8 +- launcher/src/cli.rs | 15 ++- launcher/src/lib.rs | 1 + launcher/src/main.rs | 60 +----------- launcher/src/mods_page.rs | 115 +++++++++++++++++----- launcher/src/native_mods.rs | 58 +---------- launcher/src/native_status.rs | 179 ++++++++++++++++++++++++++++++++++ 15 files changed, 329 insertions(+), 199 deletions(-) create mode 100644 launcher/src/native_status.rs diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 9c38e47f2b..305859b567 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -73,7 +73,7 @@ MCP: `server`, `server_command`, `server_log`, `server_stop`. ## `horizon-launcher native-build` -Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, with the launcher's own Rust, as the version the "Native mods" instance plays. Not sandboxed: the mods' code runs with your rights. +Native mods (docs/native-mods.md): compile mixin bundles on the Horizon revision they pin, with the launcher's own Rust, as the version the "Native mods" instance plays. Not sandboxed: the mods' code runs with your rights. horizon-launcher native-build [FOLDER] diff --git a/docs/native-mods.md b/docs/native-mods.md index b93c3cfc1f..f54b61940a 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -1,4 +1,4 @@ -# Native mods (experimental) +# Native mods Native mods are **mixin bundles** that change the game's own Rust source before it is compiled, with [horizon-mixins](https://github.com/horizon-rust/horizon-mixins) (MIT, by levzzz5154, made for Horizon). A bundle @@ -6,9 +6,9 @@ is a `horizon-mixin.toml` and a few `.rs` helpers that hook into named functions RETURN, MODIFY_ARG, OVERWRITE...; the tool's README lists them). A port such as Veinminer is three HEAD hooks and one module. Start a mod from [horizon-native-mod-template](https://github.com/horizon-rust/horizon-native-mod-template). -Native mods are native code with the player's rights, not sandboxed. The launcher turns them on only when the player -installs one (after a notice) and builds them only behind a red warning; the registry lists one only after a review; and -they play in a game of their own, built for the instance they are installed in. +Native mods are native code with the player's rights, not sandboxed. They are always on in the launcher: there is no +switch. The first install shows a trust notice, and every build waits behind a red warning; the registry lists one only +after a review; and they play in a game of their own, built for the instance they are installed in. ## Pinned to a Nightly @@ -41,10 +41,13 @@ mod shows one. ## What the launcher does -In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads -Rust and can take 20 to 30 minutes; later builds are faster) and turns on Settings → Game → **Native mods -(experimental)**, which stays off for players who never install one; `mods install` does the same. Then **Build**. The launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the -row shows each step, the error if one fails, and the build the instance plays. From the command line: +In the window: install a native mod from the Mods page, into the instance picked on the left. The first one shows a +trust notice once (native mods aren't sandboxed; the first build downloads Rust and can take 20 to 30 minutes; later +builds are faster); `mods install` prints it too. The Mods page's installed list then has a **Native build** row for +that instance: **Build** while the version it plays doesn't hold the native mods turned on (it isn't a `native-*` +build, or the `mods` in its `native-mods.json` differ from those turned on, by id and version), else "Up to date". The +launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the row shows each step and +the error if one fails. From the command line: `horizon-launcher native-build [FOLDER] [--instance NAME] --trust-native-code`. FOLDER is a bundle, an unpacked `.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool: trusting native code is the player's decision, never an agent's. @@ -115,8 +118,8 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ ## Trust -- **Off until the player installs one.** Settings → Game → Native mods stays off for a player who never installs a - native mod; the first install shows the notice and turns it on. The command needs `--trust-native-code`; the window +- **A notice, then a warning.** The first native install shows a trust notice once. The command needs + `--trust-native-code`; the window shows a warning in red before every build: the mods run with full access to the computer, and their ids and versions. The Mods page says who made each one, and "Not made or endorsed by the Horizon project" for any mod not Horizon's own. @@ -139,5 +142,6 @@ Only the native builds an instance plays are kept (`native_mods::prune`); `nativ ## Left to do -- Rebuild after the mods or the Nightly change (asking first, as it compiles for minutes). +- Offer a rebuild when a newer Nightly is out (asking first, as it compiles for minutes). The Mods page already offers + Build when the mods turned on change. - A prebuilt horizon-mixins from its CI instead of building it on the player's computer. diff --git a/launcher/lang/de.json b/launcher/lang/de.json index c31dd48145..1a3760fecf 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -173,9 +173,6 @@ "Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Nicht vorbereitet. Horizon kann Minecraft {version} von Mojang für dein Konto herunterladen oder deine eigene Installation verwenden.", "Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Nicht vorbereitet. Melde dich zuerst mit dem Microsoft-Konto an, das Minecraft {version} besitzt.", "Cancel": "Abbrechen", - "Native mods (experimental)": "Native Mods (experimentell)", - "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Native Mods von der Mods-Seite werden mit dem launcher-eigenen Rust in das Spiel kompiliert. Nicht in einer Sandbox: Sie können alles tun, was du auf diesem Computer tun kannst.", - "{version} plays in this instance.": "Diese Instanz spielt {version}.", "Install Horizon first.": "Installiere zuerst Horizon.", "Starting the build": "Starte den Build", "These mods will run with full access to your computer": "Diese Mods erhalten vollen Zugriff auf deinen Computer", @@ -191,6 +188,9 @@ "Delete…": "Löschen…", "Keep": "Behalten", "Move to Trash": "In den Papierkorb", - "Install and turn on native mods": "Installieren und native Mods einschalten", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller." + "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Native Mods laufen nicht in einer Sandbox: Sie haben vollen Zugriff auf deinen Computer, wie jedes Programm. Installiere nur Mods, deren Autoren du vertraust. Um mit einer zu spielen, baue das Spiel auf der Mods-Seite; der erste Build lädt Rust herunter und dauert 20 bis 30 Minuten.", + "Native build": "Nativer Build", + "No native mod is turned on.": "Keine native Mod ist eingeschaltet.", + "Build the game to play with the native mods turned on.": "Baue das Spiel, um mit den eingeschalteten nativen Mods zu spielen.", + "Up to date: {instance} plays these mods.": "Auf dem neuesten Stand: {instance} spielt mit diesen Mods." } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 4c350b3926..ea0371baa8 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -173,9 +173,6 @@ "Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "No preparado. Horizon puede descargar Minecraft {version} desde Mojang para tu cuenta, o usar tu propia instalación.", "Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "No preparado. Inicia sesión primero con la cuenta de Microsoft que posea Minecraft {version}.", "Cancel": "Cancelar", - "Native mods (experimental)": "Mods nativos (experimental)", - "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Los mods nativos de la página Mods se compilan en el juego con el compilador Rust del propio lanzador. No están aislados: pueden hacer cualquier cosa que tú puedas hacer en este ordenador.", - "{version} plays in this instance.": "{version} se ejecuta en esta instancia.", "Install Horizon first.": "Instala Horizon primero.", "Starting the build": "Iniciando compilación", "These mods will run with full access to your computer": "Estos mods se ejecutarán con acceso completo a tu ordenador", @@ -190,7 +187,10 @@ "Keep": "Conservar", "Move to Trash": "Mover a la papelera", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.", - "Install and turn on native mods": "Instalar y activar los mods nativos", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas." + "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Los mods nativos no están aislados: se ejecutan con acceso total a tu ordenador, como cualquier programa. Instala solo mods de autores en los que confíes. Para jugar con uno, compila el juego en la página Mods; la primera compilación descarga Rust y tarda de 20 a 30 minutos.", + "Native build": "Compilación nativa", + "No native mod is turned on.": "No hay ningún mod nativo activado.", + "Build the game to play with the native mods turned on.": "Compila el juego para jugar con los mods nativos activados.", + "Up to date: {instance} plays these mods.": "Al día: {instance} juega con estos mods." } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 9b69bd8420..75ec54c677 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -177,9 +177,6 @@ "Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Garde les {count} plus récentes, celle utilisée et celles de vos instances. Une version supprimée se réinstalle depuis la page Versions.", "Installed versions use {size}.": "Les versions installées occupent {size}.", "{size} on disk": "{size} sur le disque", - "Native mods (experimental)": "Mods natifs (expérimental)", - "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Les mods natifs de la page Mods sont compilés dans le jeu avec le Rust du launcher. Sans bac à sable : ils peuvent faire tout ce que vous pouvez faire sur cet ordinateur.", - "{version} plays in this instance.": "Cette instance joue {version}.", "Install Horizon first.": "Installez d'abord Horizon.", "Starting the build": "Début de la compilation", "These mods will run with full access to your computer": "Ces mods auront un accès complet à votre ordinateur", @@ -187,10 +184,13 @@ "Mods: {mods}": "Mods : {mods}", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Jouer le télécharge depuis Mojang pour votre compte la première fois, avec le Java qu'il lui faut.", "The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "La compilation construit le jeu : 20 à 30 minutes et quelques Go de disque la première fois (elle télécharge Rust), quelques minutes ensuite.", - "Install and turn on native mods": "Installer et activer les mods natifs", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Les mods natifs sont compilés dans un jeu à part. En installer un active les mods natifs : vous compilez ensuite le jeu avec lui dans Paramètres > Mods natifs. La première compilation télécharge Rust et peut prendre 20 à 30 minutes ; les suivantes sont plus rapides.", "Delete…": "Supprimer…", "Keep": "Garder", "Move to Trash": "Mettre à la corbeille", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", + "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Les mods natifs ne sont pas isolés : ils ont un accès complet à votre ordinateur, comme n'importe quel programme. N'installez que des mods d'auteurs en qui vous avez confiance. Pour jouer avec, compilez le jeu depuis la page Mods ; la première compilation télécharge Rust et prend 20 à 30 minutes.", + "Native build": "Compilation native", + "No native mod is turned on.": "Aucun mod natif n'est activé.", + "Build the game to play with the native mods turned on.": "Compilez le jeu pour jouer avec les mods natifs activés.", + "Up to date: {instance} plays these mods.": "À jour : {instance} joue avec ces mods." } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 5b1986d52a..289c56280d 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -177,9 +177,6 @@ "Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "Оставляет {count} самых новых, используемую и те, что нужны вашим сборкам. Удалённую версию можно снова установить на странице «Версии».", "Installed versions use {size}.": "Установленные версии занимают {size}.", "{size} on disk": "{size} на диске", - "Native mods (experimental)": "Нативные моды (экспериментально)", - "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Нативные моды со страницы «Моды» компилируются в игру собственным Rust лаунчера. Без песочницы: они могут делать на этом компьютере всё, что можете вы.", - "{version} plays in this instance.": "В этой сборке запускается {version}.", "Install Horizon first.": "Сначала установите Horizon.", "Starting the build": "Начало сборки", "These mods will run with full access to your computer": "Эти моды получат полный доступ к вашему компьютеру", @@ -187,10 +184,13 @@ "Mods: {mods}": "Моды: {mods}", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). При первом запуске «Играть» скачает его у Mojang для вашего аккаунта вместе с нужной ему Java.", "The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.": "Сборка компилирует игру: 20–30 минут и несколько ГБ на диске в первый раз (она скачивает Rust), затем несколько минут.", - "Install and turn on native mods": "Установить и включить нативные моды", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативные моды компилируются в отдельную игру. Установка такого мода включает нативные моды: затем вы собираете с ним игру в «Настройки > Нативные моды». Первая сборка скачивает Rust и может занять 20–30 минут; следующие быстрее.", "Delete…": "Удалить…", "Keep": "Оставить", "Move to Trash": "В корзину", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", + "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Нативные моды не изолированы: у них полный доступ к вашему компьютеру, как у любой программы. Устанавливайте только моды авторов, которым доверяете. Чтобы играть с модом, соберите игру на странице «Моды»; первая сборка скачивает Rust и занимает 20–30 минут.", + "Native build": "Нативная сборка", + "No native mod is turned on.": "Ни один нативный мод не включён.", + "Build the game to play with the native mods turned on.": "Соберите игру, чтобы играть с включёнными нативными модами.", + "Up to date: {instance} plays these mods.": "Актуально: {instance} играет с этими модами." } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index cf4b3497d4..867d2b1da6 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -173,9 +173,6 @@ "Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Не підготовлено. Horizon може завантажити Minecraft {version} від Mojang для вашого облікового запису або використати ваше встановлення.", "Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Не підготовлено. Спершу увійдіть з обліковим записом Microsoft, який має Minecraft {version}.", "Cancel": "Скасувати", - "Native mods (experimental)": "Нативні моди (експериментально)", - "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Нативні моди зі сторінки «Моди» компілюються в гру власним Rust лаунчера. Без пісочниці: вони можуть робити все, що можете ви на цьому комп’ютері.", - "{version} plays in this instance.": "Цей екземпляр використовує {version}.", "Install Horizon first.": "Спершу встановіть Horizon.", "Starting the build": "Запуск збирання", "These mods will run with full access to your computer": "Ці моди працюватимуть із повним доступом до вашого комп’ютера", @@ -191,6 +188,9 @@ "Delete…": "Видалити…", "Keep": "Залишити", "Move to Trash": "Перемістити до кошика", - "Install and turn on native mods": "Встановити й увімкнути нативні моди", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші." + "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Нативні моди не ізольовані: вони мають повний доступ до вашого комп'ютера, як будь-яка програма. Встановлюйте лише моди авторів, яким довіряєте. Щоб грати з модом, зберіть гру на сторінці «Моди»; перша збірка завантажує Rust і триває 20–30 хвилин.", + "Native build": "Нативна збірка", + "No native mod is turned on.": "Жоден нативний мод не ввімкнено.", + "Build the game to play with the native mods turned on.": "Зберіть гру, щоб грати з увімкненими нативними модами.", + "Up to date: {instance} plays these mods.": "Актуально: {instance} грає з цими модами." } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index d0bc4fbbf7..2e17470e33 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -177,9 +177,6 @@ "Keeps the {count} newest, the one in use and the ones your instances use. Deleted versions install again from the Versions page.": "保留最新的 {count} 个版本、当前使用的版本,以及你的实例所使用的版本。已删除的版本可从“版本”页面重新安装。", "Installed versions use {size}.": "已安装的版本共占用 {size}。", "{size} on disk": "磁盘占用 {size}", - "Native mods (experimental)": "原生模组(实验性)", - "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "“模组”页面中的原生模组会使用启动器自己的 Rust 工具链编译到游戏中。它们不受沙盒限制:你能在这台电脑上执行的操作,它们都能执行。", - "{version} plays in this instance.": "此实例使用 {version} 进行游戏。", "Install Horizon first.": "请先安装 Horizon。", "Starting the build": "开始构建", "These mods will run with full access to your computer": "这些模组运行时将拥有对你电脑的完全访问权限", @@ -190,7 +187,10 @@ "Keep": "保留", "Move to Trash": "移到回收站", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "要将 {name} 连同其世界和设置一起移到回收站吗?你之后可以从那里恢复。", - "Install and turn on native mods": "安装并启用原生模组", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。" + "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "原生模组不在沙盒中运行:它们和任何程序一样,可以完全访问你的电脑。只安装你信任其作者的模组。要使用模组游玩,请在模组页面构建游戏;首次构建会下载 Rust,需要 20 到 30 分钟。", + "Native build": "原生构建", + "No native mod is turned on.": "没有开启任何原生模组。", + "Build the game to play with the native mods turned on.": "构建游戏,以使用已开启的原生模组游玩。", + "Up to date: {instance} plays these mods.": "已是最新:{instance} 使用这些模组游玩。" } diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 67c10fd9fd..92e655f18c 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2970,12 +2970,8 @@ pub struct Settings { /// The window's language. Unset follows the system's (`i18n::Language::system`). #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, - /// Native mods (experimental) are on (`native_mods`): mixin mods compiled into a game of - /// their own. Off until the player installs a native mod (`native_mods::turns_on`), or turns - /// them on in Settings. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub native_mods: bool, - /// The player has read what installing a native mod starts (`native_mods::needs_notice`). + /// The player has read the notice before the first native mod (`native_status::NOTICE`). + /// Native mods are always on: an older file's `native_mods` switch is ignored. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub native_mods_noticed: bool, /// Developer options (hidden: seven clicks on the sidebar's launcher version reveal them): diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index 994ef02ecd..5100c97bb0 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -5,7 +5,7 @@ use horizon_launcher::account::{self, Accounts}; use horizon_launcher::backend::{self, Release}; use horizon_launcher::instances; use horizon_launcher::loaders; -use horizon_launcher::native_mods; +use horizon_launcher::native_status; use horizon_launcher::other_launchers; use horizon_launcher::server_list; use std::io::{IsTerminal, Write}; @@ -706,7 +706,7 @@ enum Cmd { #[arg(long)] accept_eula: bool, }, - /// Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Horizon + /// Native mods (docs/native-mods.md): compile mixin bundles on the Horizon /// revision they pin, with the launcher's own Rust, as the version the "Native mods" instance /// plays. Not sandboxed: the mods' code runs with your rights NativeBuild { @@ -2233,14 +2233,11 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> { println!(" not ported: {s}"); } } - // A native mod turns Native mods on, with the window's notice, once. + // The first native mod: the window's trust notice, once. let mut settings = backend::Settings::load(); - let list = installed::Installed::load(&home).entries; - if !settings.native_mods && native_mods::turns_on(false, &list) { - if !settings.native_mods_noticed { - eprintln!("{}", native_mods::NOTICE); - } - settings.native_mods = true; + let native = done.iter().any(|e| native_status::is_native_file(&e.file)); + if native && !settings.native_mods_noticed { + eprintln!("{}", native_status::NOTICE); settings.native_mods_noticed = true; settings.save(); } diff --git a/launcher/src/lib.rs b/launcher/src/lib.rs index e68145fcc6..a4398781b0 100644 --- a/launcher/src/lib.rs +++ b/launcher/src/lib.rs @@ -22,6 +22,7 @@ pub mod minecraft_versions; pub mod modpacks; pub mod mojang; pub mod native_mods; +pub mod native_status; pub mod nightly; pub mod other_launchers; pub mod packs; diff --git a/launcher/src/main.rs b/launcher/src/main.rs index bafe16f83d..de081f7d9a 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -175,8 +175,8 @@ struct Launcher { disk: Vec<(String, u64)>, disk_for: Vec, install_error: Option<(String, String)>, - /// Native mods (`native_mods`): the build's step while it runs, why it failed, and the - /// warning before a build while it is open, with the mods it names. + /// Native mods (`native_mods`, on the Mods page): the build's step while it runs, why it + /// failed, and the warning before a build while it is open, with the mods it names. native_step: Option, native_error: Option, native_confirm: Option, String>>, @@ -3296,7 +3296,6 @@ impl Launcher { .child(self.proxy_row(n + 4, cx)) .child(group("Game", "gamepad")) .child(self.game_args_row(n + 5, cx)) - .child(self.native_mods_row(n + 5, cx)) .child(group("AI", "terminal")) .child(self.no_ai_row(n + 6, cx)) .child(group("About", "info-box")) @@ -3353,59 +3352,6 @@ impl Launcher { .children(self.dev_options_section(cx)) } - /// Settings' Game arguments row: the extra arguments the game starts with, kept in - /// `settings.json` and appended to the launcher's own (quotes group a word with spaces). - /// Settings' Native mods row (docs/native-mods.md): off by default. On, Build compiles the - /// native mods installed in the instance Play starts, after a warning that names them, and - /// that instance then plays the build. - fn native_mods_row(&self, n: usize, cx: &mut Context) -> Div { - let t = self.theme; - let on = self.settings.native_mods; - let toggle = - switch("native-mods", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.settings.native_mods = !this.settings.native_mods; - this.native_confirm = None; - this.settings.save(); - cx.notify(); - })); - let mut detail = div().flex().flex_col().gap(px(4.)).child( - tr("Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.").to_string(), - ); - if on { - let status = match (&self.native_step, &self.native_error) { - (Some(step), _) => Some(div().child(step.clone())), - (None, Some(e)) => Some(div().text_color(t.danger).child(e.clone())), - (None, None) => instances::selected() - .version - .filter(|v| native_mods::is_native(v)) - .map(|v| { - div().child(trf("{version} plays in this instance.", &[("version", &v)])) - }), - }; - detail = detail.children(status); - if self.native_step.is_none() && self.native_confirm.is_none() { - detail = detail.child(div().flex().gap(px(8.)).mt(px(4.)).child( - button("native-build", "Build", Kind::Primary, &t).on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.native_confirm = Some(this.native_mods_to_build().map(|(_, b)| b)); - cx.notify(); - }, - )), - )); - } - if let Some(mods) = &self.native_confirm { - detail = detail.child(self.native_warning(mods, cx)); - } - } - index_row( - n, - tr("Native mods (experimental)"), - Some(detail.into_any_element()), - Some(toggle.into_any_element()), - &t, - ) - } - /// The warning before a native build: what the mods can do and which mods. The build starts /// only from its button. fn native_warning( @@ -3458,6 +3404,8 @@ impl Launcher { )) } + /// Settings' Game arguments row: the extra arguments the game starts with, kept in + /// `settings.json` and appended to the launcher's own (quotes group a word with spaces). fn game_args_row(&self, n: usize, cx: &mut Context) -> Div { let t = self.theme; let focused = self.args_focused; diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index 73c2698486..b336a710d4 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -11,6 +11,7 @@ use horizon_launcher::browser::{ Support, }; use horizon_launcher::modpacks; +use horizon_launcher::native_status; use horizon_port_pack::check::Check; use java_instance::Target; use std::collections::HashMap; @@ -88,8 +89,11 @@ pub(super) struct Mods { message: Option>, updates: HashMap<(SourceId, String), String>, confirm_remove: Option, - /// A native mod's Install waiting for the one-time notice (`native_mods::needs_notice`). + /// A native mod's Install waiting for the one-time trust notice (`native_status::NOTICE`). native_notice: Option<(SourceId, String, Option)>, + /// The selected instance's native build state, and the `native_status::fingerprint` it was + /// read for: read again only when that changes. + native_state: std::cell::RefCell>, /// Java mods' checks, by source and project. pub(super) checks: HashMap<(SourceId, String), Checking>, /// Horizon registry projects' trust and capabilities, by id ([`horizon::trust_and_capabilities`]). @@ -173,6 +177,7 @@ impl Mods { busy: None, message: None, native_notice: None, + native_state: Default::default(), updates: HashMap::new(), confirm_remove: None, checks: HashMap::new(), @@ -299,7 +304,6 @@ impl Launcher { let mut to_check = Vec::new(); let mut loaded = Vec::new(); let mut done = false; - let mut native_on = false; while let Ok(event) = self.mods.rx.try_recv() { changed = true; let m = &mut self.mods; @@ -330,7 +334,6 @@ impl Launcher { m.busy = None; m.message = Some(result); m.installed = installed::Installed::load(&m.home()).entries; - native_on = native_mods::turns_on(native_on, &m.installed); m.installed_list.reset(); if modpack { // The Modpacks page reads its list again. @@ -358,12 +361,6 @@ impl Launcher { } } } - // A native mod installed: Native mods turn on, so Settings offers its Build. - if native_on && !self.settings.native_mods { - self.settings.native_mods = true; - self.settings.native_mods_noticed = true; - self.settings.save(); - } // `HORIZON_LAUNCHER_PROJECT=` (with `HORIZON_LAUNCHER_PAGE=mods`): opens that // project's page once a search finds it, for screenshots. if self.mods.browse.open.is_none() { @@ -654,12 +651,9 @@ impl Launcher { if self.mods.busy.is_some() { return; } - // The first native mod: what it starts, before anything is installed. + // The first native mod: the trust notice, before anything is installed. let first_native = source == SourceId::Horizon - && native_mods::needs_notice( - self.settings.native_mods, - self.settings.native_mods_noticed, - ) + && !self.settings.native_mods_noticed && horizon::is_native_mod(&id); if first_native { self.mods.native_notice = Some((source, id, known)); @@ -678,6 +672,9 @@ impl Launcher { let tx = self.mods.tx.clone(); let home = self.mods.home(); let java = self.mods.java.clone(); + let instance = self + .selected_instance() + .map_or_else(|| "Horizon".to_string(), |i| i.name.clone()); let world = self.mods.world.clone().and_then(|path| { let (_, name) = self.mods.worlds.iter().find(|(p, _)| *p == path)?; Some((path, name.clone())) @@ -752,6 +749,12 @@ impl Launcher { .iter() .map(|e| format!("{} {}", e.title, e.number)) .collect(); + if done.iter().any(|e| native_status::is_native_file(&e.file)) { + return Ok(format!( + "Installed {} into {instance}. Build to play with it.", + names.join(", ") + )); + } if p.source == SourceId::Horizon && p.kind == PackKind::Mod { return Ok(format!( "Installed {} into horizon-mods and turned it on, checksum checked \ @@ -1019,8 +1022,8 @@ impl Launcher { } /// The install under way and the last install's answer. - /// The one-time notice before the first native mod is installed: what the build takes. Its - /// button installs the mod; Native mods then turn on. + /// The one-time notice before the first native mod is installed: native mods aren't + /// sandboxed, and what the build takes. Its button installs the mod. fn native_notice(&self, cx: &mut Context) -> Option
{ self.mods.native_notice.as_ref()?; let t = self.theme; @@ -1032,20 +1035,14 @@ impl Launcher { .p(px(16.)) .text_size(px(theme::UI.px)) .text_color(t.ink) - .child(tr(native_mods::NOTICE).to_string()) + .child(tr(native_status::NOTICE).to_string()) .child( div() .flex() .gap(px(8.)) .child( - button( - "native-notice-install", - "Install and turn on native mods", - Kind::Primary, - &t, - ) - .on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { + button("native-notice-install", "Install", Kind::Primary, &t).on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { this.settings.native_mods_noticed = true; this.settings.save(); if let Some((source, id, known)) = @@ -1054,8 +1051,8 @@ impl Launcher { this.mods_install(source, id, known); } cx.notify(); - }, - )), + }), + ), ) .child( button("native-notice-cancel", "Cancel", Kind::Secondary, &t).on_click( @@ -1121,6 +1118,10 @@ impl Launcher { return None; } let busy = self.mods.busy.is_some(); + let native = self.mods.java.is_none() + && entries + .iter() + .any(|e| native_status::is_native_file(&e.file)); let row = |i: usize| { let e = entries[i]; let key = (e.source, e.project.clone()); @@ -1280,10 +1281,70 @@ impl Launcher { .child(caps("Installed").text_color(t.muted)) .children(update_all), ) + .children(native.then(|| self.native_build(cx)).flatten()) .child(list), ) } + /// The selected instance's native build (docs/native-mods.md): building, failed, up to + /// date, or Build when the version it plays lacks the native mods turned on. Build opens the + /// trust warning first (`native_warning`). + fn native_build(&self, cx: &mut Context) -> Option
{ + let t = self.theme; + let instance = self.selected_instance()?; + let version = self.version_of(instance).cloned(); + let home = self.mods.home(); + let key = native_status::fingerprint(&home, version.as_deref()); + let mut memo = self.mods.native_state.borrow_mut(); + let state = match memo.as_ref() { + Some((k, state)) if *k == key => *state, + _ => { + let built = version.as_deref().and_then(native_status::built); + let state = native_status::state(&native_status::enabled(&home), built.as_deref()); + *memo = Some((key, state)); + state + } + }; + let line = |s: String| div().child(s); + let (status, can_build) = match (&self.native_step, &self.native_error, state) { + (Some(step), _, _) => (line(step.clone()).text_color(t.accent), false), + (None, Some(e), _) => (line(e.clone()).text_color(t.danger), true), + (None, None, native_status::State::NoMods) => { + (line(tr("No native mod is turned on.").into()), false) + } + (None, None, native_status::State::NeedsBuild) => ( + line(tr("Build the game to play with the native mods turned on.").into()), + true, + ), + (None, None, native_status::State::UpToDate) => ( + line(trf( + "Up to date: {instance} plays these mods.", + &[("instance", &instance.name)], + )), + false, + ), + }; + let build = (can_build && self.native_confirm.is_none()).then(|| { + button("native-build", "Build", Kind::Primary, &t) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.native_confirm = Some(this.native_mods_to_build().map(|(_, b)| b)); + cx.notify(); + })) + .into_any_element() + }); + let mut detail = div().flex().flex_col().gap(px(4.)).child(status); + if let Some(mods) = &self.native_confirm { + detail = detail.child(self.native_warning(mods, cx)); + } + Some(index_row( + 0, + tr("Native build"), + Some(detail.into_any_element()), + build, + &t, + )) + } + fn mods_results(&mut self, cx: &mut Context) -> Div { let t = self.theme; let m = &self.mods; diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 654f53aafc..545462a2f4 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -1,4 +1,4 @@ -//! Native mods (docs/native-mods.md), experimental and off by default: mixin bundles that +//! Native mods (docs/native-mods.md): mixin bundles that //! levzzz5154's horizon-mixins (MIT, ) applies to //! Horizon's source, which the launcher then compiles into a game of its own with its portable Rust //! ([`rust_toolchain`]). Unlike `.hmod` mods they are not sandboxed: their code runs with the @@ -33,26 +33,6 @@ pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"; const PROFILE: &str = "release"; const FEATURES: &str = "horizon-client/mods"; -/// Whether Native mods are on after an install into a game folder whose list is `installed`: -/// installing a native mod turns them on, so the player only has to build. A player who never -/// installs one keeps them off; one already on stays on. -pub fn turns_on(on: bool, installed: &[crate::browser::installed::Entry]) -> bool { - on || installed.iter().any(|e| { - e.file - .strip_prefix(INSTALLED) - .is_some_and(|rest| rest.starts_with('/')) - }) -} - -/// Whether installing a native mod shows its notice first (what the first build takes): once, -/// for a player whose Native mods are still off. -pub fn needs_notice(on: bool, noticed: bool) -> bool { - !on && !noticed -} - -/// The notice before the first native mod is installed. -pub const NOTICE: &str = "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster."; - /// Whether an installed version is a native-mods build. pub fn is_native(version: &str) -> bool { version.starts_with(PREFIX) @@ -798,42 +778,6 @@ pub fn prune(keep: &str) { mod tests { use super::*; - /// Ludovic's decision (2026-10-29): installing a native mod turns Native mods on, after a - /// notice shown once; players who never install one keep them off. - #[test] - fn installing_a_native_mod_turns_native_mods_on() { - let entry = |file: &str| { - serde_json::from_value::(serde_json::json!({ - "source": "horizon", "project": "p", "slug": "p", "title": "P", "kind": "mod", - "version": "1", "number": "1", "file": file, "checksum": "", "page": "" - })) - .unwrap() - }; - let plain = [ - entry("horizon-mods/zoom.hmod"), - entry("resourcepacks/x.zip"), - ]; - assert!(!turns_on(false, &[])); - assert!(!turns_on(false, &plain), "no native mod: they stay off"); - assert!(!turns_on( - false, - &[entry("horizon-native-mods-old/x.hnmod")] - )); - let native = [ - plain[0].clone(), - entry("horizon-native-mods/veinminer.hnmod"), - ]; - assert!(turns_on(false, &native)); - assert!(turns_on(true, &plain), "on stays on"); - // The notice: before the first one only. - assert!(needs_notice(false, false)); - assert!(!needs_notice(false, true)); - assert!(!needs_notice(true, false)); - // Settings without the fields read as off and not noticed. - let s: backend::Settings = serde_json::from_str("{}").unwrap(); - assert!(!s.native_mods && !s.native_mods_noticed); - } - fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("horizon-native-{name}-{}", std::process::id())); let _ = fs::remove_dir_all(&d); diff --git a/launcher/src/native_status.rs b/launcher/src/native_status.rs new file mode 100644 index 0000000000..ac8e03df44 --- /dev/null +++ b/launcher/src/native_status.rs @@ -0,0 +1,179 @@ +//! Whether an instance plays its native mods (docs/native-mods.md): the mods turned on in its +//! folder against the ones its version was built with (`native-mods.json`, written by +//! `native_mods::build`). The Mods page shows the answer beside its Build button. +use crate::{backend, browser::java, native_mods}; +use std::fs; +use std::path::Path; + +/// Shown once, before the first native mod is installed. +pub const NOTICE: &str = "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes."; + +/// A native mod: its id and version. +pub type Mod = (String, String); + +/// Whether an installed file (`installed::Entry::file`) is a native mod. +pub fn is_native_file(file: &str) -> bool { + file.strip_prefix(native_mods::INSTALLED) + .is_some_and(|rest| rest.starts_with('/') && rest.ends_with(".hnmod")) +} + +/// Where an instance's native mods stand. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum State { + /// No native mod is turned on: nothing to build. + NoMods, + /// The version it plays doesn't hold the mods turned on (or isn't a native build). + NeedsBuild, + /// The version it plays was built with exactly these mods. + UpToDate, +} + +/// The state for the mods turned on, `enabled`, and those of the version played, `built` (None +/// when it is no native build). Order doesn't matter; a mod at another version needs a build. +pub fn state(enabled: &[Mod], built: Option<&[Mod]>) -> State { + if enabled.is_empty() { + return State::NoMods; + } + let sorted = |mods: &[Mod]| { + let mut v = mods.to_vec(); + v.sort(); + v.dedup(); + v + }; + match built { + Some(built) if sorted(built) == sorted(enabled) => State::UpToDate, + _ => State::NeedsBuild, + } +} + +/// The native mods turned on in game folder `home`, read from their `.hnmod`s. One that can't be +/// read counts with its file name and no version, so it always needs a build (which says why). +pub fn enabled(home: &Path) -> Vec { + let mut out = Vec::new(); + for path in hnmods(home) { + let (id, version) = match horizon_mods::index::native::Bundle::read(&path) { + Ok(b) => (b.manifest.id, b.manifest.version), + Err(_) => (stem(&path), String::new()), + }; + if java::is_enabled(home, &id) { + out.push((id, version)); + } + } + out +} + +/// The mods installed version `version` was built with, or None when it isn't a native build. +pub fn built(version: &str) -> Option> { + if !native_mods::is_native(version) { + return None; + } + let path = backend::version_dir(version).join("native-mods.json"); + let about: serde_json::Value = serde_json::from_str(&fs::read_to_string(path).ok()?).ok()?; + Some(parse_built(&about)) +} + +fn parse_built(about: &serde_json::Value) -> Vec { + let mods = about["mods"].as_array().map(Vec::as_slice).unwrap_or(&[]); + mods.iter() + .filter_map(|m| Some((m["id"].as_str()?.into(), m["version"].as_str()?.into()))) + .collect() +} + +/// What [`state`] depends on, cheap to read on every frame: the folder, the version, the on/off +/// list and each `.hnmod`'s size and time. The Mods page reads the mods again when it changes. +pub fn fingerprint(home: &Path, version: Option<&str>) -> String { + let mut out = format!("{}\n{version:?}\n", home.display()); + // The on/off list (`browser::java::is_enabled`). + out += &fs::read_to_string(home.join("horizon-mods.txt")).unwrap_or_default(); + for path in hnmods(home) { + let meta = fs::metadata(&path).ok(); + let time = meta.as_ref().and_then(|m| m.modified().ok()); + out += &format!("\n{} {:?} {time:?}", path.display(), meta.map(|m| m.len())); + } + out +} + +fn hnmods(home: &Path) -> Vec { + let mut paths: Vec<_> = fs::read_dir(home.join(native_mods::INSTALLED)) + .map(|dir| dir.flatten().map(|e| e.path()).collect()) + .unwrap_or_default(); + paths.retain(|p| p.extension().is_some_and(|e| e == "hnmod")); + paths.sort(); + paths +} + +fn stem(path: &Path) -> String { + path.file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn m(id: &str, version: &str) -> Mod { + (id.into(), version.into()) + } + + #[test] + fn a_build_is_needed_until_the_version_holds_the_mods_turned_on() { + let on = [m("veinminer", "1.0"), m("zoom", "2.1")]; + assert_eq!(state(&[], None), State::NoMods); + assert_eq!( + state(&[], Some(&on)), + State::NoMods, + "nothing on: nothing to build" + ); + assert_eq!(state(&on, None), State::NeedsBuild, "not a native build"); + let reordered = [m("zoom", "2.1"), m("veinminer", "1.0")]; + assert_eq!(state(&on, Some(&reordered)), State::UpToDate); + assert_eq!( + state(&on, Some(&[m("veinminer", "1.0")])), + State::NeedsBuild, + "a mod turned on since" + ); + assert_eq!( + state(&on[..1], Some(&on)), + State::NeedsBuild, + "a mod turned off since" + ); + assert_eq!( + state(&on, Some(&[m("veinminer", "1.1"), m("zoom", "2.1")])), + State::NeedsBuild, + "a mod updated since" + ); + } + + #[test] + fn native_mods_json_gives_the_built_mods() { + let about = serde_json::json!({ + "base": "v0.1.0", "horizon": "abc", "rust": "1.99.1", + "mods": [{"id": "zoom", "version": "2.1"}, {"id": "x"}], + }); + assert_eq!(parse_built(&about), [m("zoom", "2.1")]); + assert!(parse_built(&serde_json::json!({})).is_empty()); + assert_eq!(built("v0.1.0"), None, "not a native build"); + } + + #[test] + fn only_hnmods_in_the_native_folder_are_native() { + assert!(is_native_file("horizon-native-mods/veinminer.hnmod")); + assert!(!is_native_file("horizon-native-mods-old/x.hnmod")); + assert!(!is_native_file("horizon-mods/zoom.hmod")); + assert!(!is_native_file("resourcepacks/x.zip")); + } + + /// Settings files from before native mods were on by default still read; their + /// `native_mods` key is ignored, and the notice stays read. + #[test] + fn old_settings_with_the_native_mods_switch_still_read() { + let s: backend::Settings = + serde_json::from_str(r#"{"native_mods": true, "native_mods_noticed": true}"#).unwrap(); + assert!(s.native_mods_noticed); + let s: backend::Settings = serde_json::from_str(r#"{"native_mods": false}"#).unwrap(); + assert!(!s.native_mods_noticed); + let saved = serde_json::to_string(&s).unwrap(); + assert!(!saved.contains("native_mods"), "{saved}"); + } +} From 6774e47aaebe4e91663b70e403c57771ae6d9079 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:56:57 +0200 Subject: [PATCH 056/968] Launcher: moving a world to the trash no longer closes it on Windows The trash crate starts COM on the calling thread and panics when that thread already runs COM in the other mode (RPC_E_CHANGED_MODE). Deleting a world runs on a worker of GPUI's thread pool, which can, and a panic in that callback cannot unwind, so the launcher aborted and the world stayed. The system trash now runs on a thread of its own, and a panic there comes back as an error. Fixes #3784 Co-Authored-By: Claude Opus 5.5 --- launcher/src/desktop_seams.rs | 48 +++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/launcher/src/desktop_seams.rs b/launcher/src/desktop_seams.rs index 2815748eaf..8767bfdb7c 100644 --- a/launcher/src/desktop_seams.rs +++ b/launcher/src/desktop_seams.rs @@ -38,16 +38,33 @@ pub fn to_folder(path: &Path, dir: &Path) -> Result<(), String> { } fn system_trash(path: &Path) -> Result<(), String> { - #[allow(unused_mut)] - let mut trash = trash::TrashContext::default(); - // The crate's default on macOS asks Finder through AppleScript, which needs the player to - // allow the launcher to control Finder first; the file manager call needs nothing. - #[cfg(target_os = "macos")] - trash::macos::TrashContextExtMacos::set_delete_method( - &mut trash, - trash::macos::DeleteMethod::NsFileManager, - ); - trash.delete(path).map_err(|e| e.to_string()) + // On Windows the crate starts COM on the calling thread and panics when that thread already + // runs it in the other mode (RPC_E_CHANGED_MODE). A worker of GPUI's thread pool can, and a + // panic there aborts the launcher (#3784); a thread of its own starts without COM. + on_own_thread(|| { + #[allow(unused_mut)] + let mut trash = trash::TrashContext::default(); + // The crate's default on macOS asks Finder through AppleScript, which needs the player + // to allow the launcher to control Finder first; the file manager call needs nothing. + #[cfg(target_os = "macos")] + trash::macos::TrashContextExtMacos::set_delete_method( + &mut trash, + trash::macos::DeleteMethod::NsFileManager, + ); + trash.delete(path).map_err(|e| e.to_string()) + }) +} + +/// Runs `f` on a new thread and waits for it; a panic there comes back as an error. +fn on_own_thread(f: impl FnOnce() -> Result + Send) -> Result { + std::thread::scope(|s| s.spawn(f).join()).unwrap_or_else(|panic| { + let why = panic + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "it panicked".into()); + Err(why) + }) } /// The file copies go to instead of the clipboard, when `HORIZON_LAUNCHER_CLIPBOARD` is set. @@ -79,4 +96,15 @@ mod tests { assert!(std::fs::read_dir(&home).unwrap().next().is_none()); std::fs::remove_dir_all(&root).unwrap(); } + + #[test] + fn the_trash_runs_on_a_thread_of_its_own_and_its_panic_is_an_error() { + let caller = std::thread::current().id(); + assert_eq!( + on_own_thread(|| Ok(std::thread::current().id() != caller)), + Ok(true) + ); + let failed: Result<(), String> = on_own_thread(|| panic!("Call to CoInitializeEx failed")); + assert_eq!(failed, Err("Call to CoInitializeEx failed".into())); + } } From 791ac3d928e469bd57534d2b58e83f8e24ae3261 Mon Sep 17 00:00:00 2001 From: Aria Figueredo Date: Thu, 29 Oct 2026 14:06:44 -0500 Subject: [PATCH 057/968] Content mods: allow a block default state override --- crates/horizon-client/src/mod_content.rs | 56 ++++++++++++- .../horizon-client/src/mod_content_tests.rs | 79 +++++++++++++++++++ crates/horizon-mods/src/content.rs | 6 ++ docs/modpacks.md | 5 ++ 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 5fd88d3a44..9c80cd6ebd 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -234,6 +234,7 @@ struct NewBlock<'a> { rename: Rename, /// The template's item, if it has one. item: Option, + default: u32, } /// An item being added (declared, or a block's). struct NewItem { @@ -323,6 +324,58 @@ impl<'a> Plan<'a> { let states = range(&decl.template).ok_or_else(|| { format!("{}: template {} is not a block", decl.id, decl.template) })?; + let mut default = *defaults.get(&decl.template).ok_or_else(|| { + format!( + "{}: template {} has no default state", + decl.id, decl.template + ) + })?; + if !decl.default_state.is_empty() { + let Some(J::Arr(rows)) = facts.get("states") else { + unreachable!() + }; + let properties = |id: u32| -> Option<&J> { + let J::Arr(row) = rows.get(id as usize)? else { + return None; + }; + row.get(4)?.get("properties") + }; + let mut wanted = properties(default).cloned().ok_or_else(|| { + format!("{}: template default has no state properties", decl.id) + })?; + for (key, value) in &decl.default_state { + if wanted.get(key).is_none() { + return Err(format!( + "{}: unknown state property {key} on {}", + decl.id, decl.template + ) + .into()); + } + wanted.set(key, J::Str(value.clone())); + } + let matches: Vec = states + .clone() + .filter(|id| { + properties(*id).is_some_and(|p| { + let (J::Obj(a), J::Obj(b)) = (p, &wanted) else { + return false; + }; + a.len() == b.len() && b.iter().all(|(k, v)| p.get(k) == Some(v)) + }) + }) + .collect(); + if matches.len() != 1 { + return Err(format!( + "{}: default_state {:?} matches {} states of {}", + decl.id, + decl.default_state, + matches.len(), + decl.template + ) + .into()); + } + default = matches[0]; + } let mut rename = Rename::new(&decl.template, &decl.id); rename.first = next; next += states.len() as u32; @@ -337,6 +390,7 @@ impl<'a> Plan<'a> { decl, rename, item: None, + default, }); } } @@ -546,7 +600,7 @@ impl<'a> Plan<'a> { states(doc, "states", false, false)?; let defaults = doc.at("defaults").ok_or("no defaults")?; for b in &self.blocks { - let default = self.defaults[&b.decl.template]; + let default = b.default; let id = b.rename.id(default).ok_or("a default outside its block")?; defaults.set(&b.decl.id, J::Num(id.into())); } diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 88ecf55aae..de3d1c8895 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -1080,3 +1080,82 @@ fn a_replacing_mod_tag_reaches_the_root() { ); let _ = fs::remove_dir_all(&scratch); } + +#[test] +fn content_block_default_state_is_validated_and_overlaid() { + let scratch = std::env::temp_dir().join(format!("horizon-mod-default-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let base = scratch.join("base"); + fs::create_dir_all(&base).unwrap(); + let facts = r#"{"states":[ + ["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"up","extended":"false"}}], + ["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"false"}}], + ["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"true"}}]], + "defaults":{"minecraft:piston":0}}"#; + fs::write(base.join("facts.json"), facts).unwrap(); + fs::write(base.join("gameplay-facts.json"), r#"{"states":[{"item":"minecraft:piston"},{"item":"minecraft:piston"},{"item":"minecraft:piston"}]}"#).unwrap(); + fs::write(base.join("items.json"), r#"{"minecraft:piston":{}}"#).unwrap(); + fs::write(base.join("registries.json"), r#"{"minecraft:item":{"entries":{"minecraft:piston":{"protocol_id":0}}},"minecraft:block":{"entries":{"minecraft:piston":{"protocol_id":0}}}}"#).unwrap(); + for (declaration, expected) in [ + (r#"{"template":"minecraft:piston"}"#, Some(3)), + ( + r#"{"template":"minecraft:piston","default_state":{"facing":"north"}}"#, + Some(4), + ), + ( + r#"{"template":"minecraft:piston","default_state":{"facing":"north","extended":"true"}}"#, + Some(5), + ), + ( + r#"{"template":"minecraft:piston","default_state":{"facing":"sideways"}}"#, + None, + ), + ( + r#"{"template":"minecraft:piston","default_state":{"direction":"north"}}"#, + None, + ), + ] { + let m = folder_mod( + &scratch.join("mod"), + &[ + ("horizon-mod.toml", &manifest("testmod", "")), + ("data/testmod/horizon/block/test.json", declaration), + ], + ); + let mods = [m]; + match (Plan::new(&base, &mods), expected) { + (Ok(plan), Some(id)) => { + let mut doc: J = serde_json::from_str(facts).unwrap(); + plan.json("facts.json", &mut doc).unwrap(); + assert_eq!( + doc.get("defaults").unwrap().get("testmod:test"), + Some(&J::Num(id.into())) + ); + } + (Err(e), None) => assert!(e.to_string().contains("testmod:test"), "{e}"), + _ => panic!("unexpected result for {declaration}"), + } + } + // A malformed registry with duplicate states must not choose one silently. + let duplicate = facts.replace( + r#"["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"true"}}]"#, + r#"["minecraft:piston",false,false,"",{"id":"minecraft:piston","properties":{"facing":"north","extended":"false"}}]"#, + ); + fs::write(base.join("facts.json"), duplicate).unwrap(); + let m = folder_mod( + &scratch.join("mod"), + &[ + ("horizon-mod.toml", &manifest("testmod", "")), + ( + "data/testmod/horizon/block/test.json", + r#"{"template":"minecraft:piston","default_state":{"facing":"north"}}"#, + ), + ], + ); + let error = match Plan::new(&base, &[m]) { + Err(e) => e.to_string(), + Ok(_) => panic!("duplicate states accepted"), + }; + assert!(error.contains("matches 2 states"), "{error}"); + fs::remove_dir_all(scratch).unwrap(); +} diff --git a/crates/horizon-mods/src/content.rs b/crates/horizon-mods/src/content.rs index 22c4ea3ea9..b94021400c 100644 --- a/crates/horizon-mods/src/content.rs +++ b/crates/horizon-mods/src/content.rs @@ -81,6 +81,8 @@ struct BlockFile { template: String, #[serde(default)] properties: BlockProperties, + #[serde(default)] + default_state: BTreeMap, #[serde(default = "yes")] item: bool, } @@ -97,6 +99,8 @@ pub struct Block { /// The vanilla block it copies, `minecraft:`. pub template: String, pub properties: BlockProperties, + /// State properties overlaid on the template default. + pub default_state: BTreeMap, /// Also add a block item with the same id. pub item: bool, } @@ -333,6 +337,7 @@ impl Content { id: block, template: file.template, properties: file.properties, + default_state: file.default_state, item: file.item, }); } @@ -499,6 +504,7 @@ capabilities = ["registries"] [Block { id: "ruby:ruby_block".into(), template: "minecraft:iron_block".into(), + default_state: BTreeMap::new(), properties: BlockProperties { hardness: Some(4.0), light: Some(3), diff --git a/docs/modpacks.md b/docs/modpacks.md index 533fcf4dd9..163c5b96bf 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -216,6 +216,11 @@ properties and shapes the new block takes, and overrides what differs: } ``` +A declaration may also set `"default_state": {"facing": "north"}`. The map +changes only those state properties on the template's default; omitted properties keep +their template values. Names and values must identify exactly one template state, or +the content root is rejected. Leave the map out to keep the template default. + Most decoration and resource blocks are vanilla classes with other numbers: Macaw's doors are `DoorBlock`s, XTones are full cubes, ore sets are `DropExperienceBlock`s. So templates cover the data tier without code. Templates also give mod blocks every behaviour Horizon already From df440f90b28de76167dbf07676d346d5ac2d0d45 Mon Sep 17 00:00:00 2001 From: Aria Figueredo Date: Thu, 29 Oct 2026 14:21:35 -0500 Subject: [PATCH 058/968] Content mods: set per-state outline and collision shapes --- crates/horizon-client/src/mod_content.rs | 191 ++++++++++++++++++ .../horizon-client/src/mod_content_tests.rs | 113 +++++++++++ crates/horizon-mods/src/content.rs | 35 ++++ docs/modpacks.md | 17 ++ 4 files changed, 356 insertions(+) diff --git a/crates/horizon-client/src/mod_content.rs b/crates/horizon-client/src/mod_content.rs index 5fd88d3a44..3896be5f84 100644 --- a/crates/horizon-client/src/mod_content.rs +++ b/crates/horizon-client/src/mod_content.rs @@ -15,6 +15,7 @@ //! The loaders need no mod code, vanilla state ids never move, and with no //! content mod the root is the base root itself. use crate::{Result, blocks::MAP_COLORS}; +use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape}; use horizon_mods::content::{Block, Content, Rarity}; use horizon_world::nbt::{self, JavaString, Limits, Tag}; use serde::{Deserialize, Serialize, de}; @@ -228,12 +229,98 @@ impl Rename { } } +// DiscreteVoxelShape.forAllEdges: merge consecutive boundary edges. +fn shape_edges(shape: &VoxelShape) -> Vec { + let sizes = shape.sizes(); + let mut result = Vec::new(); + for axis in [2, 0, 1] { + let a = (axis + 1) % 3; + let b = (axis + 2) % 3; + for u in 0..=sizes[a] { + for v in 0..=sizes[b] { + let mut start = None; + for t in 0..=sizes[axis] { + let mut count = 0; + let mut odd = 0; + for du in 0..=1 { + for dv in 0..=1 { + let mut p = [0i32; 3]; + p[axis] = t as i32; + p[a] = u as i32 + du - 1; + p[b] = v as i32 + dv - 1; + if shape.is_full(p[0], p[1], p[2]) { + count += 1; + odd ^= du ^ dv; + } + } + } + let edge = count == 1 || count == 3 || (count == 2 && odd == 0); + if edge { + if start.is_none() { + start = Some(t); + } + } else if let Some(first) = start.take() { + let mut from = [0.0; 3]; + let mut to = [0.0; 3]; + from[a] = shape.coordinates(Axis::ALL[a])[u]; + from[b] = shape.coordinates(Axis::ALL[b])[v]; + to[a] = from[a]; + to[b] = from[b]; + from[axis] = shape.coordinates(Axis::ALL[axis])[first]; + to[axis] = shape.coordinates(Axis::ALL[axis])[t]; + result.push(J::Arr(from.into_iter().chain(to).map(J::f64).collect())); + } + } + } + } + } + result +} + +fn encode_shape(shape: &VoxelShape) -> Tag { + let mut c = nbt::Compound::new(); + let (min, max) = shape.discrete_bounds(); + c.insert("canonical".into(), Tag::Byte(0)); + c.insert("cube".into(), Tag::Byte(i8::from(shape.is_cube()))); + let axes = shape.cube_coordinate_axes(); + c.insert( + "cube_axes".into(), + Tag::Byte((axes[0] as i8) | ((axes[1] as i8) << 1) | ((axes[2] as i8) << 2)), + ); + for (name, axis) in ["x", "y", "z"].into_iter().zip(Axis::ALL) { + c.insert( + name.into(), + Tag::LongArray( + shape + .coordinates(axis) + .iter() + .map(|n| n.to_bits() as i64) + .collect(), + ), + ); + } + let [x, y, z] = shape.sizes(); + let mut occupied = Vec::with_capacity(x * y * z); + for i in 0..x { + for j in 0..y { + for k in 0..z { + occupied.push(u8::from(shape.is_full(i as i32, j as i32, k as i32))); + } + } + } + c.insert("occupied".into(), Tag::ByteArray(occupied)); + c.insert("discrete_min".into(), Tag::IntArray(min.to_vec())); + c.insert("discrete_max".into(), Tag::IntArray(max.to_vec())); + Tag::Compound(c) +} + /// A block being added: its declaration and where its states go. struct NewBlock<'a> { decl: &'a Block, rename: Rename, /// The template's item, if it has one. item: Option, + shapes: Option>, } /// An item being added (declared, or a block's). struct NewItem { @@ -323,6 +410,52 @@ impl<'a> Plan<'a> { let states = range(&decl.template).ok_or_else(|| { format!("{}: template {} is not a block", decl.id, decl.template) })?; + let shapes = if let Some(spec) = &decl.shapes { + let Some(J::Arr(rows)) = facts.get("states") else { + unreachable!() + }; + let mut used = BTreeSet::new(); + let mut out = Vec::new(); + for id in states.clone() { + let J::Arr(row) = &rows[id as usize] else { + unreachable!() + }; + let Some(J::Str(value)) = row + .get(4) + .and_then(|x| x.get("properties")) + .and_then(|x| x.get(&spec.property)) + else { + return Err(format!( + "{}: template state lacks property {}", + decl.id, spec.property + ) + .into()); + }; + let boxes = spec.values.get(value).ok_or_else(|| { + format!("{}: no shape for {}={value}", decl.id, spec.property) + })?; + used.insert(value.as_str()); + let mut shape = VoxelShape::empty(); + for b in boxes { + let part = VoxelShape::from_box(Aabb::new( + Vec3::new(b[0], b[1], b[2]), + Vec3::new(b[3], b[4], b[5]), + ))?; + shape = shape.join_unoptimized(&part, ShapeOp::OR)?; + } + out.push(shape); + } + if used.len() != spec.values.len() { + return Err(format!( + "{}: shape values include an unknown {} value", + decl.id, spec.property + ) + .into()); + } + Some(out) + } else { + None + }; let mut rename = Rename::new(&decl.template, &decl.id); rename.first = next; next += states.len() as u32; @@ -337,6 +470,7 @@ impl<'a> Plan<'a> { decl, rename, item: None, + shapes, }); } } @@ -445,6 +579,34 @@ impl<'a> Plan<'a> { row.set("item", J::Str("minecraft:air".into())); } self.override_gameplay(b, &mut row); + if let Some(shapes) = &b.shapes { + let shape = &shapes[i]; + let boxes = shape + .boxes() + .iter() + .map(|b| { + J::Arr( + [ + b.min(Axis::X), + b.min(Axis::Y), + b.min(Axis::Z), + b.max(Axis::X), + b.max(Axis::Y), + b.max(Axis::Z), + ] + .into_iter() + .map(J::f64) + .collect(), + ) + }) + .collect(); + row.set("outline", J::Arr(boxes)); + row.set("outline_edges", J::Arr(shape_edges(shape))); + row.set( + "full_collision", + J::Bool(!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?), + ); + } } list.push(row); } @@ -649,6 +811,32 @@ impl<'a> Plan<'a> { Ok(()) } + fn collision_shapes(&self, tag: &mut Tag) -> Result<()> { + let Tag::Compound(root) = tag else { + return Err("collision table is not a compound".into()); + }; + let mut indices = Vec::new(); + let Some(Tag::List(entries)) = root.get_mut(&JavaString::from("shapes")) else { + return Err("collision table has no shapes".into()); + }; + for block in &self.blocks { + if let Some(shapes) = &block.shapes { + for (i, shape) in shapes.iter().enumerate() { + let id = entries.len() as i32; + entries.push(encode_shape(shape)); + indices.push((block.rename.first as usize + i, id)); + } + } + } + let Some(Tag::IntArray(mapping)) = root.get_mut(&JavaString::from("cached_shapes")) else { + return Err("collision table has no mapping".into()); + }; + for (state, id) in indices { + mapping[state] = id; + } + Ok(()) + } + /// One NBT table: `states` lists, `cached_shapes` arrays and `blocks` /// compounds, wherever they are. fn nbt(&self, tag: &mut Tag) { @@ -1113,6 +1301,9 @@ fn tables(base: &Path, plan: &Plan, overlay: &Overlay<'_>) -> Result<()> { let (mut named, _) = nbt::read_named(&fs::read(&path)?, Limits::tables()) .map_err(|e| format!("{name}: {e}"))?; plan.nbt(&mut named.tag); + if name == "cached-collision.nbt" || name == "worldgen/blocks/cached-collision.nbt" { + plan.collision_shapes(&mut named.tag)?; + } nbt::write_named(&named, Limits::tables()).map_err(|e| format!("{name}: {e}"))? }; overlay.write(name, &bytes) diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 88ecf55aae..a52e48d55e 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -1080,3 +1080,116 @@ fn a_replacing_mod_tag_reaches_the_root() { ); let _ = fs::remove_dir_all(&scratch); } + +#[test] +#[ignore = "full content root: needs prepared vanilla data"] +fn content_shapes_drive_outline_and_collision() { + use horizon_core::{ + geometry::{Aabb, Axis, Vec3}, + position::BlockPos, + }; + use horizon_world::{collision::CachedCollisionRegistry, registry::BlockStateId}; + let Some(base) = prepared() else { + return; + }; + let scratch = + std::env::temp_dir().join(format!("horizon-content-shapes-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let m = folder_mod( + &scratch.join("mod"), + &[ + ("horizon-mod.toml", &manifest("shapetest", "")), + ( + "data/shapetest/horizon/block/rod.json", + r#"{"template":"minecraft:oak_log","shapes":{"property":"axis","values":{"x":[[0,0.25,0.25,1,0.75,0.75]],"y":[[0.25,0,0.25,0.75,1,0.75]],"z":[[0.25,0.25,0,0.75,0.75,1]]}}}"#, + ), + ( + "data/shapetest/horizon/block/plain.json", + r#"{"template":"minecraft:oak_log"}"#, + ), + ], + ); + let root = resource_root(&base, &[m], &scratch.join("cache")).unwrap(); + let assets = Assets::load(&root).unwrap(); + let registry = assets.registry; + let facts = BlockFacts::load(&root, registry).unwrap(); + let (named, _) = nbt::read_named( + &fs::read(root.join("cached-collision.nbt")).unwrap(), + Limits::tables(), + ) + .unwrap(); + let collision = CachedCollisionRegistry::from_nbt(&named.tag, registry).unwrap(); + for axis in ["x", "y", "z"] { + let state = (0..registry.len()) + .map(|i| BlockStateId(i as u32)) + .find(|id| { + let s = registry.state(*id).unwrap(); + s.name == "shapetest:rod" && s.properties.get("axis").is_some_and(|v| v == axis) + }) + .unwrap(); + let shape = collision.cached_shape(state).unwrap().unwrap(); + let f = facts.get(state); + assert_eq!(shape.boxes().len(), 1); + assert_eq!(f.outline.len(), 1); + assert_eq!(f.outline_edges.len(), 12); + let expected = match axis { + "x" => [0., 0.25, 0.25, 1., 0.75, 0.75], + "y" => [0.25, 0., 0.25, 0.75, 1., 0.75], + _ => [0.25, 0.25, 0., 0.75, 0.75, 1.], + }; + assert_eq!(f.outline[0], expected); + let start = Vec3::new(-1., 0.5, 0.5); + let end = Vec3::new(2., 0.5, 0.5); + let target = BlockPos::new(0, 0, 0); + assert!(shape.clip(start, end, target).is_some()); + let bounds = f.outline[0]; + let outline = Aabb::new( + Vec3::new(bounds[0], bounds[1], bounds[2]), + Vec3::new(bounds[3], bounds[4], bounds[5]), + ); + assert_eq!( + shape.clip(start, end, target), + Aabb::clip_boxes([outline], start, end, target) + ); + let player = Aabb::new(Vec3::new(-1., 0.4, 0.4), Vec3::new(-0.2, 0.6, 0.6)); + assert!((shape.collide(Axis::X, player, 2.) - (expected[0] + 0.2)).abs() < 1e-9); + let miss = Aabb::new(Vec3::new(-1., 0.05, 0.05), Vec3::new(-0.2, 0.15, 0.15)); + assert_eq!(shape.collide(Axis::X, miss, 2.), 2.); + } + let plain = registry.default_state("shapetest:plain").unwrap(); + let oak = registry.default_state("minecraft:oak_log").unwrap(); + assert_eq!(facts.get(plain).outline, facts.get(oak).outline); + assert_eq!( + collision.cached_shape(plain).unwrap().unwrap().boxes(), + collision.cached_shape(oak).unwrap().unwrap().boxes() + ); + fs::remove_dir_all(scratch).unwrap(); +} + +#[test] +fn shapes_reject_unknown_or_missing_template_values() { + let Some(base) = prepared() else { return }; + let scratch = + std::env::temp_dir().join(format!("horizon-shape-invalid-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + for declaration in [ + r#"{"template":"minecraft:oak_log","shapes":{"property":"typo","values":{"x":[]}}}"#, + r#"{"template":"minecraft:oak_log","shapes":{"property":"axis","values":{"x":[]}}}"#, + r#"{"template":"minecraft:oak_log","shapes":{"property":"axis","values":{"x":[],"y":[],"z":[],"bad":[]}}}"#, + ] { + let m = folder_mod( + &scratch.join("mod"), + &[ + ("horizon-mod.toml", &manifest("shapetest", "")), + ("data/shapetest/horizon/block/rod.json", declaration), + ], + ); + let mods = [m]; + let e = match Plan::new(&base, &mods) { + Ok(_) => panic!("accepted {declaration}"), + Err(e) => e.to_string(), + }; + assert!(e.contains("shapetest:rod"), "{e}"); + } + fs::remove_dir_all(scratch).unwrap(); +} diff --git a/crates/horizon-mods/src/content.rs b/crates/horizon-mods/src/content.rs index 22c4ea3ea9..a748ce3a59 100644 --- a/crates/horizon-mods/src/content.rs +++ b/crates/horizon-mods/src/content.rs @@ -75,12 +75,21 @@ pub struct BlockProperties { pub sounds: Option, } +/// Per-state outline and collision boxes, selected by one state property. +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct BlockShapes { + pub property: String, + pub values: BTreeMap>, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct BlockFile { template: String, #[serde(default)] properties: BlockProperties, + shapes: Option, #[serde(default = "yes")] item: bool, } @@ -97,6 +106,7 @@ pub struct Block { /// The vanilla block it copies, `minecraft:`. pub template: String, pub properties: BlockProperties, + pub shapes: Option, /// Also add a block item with the same id. pub item: bool, } @@ -333,6 +343,7 @@ impl Content { id: block, template: file.template, properties: file.properties, + shapes: file.shapes, item: file.item, }); } @@ -393,6 +404,20 @@ fn check_block(file: &BlockFile) -> Result<(), String> { file.template )); } + if let Some(shapes) = &file.shapes { + if shapes.property.is_empty() || shapes.values.is_empty() { + return Err("shapes needs a property and values".into()); + } + for boxes in shapes.values.values() { + if boxes.len() > 16 + || boxes + .iter() + .any(|b| b.iter().any(|n| !n.is_finite()) || (0..3).any(|i| b[i] > b[i + 3])) + { + return Err("shape needs at most 16 finite boxes with ordered bounds".into()); + } + } + } let p = &file.properties; let finite = |v: Option| v.is_none_or(f32::is_finite); if !finite(p.hardness) || !finite(p.explosion_resistance) || !finite(p.friction) { @@ -499,6 +524,7 @@ capabilities = ["registries"] [Block { id: "ruby:ruby_block".into(), template: "minecraft:iron_block".into(), + shapes: None, properties: BlockProperties { hardness: Some(4.0), light: Some(3), @@ -591,6 +617,15 @@ capabilities = ["registries"] br#"{"template": "minecraft:stone", "glow": true}"#, )], ); + refused("shape-reversed", &[("data/ruby/horizon/block/a.json", br#"{"template":"minecraft:stone","shapes":{"property":"axis","values":{"x":[[1,0,0,0,1,1]]}}}"#)]); + refused("shape-malformed", &[("data/ruby/horizon/block/a.json", br#"{"template":"minecraft:stone","shapes":{"property":"axis","values":{"x":[[0,0,0,1,1]]}}}"#)]); + refused( + "shape-no-values", + &[( + "data/ruby/horizon/block/a.json", + br#"{"template":"minecraft:stone","shapes":{"property":"axis","values":{}}}"#, + )], + ); let e = refused( "clash", &[ diff --git a/docs/modpacks.md b/docs/modpacks.md index 533fcf4dd9..5651a35c27 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -216,6 +216,23 @@ properties and shapes the new block takes, and overrides what differs: } ``` +A block may define `shapes` to replace both its selection outline and collision +shape. It selects a list of boxes by one template state property: + +```json +"shapes": { "property": "axis", "values": { + "x": [[0, 0.25, 0.25, 1, 0.75, 0.75]], + "y": [[0.25, 0, 0.25, 0.75, 1, 0.75]], + "z": [[0.25, 0.25, 0, 0.75, 0.75, 1]] +} } +``` + +Each box is `[min_x, min_y, min_z, max_x, max_y, max_z]` in block units. +Each value of the template property needs an entry. Up to 16 boxes form a union; +an empty list is an empty shape. Invalid bounds or values reject the content root. +Without `shapes`, both shapes remain the template's. Other properties such as +occlusion, light, and support still come from the template. + Most decoration and resource blocks are vanilla classes with other numbers: Macaw's doors are `DoorBlock`s, XTones are full cubes, ore sets are `DropExperienceBlock`s. So templates cover the data tier without code. Templates also give mod blocks every behaviour Horizon already From abeb5845dda475c2be0619a0f65f7115c5ec82a6 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:05:56 +0500 Subject: [PATCH 059/968] Launcher: native builds use the fast profile, at low priority - The workspace's `fast` profile (no LTO, 16 codegen units, incremental) where the pinned source has it, else `release`; native-mods.json records which. - The game's build and horizon-mixins' install run at nice 19 with idle I/O (Linux), nice 19 (macOS), below-normal priority (Windows). - CARGO_BUILD_JOBS passes through: it changes no flag. - A native build's copied programs keep their permissions (a clone left horizon-ctl and horizon-server not executable). Co-Authored-By: Claude Opus 5.5 --- docs/native-mods.md | 40 ++++++++++++++++++----- launcher/Cargo.lock | 1 + launcher/Cargo.toml | 3 ++ launcher/src/native_mods.rs | 48 ++++++++++++++++++++++++--- launcher/src/rust_toolchain.rs | 60 +++++++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 14 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 9b6c844758..b84c0e9509 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -67,10 +67,16 @@ trusting native code is the player's decision, never an agent's. 4. **Mixins.** The bundles are copied first, so the build and its key use that copy whatever happens to the folder meanwhile. `horizon-mixins apply` stages the modded source; the launcher copies only the files that changed into `native/stage/`, so Cargo, which goes by file times, recompiles only the crates the mods touch. -5. **Compile**: `cargo build --locked --profile release --features horizon-client/mods -p horizon-client`, the - release's own command (game-build.yml), into `native/target/`. Cargo downloads the locked crates from - crates.io (Horizon has no git dependencies). The log is `native/build.log`; a failure shows its last lines (a - horizon-mixins diagnostic in one line). +5. **Compile**: `cargo build --locked --profile fast [--features horizon-client/mods] -p horizon-client`, into + `native/target/`. `fast` is the workspace's own profile: the release's optimizations without its link-time + ones (`lto = false`, `codegen-units = 16`, incremental), so a rebuild recompiles and links only what changed. + A revision whose `Cargo.toml` has no `fast` builds with `release`, the release's own command (game-build.yml); + `native-mods.json` says which (`profile`). The mods feature is passed while the revision still has it. Cargo + downloads the locked crates from crates.io (Horizon has no git dependencies). The compiler and the + horizon-mixins build run at the lowest priority (`rust_toolchain::low_priority`: nice 19 and the idle I/O class + on Linux, nice 19 on macOS, below-normal priority on Windows), so the desktop and a game in play stay smooth; + on an otherwise idle computer they still use every core. The log is `native/build.log`; a failure shows its + last lines (a horizon-mixins diagnostic in one line). 6. **Install**: a new version, `native--`, a copy of the starting version's folder (resources included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json` saying which release or Nightly it started from (`base`) and which commit, mods, horizon-mixins and Rust made @@ -89,8 +95,8 @@ rebuild); `native_mods::update_for` says which one, if any, the window can offer The compiler runs as the release's build does, whatever the player's own Rust setup: the launcher's toolchain drops the variables that change compiler flags or the target (`RUSTFLAGS`, `CARGO_ENCODED_RUSTFLAGS`, -`RUSTDOCFLAGS`, `CARGO_INCREMENTAL`, `CARGO_PROFILE_*`, `CARGO_BUILD_*`, `CARGO_TARGET_*`, rustup's and the -wrappers'), and starts Cargo from the disk's root with `--manifest-path`. Cargo reads `.cargo/config.toml` from +`RUSTDOCFLAGS`, `CARGO_INCREMENTAL`, `CARGO_PROFILE_*`, `CARGO_BUILD_*` but `CARGO_BUILD_JOBS`, `CARGO_TARGET_*`, +rustup's and the wrappers'), and starts Cargo from the disk's root with `--manifest-path`. Cargo reads `.cargo/config.toml` from the folder it starts in and every one above it, whatever `CARGO_HOME` says, so from the launcher's data folder it would read the player's `~/.cargo/config.toml`, whose `[profile]`, `[build]` and `[target]` settings beat the workspace and the environment; no switch turns that search off. The source's own `.cargo/config.toml`, if it @@ -102,7 +108,23 @@ The key hashes the commit, the horizon-mixins commit, Rust's version, the profil every bundle. The same set on the same commit is never built twice. A change gives a new build, and `native/target/` stays, so a rebuild compiles only `horizon-client` and what the mods touched. -Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on Nightly `83f15fa`, +Measured 2026-10-30 on Linux (x86_64 desktop), Zoom 2.0.0 and FPS Counter 2.0.0 on Nightly `6f8c7a0`, the `fast` +profile, the launcher at nice 19 with `CARGO_BUILD_JOBS=10` (an empty launcher home: Rust, the crates and the +source downloaded first): + +| What | Time | +|---|---| +| First build: Rust, horizon-mixins, the whole game (384 crates) | 4 min 29 s in all; 3 min 6 s of compiling the game; `native/target/` about 2 GB | +| One mod switched off, rebuild | 56 s in all, 51 s of compiling (only `horizon-client`) | +| That mod switched on again | under 1 s: the kept build plays again | +| Nothing changed, rebuild | 1 s, nothing compiled | + +The same set with the `release` profile took 7 min 6 s for the first build and 2 min 34 s per rebuild (2 min 28 s of +compiling `horizon-client`, mostly its link-time optimization). In play, a `fast` game drew the same frames per +second as the Nightly's own `release` build (vsync-bound, about 112 FPS at 120 Hz), with about 0.1 ms more CPU per +frame (0.57 to 0.68 ms against 0.51 to 0.54 ms). + +Measured before builds used `fast`, with the `release` profile, on 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on Nightly `83f15fa`, `CARGO_BUILD_JOBS=8` while other agents' builds ran (upper bounds): | What | Size, time | @@ -111,7 +133,7 @@ Measured 2026-10-28 on the Mac mini (M4 Pro, 14 cores) with Veinminer 0.3.0 on N | horizon-mixins (`native/tools/`) | 7 MB; built once in 19 s | | Crates in the launcher's Cargo home (`/cargo/`) | 289 MB | | Source of one commit, and the stage | 64 MB each | -| First build: release profile, the whole game | 9 min 10 s of compiling, 9 min 41 s in all; `native/target/` 1.5 GB | +| First build: the whole game | 9 min 10 s of compiling, 9 min 41 s in all; `native/target/` 1.5 GB | | Rebuild after a helper's code changed | 2 min 46 s of compiling (only `horizon-client`), 2 min 53 s in all | | A change horizon-mixins ignores (a comment) | nothing compiled; 31 s, mostly the version folder's copy | @@ -122,7 +144,7 @@ Measured 2026-10-29 on Windows 11 (Ryzen 7 9800X3D, 16 threads), the same mod an |---|---| | Rust 1.99.1 and MinGW-w64 (`/rust/`) | 1.7 GB, MinGW-w64 875 MB of it | | Crates in the launcher's Cargo home | 657 MB | -| First build: release profile, the whole game | 6 min 57 s of compiling, 8 min 3 s in all; `native/target/` 2.0 GB | +| First build: the whole game | 6 min 57 s of compiling, 8 min 3 s in all; `native/target/` 2.0 GB | Measured 2026-10-28 on Linux (an x86_64 server with no Rust of its own), Zoom on Horizon `1149c92`, 3 jobs while CI ran: `rust install` in 11 s (592 MB), the first build in 12 min 9 s. The built game ran Zoom's proof (under `xvfb-run`: diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 4469365150..e39460c140 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2754,6 +2754,7 @@ dependencies = [ "horizon-world", "image", "keyring", + "libc", "objc2-app-kit", "oo7", "proc-macro2", diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index eb8de009ae..ea8eaaa869 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -55,6 +55,9 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg", # Microsoft account secrets in the OS keychain: the Secret Service on Linux through oo7 (the same # build gpui uses), Keychain and Credential Manager through keyring. +# Native builds at low priority (rust_toolchain::low_priority); already in the lockfile. +[target.'cfg(unix)'.dependencies] +libc = "0.2" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] oo7 = { version = "0.5", default-features = false, features = ["async-std", "native_crypto"] } futures-lite = "2" diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 2f8f784036..f25693cfc9 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -27,8 +27,12 @@ const PREFIX: &str = "native-"; /// horizon-mixins, for bundles that don't name the revision they were tested with. const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins"; pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"; -/// The profile and features of the release's own game (game-build.yml). -const PROFILE: &str = "release"; +/// The profile builds use: the workspace's `fast` (the release's optimizations without its +/// link-time ones, incremental), so a rebuild recompiles and links only what the mods changed. +/// A revision without it builds with `release` ([`profile_of`]). +const PROFILE: &str = "fast"; +const FALLBACK_PROFILE: &str = "release"; +/// The features of the release's own game (game-build.yml). const FEATURES: &str = "horizon-client/mods"; /// Whether Native mods are on after an install into a game folder whose list is `installed`: @@ -335,6 +339,16 @@ fn has_mods_feature(source: &Path) -> bool { value.get("features").and_then(|f| f.get("mods")).is_some() } +/// The profile the source at `source` builds with: [`PROFILE`] where its Cargo.toml has it. +fn profile_of(source: &Path) -> &'static str { + let text = fs::read_to_string(source.join("Cargo.toml")).unwrap_or_default(); + let value: toml::Table = text.parse().unwrap_or_default(); + match value.get("profile").and_then(|p| p.get(PROFILE)) { + Some(_) => PROFILE, + None => FALLBACK_PROFILE, + } +} + /// The Horizon commit `version` (an installed release or Nightly) was built from, from GitHub. pub fn revision_of(version: &str) -> Result { let reference = match version.strip_prefix("nightly-") { @@ -552,6 +566,7 @@ fn tool(rust: &Toolchain, rev: &str) -> Result { .join(format!("horizon-mixins{}", std::env::consts::EXE_SUFFIX)); if !bin.is_file() { let mut cmd = rust.command(&rust.cargo()); + rust_toolchain::low_priority(&mut cmd); cmd.args([ "install", "--locked", "--git", MIXINS_GIT, "--rev", rev, "--root", ]) @@ -577,6 +592,7 @@ fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) /// the workspace's own (`stage/.cargo/config.toml`, when it has one) applies. fn compile(rust: &Toolchain, stage: &Path, target: &Path) -> Command { let mut cmd = rust.command(&rust.cargo()); + rust_toolchain::low_priority(&mut cmd); cmd.env("CARGO_TARGET_DIR", target); cmd.arg("build"); let config = stage.join(".cargo").join("config.toml"); @@ -584,7 +600,7 @@ fn compile(rust: &Toolchain, stage: &Path, target: &Path) -> Command { cmd.arg("--config").arg(config); } cmd.arg("--manifest-path").arg(stage.join("Cargo.toml")); - cmd.args(["--locked", "--profile", PROFILE]); + cmd.args(["--locked", "--profile", profile_of(stage)]); if has_mods_feature(stage) { cmd.args(["--features", FEATURES]); } @@ -730,6 +746,7 @@ pub fn build( sync(&fresh, &stage)?; let _ = fs::remove_dir_all(&fresh); let target = real.join("target"); + let profile = profile_of(&stage); let mut crates = 0; run( compile(&rust, &stage, &target), @@ -749,9 +766,10 @@ pub fn build( "horizon_mixins": mixins, "hooks": mods.iter().find_map(|m| m.hooks), "rust": rust_toolchain::VERSION, + "profile": profile, "mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::>(), }); - let binary = target.join(PROFILE).join(backend::CLIENT); + let binary = target.join(profile).join(backend::CLIENT); install(base, &version, &binary, &about)?; } else { // Used again: it counts as the newest of the builds kept (`prune`). @@ -792,7 +810,9 @@ fn install( let copied = if entry.path().is_dir() { backend::copy_dir(&entry.path(), &to) } else { + // A clone is a new file: horizon-ctl and horizon-server keep their permissions. horizon_prepare::clone_or_copy(&entry.path(), &to) + .and_then(|()| fs::set_permissions(&to, entry.metadata()?.permissions())) }; copied.map_err(|e| format!("Could not copy {}: {e}", entry.path().display()))?; } @@ -1279,6 +1299,26 @@ mod tests { assert_eq!(args[2], stage.join(".cargo/config.toml")); } + #[test] + fn builds_use_the_fast_profile_where_the_source_has_it() { + let d = dir("profile"); + assert_eq!(profile_of(&d), "release"); + fs::write(d.join("Cargo.toml"), "[profile.release]\nlto = \"thin\"\n").unwrap(); + assert_eq!(profile_of(&d), "release"); + fs::write( + d.join("Cargo.toml"), + "[profile.release]\nlto = \"thin\"\n[profile.fast]\ninherits = \"release\"\n", + ) + .unwrap(); + assert_eq!(profile_of(&d), "fast"); + let rust = Toolchain { + dir: PathBuf::from("/rust/1.99.1-x"), + }; + let cmd = compile(&rust, &d, Path::new("/native/target")); + let args: Vec<_> = cmd.get_args().collect(); + assert!(args.windows(2).any(|w| w == ["--profile", "fast"])); + } + #[test] fn tool_errors_read_in_one_line() { let err = "{\n \"code\": \"E_REVISION\",\n \"message\": \"requires exact Horizon revision a\",\n \"file\": \"m.toml\"\n}"; diff --git a/launcher/src/rust_toolchain.rs b/launcher/src/rust_toolchain.rs index 3259a03c8f..353d6edb02 100644 --- a/launcher/src/rust_toolchain.rs +++ b/launcher/src/rust_toolchain.rs @@ -186,15 +186,56 @@ impl Toolchain { /// Whether the player's environment variable `name` stays out of a build: it would pick another /// Rust or wrap it (rustup, `RUSTC_WRAPPER`), or change the compiler's flags (`RUSTFLAGS`, a -/// profile, incremental, the build's target, linker or jobs: `CARGO_BUILD_*`, `CARGO_TARGET_*`), +/// profile, incremental, the build's target or linker: `CARGO_BUILD_*` but the jobs, +/// `CARGO_TARGET_*`), /// so the build would differ from the release's and recompile everything after any change. pub fn scrubbed(name: &str) -> bool { const PREFIXES: [&str; 3] = ["CARGO_PROFILE_", "CARGO_BUILD_", "CARGO_TARGET_"]; // Windows' variable names ignore case. let name = name.to_ascii_uppercase(); + // The number of jobs changes no flag: a player may still limit it. + if name == "CARGO_BUILD_JOBS" { + return false; + } SCRUBBED.contains(&name.as_str()) || PREFIXES.iter().any(|p| name.starts_with(p)) } +/// Runs `cmd` (a build) below the player's own programs, so the desktop and a game in play stay +/// smooth while it compiles; with the computer otherwise idle it still gets every core. Unix: nice +/// 19, and on Linux the idle I/O class too; Windows: below-normal priority. +pub fn low_priority(cmd: &mut Command) -> &mut Command { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // SAFETY: between fork and exec the closure only makes two system calls, which are + // async-signal-safe, on the child itself; a failure (not allowed) leaves the priority. + unsafe { + cmd.pre_exec(|| { + libc::setpriority(libc::PRIO_PROCESS, 0, 19); + #[cfg(target_os = "linux")] + { + const IOPRIO_WHO_PROCESS: libc::c_long = 1; + const IOPRIO_CLASS_IDLE: libc::c_long = 3; + libc::syscall( + libc::SYS_ioprio_set, + IOPRIO_WHO_PROCESS, + 0 as libc::c_long, + IOPRIO_CLASS_IDLE << 13, + ); + } + Ok(()) + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x0000_4000; + cmd.creation_flags(BELOW_NORMAL_PRIORITY_CLASS); + } + cmd +} + const SCRUBBED: [&str; 8] = [ "RUSTUP_TOOLCHAIN", "RUSTC_WRAPPER", @@ -472,6 +513,7 @@ mod tests { assert!(scrubbed(name), "{name}"); } for name in [ + "CARGO_BUILD_JOBS", "PATH", "HOME", "CARGO_HOME", @@ -484,6 +526,22 @@ mod tests { } } + #[cfg(target_os = "linux")] + #[test] + fn builds_run_at_the_lowest_priority() { + // `nice` alone prints the niceness it runs at; ionice -p the I/O class of a process. + let mut cmd = Command::new("sh"); + cmd.args(["-c", "nice; ionice -p $$ 2>/dev/null || echo idle"]); + let out = low_priority(&mut cmd).output().unwrap(); + let text = String::from_utf8_lossy(&out.stdout); + let mut lines = text.lines(); + assert_eq!(lines.next(), Some("19"), "{text}"); + assert!( + lines.next().is_some_and(|l| l.starts_with("idle")), + "{text}" + ); + } + #[test] fn component_files_land_under_the_prefix_and_nothing_else_does() { let at = |p: &str| component_path(Path::new(p)); From e71634732930e579497ff8d71de395d2647bce2f Mon Sep 17 00:00:00 2001 From: spider <51022611+spidercraft@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:06:22 +0100 Subject: [PATCH 060/968] Play with Friends: leaving the world ends its invite code On the server thread (the default single player), Save & Quit goes through leave_server, which stopped the integrated server but kept the sharing state: back in a world, the old code still showed with nothing behind it. leave_server now ends the code, as lan_close already did for the in-frame path. test_friends checks it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 2 ++ tools/test_friends.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 82268627da..dac377bc5f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -575,6 +575,8 @@ impl Game<'_> { self.keys.clear(); self.left = false; self.right = false; + // The shared world is closing: its invite code ends with it. + self.friends_lan_closed(); // Single player's server thread saves the world before the title shows. let single_player = match self.integrated.take() { Some(server) => { diff --git a/tools/test_friends.py b/tools/test_friends.py index e8f2360a59..020a4ed70d 100644 --- a/tools/test_friends.py +++ b/tools/test_friends.py @@ -287,6 +287,16 @@ def full(args, work): assert "Couldn't reach your friend's game" in reason, reason print(f'after stopping: "{reason}"') guest.stop() + if Game.server_thread: + # Leaving the world ends its code; coming back shares a new one. + host('friends host') + host.wait('friends status', lambda v: v['code'], 30, 'invite code') + host('disconnect') + host.wait('screen', lambda v: v['screen'] == 'title', 60, 'the title screen') + assert host('friends status')['code'] is None, 'the code outlived its world' + assert host_world(host) != code + host('friends stop') + print('leaving the world ends its code') # (b) A self-hosted relay, run locally: the short code, relayed. relay, relay_log, relay_url = start_relay(args, work) set_relay(host, relay_url) From 8ee30558dada58d7e43003a3ab1861aabea9291b Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:26:33 +0500 Subject: [PATCH 061/968] Launcher: show where native mods are installed and what each instance's build plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Each installed native mod says "Installed in {instance}" and has Open folder (/horizon-native-mods/); "Installs into {instance}" shows even with one instance. - The Native build row is the picked instance's own: a Not built / Needs rebuild / Up to date badge, the native-* build it plays and the Horizon it was built on (native-mods.json's base). A build's progress and error show only on the instance being built. - Update to Horizon {version} when update_for finds a newer release or Nightly; its trust warning says it compiles from scratch and takes longer. Build never moves the Horizon version. - The instance list shows a native build as "Native build · Nightly 10-28". - Strings in all six languages; docs/native-mods.md. Co-Authored-By: Claude Opus 5.5 --- docs/native-mods.md | 27 ++++-- launcher/lang/de.json | 18 +++- launcher/lang/es.json | 18 +++- launcher/lang/fr.json | 18 +++- launcher/lang/ru.json | 18 +++- launcher/lang/uk.json | 18 +++- launcher/lang/zh.json | 18 +++- launcher/src/instances_page.rs | 10 +++ launcher/src/main.rs | 43 ++++++++- launcher/src/mods_page.rs | 160 +++++++++++++++++++++++++-------- launcher/src/native_mods.rs | 2 +- launcher/src/native_status.rs | 75 ++++++++++++++-- 12 files changed, 351 insertions(+), 74 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 9c44fb3c3a..115c372f64 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -41,13 +41,23 @@ mod shows one. ## What the launcher does -In the window: install a native mod from the Mods page, into the instance picked on the left. The first one shows a -trust notice once (native mods aren't sandboxed; the first build downloads Rust and can take 20 to 30 minutes; later -builds are faster); `mods install` prints it too. The Mods page's installed list then has a **Native build** row for -that instance: **Build** while the version it plays doesn't hold the native mods turned on (it isn't a `native-*` -build, or the `mods` in its `native-mods.json` differ from those turned on, by id and version), else "Up to date". The -launcher names the mods, warns in red, and builds only from "I trust these mods: build"; the row shows each step and -the error if one fails. From the command line: +In the window: install a native mod from the Mods page, into the instance picked on the left ("Installs into +{instance}", shown even with one instance). The first one shows a trust notice once (native mods aren't sandboxed; the +first build downloads Rust and can take 20 to 30 minutes; later builds are faster); `mods install` prints it too. Each +installed native mod's row says "Installed in {instance}" and has **Open folder** (`/horizon-native-mods/`). +The installed list has a **Native build of {instance}** row for the instance picked on the left, never another's: + +- a badge: **Not built** (the version it plays isn't a `native-*` build), **Needs rebuild** (the `mods` in its + `native-mods.json` differ from those turned on, by id and version) or **Up to date**, with **Build** for the first + two; +- for a native build, "Plays native-…, built on Horizon {version}" (the `base` of its `native-mods.json`); +- **Update to Horizon {version}** when a newer release or Nightly is installed (`native_mods::update_for`). Build + stays on the Horizon the instance plays; only Update moves it, and its warning says the build starts from scratch + and takes longer. + +The instance list on the left shows such an instance as "Native build · Nightly 10-28" (its base). The launcher names +the mods, warns in red, and builds only from "I trust these mods: build"; the row shows each step and the error if +one fails, on the instance being built only; one build runs at a time. From the command line: `horizon-launcher native-build [FOLDER] [--instance NAME] [--update] --trust-native-code`. FOLDER is a bundle, an unpacked `.hnmod`, or a folder of them; without it, the native mods turned on in the instance. It is not an MCP tool: trusting native code is the player's decision, never an agent's. @@ -184,6 +194,5 @@ each was built or last used): switching a mod off and on again plays the kept bu ## Left to do -- Offer **Update** in the window when `update_for` finds a newer Horizon (asking first, as it compiles for minutes). - The Mods page already offers Build when the mods turned on change. +- A list of every native build, to delete the ones no instance needs. - A prebuilt horizon-mixins from its CI instead of building it on the player's computer. diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 1a3760fecf..d13498344b 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -189,8 +189,20 @@ "Keep": "Behalten", "Move to Trash": "In den Papierkorb", "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Native Mods laufen nicht in einer Sandbox: Sie haben vollen Zugriff auf deinen Computer, wie jedes Programm. Installiere nur Mods, deren Autoren du vertraust. Um mit einer zu spielen, baue das Spiel auf der Mods-Seite; der erste Build lädt Rust herunter und dauert 20 bis 30 Minuten.", - "Native build": "Nativer Build", "No native mod is turned on.": "Keine native Mod ist eingeschaltet.", - "Build the game to play with the native mods turned on.": "Baue das Spiel, um mit den eingeschalteten nativen Mods zu spielen.", - "Up to date: {instance} plays these mods.": "Auf dem neuesten Stand: {instance} spielt mit diesen Mods." + "Up to date: {instance} plays these mods.": "Auf dem neuesten Stand: {instance} spielt mit diesen Mods.", + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Das Update stellt diese Instanz auf Horizon {version} um. Es kompiliert das Spiel von Grund auf und dauert daher länger als ein erneuter Build: bis zu 30 Minuten.", + "Installs into {instance}.": "Wird in {instance} installiert.", + "Installs into {instance}: pick another instance on the left.": "Wird in {instance} installiert: wähle links eine andere Instanz.", + "Installed in {instance}": "Installiert in {instance}", + "Building": "Wird gebaut", + "{instance} doesn't play its native mods yet: build the game with them.": "{instance} spielt seine nativen Mods noch nicht: baue das Spiel mit ihnen.", + "Not built": "Nicht gebaut", + "The native mods turned on changed since {instance} was built: build again to play them.": "Die eingeschalteten nativen Mods haben sich seit dem Build von {instance} geändert: baue erneut, um mit ihnen zu spielen.", + "Needs rebuild": "Neuer Build nötig", + "{instance} is building: one build at a time.": "{instance} wird gerade gebaut: immer nur ein Build gleichzeitig.", + "Update to Horizon {version}": "Auf Horizon {version} aktualisieren", + "Native build of {instance}": "Nativer Build von {instance}", + "Native build · {version}": "Nativer Build · {version}", + "Plays {build}, built on Horizon {version}.": "Spielt {build}, gebaut auf Horizon {version}." } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index ea0371baa8..b938803bf6 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -189,8 +189,20 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Los mods nativos no están aislados: se ejecutan con acceso total a tu ordenador, como cualquier programa. Instala solo mods de autores en los que confíes. Para jugar con uno, compila el juego en la página Mods; la primera compilación descarga Rust y tarda de 20 a 30 minutos.", - "Native build": "Compilación nativa", "No native mod is turned on.": "No hay ningún mod nativo activado.", - "Build the game to play with the native mods turned on.": "Compila el juego para jugar con los mods nativos activados.", - "Up to date: {instance} plays these mods.": "Al día: {instance} juega con estos mods." + "Up to date: {instance} plays these mods.": "Al día: {instance} juega con estos mods.", + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Actualizar pasa esta instancia a Horizon {version}. Compila el juego desde cero, así que tarda más que una recompilación: hasta 30 minutos.", + "Installs into {instance}.": "Se instala en {instance}.", + "Installs into {instance}: pick another instance on the left.": "Se instala en {instance}: elige otra instancia a la izquierda.", + "Installed in {instance}": "Instalado en {instance}", + "Building": "Compilando", + "{instance} doesn't play its native mods yet: build the game with them.": "{instance} aún no juega con sus mods nativos: compila el juego con ellos.", + "Not built": "Sin compilar", + "The native mods turned on changed since {instance} was built: build again to play them.": "Los mods nativos activados cambiaron desde que se compiló {instance}: vuelve a compilar para jugar con ellos.", + "Needs rebuild": "Requiere recompilar", + "{instance} is building: one build at a time.": "{instance} se está compilando: una compilación a la vez.", + "Update to Horizon {version}": "Actualizar a Horizon {version}", + "Native build of {instance}": "Compilación nativa de {instance}", + "Native build · {version}": "Compilación nativa · {version}", + "Plays {build}, built on Horizon {version}.": "Juega {build}, compilado sobre Horizon {version}." } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 75ec54c677..4f47f56dda 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -189,8 +189,20 @@ "Move to Trash": "Mettre à la corbeille", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Les mods natifs ne sont pas isolés : ils ont un accès complet à votre ordinateur, comme n'importe quel programme. N'installez que des mods d'auteurs en qui vous avez confiance. Pour jouer avec, compilez le jeu depuis la page Mods ; la première compilation télécharge Rust et prend 20 à 30 minutes.", - "Native build": "Compilation native", "No native mod is turned on.": "Aucun mod natif n'est activé.", - "Build the game to play with the native mods turned on.": "Compilez le jeu pour jouer avec les mods natifs activés.", - "Up to date: {instance} plays these mods.": "À jour : {instance} joue avec ces mods." + "Up to date: {instance} plays these mods.": "À jour : {instance} joue avec ces mods.", + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "La mise à jour passe cette instance à Horizon {version}. Elle recompile le jeu entièrement, donc elle prend plus de temps qu'une recompilation : jusqu'à 30 minutes.", + "Installs into {instance}.": "S'installe dans {instance}.", + "Installs into {instance}: pick another instance on the left.": "S'installe dans {instance} : choisissez une autre instance à gauche.", + "Installed in {instance}": "Installé dans {instance}", + "Building": "Compilation", + "{instance} doesn't play its native mods yet: build the game with them.": "{instance} ne joue pas encore avec ses mods natifs : compilez le jeu avec eux.", + "Not built": "Non compilé", + "The native mods turned on changed since {instance} was built: build again to play them.": "Les mods natifs activés ont changé depuis la compilation de {instance} : compilez à nouveau pour jouer avec.", + "Needs rebuild": "À recompiler", + "{instance} is building: one build at a time.": "{instance} est en cours de compilation : une seule à la fois.", + "Update to Horizon {version}": "Mettre à jour vers Horizon {version}", + "Native build of {instance}": "Compilation native de {instance}", + "Native build · {version}": "Compilation native · {version}", + "Plays {build}, built on Horizon {version}.": "Joue {build}, compilé sur Horizon {version}." } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 289c56280d..a996dfd5e7 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -189,8 +189,20 @@ "Move to Trash": "В корзину", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Нативные моды не изолированы: у них полный доступ к вашему компьютеру, как у любой программы. Устанавливайте только моды авторов, которым доверяете. Чтобы играть с модом, соберите игру на странице «Моды»; первая сборка скачивает Rust и занимает 20–30 минут.", - "Native build": "Нативная сборка", "No native mod is turned on.": "Ни один нативный мод не включён.", - "Build the game to play with the native mods turned on.": "Соберите игру, чтобы играть с включёнными нативными модами.", - "Up to date: {instance} plays these mods.": "Актуально: {instance} играет с этими модами." + "Up to date: {instance} plays these mods.": "Актуально: {instance} играет с этими модами.", + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Обновление переведёт эту сборку на Horizon {version}. Игра компилируется с нуля, поэтому это дольше обычной пересборки: до 30 минут.", + "Installs into {instance}.": "Устанавливается в {instance}.", + "Installs into {instance}: pick another instance on the left.": "Устанавливается в {instance}: выберите другую сборку слева.", + "Installed in {instance}": "Установлен в {instance}", + "Building": "Компиляция", + "{instance} doesn't play its native mods yet: build the game with them.": "{instance} пока не играет со своими нативными модами: скомпилируйте игру с ними.", + "Not built": "Не скомпилировано", + "The native mods turned on changed since {instance} was built: build again to play them.": "Включённые нативные моды изменились с последней компиляции {instance}: скомпилируйте снова, чтобы играть с ними.", + "Needs rebuild": "Нужна перекомпиляция", + "{instance} is building: one build at a time.": "{instance} компилируется: только одна компиляция за раз.", + "Update to Horizon {version}": "Обновить до Horizon {version}", + "Native build of {instance}": "Нативная компиляция: {instance}", + "Native build · {version}": "Нативная сборка · {version}", + "Plays {build}, built on Horizon {version}.": "Играет {build}, скомпилировано на Horizon {version}." } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index 867d2b1da6..9ebbee2d83 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -189,8 +189,20 @@ "Keep": "Залишити", "Move to Trash": "Перемістити до кошика", "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Нативні моди не ізольовані: вони мають повний доступ до вашого комп'ютера, як будь-яка програма. Встановлюйте лише моди авторів, яким довіряєте. Щоб грати з модом, зберіть гру на сторінці «Моди»; перша збірка завантажує Rust і триває 20–30 хвилин.", - "Native build": "Нативна збірка", "No native mod is turned on.": "Жоден нативний мод не ввімкнено.", - "Build the game to play with the native mods turned on.": "Зберіть гру, щоб грати з увімкненими нативними модами.", - "Up to date: {instance} plays these mods.": "Актуально: {instance} грає з цими модами." + "Up to date: {instance} plays these mods.": "Актуально: {instance} грає з цими модами.", + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Оновлення переведе цей екземпляр на Horizon {version}. Гра компілюється з нуля, тому це довше за звичайне перезбирання: до 30 хвилин.", + "Installs into {instance}.": "Встановлюється в {instance}.", + "Installs into {instance}: pick another instance on the left.": "Встановлюється в {instance}: виберіть інший екземпляр ліворуч.", + "Installed in {instance}": "Встановлено в {instance}", + "Building": "Збирання", + "{instance} doesn't play its native mods yet: build the game with them.": "{instance} ще не грає зі своїми нативними модами: зберіть гру з ними.", + "Not built": "Не зібрано", + "The native mods turned on changed since {instance} was built: build again to play them.": "Увімкнені нативні моди змінилися після збирання {instance}: зберіть знову, щоб грати з ними.", + "Needs rebuild": "Потрібне перезбирання", + "{instance} is building: one build at a time.": "{instance} збирається: лише одне збирання за раз.", + "Update to Horizon {version}": "Оновити до Horizon {version}", + "Native build of {instance}": "Нативна збірка {instance}", + "Native build · {version}": "Нативна збірка · {version}", + "Plays {build}, built on Horizon {version}.": "Грає {build}, зібрано на Horizon {version}." } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index 2e17470e33..338bd2917b 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -189,8 +189,20 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "要将 {name} 连同其世界和设置一起移到回收站吗?你之后可以从那里恢复。", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "原生模组不在沙盒中运行:它们和任何程序一样,可以完全访问你的电脑。只安装你信任其作者的模组。要使用模组游玩,请在模组页面构建游戏;首次构建会下载 Rust,需要 20 到 30 分钟。", - "Native build": "原生构建", "No native mod is turned on.": "没有开启任何原生模组。", - "Build the game to play with the native mods turned on.": "构建游戏,以使用已开启的原生模组游玩。", - "Up to date: {instance} plays these mods.": "已是最新:{instance} 使用这些模组游玩。" + "Up to date: {instance} plays these mods.": "已是最新:{instance} 使用这些模组游玩。", + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "更新会将此实例切换到 Horizon {version}。它会从头编译游戏,因此比重新构建更久:最多 30 分钟。", + "Installs into {instance}.": "安装到 {instance}。", + "Installs into {instance}: pick another instance on the left.": "安装到 {instance}:可在左侧选择其他实例。", + "Installed in {instance}": "已安装到 {instance}", + "Building": "构建中", + "{instance} doesn't play its native mods yet: build the game with them.": "{instance} 尚未使用其原生模组:请用它们构建游戏。", + "Not built": "未构建", + "The native mods turned on changed since {instance} was built: build again to play them.": "自 {instance} 上次构建以来,开启的原生模组已变化:重新构建后即可使用。", + "Needs rebuild": "需要重新构建", + "{instance} is building: one build at a time.": "{instance} 正在构建:一次只能进行一个构建。", + "Update to Horizon {version}": "更新到 Horizon {version}", + "Native build of {instance}": "{instance} 的原生构建", + "Native build · {version}": "原生构建 · {version}", + "Plays {build}, built on Horizon {version}.": "运行 {build},基于 Horizon {version} 构建。" } diff --git a/launcher/src/instances_page.rs b/launcher/src/instances_page.rs index c358d154fd..f1bb09a6a2 100644 --- a/launcher/src/instances_page.rs +++ b/launcher/src/instances_page.rs @@ -100,6 +100,9 @@ impl Launcher { } pub(super) fn select_instance(&mut self, instance: &Instance) { + // A native build's warning names the mods of the instance it was opened for. + self.native_confirm = None; + self.native_update = None; self.settings.instance = (instance.kind != InstanceKind::Default).then(|| instance.id.clone()); self.settings.save(); @@ -199,6 +202,13 @@ impl Launcher { (None, Some(own)) => trf("{version} is not installed", &[("version", own)]), (None, None) => tr("No version installed").into(), (Some(_), _) if !prepared => tr("Needs preparing").into(), + (Some(tag), _) if self.native_build_of(tag).is_some() => { + let build = self.native_build_of(tag).unwrap_or_default(); + trf( + "Native build · {version}", + &[("version", &build.horizon_short())], + ) + } (Some(tag), Some(_)) => format!("{} · {tag}", tr("Ready")), (Some(_), None) => tr("Ready").into(), }, diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 7a6f8415f3..31b10c2778 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -55,6 +55,7 @@ use horizon_launcher::backend::{self, Job, Msg, Release, Running, Settings, Upda use horizon_launcher::i18n::{self, tr, trf}; use horizon_launcher::instances; use horizon_launcher::native_mods; +use horizon_launcher::native_status; use horizon_launcher::telemetry; use scrollbar::Scrollbar; use std::borrow::Cow; @@ -180,6 +181,15 @@ struct Launcher { native_step: Option, native_error: Option, native_confirm: Option, String>>, + /// The instance (id) the build under way, or the last one's error, is for. + native_for: Option, + /// The newer Horizon the open warning's build moves to (Update), else None: a build stays + /// on the Horizon the instance plays. + native_update: Option, + /// What each installed native build was made from, by version. A build's version names its + /// contents, so an entry never goes stale. + native_builds: + std::cell::RefCell>>, confirm_remove: Option, picker_open: bool, /// The top bar's quick switch of who plays (`switch_page.rs`). @@ -405,6 +415,9 @@ impl Launcher { native_step: None, native_error: None, native_confirm: None, + native_for: None, + native_update: None, + native_builds: Default::default(), confirm_remove: None, picker_open: false, account_menu: false, @@ -590,8 +603,21 @@ impl Launcher { Ok((folder, found)) } + /// What installed native build `version` was made from (`native_status::Build`), read once. + fn native_build_of(&self, version: &str) -> Option { + if !native_mods::is_native(version) { + return None; + } + self.native_builds + .borrow_mut() + .entry(version.to_owned()) + .or_insert_with(|| native_status::Build::read(version)) + .clone() + } + /// Builds the selected instance's native mods, once the player accepted the warning. The - /// build stays on the Horizon the instance plays (`native_mods::base_for`). + /// build stays on the Horizon the instance plays (`native_mods::base_for`), unless the + /// warning was Update's: then it moves to that newer Horizon. fn build_native(&mut self) { if self.native_step.is_some() { return; @@ -599,8 +625,12 @@ impl Launcher { self.native_confirm = None; self.native_error = None; let instance = instances::selected(); + self.native_for = Some(instance.id.clone()); let plays = self.version_of(&instance).cloned(); - let base = native_mods::base_for(plays.as_deref(), &self.installed, false); + let base = match self.native_update.take() { + Some(newer) => Some(newer), + None => native_mods::base_for(plays.as_deref(), &self.installed, false), + }; let Some(base) = base else { self.native_error = Some(tr("Install Horizon first.").into()); return; @@ -3370,7 +3400,13 @@ impl Launcher { .child(div().text_color(t.danger).child(tr("These mods will run with full access to your computer").to_string())) .child(tr("They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.").to_string()) .child(trf("Mods: {mods}", &[("mods", &list)])) - .child(tr("The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.").to_string()); + .child(tr("The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.").to_string()) + .children(self.native_update.as_ref().map(|newer| { + div().text_color(t.warn).child(trf( + "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.", + &[("version", &native_status::horizon_label(newer))], + )) + })); let mut buttons = div().flex().gap(px(8.)).mt(px(4.)); if mods.is_ok() { buttons = buttons.child( @@ -3390,6 +3426,7 @@ impl Launcher { button("native-cancel", "Cancel", Kind::Secondary, &t).on_click(cx.listener( |this, _: &ClickEvent, _, cx| { this.native_confirm = None; + this.native_update = None; cx.notify(); }, )), diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index b336a710d4..11f886096e 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -962,24 +962,27 @@ impl Launcher { page(body.child(self.mods_results(cx))) } - /// Which instance installs go into, when there is more than one to pick from. A modpack - /// becomes an instance of its own. + /// Which instance installs go into, even when it is the only one: native mods build into it. + /// A modpack becomes an instance of its own. fn mods_target_line(&self, cx: &mut Context) -> Option
{ let instance = self.selected_instance()?; if self.mods.kind == PackKind::DataPack { return Some(self.mods_world_picker(&instance.name.clone(), cx)); } - if self.instances.list.len() < 2 || self.mods.kind == PackKind::Modpack { + if self.mods.kind == PackKind::Modpack { return None; } - Some( - text(theme::CAPTION) - .text_color(self.theme.muted) - .child(format!( - "Installs into {}: pick another instance on the left.", - instance.name - )), - ) + let name = [("instance", instance.name.as_str())]; + Some(text(theme::CAPTION).text_color(self.theme.muted).child( + if self.instances.list.len() < 2 { + trf("Installs into {instance}.", &name) + } else { + trf( + "Installs into {instance}: pick another instance on the left.", + &name, + ) + }, + )) } /// The world data packs go into: one of the instance's, picked here. @@ -1122,9 +1125,11 @@ impl Launcher { && entries .iter() .any(|e| native_status::is_native_file(&e.file)); + let instance = self.selected_instance().map(|i| i.name.clone()); let row = |i: usize| { let e = entries[i]; let key = (e.source, e.project.clone()); + let is_native = self.mods.java.is_none() && native_status::is_native_file(&e.file); let mut right = div().flex().items_center().gap(px(8.)); if let Some(number) = self.mods.updates.get(&key) { let mut update = button( @@ -1207,6 +1212,21 @@ impl Launcher { })), ); } + if is_native { + let folder = self.mods.home().join(native_mods::INSTALLED); + right = right.child( + button( + SharedString::from(format!("mods-folder-{}", e.project)), + "Open folder", + Kind::Ghost, + &t, + ) + .on_click(move |_, _, cx| { + let _ = std::fs::create_dir_all(&folder); + cx.open_with_system(&folder); + }), + ); + } right = right.child(remove); let detail = div() .flex() @@ -1226,6 +1246,11 @@ impl Launcher { }, e.file )) + .children(instance.as_ref().filter(|_| is_native).map(|name| { + div() + .text_color(t.ink) + .child(trf("Installed in {instance}", &[("instance", name)])) + })) .children(e.skipped.iter().map(|s| { div() .text_color(t.ink_soft) @@ -1286,10 +1311,13 @@ impl Launcher { ) } - /// The selected instance's native build (docs/native-mods.md): building, failed, up to - /// date, or Build when the version it plays lacks the native mods turned on. Build opens the - /// trust warning first (`native_warning`). + /// The selected instance's native build (docs/native-mods.md): which build it plays, the + /// Horizon it was built on, and whether it holds the native mods turned on (Up to date, + /// Needs rebuild, Not built), with Build, and Update when a newer Horizon is installed. Both + /// open the trust warning first (`native_warning`). A build under way, or its error, shows + /// only on the instance it is for. fn native_build(&self, cx: &mut Context) -> Option
{ + use native_status::State; let t = self.theme; let instance = self.selected_instance()?; let version = self.version_of(instance).cloned(); @@ -1305,42 +1333,102 @@ impl Launcher { state } }; + let build = version.as_deref().and_then(|v| self.native_build_of(v)); + let update = build + .as_ref() + .filter(|_| state != State::NoMods) + .and_then(|b| native_mods::newer_than(&b.base, &self.installed)); + let mine = self.native_for.as_deref() == Some(instance.id.as_str()); + let step = self.native_step.as_ref().filter(|_| mine); + let error = self.native_error.as_ref().filter(|_| mine); + let name = [("instance", instance.name.as_str())]; let line = |s: String| div().child(s); - let (status, can_build) = match (&self.native_step, &self.native_error, state) { - (Some(step), _, _) => (line(step.clone()).text_color(t.accent), false), - (None, Some(e), _) => (line(e.clone()).text_color(t.danger), true), - (None, None, native_status::State::NoMods) => { - (line(tr("No native mod is turned on.").into()), false) - } - (None, None, native_status::State::NeedsBuild) => ( - line(tr("Build the game to play with the native mods turned on.").into()), - true, + let (status, badge) = match (step, error, state) { + (Some(step), _, _) => ( + line(step.clone()).text_color(t.accent), + Some((tr("Building"), t.accent)), ), - (None, None, native_status::State::UpToDate) => ( + (None, Some(e), _) => (line(e.clone()).text_color(t.danger), None), + (None, None, State::NoMods) => (line(tr("No native mod is turned on.").into()), None), + (None, None, State::NotBuilt) => ( line(trf( - "Up to date: {instance} plays these mods.", - &[("instance", &instance.name)], + "{instance} doesn't play its native mods yet: build the game with them.", + &name, )), - false, + Some((tr("Not built"), t.warn)), + ), + (None, None, State::Outdated) => ( + line(trf( + "The native mods turned on changed since {instance} was built: build again to play them.", + &name, + )), + Some((tr("Needs rebuild"), t.warn)), + ), + (None, None, State::UpToDate) => ( + line(trf("Up to date: {instance} plays these mods.", &name)), + Some((tr("Up to date"), t.success)), ), }; - let build = (can_build && self.native_confirm.is_none()).then(|| { - button("native-build", "Build", Kind::Primary, &t) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + let mut detail = div().flex().flex_col().gap(px(4.)).child(status); + if let (Some(tag), Some(build)) = (&version, &build) { + detail = detail.child(div().text_color(t.muted).child(trf( + "Plays {build}, built on Horizon {version}.", + &[("build", tag), ("version", &build.horizon())], + ))); + } + if self.native_step.is_some() && !mine { + let other = self + .native_for + .as_deref() + .and_then(|id| self.instances.list.iter().find(|i| i.id == id)); + detail = detail.child(div().text_color(t.muted).child(trf( + "{instance} is building: one build at a time.", + &[("instance", other.map_or("", |i| i.name.as_str()))], + ))); + } + let idle = self.native_step.is_none() && self.native_confirm.is_none(); + let can_build = + idle && matches!(state, State::NotBuilt | State::Outdated) || idle && error.is_some(); + let mut right = div() + .flex() + .items_center() + .gap(px(8.)) + .children(badge.map(|(label, color)| blocks::badge(label.to_string(), color, &t))); + if let Some(newer) = update.filter(|_| idle) { + right = right.child( + button( + "native-update", + trf( + "Update to Horizon {version}", + &[("version", &native_status::horizon_label(&newer))], + ), + Kind::Secondary, + &t, + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.native_update = Some(newer.clone()); this.native_confirm = Some(this.native_mods_to_build().map(|(_, b)| b)); cx.notify(); - })) - .into_any_element() - }); - let mut detail = div().flex().flex_col().gap(px(4.)).child(status); + })), + ); + } + if can_build { + right = right.child(button("native-build", "Build", Kind::Primary, &t).on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.native_update = None; + this.native_confirm = Some(this.native_mods_to_build().map(|(_, b)| b)); + cx.notify(); + }), + )); + } if let Some(mods) = &self.native_confirm { detail = detail.child(self.native_warning(mods, cx)); } Some(index_row( 0, - tr("Native build"), + trf("Native build of {instance}", &name), Some(detail.into_any_element()), - build, + Some(right.into_any_element()), &t, )) } diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index aace9999a2..4067720e2f 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -394,7 +394,7 @@ pub fn update_for(version: &str, installed: &[String]) -> Option { newer_than(&about(version)?.base, installed) } -fn newer_than(base: &str, installed: &[String]) -> Option { +pub fn newer_than(base: &str, installed: &[String]) -> Option { newest(installed) .filter(|n| *n != base && backend::compare_versions(n, base).is_gt()) .cloned() diff --git a/launcher/src/native_status.rs b/launcher/src/native_status.rs index ac8e03df44..2c70b10b4e 100644 --- a/launcher/src/native_status.rs +++ b/launcher/src/native_status.rs @@ -22,8 +22,10 @@ pub fn is_native_file(file: &str) -> bool { pub enum State { /// No native mod is turned on: nothing to build. NoMods, - /// The version it plays doesn't hold the mods turned on (or isn't a native build). - NeedsBuild, + /// The version it plays isn't a native build. + NotBuilt, + /// The version it plays is a native build, made with other mods than those turned on. + Outdated, /// The version it plays was built with exactly these mods. UpToDate, } @@ -41,8 +43,47 @@ pub fn state(enabled: &[Mod], built: Option<&[Mod]>) -> State { v }; match built { + None => State::NotBuilt, Some(built) if sorted(built) == sorted(enabled) => State::UpToDate, - _ => State::NeedsBuild, + Some(_) => State::Outdated, + } +} + +/// What a native build was made from, as the Mods page and the instance list show it. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Build { + /// The release or Nightly it was built on (`native_mods::About::base`). + pub base: String, +} + +impl Build { + /// Installed native build `version`'s, from its `native-mods.json`; None for another version. + pub fn read(version: &str) -> Option { + Some(Build { + base: native_mods::about(version)?.base, + }) + } + + /// The Horizon it was built on, as a player reads it (`horizon_label`). + pub fn horizon(&self) -> String { + horizon_label(&self.base) + } + + /// The same, short enough for the instance list: `Nightly 10-28`. + pub fn horizon_short(&self) -> String { + match crate::nightly::date_of(&self.base) { + Some(date) => format!("Nightly {}", &date[5..]), + None => self.base.clone(), + } + } +} + +/// A release or Nightly as a player reads it beside "Horizon": `v0.2.93` as it is, +/// `nightly-2026-10-29-4baa571` as `Nightly 2026-10-29`. +pub fn horizon_label(version: &str) -> String { + match crate::nightly::date_of(version) { + Some(date) => format!("Nightly {date}"), + None => version.to_owned(), } } @@ -125,22 +166,22 @@ mod tests { State::NoMods, "nothing on: nothing to build" ); - assert_eq!(state(&on, None), State::NeedsBuild, "not a native build"); + assert_eq!(state(&on, None), State::NotBuilt, "not a native build"); let reordered = [m("zoom", "2.1"), m("veinminer", "1.0")]; assert_eq!(state(&on, Some(&reordered)), State::UpToDate); assert_eq!( state(&on, Some(&[m("veinminer", "1.0")])), - State::NeedsBuild, + State::Outdated, "a mod turned on since" ); assert_eq!( state(&on[..1], Some(&on)), - State::NeedsBuild, + State::Outdated, "a mod turned off since" ); assert_eq!( state(&on, Some(&[m("veinminer", "1.1"), m("zoom", "2.1")])), - State::NeedsBuild, + State::Outdated, "a mod updated since" ); } @@ -156,6 +197,26 @@ mod tests { assert_eq!(built("v0.1.0"), None, "not a native build"); } + #[test] + fn builds_name_the_horizon_they_were_built_on() { + assert_eq!(horizon_label("v0.2.93"), "v0.2.93"); + assert_eq!( + horizon_label("nightly-2026-10-29-4baa571"), + "Nightly 2026-10-29" + ); + assert_eq!(horizon_label("nightly-2026-10-29"), "Nightly 2026-10-29"); + let build = Build { + base: "nightly-2026-10-28-0c02067".into(), + }; + assert_eq!(build.horizon(), "Nightly 2026-10-28"); + assert_eq!(build.horizon_short(), "Nightly 10-28"); + let release = Build { + base: "v0.2.93".into(), + }; + assert_eq!(release.horizon_short(), "v0.2.93"); + assert_eq!(Build::read("v0.1.0"), None, "not a native build"); + } + #[test] fn only_hnmods_in_the_native_folder_are_native() { assert!(is_native_file("horizon-native-mods/veinminer.hnmod")); From f2a3a76c07c73a4f6f74e1b1bfd244a4153760df Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:29:03 +0500 Subject: [PATCH 062/968] Launcher: simpler Build condition for clippy 1.95 Co-Authored-By: Claude Opus 5.5 --- launcher/src/mods_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index 11f886096e..636c2a19ce 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -1388,7 +1388,7 @@ impl Launcher { } let idle = self.native_step.is_none() && self.native_confirm.is_none(); let can_build = - idle && matches!(state, State::NotBuilt | State::Outdated) || idle && error.is_some(); + idle && (error.is_some() || matches!(state, State::NotBuilt | State::Outdated)); let mut right = div() .flex() .items_center() From 32cce66761e314880aae2f0f9b4403a8dd50877d Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:32:10 +0500 Subject: [PATCH 063/968] Launcher: call the Horizon update button "Rebuild on Horizon X" A mod row's "Update to 2.0.0" sat just below "Update to Horizon X"; the two did different things under the same word. Co-Authored-By: Claude Opus 5.5 --- docs/native-mods.md | 4 ++-- launcher/lang/de.json | 4 ++-- launcher/lang/es.json | 4 ++-- launcher/lang/fr.json | 4 ++-- launcher/lang/ru.json | 4 ++-- launcher/lang/uk.json | 4 ++-- launcher/lang/zh.json | 4 ++-- launcher/src/main.rs | 2 +- launcher/src/mods_page.rs | 2 +- launcher/src/native_mods.rs | 2 +- 10 files changed, 17 insertions(+), 17 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 115c372f64..16c593a51c 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -51,8 +51,8 @@ The installed list has a **Native build of {instance}** row for the instance pic `native-mods.json` differ from those turned on, by id and version) or **Up to date**, with **Build** for the first two; - for a native build, "Plays native-…, built on Horizon {version}" (the `base` of its `native-mods.json`); -- **Update to Horizon {version}** when a newer release or Nightly is installed (`native_mods::update_for`). Build - stays on the Horizon the instance plays; only Update moves it, and its warning says the build starts from scratch +- **Rebuild on Horizon {version}** when a newer release or Nightly is installed (`native_mods::update_for`). Build + stays on the Horizon the instance plays; only that button moves it, and its warning says the build starts from scratch and takes longer. The instance list on the left shows such an instance as "Native build · Nightly 10-28" (its base). The launcher names diff --git a/launcher/lang/de.json b/launcher/lang/de.json index d13498344b..4fa193a92e 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -191,7 +191,7 @@ "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Native Mods laufen nicht in einer Sandbox: Sie haben vollen Zugriff auf deinen Computer, wie jedes Programm. Installiere nur Mods, deren Autoren du vertraust. Um mit einer zu spielen, baue das Spiel auf der Mods-Seite; der erste Build lädt Rust herunter und dauert 20 bis 30 Minuten.", "No native mod is turned on.": "Keine native Mod ist eingeschaltet.", "Up to date: {instance} plays these mods.": "Auf dem neuesten Stand: {instance} spielt mit diesen Mods.", - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Das Update stellt diese Instanz auf Horizon {version} um. Es kompiliert das Spiel von Grund auf und dauert daher länger als ein erneuter Build: bis zu 30 Minuten.", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.": "Damit wechselt diese Instanz auf Horizon {version}. Das Spiel wird von Grund auf kompiliert und dauert daher länger als ein normaler Build: bis zu 30 Minuten.", "Installs into {instance}.": "Wird in {instance} installiert.", "Installs into {instance}: pick another instance on the left.": "Wird in {instance} installiert: wähle links eine andere Instanz.", "Installed in {instance}": "Installiert in {instance}", @@ -201,7 +201,7 @@ "The native mods turned on changed since {instance} was built: build again to play them.": "Die eingeschalteten nativen Mods haben sich seit dem Build von {instance} geändert: baue erneut, um mit ihnen zu spielen.", "Needs rebuild": "Neuer Build nötig", "{instance} is building: one build at a time.": "{instance} wird gerade gebaut: immer nur ein Build gleichzeitig.", - "Update to Horizon {version}": "Auf Horizon {version} aktualisieren", + "Rebuild on Horizon {version}": "Mit Horizon {version} neu bauen", "Native build of {instance}": "Nativer Build von {instance}", "Native build · {version}": "Nativer Build · {version}", "Plays {build}, built on Horizon {version}.": "Spielt {build}, gebaut auf Horizon {version}." diff --git a/launcher/lang/es.json b/launcher/lang/es.json index b938803bf6..4f37aeb94b 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -191,7 +191,7 @@ "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Los mods nativos no están aislados: se ejecutan con acceso total a tu ordenador, como cualquier programa. Instala solo mods de autores en los que confíes. Para jugar con uno, compila el juego en la página Mods; la primera compilación descarga Rust y tarda de 20 a 30 minutos.", "No native mod is turned on.": "No hay ningún mod nativo activado.", "Up to date: {instance} plays these mods.": "Al día: {instance} juega con estos mods.", - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Actualizar pasa esta instancia a Horizon {version}. Compila el juego desde cero, así que tarda más que una recompilación: hasta 30 minutos.", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.": "Esto pasa esta instancia a Horizon {version}. Compila el juego desde cero, así que tarda más que una recompilación normal: hasta 30 minutos.", "Installs into {instance}.": "Se instala en {instance}.", "Installs into {instance}: pick another instance on the left.": "Se instala en {instance}: elige otra instancia a la izquierda.", "Installed in {instance}": "Instalado en {instance}", @@ -201,7 +201,7 @@ "The native mods turned on changed since {instance} was built: build again to play them.": "Los mods nativos activados cambiaron desde que se compiló {instance}: vuelve a compilar para jugar con ellos.", "Needs rebuild": "Requiere recompilar", "{instance} is building: one build at a time.": "{instance} se está compilando: una compilación a la vez.", - "Update to Horizon {version}": "Actualizar a Horizon {version}", + "Rebuild on Horizon {version}": "Recompilar con Horizon {version}", "Native build of {instance}": "Compilación nativa de {instance}", "Native build · {version}": "Compilación nativa · {version}", "Plays {build}, built on Horizon {version}.": "Juega {build}, compilado sobre Horizon {version}." diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 4f47f56dda..e5e86c7efd 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -191,7 +191,7 @@ "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Les mods natifs ne sont pas isolés : ils ont un accès complet à votre ordinateur, comme n'importe quel programme. N'installez que des mods d'auteurs en qui vous avez confiance. Pour jouer avec, compilez le jeu depuis la page Mods ; la première compilation télécharge Rust et prend 20 à 30 minutes.", "No native mod is turned on.": "Aucun mod natif n'est activé.", "Up to date: {instance} plays these mods.": "À jour : {instance} joue avec ces mods.", - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "La mise à jour passe cette instance à Horizon {version}. Elle recompile le jeu entièrement, donc elle prend plus de temps qu'une recompilation : jusqu'à 30 minutes.", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.": "Cela passe cette instance à Horizon {version}. Le jeu est recompilé entièrement, donc cela prend plus de temps qu'une recompilation normale : jusqu'à 30 minutes.", "Installs into {instance}.": "S'installe dans {instance}.", "Installs into {instance}: pick another instance on the left.": "S'installe dans {instance} : choisissez une autre instance à gauche.", "Installed in {instance}": "Installé dans {instance}", @@ -201,7 +201,7 @@ "The native mods turned on changed since {instance} was built: build again to play them.": "Les mods natifs activés ont changé depuis la compilation de {instance} : compilez à nouveau pour jouer avec.", "Needs rebuild": "À recompiler", "{instance} is building: one build at a time.": "{instance} est en cours de compilation : une seule à la fois.", - "Update to Horizon {version}": "Mettre à jour vers Horizon {version}", + "Rebuild on Horizon {version}": "Recompiler avec Horizon {version}", "Native build of {instance}": "Compilation native de {instance}", "Native build · {version}": "Compilation native · {version}", "Plays {build}, built on Horizon {version}.": "Joue {build}, compilé sur Horizon {version}." diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index a996dfd5e7..0bc55a1fb9 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -191,7 +191,7 @@ "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Нативные моды не изолированы: у них полный доступ к вашему компьютеру, как у любой программы. Устанавливайте только моды авторов, которым доверяете. Чтобы играть с модом, соберите игру на странице «Моды»; первая сборка скачивает Rust и занимает 20–30 минут.", "No native mod is turned on.": "Ни один нативный мод не включён.", "Up to date: {instance} plays these mods.": "Актуально: {instance} играет с этими модами.", - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Обновление переведёт эту сборку на Horizon {version}. Игра компилируется с нуля, поэтому это дольше обычной пересборки: до 30 минут.", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.": "Это переведёт сборку на Horizon {version}. Игра компилируется с нуля, поэтому это дольше обычной пересборки: до 30 минут.", "Installs into {instance}.": "Устанавливается в {instance}.", "Installs into {instance}: pick another instance on the left.": "Устанавливается в {instance}: выберите другую сборку слева.", "Installed in {instance}": "Установлен в {instance}", @@ -201,7 +201,7 @@ "The native mods turned on changed since {instance} was built: build again to play them.": "Включённые нативные моды изменились с последней компиляции {instance}: скомпилируйте снова, чтобы играть с ними.", "Needs rebuild": "Нужна перекомпиляция", "{instance} is building: one build at a time.": "{instance} компилируется: только одна компиляция за раз.", - "Update to Horizon {version}": "Обновить до Horizon {version}", + "Rebuild on Horizon {version}": "Пересобрать на Horizon {version}", "Native build of {instance}": "Нативная компиляция: {instance}", "Native build · {version}": "Нативная сборка · {version}", "Plays {build}, built on Horizon {version}.": "Играет {build}, скомпилировано на Horizon {version}." diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index 9ebbee2d83..ec26dc0163 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -191,7 +191,7 @@ "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "Нативні моди не ізольовані: вони мають повний доступ до вашого комп'ютера, як будь-яка програма. Встановлюйте лише моди авторів, яким довіряєте. Щоб грати з модом, зберіть гру на сторінці «Моди»; перша збірка завантажує Rust і триває 20–30 хвилин.", "No native mod is turned on.": "Жоден нативний мод не ввімкнено.", "Up to date: {instance} plays these mods.": "Актуально: {instance} грає з цими модами.", - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "Оновлення переведе цей екземпляр на Horizon {version}. Гра компілюється з нуля, тому це довше за звичайне перезбирання: до 30 хвилин.", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.": "Це переведе екземпляр на Horizon {version}. Гра компілюється з нуля, тому це довше за звичайне перезбирання: до 30 хвилин.", "Installs into {instance}.": "Встановлюється в {instance}.", "Installs into {instance}: pick another instance on the left.": "Встановлюється в {instance}: виберіть інший екземпляр ліворуч.", "Installed in {instance}": "Встановлено в {instance}", @@ -201,7 +201,7 @@ "The native mods turned on changed since {instance} was built: build again to play them.": "Увімкнені нативні моди змінилися після збирання {instance}: зберіть знову, щоб грати з ними.", "Needs rebuild": "Потрібне перезбирання", "{instance} is building: one build at a time.": "{instance} збирається: лише одне збирання за раз.", - "Update to Horizon {version}": "Оновити до Horizon {version}", + "Rebuild on Horizon {version}": "Перезібрати на Horizon {version}", "Native build of {instance}": "Нативна збірка {instance}", "Native build · {version}": "Нативна збірка · {version}", "Plays {build}, built on Horizon {version}.": "Грає {build}, зібрано на Horizon {version}." diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index 338bd2917b..42b6ecd169 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -191,7 +191,7 @@ "Native mods aren't sandboxed: they run with full access to your computer, like any program. Install only mods whose authors you trust. To play with one, build the game on the Mods page; the first build downloads Rust and takes 20 to 30 minutes.": "原生模组不在沙盒中运行:它们和任何程序一样,可以完全访问你的电脑。只安装你信任其作者的模组。要使用模组游玩,请在模组页面构建游戏;首次构建会下载 Rust,需要 20 到 30 分钟。", "No native mod is turned on.": "没有开启任何原生模组。", "Up to date: {instance} plays these mods.": "已是最新:{instance} 使用这些模组游玩。", - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.": "更新会将此实例切换到 Horizon {version}。它会从头编译游戏,因此比重新构建更久:最多 30 分钟。", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.": "这会将此实例切换到 Horizon {version}。它会从头编译游戏,因此比普通重新构建更久:最多 30 分钟。", "Installs into {instance}.": "安装到 {instance}。", "Installs into {instance}: pick another instance on the left.": "安装到 {instance}:可在左侧选择其他实例。", "Installed in {instance}": "已安装到 {instance}", @@ -201,7 +201,7 @@ "The native mods turned on changed since {instance} was built: build again to play them.": "自 {instance} 上次构建以来,开启的原生模组已变化:重新构建后即可使用。", "Needs rebuild": "需要重新构建", "{instance} is building: one build at a time.": "{instance} 正在构建:一次只能进行一个构建。", - "Update to Horizon {version}": "更新到 Horizon {version}", + "Rebuild on Horizon {version}": "基于 Horizon {version} 重新构建", "Native build of {instance}": "{instance} 的原生构建", "Native build · {version}": "原生构建 · {version}", "Plays {build}, built on Horizon {version}.": "运行 {build},基于 Horizon {version} 构建。" diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 31b10c2778..6d1339ca8b 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -3403,7 +3403,7 @@ impl Launcher { .child(tr("The build compiles the game: 20 to 30 minutes and a few GB of disk the first time (it downloads Rust), a few minutes after that.").to_string()) .children(self.native_update.as_ref().map(|newer| { div().text_color(t.warn).child(trf( - "Update moves this instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a rebuild: up to 30 minutes.", + "This moves the instance to Horizon {version}. It compiles the game from scratch, so it takes longer than a normal rebuild: up to 30 minutes.", &[("version", &native_status::horizon_label(newer))], )) })); diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index 636c2a19ce..7c000b9529 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -1399,7 +1399,7 @@ impl Launcher { button( "native-update", trf( - "Update to Horizon {version}", + "Rebuild on Horizon {version}", &[("version", &native_status::horizon_label(&newer))], ), Kind::Secondary, diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 4067720e2f..f791ba4cb3 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -1151,7 +1151,7 @@ mod tests { } /// levzzz5154's decision (2026-10-29): a rebuild stays on the Horizon the instance plays; - /// only Update moves it to a newer one. + /// only "Rebuild on Horizon {version}" moves it to a newer one. #[test] fn a_rebuild_stays_on_the_instances_horizon_until_an_update() { let installed: Vec = [ From a554d6e7c40c63205a285419b5dd0b6023bfe7c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=A0=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=86=D0=B5=D0=B2?= <110433238+quintyo@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:39:54 +0300 Subject: [PATCH 064/968] Fix item model hand swap animation scale Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/first_person.rs | 15 +++++++++++++++ crates/horizon-client/src/game.rs | 12 ++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 586e6bfdc7..9fb6bc4251 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -481,6 +481,12 @@ impl HandHeights { } } +/// `FirstPersonHandsAndItemsRenderer.submit`: the displayed stack's model +/// scales the interpolated equip displacement independently for each hand. +pub fn equip_displacement(height: f32, swap_animation_scale: f32) -> f32 { + swap_animation_scale * (1.0 - height) +} + /// `LocalPlayer.xBob` and `yBob`: the hands lag behind the view when it /// turns. `LocalPlayer.applyInput` eases them halfway to the rotation every /// tick; `FirstPersonHandsAndItemsRenderer` turns the hands by a tenth of @@ -877,6 +883,15 @@ impl Clicks { #[cfg(test)] mod tests { use super::*; + + #[test] + fn equip_displacement_uses_the_visible_items_scale() { + let height = 0.5; + // Vanilla spear item models use 1.95; a sword uses the default 1.0. + assert_eq!(equip_displacement(height, 1.95), 0.975); + assert_eq!(equip_displacement(height, 1.0), 0.5); + assert_eq!(equip_displacement(1.0, 1.95), 0.0); + } #[test] fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() { let (bow, apple) = ( diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 431df41192..7a2922df78 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10860,7 +10860,7 @@ impl Game<'_> { 0.0 } }); - let inverse_heights = self.heights.interpolated(a).map(|h| 1.0 - h); + let heights = self.heights.interpolated(a); let view = horizon_client::camera::view_matrix(self.yaw, self.pitch); // The hand sway: both hands turn by a tenth of the bobs' lag. let [sway_x, sway_y] = self.sway.rotation(a, self.pitch, self.yaw); @@ -10886,7 +10886,15 @@ impl Game<'_> { if !shown[i] { continue; } - let (attack, inverse_height) = (attacks[i], inverse_heights[i]); + let scale = item.as_ref().map_or(1.0, |stack| { + self.pack + .item_renderer + .models + .swap_animation_scale(&horizon_client::item_render::stack_view(stack)) + }); + let attack = attacks[i]; + let inverse_height = + horizon_client::first_person::equip_displacement(heights[i], scale); // The main hand on its arm, the off hand on the other one. let left = self.settings.main_hand_left != (hand == Hand::Off); let Some(stack) = item else { From 15a22366e3a98f20fff43db53842ea608cfb6872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=A0=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=86=D0=B5=D0=B2?= <110433238+quintyo@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:53:29 +0300 Subject: [PATCH 065/968] Test swap scale from displayed item models Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/first_person.rs | 48 +++++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 9fb6bc4251..ebedd1d1de 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -886,11 +886,51 @@ mod tests { #[test] fn equip_displacement_uses_the_visible_items_scale() { + use horizon_assets::item_model::{ClientItem, ItemModels, StackView}; + use serde_json::json; + + let mut models = ItemModels::default(); + for (item, scale) in [("minecraft:spear", 1.95), ("minecraft:sword", 1.0)] { + models.definitions.insert( + item.into(), + ClientItem::decode(&json!({ + "model": { "type": "minecraft:empty" }, + "swap_animation_scale": scale, + })) + .unwrap(), + ); + models.prototypes.insert( + item.into(), + [("minecraft:item_model".into(), json!(item))].into(), + ); + } + let spear = StackView::new("minecraft:spear", 1); + let sword = StackView::new("minecraft:sword", 1); let height = 0.5; - // Vanilla spear item models use 1.95; a sword uses the default 1.0. - assert_eq!(equip_displacement(height, 1.95), 0.975); - assert_eq!(equip_displacement(height, 1.0), 0.5); - assert_eq!(equip_displacement(1.0, 1.95), 0.0); + // The model of the visible stack determines each hand's displacement. + assert_eq!( + equip_displacement(height, models.swap_animation_scale(&spear)), + 0.975 + ); + assert_eq!( + equip_displacement(height, models.swap_animation_scale(&sword)), + 0.5 + ); + assert_eq!( + equip_displacement(1.0, models.swap_animation_scale(&spear)), + 0.0 + ); + + // A component override (such as a resource-pack model) takes precedence. + let mut overridden = spear; + overridden.patch.insert( + "minecraft:item_model".into(), + Some(json!("minecraft:sword")), + ); + assert_eq!( + equip_displacement(height, models.swap_animation_scale(&overridden)), + 0.5 + ); } #[test] fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() { From 880eb7350fe58781a78f04eec209d7bb3c4e66f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=A0=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=86=D0=B5=D0=B2?= <110433238+quintyo@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:04:53 +0300 Subject: [PATCH 066/968] Cover spear swing across a held item switch Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/first_person.rs | 39 +++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index ebedd1d1de..adb71ba8c8 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -932,6 +932,45 @@ mod tests { 0.5 ); } + + #[test] + fn switching_after_a_spear_jab_keeps_the_swing_but_resets_charge() { + use crate::attack_strength::{AttackStrength, HeldStack}; + + let held = |name: &str| { + Some(HeldStack { + item: name.into(), + rest: "1".into(), + }) + }; + let spear = held("minecraft:netherite_spear"); + let sword = held("minecraft:wooden_sword"); + let mut charge = AttackStrength::default(); + for _ in 0..80 { + charge.tick(spear.clone()); + } + let mut swing = SwingState::default(); + assert!(swing.start_if_able( + Hand::Main, + SwingAnimation { + kind: 2, + duration: 23 + }, + 23 + )); + charge.reset_attack(); + swing.tick(); + charge.tick(spear); + assert_eq!((charge.ticker, charge.swap_ticker), (1, 80)); + + // Player.tick resets both counters on a different item, while the + // active swing keeps its original duration (vanilla 26.3 oracle). + swing.tick(); + charge.tick(sword); + assert_eq!((charge.ticker, charge.swap_ticker), (0, 0)); + assert_eq!(swing.current.unwrap().duration_ticks, 23); + assert_eq!(swing.animation.to_bits(), (1.0_f32 / 23.0).to_bits()); + } #[test] fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() { let (bow, apple) = ( From f8eebefe0a9de7fea854e654718bf638eabb949d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:46:26 +0100 Subject: [PATCH 067/968] Draw the item a villager holds on its crossed arms A villager's held item was never drawn: its model has no hand arms, so ItemInHandLayer found no hand. Vanilla's VillagerRenderer adds CrossedArmsItemLayer, which puts the main hand's item on the arms and shows it in the GROUND display context (CrossedArmsItemLayer.java), as the witch already does. --- crates/horizon-client/src/game/mob_blocks.rs | 6 +++--- crates/horizon-client/src/mobs/render.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 64e72769b0..c463a03804 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -340,10 +340,10 @@ impl Game<'_> { owner.crossbow_charge_duration = 25; } use horizon_assets::item_model::DisplayContext as Context; - // PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies - // on the ground. + // PandaHoldsItemLayer, FoxHeldItemLayer and CrossedArmsItemLayer + // show the item as it lies on the ground. let (context, ordinal) = match (mob.species(), left) { - (Species::Panda | Species::Fox, _) => (Context::Ground, 7), + (Species::Panda | Species::Fox | Species::Villager, _) => (Context::Ground, 7), (_, true) => (Context::ThirdPersonLeftHand, 1), (_, false) => (Context::ThirdPersonRightHand, 2), }; diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 29d299bf88..95f834d7b3 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1678,6 +1678,22 @@ pub fn held_item_transform( .unwrap_or_else(|| m.kind.model.clone()); let mut poses = Poses::new(&model); anim::setup(m, &s, &mut poses, &data.animations); + // CrossedArmsItemLayer, the layer a villager has: the main hand's item on + // the arms; only the main hand's item is drawn. + if m.species() == Species::Villager { + m.extra.main_hand.as_deref()?; + if left != m.main_arm_left() { + return None; + } + // CrossedArmsItemLayer.applyTranslation: translateToArms, then this. + let arms = part_matrix(&model, base, &poses, "arms", 0.0)?; + return Some( + arms * glam::Mat4::from_rotation_x(0.75) + * glam::Mat4::from_scale(glam::Vec3::splat(1.07)) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.13, -0.34)) + * glam::Mat4::from_rotation_x(std::f32::consts::PI), + ); + } let mut t = hand_base(m, &model, base, &poses, left)?; // ItemInHandLayer: a STAB swing of this arm thrusts its item out // (SpearAnimations.thirdPersonAttackItem, the main hand's forward From 834cc83e856d8645365158bdd0e2078da4574347 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:46:47 +0100 Subject: [PATCH 068/968] Sync a mob's main hand enchantments to its viewers SetEquipment carried only the main hand's item and count, so an enchanted item a mob holds (the offer a villager shows) never glinted on a client. Vanilla sends the whole stack (ClientboundSetEquipmentPacket); the server now adds the held enchantments from the gear table and the client keeps them there. --- crates/horizon-client/src/game/lan.rs | 10 +++++++++- crates/horizon-client/src/game/remote.rs | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5cd82b0e22..c5d22d8279 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -934,7 +934,7 @@ fn mob_equipment( if let Some(item) = item { // The worn armour keeps its stack: a dyed leather piece keeps // its colour. - let stack = worn.cloned().unwrap_or_else(|| ItemStack { + let mut stack = worn.cloned().unwrap_or_else(|| ItemStack { item: item.clone(), count: 1, // A wolf's armor shows its cracks. @@ -949,6 +949,14 @@ fn mob_equipment( None }, }); + // The main hand keeps its enchantments: the glint of the item a + // villager shows. + if slot == 0 + && let Some(g) = horizon_client::mobs::gear::gear(m) + && !g.slots[horizon_client::mobs::gear::MAIN].is_empty() + { + stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone(); + } out.push((slot, hub.stack(&Some(stack)))); } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 90d34898f1..8ee24d749f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2362,7 +2362,18 @@ impl Game<'_> { for (slot, item, stack) in items { let e = &mut m.extra; match slot { - 0 => e.main_hand = item, + 0 => { + e.main_hand = item; + // Its enchantments are the glint. + let levels = stack + .as_ref() + .map_or_else(Vec::new, |s| s.enchantments().to_vec()); + horizon_client::mobs::gear::set( + m, + horizon_client::mobs::gear::MAIN, + levels, + ); + } 1 => e.off_hand = item, 2 => { e.feet = item; From c8a6cf4364ad026db7b8d5277ad39a0e0046de59 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:46:58 +0100 Subject: [PATCH 069/968] Let a villager brain ask the level to roll its offers A brain tick holds only the mob, but AbstractVillager.getOffers makes the trades the first time they are asked for and that needs the level's trade data and random sequences. VillagerOffers has the game run ensure_offers for the villager, as the trading screen does. --- crates/horizon-client/src/game/creatures.rs | 5 +++++ crates/horizon-client/src/mobs.rs | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index c323e7bf50..a1e9db8ecc 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1773,6 +1773,11 @@ impl Game<'_> { MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob), MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target), MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other), + MobEvent::VillagerOffers { mob } => { + if let Some(i) = self.mobs.iter().position(|m| m.id == mob) { + self.ensure_offers(i); + } + } MobEvent::VillagerBirth { parent, partner, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 4e7501445b..6baad06088 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1408,6 +1408,12 @@ pub enum MobEvent { mob: u32, other: u32, }, + /// `AbstractVillager.getOffers` for a villager whose offers are not made + /// yet: the level rolls them (the brain has no trade data), so the + /// behaviour that asked reads them from the next tick on. + VillagerOffers { + mob: u32, + }, /// Two villagers make a baby (`VillagerMakeLove.tryToGiveBirth`): the /// parent has eaten and, with a bed for the child, rests; the partner /// eats, and rests too if `child` holds the child's bed and type From 4ce5d55664d48c7b19068342d77da4efebe75ea2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:47:20 +0100 Subject: [PATCH 070/968] Empty a villager's hand when it stops showing trades ShowTradesToPlayer clears the main hand every tick it has nothing to show and again when it stops, restoring the default 0.085 drop chance (ShowTradesToPlayer.clearHeldItem). Horizon left whatever the villager held in place. --- crates/horizon-client/src/mobs/finalize.rs | 2 +- .../horizon-client/src/mobs/villager_brain.rs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 157178422f..bc6fb7c459 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -862,7 +862,7 @@ pub fn set_baby(m: &mut Mob, baby: bool) { /// `EquipmentSlot` names (the `drops::SLOTS` order). pub const SLOT_NAMES: [&str; 8] = super::drops::SLOTS; -const MAINHAND: usize = 0; +pub const MAINHAND: usize = 0; const OFFHAND: usize = 1; const FEET: usize = 2; const LEGS: usize = 3; diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 40050b15a2..f36a35825e 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -2931,7 +2931,9 @@ impl B { if let Some(target) = mem(m).interaction_target.value() { mem(m).look_target.set(Tracker::Entity(target, true)); } - // No offer matches the player's item: look for a while only. + // No offer matches the player's item: hold nothing and look + // for a while only. + clear_held_item(m); *look_time = (*look_time).min(40); *look_time -= 1; } @@ -3187,6 +3189,7 @@ impl B { B::ShowTrades { t, .. } => { t.running = false; mem(m).interaction_target.clear(); + clear_held_item(m); } B::TradeWithVillager(t) => { t.running = false; @@ -3340,6 +3343,20 @@ fn show_trades_condition(m: &Mob, w: &MobWorld) -> bool { && w.target_info(target) .is_some_and(|t| t.alive && m.distance_sqr(t.position) <= 17.0) } +/// `ShowTradesToPlayer.clearHeldItem`: the main hand is empty again and +/// drops with the default chance (the shown item never dropped). +fn clear_held_item(m: &mut Mob) { + m.extra.main_hand = None; + m.extra.main_hand_count = 0; + if m.extra.gear.is_some() { + super::gear::set(m, super::gear::MAIN, Vec::new()); + } + super::finalize::set_drop_chance( + m, + super::finalize::MAINHAND, + super::drops::DEFAULT_DROP_CHANCE, + ); +} /// `BehaviorUtils.targetIsValid` for a villager. fn villager_target_valid(m: &mut Mob, w: &MobWorld, which: Mem) -> bool { let target = mem_ref(m).and_then(|mm| match which { From 837d409e313b4f4ee8cb2a214923690782fe4ffe Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:48:05 +0100 Subject: [PATCH 071/968] Hold up the offer a villager sells for the item you hold A villager looking at a player who holds a trade's cost now collects the offers in stock that take that item, holds up the first and keeps looking for 900 ticks instead of 40 (ShowTradesToPlayer.findItemsToDisplay, updateDisplayItems, displayFirstItem). The held stack keeps its count and enchantments and never drops (displayAsHeldItem). Offers not made yet are rolled by the level first. --- .../horizon-client/src/mobs/villager_brain.rs | 104 ++++++++++++++++-- 1 file changed, 97 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index f36a35825e..a6cb8ae486 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -14,10 +14,12 @@ use super::{ brain::{Timed, living_near}, path::{Path, PathNode}, }; +use crate::items::ItemStack; use horizon_core::{ geometry::Vec3, random::{LegacyRandom, RandomSource}, }; +use std::sync::Arc; /// `MemorySlot.NEVER_EXPIRE`. pub(super) const NEVER: i64 = i64::MAX; @@ -1213,6 +1215,7 @@ enum B { ShowTrades { t: Timed, look_time: i32, + display: TradeDisplay, }, TradeWithVillager(Timed), MakeLove { @@ -1592,7 +1595,7 @@ impl B { mm.look_target.set(Tracker::Entity(Who::Player, true)); true } - B::ShowTrades { t, look_time } => { + B::ShowTrades { t, look_time, .. } => { if !mem(m).interaction_target.present() || !show_trades_condition(m, cx.w) { return false; } @@ -2920,7 +2923,11 @@ impl B { }); } } - B::ShowTrades { t, look_time } => { + B::ShowTrades { + t, + look_time, + display, + } => { let still = show_trades_condition(m, cx.w) && *look_time > 0 && mem(m).interaction_target.present(); @@ -2931,10 +2938,13 @@ impl B { if let Some(target) = mem(m).interaction_target.value() { mem(m).look_target.set(Tracker::Entity(target, true)); } - // No offer matches the player's item: hold nothing and look - // for a while only. - clear_held_item(m); - *look_time = (*look_time).min(40); + display.find_items_to_display(m, cx, look_time); + if display.items.is_empty() { + // No offer matches the player's item: hold nothing and + // look for a while only. + clear_held_item(m); + *look_time = (*look_time).min(40); + } *look_time -= 1; } B::TradeWithVillager(t) => { @@ -3186,10 +3196,11 @@ impl B { mm.walk_target.clear(); mm.look_target.clear(); } - B::ShowTrades { t, .. } => { + B::ShowTrades { t, display, .. } => { t.running = false; mem(m).interaction_target.clear(); clear_held_item(m); + display.player_item = None; } B::TradeWithVillager(t) => { t.running = false; @@ -3343,6 +3354,84 @@ fn show_trades_condition(m: &Mob, w: &MobWorld) -> bool { && w.target_info(target) .is_some_and(|t| t.alive && m.distance_sqr(t.position) <= 17.0) } +/// `ItemStack.EMPTY`'s item. +const AIR: &str = "minecraft:air"; +/// What `ShowTradesToPlayer` remembers between ticks. +#[derive(Clone, Debug, Default)] +struct TradeDisplay { + /// `playerItemStack`: the item the player held at the last look, `None` + /// before the first look and after a stop. An empty hand is air, as + /// `ItemStack.EMPTY` is. + player_item: Option>, + /// `displayItems`: what the offers matching that item sell. + items: Vec, +} +impl TradeDisplay { + /// `ShowTradesToPlayer.findItemsToDisplay`: when the player's item + /// changed, collect the offers that take it and hold up the first + /// (their look time becomes 900 ticks). + fn find_items_to_display(&mut self, m: &mut Mob, cx: &mut Bx, look_time: &mut i32) { + let hand = + cx.w.player + .as_ref() + .and_then(|p| p.main_hand.clone()) + .unwrap_or_else(|| Arc::from(AIR)); + if self.player_item.as_deref() == Some(&*hand) { + return; + } + self.items.clear(); + if *hand == *AIR { + self.player_item = Some(hand); + return; + } + if m.extra.trade.as_ref().is_none_or(|t| t.unset) { + // `getOffers` makes the offers on first use, which the level + // does: look again once it has. + cx.events.push(MobEvent::VillagerOffers { mob: m.id }); + self.player_item = None; + return; + } + self.player_item = Some(hand); + self.update_display_items(m); + if let Some(first) = self.items.first() { + *look_time = 900; + display_as_held_item(m, first); + } + } + /// `ShowTradesToPlayer.updateDisplayItems`: the offers still in stock + /// that take the player's item as cost A or B + /// (`playerItemStackMatchesCostOfOffer`, `ItemStack.isSameItem`). + fn update_display_items(&mut self, m: &Mob) { + let (Some(hand), Some(trade)) = (self.player_item.as_deref(), m.extra.trade.as_ref()) + else { + return; + }; + for offer in &trade.offers { + if !offer.out_of_stock() + && (*offer.base_cost_a.stack.item == *hand + || offer + .cost_b + .as_ref() + .is_some_and(|c| *c.stack.item == *hand)) + { + // `MerchantOffer.assemble`. + self.items.push(offer.result.clone()); + } + } + } +} +/// `ShowTradesToPlayer.displayAsHeldItem`: the stack in the main hand, which +/// never drops. +fn display_as_held_item(m: &mut Mob, stack: &ItemStack) { + super::finalize::set_item(m, super::finalize::MAINHAND, &stack.item); + m.extra.main_hand_count = stack.count; + super::finalize::set_slot_enchantments( + m, + super::finalize::MAINHAND, + stack.enchantments().to_vec(), + ); + super::finalize::set_drop_chance(m, super::finalize::MAINHAND, 0.0); +} /// `ShowTradesToPlayer.clearHeldItem`: the main hand is empty again and /// drops with the default chance (the shown item never dropped). fn clear_held_item(m: &mut Mob) { @@ -3888,6 +3977,7 @@ fn show_trades() -> B { B::ShowTrades { t: timed(400, 1600), look_time: 0, + display: TradeDisplay::default(), } } fn interact(kind: Interact) -> B { From e743e4e418e0736830beb6938830add49cbc1a17 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:48:17 +0100 Subject: [PATCH 072/968] Cycle the items a villager holds up every 40 ticks With two or more matching offers a villager swapped nothing: the held item never changed. Vanilla counts 40 ticks, then holds up the next display item and wraps to the first (ShowTradesToPlayer.displayCyclingItems); start resets the counter and index. --- .../horizon-client/src/mobs/villager_brain.rs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index a6cb8ae486..8e8a6d8af5 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -1595,7 +1595,11 @@ impl B { mm.look_target.set(Tracker::Entity(Who::Player, true)); true } - B::ShowTrades { t, look_time, .. } => { + B::ShowTrades { + t, + look_time, + display, + } => { if !mem(m).interaction_target.present() || !show_trades_condition(m, cx.w) { return false; } @@ -1605,6 +1609,8 @@ impl B { mem(m).look_target.set(Tracker::Entity(target, true)); } *look_time = 40; + display.cycle_counter = 0; + display.index = 0; true } B::TradeWithVillager(t) => { @@ -2944,6 +2950,8 @@ impl B { // look for a while only. clear_held_item(m); *look_time = (*look_time).min(40); + } else { + display.display_cycling_items(m); } *look_time -= 1; } @@ -3365,6 +3373,10 @@ struct TradeDisplay { player_item: Option>, /// `displayItems`: what the offers matching that item sell. items: Vec, + /// `cycleCounter`: ticks since the held item last changed. + cycle_counter: i32, + /// `displayIndex`: the item being held up. + index: usize, } impl TradeDisplay { /// `ShowTradesToPlayer.findItemsToDisplay`: when the player's item @@ -3398,6 +3410,22 @@ impl TradeDisplay { display_as_held_item(m, first); } } + /// `ShowTradesToPlayer.displayCyclingItems`: with two or more items, a + /// new one is held up every 40 ticks, round and round. + fn display_cycling_items(&mut self, m: &mut Mob) { + if self.items.len() < 2 { + return; + } + self.cycle_counter += 1; + if self.cycle_counter >= 40 { + self.index += 1; + self.cycle_counter = 0; + if self.index > self.items.len() - 1 { + self.index = 0; + } + display_as_held_item(m, &self.items[self.index]); + } + } /// `ShowTradesToPlayer.updateDisplayItems`: the offers still in stock /// that take the player's item as cost A or B /// (`playerItemStackMatchesCostOfOffer`, `ItemStack.isSameItem`). From 5430fa0f6a7b31b8da15c416a73c4fce37e6fe11 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:59:28 +0100 Subject: [PATCH 073/968] Route map markers through addDecoration with vanilla keys A map kept one marker per player and rebuilt the item ones, with no place for a banner or frame marker. MapItemSavedData.addDecoration files each MapDecoration under a key (player, banner, frame), keeps its place in the drawing order, clamps it to the map, swaps a player's arrow for the off-map ones and drops what falls outside (MapItemSavedData.java:253). Markers now carry their name and key, and the update packet sends the name. --- crates/horizon-client/src/game/maps.rs | 170 +++++++++++++++---------- 1 file changed, 103 insertions(+), 67 deletions(-) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index b6d0b604ea..a9ec087edc 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -14,18 +14,34 @@ use horizon_client::{ use horizon_world::nbt::Tag; use std::collections::HashMap; -/// A decoration: sprite, map position (-128..127, half pixels) and rotation -/// (0..15), and the player it marks (an item's own decorations mark none). +/// What a decoration is filed under in `MapItemSavedData.decorations` (a +/// `LinkedHashMap`): a player's marker by their +/// connection. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) enum Key { + Player(u32), + /// An item's `minecraft:map_decorations` (an explorer map's target), or a + /// decoration a server sent (`addClientSideDecorations`). + Item, +} +/// A decoration (`MapDecoration`): sprite, map position (-128..127, half +/// pixels), rotation (0..15) and name, filed under its key. #[derive(Clone, Debug, PartialEq)] pub(super) struct Decoration { pub sprite: &'static str, pub x: i8, pub y: i8, pub rot: u8, - pub holder: Option, + pub name: Option, + pub key: Key, } /// The host among a map's holders (the guests are their connections). const HOST: u32 = u32::MAX; +/// `MapDecorationTypes` the markers use: the player's arrow, the arrows that +/// replace it past the map's edge. +const PLAYER: &str = "map/decorations/player"; +const PLAYER_OFF_MAP: &str = "map/decorations/player_off_map"; +const PLAYER_OFF_LIMITS: &str = "map/decorations/player_off_limits"; /// A player carrying a map, or seeing it framed /// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what /// changed since the last `ClientboundMapItemDataPacket` it was sent. @@ -130,8 +146,10 @@ impl MapData { self.holders.retain(|h| h.holds(now)); let holders = &self.holders; let before = self.decorations.len(); - self.decorations - .retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w))); + self.decorations.retain(|d| match d.key { + Key::Player(w) => holders.iter().any(|h| h.who == w), + _ => true, + }); if self.decorations.len() != before { self.decorations_changed(); } @@ -142,31 +160,66 @@ impl MapData { h.decorations = true; } } - /// `addDecoration` / `removeDecoration` for `holder`'s marker: in place, - /// so that the order (the drawing order) stays. - fn set_marker(&mut self, holder: u32, marker: Option) { - let i = self - .decorations - .iter() - .position(|d| d.holder == Some(holder)); - let changed = match (i, marker) { - (Some(i), Some(d)) if self.decorations[i] == d => false, - (Some(i), Some(d)) => { - self.decorations[i] = d; - true - } - (Some(i), None) => { - self.decorations.remove(i); - true - } - (None, Some(d)) => { - self.decorations.push(d); - true - } - (None, None) => false, + /// `MapItemSavedData.removeDecoration`: whatever `key` names goes, and + /// every holder is told the decorations changed, found or not. + fn remove_decoration(&mut self, key: Key) { + self.decorations.retain(|d| d.key != key); + self.decorations_changed(); + } + /// `MapItemSavedData.addDecoration` with `calculateDecorationLocationAndType` + /// and `calculateRotation`: `sprite` at the world position `x_pos`, + /// `z_pos` under `key`, in place when the key has a decoration so that the + /// order (the drawing order) stays. A position off the map leaves it out + /// (a player's turns into an off-map arrow, then nothing past 320 without + /// unlimited tracking) and removes what the key had. `game_time` is the + /// level's: without a level the Nether's spinning rotation is not used. + #[allow(clippy::too_many_arguments)] + fn add_decoration( + &mut self, + sprite: &'static str, + game_time: Option, + key: Key, + x_pos: f64, + z_pos: f64, + y_rot: f64, + name: Option, + ) { + let scaling = (1 << self.scale) as f32; + let dx = (x_pos - f64::from(self.center[0])) as f32 / scaling; + let dz = (z_pos - f64::from(self.center[1])) as f32 / scaling; + let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz); + let shown = if sprite != PLAYER { + (inside || self.unlimited).then_some(sprite) + } else if inside { + Some(sprite) + } else if dx.abs() < 320.0 && dz.abs() < 320.0 { + Some(PLAYER_OFF_MAP) + } else { + self.unlimited.then_some(PLAYER_OFF_LIMITS) }; - if changed { - self.decorations_changed(); + let Some(sprite) = shown else { + self.remove_decoration(key); + return; + }; + let spinning = self.dimension == "minecraft:the_nether" && game_time.is_some(); + let decoration = Decoration { + sprite, + x: clamp_map_coordinate(dx), + y: clamp_map_coordinate(dz), + rot: decoration_rotation(spinning, game_time.unwrap_or(0), y_rot), + name, + key, + }; + match self.decorations.iter().position(|d| d.key == key) { + Some(i) if self.decorations[i] == decoration => {} + Some(i) => { + self.decorations[i] = decoration; + self.decorations_changed(); + } + None => { + self.decorations.push(decoration); + self.decorations_changed(); + } } } /// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and @@ -210,7 +263,7 @@ impl MapData { x: d.x, y: d.y, rot: d.rot as i8, - name: None, + name: d.name.as_ref().map(|n| Tag::String(n.as_str().into())), }) }) .collect(), @@ -478,7 +531,11 @@ impl Game<'_> { x: d.x, y: d.y, rot: (d.rot & 15) as u8, - holder: None, + name: d + .name + .as_ref() + .map(|t| horizon_client::entity_nbt::name_text(t).plain()), + key: Key::Item, }) }) .collect() @@ -866,41 +923,19 @@ impl Game<'_> { m.holder(me).carried = game_time; m.drop_holders(game_time); // tickCarriedBy: the player marker while tracking in this dimension. - let mut marker = None; if m.tracking && m.dimension == dim { - let s = f64::from(1 << m.scale); - let dx = ((p.x - f64::from(m.center[0])) / s) as f32; - let dz = ((p.z - f64::from(m.center[1])) / s) as f32; - let inside = (-63.0..=63.0).contains(&dx) && (-63.0..=63.0).contains(&dz); - let clamp = |d: f32| { - if d <= -63.0 { - -128 - } else if d >= 63.0 { - 127 - } else { - (d * 2.0 + 0.5) as i8 - } - }; - // calculateRotation: the arrow spins in the Nether. - let rot = decoration_rotation(nether, game_time, f64::from(yaw)); - let sprite = if inside { - Some("map/decorations/player") - } else if dx.abs() < 320.0 && dz.abs() < 320.0 { - Some("map/decorations/player_off_map") - } else if m.unlimited { - Some("map/decorations/player_off_limits") - } else { - None - }; - marker = sprite.map(|sprite| Decoration { - sprite, - x: clamp(dx), - y: clamp(dz), - rot, - holder: Some(me), - }); + m.add_decoration( + PLAYER, + Some(game_time), + Key::Player(me), + p.x, + p.z, + f64::from(yaw), + None, + ); + } else if m.decorations.iter().any(|d| d.key == Key::Player(me)) { + m.remove_decoration(Key::Player(me)); } - m.set_marker(me, marker); // The item's MAP_DECORATIONS (an explorer map's target), added // after the players as addDecoration would. let s = (1 << m.scale) as f32; @@ -917,7 +952,8 @@ impl Game<'_> { x: clamp_map_coordinate(dx), y: clamp_map_coordinate(dz), rot: decoration_rotation(nether, game_time, f64::from(rotation)), - holder: None, + name: None, + key: Key::Item, }; if !items.contains(&d) { items.push(d); @@ -926,10 +962,10 @@ impl Game<'_> { if !m .decorations .iter() - .filter(|d| d.holder.is_none()) + .filter(|d| d.key == Key::Item) .eq(items.iter()) { - m.decorations.retain(|d| d.holder.is_some()); + m.decorations.retain(|d| d.key != Key::Item); m.decorations.extend(items); m.decorations_changed(); } From a5b45dead570b395f4e2cb661833e1aaa22b7f7a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:00:10 +0100 Subject: [PATCH 074/968] Load and save map banner and frame markers as vanilla does A map kept its banners and frames lists as raw tags, so the markers never showed. MapItemSavedData's constructor adds a banner marker in the banner's dye colour (MapBanner.getDecoration, the 16 banner decoration types) and a frame marker for each saved entry, without a level, and the lists are written back (MapBanner.CODEC, MapFrame.CODEC, MapItemSavedData.java:100). A locked copy keeps the banners and the decorations (MapItemSavedData.locked). --- crates/horizon-client/src/game/cartography.rs | 4 +- crates/horizon-client/src/game/maps.rs | 146 ++++++++++++++++-- 2 files changed, 136 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/game/cartography.rs b/crates/horizon-client/src/game/cartography.rs index 3310d7fbfa..a4c61173f0 100644 --- a/crates/horizon-client/src/game/cartography.rs +++ b/crates/horizon-client/src/game/cartography.rs @@ -155,7 +155,8 @@ impl MapData { m.unlimited = self.unlimited; m } - /// `MapItemSavedData.locked`: the same pixels and banner markers, locked. + /// `MapItemSavedData.locked`: the same pixels, banner markers and + /// decorations, locked (the frame markers are not copied). pub(super) fn locked_copy(&self) -> MapData { let mut m = MapData::fresh( f64::from(self.center[0]), @@ -166,6 +167,7 @@ impl MapData { m.center = self.center; m.colors = self.colors.clone(); m.banners = self.banners.clone(); + m.decorations = self.decorations.clone(); m.tracking = self.tracking; m.unlimited = self.unlimited; m.locked = true; diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index a9ec087edc..e16f140614 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -16,13 +16,16 @@ use std::collections::HashMap; /// What a decoration is filed under in `MapItemSavedData.decorations` (a /// `LinkedHashMap`): a player's marker by their -/// connection. +/// connection, the banner at a position (`MapBanner.getId`), a framed map's +/// frame by its entity (`getFrameKey`). #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum Key { Player(u32), /// An item's `minecraft:map_decorations` (an explorer map's target), or a /// decoration a server sent (`addClientSideDecorations`). Item, + Banner([i32; 3]), + Frame(i32), } /// A decoration (`MapDecoration`): sprite, map position (-128..127, half /// pixels), rotation (0..15) and name, filed under its key. @@ -35,11 +38,90 @@ pub(super) struct Decoration { pub name: Option, pub key: Key, } +/// `MapBanner`: a banner a map marks, as `banners` saves it. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct MapBanner { + pub pos: [i32; 3], + /// `DyeColor.getName` of the banner's base colour. + pub color: String, + pub name: Option, +} +impl MapBanner { + /// `MapBanner.getDecoration`: the marker of the banner's colour. + fn sprite(&self) -> &'static str { + decoration_sprite(&format!("banner_{}", self.color)).unwrap_or(BANNER_WHITE) + } + /// `MapBanner.CODEC`. + fn to_nbt(&self) -> Tag { + let mut entries = vec![ + ("pos", Tag::IntArray(self.pos.to_vec())), + ("color", Tag::String(self.color.as_str().into())), + ]; + if let Some(name) = &self.name { + entries.push(("name", Tag::String(name.as_str().into()))); + } + Tag::compound(entries) + } + fn from_nbt(tag: &Tag) -> Option { + // DyeColor.CODEC is read leniently: white when missing or unknown. + let color = horizon_client::save::text(tag.get("color")) + .filter(|c| decoration_sprite(&format!("banner_{c}")).is_some()) + .unwrap_or_else(|| "white".to_owned()); + Some(Self { + pos: block_pos(tag.get("pos"))?, + color, + name: horizon_client::save::text(tag.get("name")), + }) + } +} +/// `MapFrame`: an item frame a map hangs in, as `frames` saves it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(super) struct MapFrame { + pub pos: [i32; 3], + /// The frame's `Direction.get2DDataValue` times 90. + pub rotation: i32, + pub entity_id: i32, +} +impl MapFrame { + /// `MapFrame.CODEC`. + fn to_nbt(&self) -> Tag { + Tag::compound([ + ("pos", Tag::IntArray(self.pos.to_vec())), + ("rotation", Tag::Int(self.rotation)), + ("entity_id", Tag::Int(self.entity_id)), + ]) + } + fn from_nbt(tag: &Tag) -> Option { + let int = |k: &str| horizon_client::save::number(tag.get(k)).map(|v| v as i32); + Some(Self { + pos: block_pos(tag.get("pos"))?, + rotation: int("rotation")?, + entity_id: int("entity_id")?, + }) + } +} +/// `BlockPos.CODEC`: three ints, as an int array or a list. +fn block_pos(tag: Option<&Tag>) -> Option<[i32; 3]> { + match tag? { + Tag::IntArray(v) if v.len() == 3 => Some([v[0], v[1], v[2]]), + Tag::List(l) if l.len() == 3 => { + let int = |t: &Tag| match t { + Tag::Int(v) => Some(*v), + _ => None, + }; + Some([int(&l[0])?, int(&l[1])?, int(&l[2])?]) + } + _ => None, + } +} /// The host among a map's holders (the guests are their connections). const HOST: u32 = u32::MAX; /// `MapDecorationTypes` the markers use: the player's arrow, the arrows that -/// replace it past the map's edge. +/// replace it past the map's edge, the item frame's, the white banner's (the +/// fallback colour). const PLAYER: &str = "map/decorations/player"; +const FRAME: &str = "map/decorations/frame"; +const BANNER_WHITE: &str = "map/decorations/white_banner"; const PLAYER_OFF_MAP: &str = "map/decorations/player_off_map"; const PLAYER_OFF_LIMITS: &str = "map/decorations/player_off_limits"; /// A player carrying a map, or seeing it framed @@ -82,10 +164,9 @@ pub(super) struct MapData { pub unlimited: bool, pub colors: Vec, pub decorations: Vec, - /// `MapBanner` and `MapFrame` entries as saved (`banners`, `frames`), kept - /// so a save does not drop them. - pub banners: Vec, - frames: Vec, + /// `bannerMarkers` and `frameMarkers` (`banners`, `frames` when saved). + pub banners: Vec, + frames: Vec, holders: Vec, /// Changed since the last save / texture upload. dirty: bool, @@ -293,8 +374,14 @@ impl MapData { ("trackingPosition", flag(self.tracking)), ("unlimitedTracking", flag(self.unlimited)), ("colors", Tag::ByteArray(self.colors.clone())), - ("banners", Tag::List(self.banners.clone())), - ("frames", Tag::List(self.frames.clone())), + ( + "banners", + Tag::List(self.banners.iter().map(MapBanner::to_nbt).collect()), + ), + ( + "frames", + Tag::List(self.frames.iter().map(MapFrame::to_nbt).collect()), + ), ]), ), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), @@ -320,7 +407,7 @@ impl MapData { Some(Tag::List(l)) => l.clone(), _ => Vec::new(), }; - Some(Self { + let mut map = Self { center: [int("xCenter")?, int("zCenter")?], scale: int("scale").unwrap_or(0).clamp(0, 4) as u8, dimension, @@ -329,12 +416,29 @@ impl MapData { unlimited: int("unlimitedTracking").unwrap_or(0) != 0, colors, decorations: Vec::new(), - banners: list("banners"), - frames: list("frames"), + banners: list("banners") + .iter() + .filter_map(MapBanner::from_nbt) + .collect(), + frames: list("frames") + .iter() + .filter_map(MapFrame::from_nbt) + .collect(), holders: Vec::new(), dirty: false, texture_dirty: true, - }) + }; + // The constructor adds each marker without a level: no Nether spin. + for b in map.banners.clone() { + let (x, z) = (f64::from(b.pos[0]), f64::from(b.pos[2])); + map.add_decoration(b.sprite(), None, Key::Banner(b.pos), x, z, 180.0, b.name); + } + for f in map.frames.clone() { + let (x, z) = (f64::from(f.pos[0]), f64::from(f.pos[2])); + let rotation = f64::from(f.rotation); + map.add_decoration(FRAME, None, Key::Frame(f.entity_id), x, z, rotation, None); + } + Some(map) } } #[derive(Default)] @@ -363,7 +467,7 @@ pub(super) fn map_id(stack: &ItemStack) -> Option { stack.data().and_then(|d| d.map_id) } /// `MapDecorationTypes` Horizon draws: (type, sprite). -const DECORATION_TYPES: [(&str, &str); 24] = [ +const DECORATION_TYPES: [(&str, &str); 40] = [ ("player", "map/decorations/player"), ("frame", "map/decorations/frame"), ("red_marker", "map/decorations/red_marker"), @@ -374,6 +478,22 @@ const DECORATION_TYPES: [(&str, &str); 24] = [ ("player_off_limits", "map/decorations/player_off_limits"), ("mansion", "map/decorations/woodland_mansion"), ("monument", "map/decorations/ocean_monument"), + ("banner_white", "map/decorations/white_banner"), + ("banner_orange", "map/decorations/orange_banner"), + ("banner_magenta", "map/decorations/magenta_banner"), + ("banner_light_blue", "map/decorations/light_blue_banner"), + ("banner_yellow", "map/decorations/yellow_banner"), + ("banner_lime", "map/decorations/lime_banner"), + ("banner_pink", "map/decorations/pink_banner"), + ("banner_gray", "map/decorations/gray_banner"), + ("banner_light_gray", "map/decorations/light_gray_banner"), + ("banner_cyan", "map/decorations/cyan_banner"), + ("banner_purple", "map/decorations/purple_banner"), + ("banner_blue", "map/decorations/blue_banner"), + ("banner_brown", "map/decorations/brown_banner"), + ("banner_green", "map/decorations/green_banner"), + ("banner_red", "map/decorations/red_banner"), + ("banner_black", "map/decorations/black_banner"), ("red_x", "map/decorations/red_x"), ("village_desert", "map/decorations/desert_village"), ("village_plains", "map/decorations/plains_village"), From e108d1d57f74dd97ecbafbcd8b1bab3ca6b88c94 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:00:29 +0100 Subject: [PATCH 075/968] Read a placed banner as a map marker source A map had no way to ask what banner stands at a position. MapBanner.fromWorld returns the banner block entity's base colour (the block's dye colour) and custom name, or nothing when no banner is there (MapBanner.java:25). --- crates/horizon-client/src/game/banners.rs | 34 ++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/banners.rs b/crates/horizon-client/src/game/banners.rs index 05beda3f81..b2ddb50615 100644 --- a/crates/horizon-client/src/game/banners.rs +++ b/crates/horizon-client/src/game/banners.rs @@ -1,7 +1,7 @@ //! Placed banners (`BannerBlockEntity`): the pattern layers move from the //! banner item to the block when placed and back to the dropped item when //! broken (`copy_components` in the banner loot tables). -use super::Game; +use super::{Game, maps::MapBanner}; use horizon_client::{ banner::{self, BannerLayer}, containers::BlockEntity, @@ -16,6 +16,38 @@ impl Game<'_> { }; layer_textures(&self.res.stations().banners, &b.layers) } + /// The banner at `pos` as a map marker source (`MapBanner.fromWorld`). + pub(super) fn map_banner_at(&self, pos: [i32; 3]) -> Option { + let state = self.world.block(pos[0], pos[1], pos[2]); + map_banner( + pos, + self.world.block_entities.get(&(pos[0], pos[1], pos[2])), + &self.res.facts().get(state).name, + ) + } +} + +/// `MapBanner.fromWorld`: the banner whose block entity is `entity` at `pos`, +/// with the base colour of its block (`BannerBlockEntity.getBaseColor` is +/// `AbstractBannerBlock.getColor`) and its custom name; none when the block +/// entity is not a banner. +pub(super) fn map_banner( + pos: [i32; 3], + entity: Option<&BlockEntity>, + block: &str, +) -> Option { + let Some(BlockEntity::Banner(banner)) = entity else { + return None; + }; + let block = block.strip_prefix("minecraft:").unwrap_or(block); + let color = block + .strip_suffix("_wall_banner") + .or_else(|| block.strip_suffix("_banner"))?; + Some(MapBanner { + pos, + color: color.to_owned(), + name: banner.name.clone(), + }) } /// Texture names and tints of banner layers (`Sheets.getBannerSprite`). From 955214ae1ba1f7410180d41486e82123927e0dbe Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:00:50 +0100 Subject: [PATCH 076/968] Toggle a banner's marker on a map A map could not add or drop a banner marker on request. MapItemSavedData.toggleBanner works only for a banner inside the map: one whose marker stands as it is loses it, otherwise the marker is added (renewed when the banner changed) unless the map already tracks more than 256 markers (MapItemSavedData.java:371, MapDecorationType.trackCount). --- crates/horizon-client/src/game/maps.rs | 65 ++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index e16f140614..487d05b64e 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -303,6 +303,55 @@ impl MapData { } } } + /// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit` + /// decorations count (`MapDecorationType.trackCount`). + fn tracked_over_limit(&self, limit: usize) -> bool { + self.decorations + .iter() + .filter(|d| tracks_count(d.sprite)) + .count() + > limit + } + /// `MapItemSavedData.toggleBanner`: a banner inside the map that has no + /// marker gets one (unless the map tracks too many), one whose marker is + /// there as it stands loses it, and a changed banner's marker is renewed. + /// `current` is the banner at `pos` (`MapBanner.fromWorld`); true when + /// the markers changed. + fn toggle_banner(&mut self, pos: [i32; 3], current: Option, game_time: i64) -> bool { + let (x_pos, z_pos) = (f64::from(pos[0]) + 0.5, f64::from(pos[2]) + 0.5); + let scale = f64::from(1 << self.scale); + let xd = (x_pos - f64::from(self.center[0])) / scale; + let yd = (z_pos - f64::from(self.center[1])) / scale; + if !(xd >= -63.0 && yd >= -63.0 && xd <= 63.0 && yd <= 63.0) { + return false; + } + let Some(banner) = current else { + return false; + }; + if let Some(i) = self.banners.iter().position(|b| *b == banner) { + self.banners.remove(i); + self.remove_decoration(Key::Banner(pos)); + self.dirty = true; + return true; + } + if self.tracked_over_limit(256) { + return false; + } + self.banners.retain(|b| b.pos != pos); + self.banners.push(banner.clone()); + let sprite = banner.sprite(); + self.add_decoration( + sprite, + Some(game_time), + Key::Banner(pos), + x_pos, + z_pos, + 180.0, + banner.name, + ); + self.dirty = true; + true + } /// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and /// the decorations (at most every 5th call while they changed). fn update_packet( @@ -516,6 +565,22 @@ fn shown_on_frame(sprite: &str) -> bool { Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits") ) } +/// `MapDecorationType.trackCount`: the players' arrows, the markers, the +/// frames and the banners count toward a map's limit, the structures do not. +fn tracks_count(sprite: &str) -> bool { + decoration_type(sprite).is_some_and(|t| { + t.starts_with("banner_") + || matches!( + t, + "player" + | "frame" + | "red_marker" + | "blue_marker" + | "player_off_map" + | "player_off_limits" + ) + }) +} /// The sprite of a decoration type that a `minecraft:map_decorations` /// entry or a server's packet names. fn decoration_sprite(id: &str) -> Option<&'static str> { From 910d5479eb2cbd6401d7ad25a05951ec555605ac Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:01:11 +0100 Subject: [PATCH 077/968] Mark a banner on the map when the map is used on it A map used on a placed banner did nothing. MapItem.useOn toggles that banner's marker through toggleBanner: SUCCESS swings and counts as a use, FAIL (the map refuses) ends the use quietly, and the item is never placed or used otherwise (MapItem.java:336). --- crates/horizon-client/src/game.rs | 1 + crates/horizon-client/src/game/maps.rs | 30 ++++++++++++++++++++++++ crates/horizon-client/src/game/remote.rs | 13 +++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5a7cb39ab9..0ce469573f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4642,6 +4642,7 @@ impl Game<'_> { || self.place_painting(hit, &stack) || self.place_item_frame(hit, &stack) || self.use_lodestone_compass(hit, &stack) + || self.use_map_on_banner(hit, &stack) || self.place_armor_stand(hit, &stack) || self.place_cushion(hit, &stack) || self.place_end_crystal(hit, &stack)) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 487d05b64e..799e6e59eb 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -975,6 +975,36 @@ impl Game<'_> { self.swing_arm(); true } + /// `MapItem.useOn`: a map used on a banner toggles that banner's marker + /// on the map; when the map's data says no (`toggleBanner` fails) the use + /// ends without a swing, and nothing else is done with the map either way. + pub(super) fn use_map_on_banner(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool { + let Some(id) = map_id(stack) else { + return false; + }; + // ItemStack.useOn passes in adventure mode (its `can_place_on` + // exception is not ported). + if !self.may_build() { + return false; + } + let [x, y, z] = hit.pos; + let block = self.res.facts().get(self.world.block(x, y, z)).name.clone(); + if !self.block_in_tag("minecraft:banners", &block) { + return false; + } + let current = self.map_banner_at(hit.pos); + let game_time = self.world.game_time; + let failed = self + .map(id) + .is_some_and(|m| !m.toggle_banner(hit.pos, current, game_time)); + if failed { + return true; + } + self.trigger_block("minecraft:item_used_on_block", hit.pos, stack); + self.stats.add("used", &stack.item, 1); + self.swing_arm(); + true + } /// `CompassItem.useOn`: a compass used on a lodestone locks onto it (a /// single one in survival changes in place, else one is split off). pub(super) fn use_lodestone_compass(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 8ee24d749f..1c24f32a02 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5051,7 +5051,8 @@ impl Game<'_> { }); return true; } else if let Some(stack) = held.as_ref() - && self.predict_item_on_block(stack, &hit) + && (self.predict_item_on_block(stack, &hit) + || self.predict_map_on_banner(stack, &hit)) { // Item.useOn succeeded here (InteractionResult.SUCCESS): // this hand swings and the click ends, so the other hand @@ -5123,6 +5124,16 @@ impl Game<'_> { } } + /// `MapItem.useOn` on this client: a map used on a banner is SUCCESS + /// whatever its data says (the server toggles the marker, or fails + /// quietly), so this hand swings and the click ends. `ItemStack.useOn` + /// passes in adventure mode. + fn predict_map_on_banner(&self, stack: &ItemStack, hit: &super::Hit) -> bool { + super::maps::map_id(stack).is_some() + && self.may_build() + && self.block_in_tag("minecraft:banners", &self.block_name(hit.pos)) + } + /// MultiPlayerGameMode.useItemOn inside startPrediction: the blocks the /// use changed on this client's level at once, each kept as predicted /// under the use's sequence until the server acknowledges it (a use the From 4941dd40b280e11fcf5f333111d991a76acd5dde Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:01:34 +0100 Subject: [PATCH 078/968] Drop a map's banner marker when its banner is gone A banner marker stayed on the map after its banner was broken or changed. MapItem.update calls MapItemSavedData.checkBanners for every column it reads: a marker in that column whose banner is no longer the saved one (colour, name or the banner itself) loses its decoration and its entry (MapItemSavedData.java:401). --- crates/horizon-client/src/game/maps.rs | 36 ++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 799e6e59eb..0255dfdb28 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -352,6 +352,22 @@ impl MapData { self.dirty = true; true } + /// `MapItemSavedData.checkBanners`: a banner marker in the column `x`, + /// `z` whose banner is gone or changed (`current` reads + /// `MapBanner.fromWorld` at a position) is dropped with its decoration. + fn check_banners(&mut self, x: i32, z: i32, current: impl Fn([i32; 3]) -> Option) { + let stale: Vec = self + .banners + .iter() + .filter(|b| b.pos[0] == x && b.pos[2] == z && current(b.pos).as_ref() != Some(*b)) + .cloned() + .collect(); + for banner in stale { + self.banners.retain(|b| *b != banner); + self.remove_decoration(Key::Banner(banner.pos)); + self.dirty = true; + } + } /// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and /// the decorations (at most every 5th call while they changed). fn update_packet( @@ -1257,6 +1273,25 @@ impl Game<'_> { } } } + /// `MapItemSavedData.checkBanners` on map `id` for the column `x`, `z` + /// that `MapItem.update` is reading. + fn check_banners(&mut self, id: i32, x: i32, z: i32) { + let (world, res) = (&self.world, &self.res); + let Some(m) = self.maps.data.get_mut(&id) else { + return; + }; + if m.banners.is_empty() { + return; + } + m.check_banners(x, z, |pos| { + let state = world.block(pos[0], pos[1], pos[2]); + super::banners::map_banner( + pos, + world.block_entities.get(&(pos[0], pos[1], pos[2])), + &res.facts().get(state).name, + ) + }); + } /// `MapItem.update` for the player holding map `id`. fn update_map(&mut self, id: i32) { let dim = self.dimension_id(); @@ -1356,6 +1391,7 @@ impl Game<'_> { } } } + self.check_banners(id, x, z); height += f64::from(y) / f64::from(scale * scale); add(color, 1); } From 079b4bea68816feee6caf0ac4905217ba9fe7d62 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:01:52 +0100 Subject: [PATCH 079/968] Hide players in map invisibility gear from others' maps A player wearing a carved pumpkin was still drawn on other players' maps. MapItemSavedData.tickCarriedBy removes the marker of every other holder for whom hasMapInvisibilityItemEquipped finds a piece of #map_invisibility_equipment in an armor slot (MapItemSavedData.java:225). Each holder keeps what it wore on its own tick. --- crates/horizon-client/src/game/maps.rs | 36 +++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 0255dfdb28..1d7b16e0f5 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -137,6 +137,9 @@ struct Holder { dirty: Option<[i32; 4]>, decorations: bool, tick: u32, + /// Wore map invisibility gear on its last tick + /// (`MapItemSavedData.hasMapInvisibilityItemEquipped`). + invisible: bool, } impl Holder { fn new(who: u32) -> Self { @@ -148,6 +151,7 @@ impl Holder { dirty: Some([0, 0, 127, 127]), decorations: true, tick: 0, + invisible: false, } } /// Still carrying the map, or seeing it framed (frames tick every 10). @@ -303,6 +307,19 @@ impl MapData { } } } + /// `tickCarriedBy`'s check on every holder but `who`, the player ticking: + /// one wearing map invisibility gear has no marker. + fn hide_invisible(&mut self, who: u32) { + let hidden: Vec = self + .holders + .iter() + .filter(|h| h.who != who && h.invisible) + .map(|h| h.who) + .collect(); + for w in hidden { + self.remove_decoration(Key::Player(w)); + } + } /// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit` /// decorations count (`MapDecorationType.trackCount`). fn tracked_over_limit(&self, limit: usize) -> bool { @@ -1116,6 +1133,19 @@ impl Game<'_> { self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); } } + /// `MapItemSavedData.hasMapInvisibilityItemEquipped`: a piece of + /// `#map_invisibility_equipment` (a carved pumpkin) in an armor slot; the + /// hands do not count. + fn map_invisibility_worn(&self, worn: &[Option]) -> bool { + let tag = self + .res + .items() + .item_tags + .get("minecraft:map_invisibility_equipment"); + worn.iter() + .flatten() + .any(|s| tag.is_some_and(|t| t.contains(&*s.item))) + } /// Held maps update; every carried map tracks the player marker. pub(super) fn tick_maps(&mut self) { let Some(p) = self.player.as_ref().map(|p| p.position) else { @@ -1147,11 +1177,14 @@ impl Game<'_> { .collect(); let (yaw, dim) = (self.yaw, self.dimension_id()); let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time); + let invisible = self.map_invisibility_worn(&self.armor); // The guest whose turn it is, else the host. let me = self.lan_acting.unwrap_or(HOST); for (id, targets) in carried { let Some(m) = self.map(id) else { continue }; - m.holder(me).carried = game_time; + let h = m.holder(me); + h.carried = game_time; + h.invisible = invisible; m.drop_holders(game_time); // tickCarriedBy: the player marker while tracking in this dimension. if m.tracking && m.dimension == dim { @@ -1167,6 +1200,7 @@ impl Game<'_> { } else if m.decorations.iter().any(|d| d.key == Key::Player(me)) { m.remove_decoration(Key::Player(me)); } + m.hide_invisible(me); // The item's MAP_DECORATIONS (an explorer map's target), added // after the players as addDecoration would. let s = (1 << m.scale) as f32; From 811545ed8d97d84f2ed6673df3f3627c7791d063 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:02:52 +0100 Subject: [PATCH 080/968] Mark a framed map's item frame on the map A map hanging in an item frame never showed the frame's marker, and a player who saw it framed without carrying it kept a marker. ServerEntity.sendChanges ticks every 10 ticks tickCarriedBy(player, stack, frame) for each player: one carrying no copy loses their marker, and with position tracking the frame's marker is added under its entity and the frame saved with its block, rotation (2D direction times 90) and entity, replacing an older entity's marker at the same block (MapItemSavedData.java:159). --- crates/horizon-client/src/game/maps.rs | 92 ++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 1d7b16e0f5..6d7f23a279 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -114,6 +114,26 @@ fn block_pos(tag: Option<&Tag>) -> Option<[i32; 3]> { _ => None, } } +/// The item frame a map hangs in, as `tickCarriedBy` reads it +/// (`placedInFrame`): the block it is in, its `Direction.get3DDataValue` +/// (down 0, up 1, north 2, south 3, west 4, east 5) and its entity id. +#[derive(Clone, Copy)] +struct Framed { + pos: [i32; 3], + facing: u8, + id: i32, +} +/// `Direction.get2DDataValue` of a 3D data value: south 0, west 1, north 2, +/// east 3, and -1 for up and down. +fn direction_2d(facing: u8) -> i32 { + match facing { + 3 => 0, + 4 => 1, + 2 => 2, + 5 => 3, + _ => -1, + } +} /// The host among a map's holders (the guests are their connections). const HOST: u32 = u32::MAX; /// `MapDecorationTypes` the markers use: the player's arrow, the arrows that @@ -307,8 +327,8 @@ impl MapData { } } } - /// `tickCarriedBy`'s check on every holder but `who`, the player ticking: - /// one wearing map invisibility gear has no marker. + /// `MapItemSavedData.tickCarriedBy`'s check on every holder but `who`, the + /// player ticking: one wearing map invisibility gear has no marker. fn hide_invisible(&mut self, who: u32) { let hidden: Vec = self .holders @@ -320,6 +340,50 @@ impl MapData { self.remove_decoration(Key::Player(w)); } } + /// `MapItemSavedData.tickCarriedBy` with `placedInFrame`, for `who`, a + /// player in the level of `frame`, the item frame holding the map. + /// `carries` is whether their inventory holds the map (`mapMatcher`), + /// `invisible` whether they wear map invisibility gear. Without a carried + /// copy the player has no marker; the frame gets its own, which replaces + /// the marker of another frame entity remembered at the same block. + fn tick_framed(&mut self, who: u32, now: i64, carries: bool, invisible: bool, frame: Framed) { + let h = self.holder(who); + h.framed = now; + h.invisible = invisible; + if !carries { + self.remove_decoration(Key::Player(who)); + } + self.hide_invisible(who); + if !self.tracking { + return; + } + let pos = frame.pos; + if let Some(old) = self.frames.iter().find(|f| f.pos == pos).copied() + && old.entity_id != frame.id + { + self.remove_decoration(Key::Frame(old.entity_id)); + } + let rotation = direction_2d(frame.facing) * 90; + let (x, z) = (f64::from(pos[0]), f64::from(pos[2])); + let key = Key::Frame(frame.id); + self.add_decoration(FRAME, Some(now), key, x, z, f64::from(rotation), None); + let marker = MapFrame { + pos, + rotation, + entity_id: frame.id, + }; + match self.frames.iter().position(|f| f.pos == pos) { + Some(i) if self.frames[i] == marker => {} + Some(i) => { + self.frames[i] = marker; + self.dirty = true; + } + None => { + self.frames.push(marker); + self.dirty = true; + } + } + } /// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit` /// decorations count (`MapDecorationType.trackCount`). fn tracked_over_limit(&self, limit: usize) -> bool { @@ -1274,14 +1338,30 @@ impl Game<'_> { .collect(); let now = self.world.game_time; if now % 10 == 0 && host.hub.here(g) { - let framed: Vec = self + let framed: Vec<(i32, std::sync::Arc, Framed)> = self .item_frames .iter() - .filter_map(|f| f.item.as_ref().and_then(map_id)) + .filter_map(|f| { + let item = f.item.as_ref()?; + let frame = Framed { + pos: f.pos, + facing: f.facing, + id: f.id as i32, + }; + Some((map_id(item)?, item.item.clone(), frame)) + }) .collect(); - for id in framed { + let invisible = self.map_invisibility_worn(&g.armor); + for (id, item, frame) in framed { + // tickCarriedBy's mapMatcher: the same item with the same map id. + let carries = g + .inventory + .iter() + .chain(std::iter::once(&g.offhand)) + .flatten() + .any(|s| s.item == item && map_id(s) == Some(id)); if let Some(m) = self.map(id) { - m.holder(conn).framed = now; + m.tick_framed(conn, now, carries, invisible, frame); ids.push(id); } } From 12a8cc20b409658ff9d978f1975d525ea9424ecd Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:03:24 +0100 Subject: [PATCH 081/968] Clear a map's frame marker when its item frame loses the map Taking a map out of an item frame, breaking the frame, a blast or /kill left the frame's marker and saved entry on the map. ItemFrame.removeFramedMap calls MapItemSavedData.removedFromFrame(pos, entityId) on every path that empties the frame (dropItem unless the drops are off and something caused it, onKilled), which removes the marker and the frame entry (MapItemSavedData.java:421, ItemFrame.java:247). --- crates/horizon-client/src/game/commands.rs | 8 +++++++- crates/horizon-client/src/game/frames.rs | 6 ++++++ crates/horizon-client/src/game/maps.rs | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 861ade06d2..97dddc0079 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1742,7 +1742,13 @@ impl Game<'_> { self.creatures.projectiles.retain(|p| p.id != id); } Target::Stand(id) => self.armor_stands.retain(|a| a.id != id), - Target::Frame(id) => self.item_frames.retain(|f| f.id != id), + Target::Frame(id) => { + // ItemFrame.onKilled. + if let Some(frame) = self.item_frames.iter().find(|f| f.id == id).cloned() { + self.remove_framed_map(&frame); + } + self.item_frames.retain(|f| f.id != id); + } Target::Painting(id) => self.paintings.retain(|p| p.id != id), Target::Boat(id) => self.boats.retain(|b| b.id != id), Target::Cart(id) => self.carts.retain(|c| c.id != id), diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index 23383bdd8c..78cc317262 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -275,6 +275,12 @@ impl Game<'_> { } /// `ItemFrame.dropItem`: the frame (when breaking) and the held item. fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) { + // The framed map forgets the frame, except when the drops are off and + // something caused the break (`removeFramedMap` runs only for a null + // `causedBy` then). + if self.ui.rules.bool("entity_drops") || !by_player { + self.remove_framed_map(f); + } if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) { return; } diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 6d7f23a279..aeff800e18 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -384,6 +384,13 @@ impl MapData { } } } + /// `MapItemSavedData.removedFromFrame`: the frame entity's marker goes, + /// and the frame remembered at its block. + fn removed_from_frame(&mut self, pos: [i32; 3], entity_id: i32) { + self.remove_decoration(Key::Frame(entity_id)); + self.frames.retain(|f| f.pos != pos); + self.dirty = true; + } /// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit` /// decorations count (`MapDecorationType.trackCount`). fn tracked_over_limit(&self, limit: usize) -> bool { @@ -1072,6 +1079,15 @@ impl Game<'_> { self.swing_arm(); true } + /// `ItemFrame.removeFramedMap`: the map `frame` holds forgets the frame. + pub(super) fn remove_framed_map(&mut self, frame: &horizon_client::entities::ItemFrame) { + let Some(id) = frame.item.as_ref().and_then(map_id) else { + return; + }; + if let Some(m) = self.map(id) { + m.removed_from_frame(frame.pos, frame.id as i32); + } + } /// `MapItem.useOn`: a map used on a banner toggles that banner's marker /// on the map; when the map's data says no (`toggleBanner` fails) the use /// ends without a swing, and nothing else is done with the map either way. From 06e43b14df39d134f00934a2f5065ddaf1e3ce71 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 21:04:01 +0100 Subject: [PATCH 082/968] List a map's id, lock and scale in its tooltip A filled map's tooltip showed none of its lines. MapId.addToTooltip gives Unknown Map when the client has no data for it, else the id line (not for a named map or one a cartography table is locking or scaling), Locked, and with advanced tooltips the scale and level, a scaling copy counting one more up to 4 (MapId.java:27). The lines sit between the instrument and bees lines as in ItemStack.addDetailsToTooltipComponents. --- crates/horizon-client/src/game/stations.rs | 11 +++++ crates/horizon-client/src/item_tooltip.rs | 47 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index ed478ba565..cad01fc722 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -1781,6 +1781,17 @@ impl Game<'_> { { push(&mut lines, text, gray); } + // MapId.addToTooltip: the saved data's lock and scale, when it is known. + let map = stack + .data() + .and_then(|d| d.map_id) + .and_then(|id| self.maps.data.get(&id)) + .map(|m| (m.locked, m.scale)); + for text in + horizon_client::item_tooltip::map_id_lines(stack, &display, advanced, map, &translate) + { + push(&mut lines, text, gray); + } // Bees.addToTooltip. if let Some(text) = horizon_client::item_tooltip::bees_line(stack, &display, &translate) { push(&mut lines, text, gray); diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 4580ced913..75a0f91a09 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -295,6 +295,53 @@ pub fn instrument_line( Some(translate(&key).unwrap_or(key)) } +/// `MapId.addToTooltip`: a map whose data is not known (`map` is `None`, else +/// its `locked` flag and `scale`) reads "Unknown Map"; a known one gives its +/// id (unless it has a custom name or is to be locked or scaled), "Locked" +/// when it is or is being locked, and with advanced tooltips its scale (a +/// copy being scaled one up, at most 4) and level. +pub fn map_id_lines( + stack: &ItemStack, + display: &TooltipDisplay, + advanced: bool, + map: Option<(bool, u8)>, + translate: &dyn Fn(&str) -> Option, +) -> Vec { + let Some(id) = stack.data().and_then(|d| d.map_id) else { + return Vec::new(); + }; + if !display.shows("minecraft:map_id") { + return Vec::new(); + } + let text = |key: &str, fallback: &str| translate(key).unwrap_or_else(|| fallback.to_owned()); + let Some((locked, scale)) = map else { + return vec![text("filled_map.unknown", "Unknown Map")]; + }; + // MapPostProcessing: 0 lock, 1 scale. + let post = stack.data().and_then(|d| d.map_post_processing); + let mut lines = Vec::new(); + if stack.custom_name().is_none() && post.is_none() { + lines.push(text("filled_map.id", "ID #%s").replacen("%s", &id.to_string(), 1)); + } + if locked || post == Some(0) { + lines.push(text("filled_map.locked", "Locked")); + } + if advanced { + let scale = (scale + u8::from(post == Some(1))).min(4); + lines.push(text("filled_map.scale", "Scaling at 1:%s").replacen( + "%s", + &(1u32 << scale).to_string(), + 1, + )); + lines.push( + text("filled_map.level", "(Level %s/%s)") + .replacen("%s", &scale.to_string(), 1) + .replacen("%s", "4", 1), + ); + } + lines +} + /// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without /// the component has the item's default, none). pub fn bees_line( From 6f9a766556deb8a0657681c6a50f57409fcb53f4 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:08:34 +0500 Subject: [PATCH 083/968] Native mods: native_data! for bundles' data files, hook points API 3 native_data!("", "file") compiles a file of a bundle's data/ folder (staged as native-data// by horizon-mixins) into the game; native_built_data is the folder a mod's post-build step wrote into. A .hnmod may carry a crate's own data/ folder too. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_hooks.rs | 10 +++- crates/horizon-client/src/lib.rs | 1 + crates/horizon-client/src/native_data.rs | 53 +++++++++++++++++++ crates/horizon-mods/src/index/native.rs | 22 +++++--- 4 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 crates/horizon-client/src/native_data.rs diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 48db38dc1f..459a56281c 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -12,6 +12,9 @@ //! `native_known_recipes`, `native_place_recipe`, `native_give`): what a //! recipe viewer needs. //! +//! API 3 adds bundles' data files: `horizon_client::native_data!` compiles a file of a +//! bundle's `data/` folder in, and `native_built_data` is the folder its post-build step wrote. +//! //! Names, parameters and call sites are kept from release to release, and //! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`]. //! A bundle that hooks only these names the API it needs (`hooks`), and the @@ -35,7 +38,7 @@ use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 2; +pub(crate) const NATIVE_HOOKS_API: u32 = 3; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -433,6 +436,11 @@ impl Game<'_> { let mods = Self::options_path().with_file_name(mod_list::DIR); mods.join(".horizon-mod-data").join(mod_id) } + /// The folder mod `mod_id`'s post-build step wrote into when the launcher built the game + /// (`native-data//` in the build's version folder). API 3. + pub(super) fn native_built_data(&self, mod_id: &str) -> PathBuf { + horizon_client::native_data::built(mod_id) + } /// A mod's setting `key` (one file per key in `native_data_dir`). pub(super) fn native_setting(&self, mod_id: &str, key: &str) -> Option { let path = self.native_data_dir(mod_id).join(key); diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index d9b3dcbe34..6466c11958 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -76,6 +76,7 @@ pub mod mod_content; pub mod motion; pub mod multiplayer; pub mod music; +pub mod native_data; pub mod net; pub mod nether_portal; pub mod offline_name; diff --git a/crates/horizon-client/src/native_data.rs b/crates/horizon-client/src/native_data.rs new file mode 100644 index 0000000000..5142138003 --- /dev/null +++ b/crates/horizon-client/src/native_data.rs @@ -0,0 +1,53 @@ +//! Data files of native mods (docs/native-mods.md, "Data files"). A bundle's `data/` folder is +//! staged as `native-data//` at the root of the workspace the launcher builds, and +//! [`native_data!`](crate::native_data) compiles one of its files into the game. What a mod's +//! post-build step writes at build time is read at run time from [`built`]. +use std::path::PathBuf; + +/// The bytes of file `$file` of native mod `$id`'s `data/` folder, compiled in +/// (`&'static [u8; N]`): `horizon_client::native_data!("my_mod", "table.bin")` in a helper or +/// support module of the game's crate, `crate::native_data!` in the lib's. A mod's own crate +/// reads its files with `include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/…"))`. +/// Hook points API 3. +#[macro_export] +macro_rules! native_data { + ($id:literal, $file:literal $(,)?) => { + include_bytes!($crate::native_data!(@path $id, $file)) + }; + (@path $id:literal, $file:literal) => { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../native-data/", + $id, + "/", + $file + ) + }; +} + +/// The folder native mod `mod_id`'s post-build step wrote into +/// (`HORIZON_NATIVE_DATA_DIR`): `native-data//` beside the game's executable, in the +/// native build's version folder. Empty or missing for a mod without one. +pub fn built(mod_id: &str) -> PathBuf { + let exe = std::env::current_exe().unwrap_or_default(); + let dir = exe.parent().map(PathBuf::from).unwrap_or_default(); + dir.join("native-data").join(mod_id) +} + +#[cfg(test)] +mod tests { + #[test] + fn data_is_found_from_the_workspace_root_and_beside_the_game() { + // From the workspace root, two folders above any of its crates. + let path = crate::native_data!(@path "zoom", "levels/a.bin"); + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + assert_eq!( + std::path::Path::new(path), + root.join("native-data/zoom/levels/a.bin") + ); + let exe = std::env::current_exe().unwrap(); + let dir = super::built("zoom"); + assert_eq!(dir.parent().unwrap().parent(), exe.parent()); + assert!(dir.ends_with("native-data/zoom")); + } +} diff --git a/crates/horizon-mods/src/index/native.rs b/crates/horizon-mods/src/index/native.rs index cd8b7865a9..b815f4c816 100644 --- a/crates/horizon-mods/src/index/native.rs +++ b/crates/horizon-mods/src/index/native.rs @@ -66,7 +66,8 @@ impl Manifest { pub struct Bundle { pub manifest: Manifest, - /// Validated relative paths: Rust and TOML, plus files under `mixins//data/`. + /// Validated relative paths: Rust and TOML, plus files under `mixins//data/` or a + /// `data/` folder deeper in (a crate's). pub files: BTreeMap>, } @@ -115,13 +116,13 @@ impl Bundle { .map_err(|e| e.to_string())?; manifest.check()?; let prefix = format!("mixins/{}/", manifest.id); - let data_prefix = format!("{prefix}data/"); + // Data: the bundle's `data/`, or a `data/` of one of its crates (`engine/data/`). + let in_data = |rest: &str| rest.rsplit('/').skip(1).any(|dir| dir == "data"); for name in files.keys().filter(|n| n.as_str() != MANIFEST) { - if !name.starts_with(&prefix) - || !(name.ends_with(".rs") - || name.ends_with(".toml") - || name.starts_with(&data_prefix)) - { + let Some(rest) = name.strip_prefix(&prefix) else { + return Err(format!("unexpected native source: {name}")); + }; + if !(name.ends_with(".rs") || name.ends_with(".toml") || in_data(rest)) { return Err(format!("unexpected native source: {name}")); } } @@ -217,7 +218,10 @@ mod tests { let bytes = [0, 255, 42]; let bundle = read( "data", - &[("mixins/veinminer/data/nested/table.bin", &bytes)], + &[ + ("mixins/veinminer/data/nested/table.bin", &bytes), + ("mixins/veinminer/engine/data/model.bin", &bytes), + ], &mixin(), ) .unwrap(); @@ -235,6 +239,8 @@ mod tests { "mixins/veinminer/code.dll", "mixins/veinminer/data/../code.dll", "mixins/veinminer/database/file.bin", + "mixins/veinminer/engine/data.bin", + "mixins/veinminer/engine/Cargo.lock", "mixins/other/data/file.bin", "mixins\\veinminer\\hook.rs", ] From 40b0467e20bdce6834ca2a212141e6d8893c14ad Mon Sep 17 00:00:00 2001 From: abit-foggy <159262387+abit-foggy@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:03:43 -1000 Subject: [PATCH 084/968] fix(renderer): use flat draws on Intel Gen11 Vulkan to prevent section flicker (#3529) --- crates/horizon-client-render/src/gfx.rs | 9 ++++++++ crates/horizon-client-render/src/gfx/flat.rs | 24 ++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 08140a8c3b..f1032acd4a 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -2510,10 +2510,19 @@ impl Renderer { step_mode: wgpu::VertexStepMode::Vertex, attributes: &TERRAIN_ATTRIBUTES, })]; + let info = adapter.get_info(); + let min_sections = if flat::is_broken_intel_vulkan(&info) + || std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") + { + 0 + } else { + flat::MIN_SECTIONS + }; flat::Flat::new( &device, &bindings, arena.page_vertices(), + min_sections, |layout, label, fragment, blend| { pipeline_in( layout, diff --git a/crates/horizon-client-render/src/gfx/flat.rs b/crates/horizon-client-render/src/gfx/flat.rs index 5d86888da4..db41f4f041 100644 --- a/crates/horizon-client-render/src/gfx/flat.rs +++ b/crates/horizon-client-render/src/gfx/flat.rs @@ -115,7 +115,7 @@ pub struct Flat { /// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they /// were 2.2 to 2.5 per section; a section's draw count now depends on the /// sides it shows the eye (`mesher::Sides`), so it counts sections. -const MIN_SECTIONS: usize = 1600; +pub const MIN_SECTIONS: usize = 1600; /// Whether flat draws apply: the backend emulates multi-draw, and vertex /// shaders can read storage buffers large enough for an index page. @@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool { Ok("1") => return supported(adapter, device), _ => {} } - adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device) + let info = adapter.get_info(); + (info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info)) + && supported(adapter, device) +} + +/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step +/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin +/// and flicker. +pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool { + info.backend == wgpu::Backend::Vulkan + && info.vendor == 0x8086 + && (info.name.contains("ICL") + || info.name.contains("Ice Lake") + || (info.device >= 0x8a50 && info.device <= 0x8a71)) } fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool { @@ -254,6 +267,7 @@ impl Flat { device: &wgpu::Device, terrain_layout: &wgpu::BindGroupLayout, page_vertices: u64, + min_sections: usize, pipeline: impl Fn( &wgpu::PipelineLayout, &str, @@ -357,11 +371,7 @@ impl Flat { phase1_calls: 0, solid_calls: 0, shadow_calls: [0; 2], - min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") { - 0 - } else { - MIN_SECTIONS - }, + min_sections, range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16) .max(1), } From b82ebb2261499b820ea715967ecfa5bc90f6f49f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:45:18 +0100 Subject: [PATCH 085/968] Snuff a lit candle cake with an empty hand A right-click with an empty hand on the lit candle of a candle cake did nothing. CandleCakeBlock.useItemOn extinguishes it when the hit is above half a block, with the extinguish sound and a block change event. --- crates/horizon-world/src/level/interact.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 3e0c4c3e3e..0d867d527e 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -744,6 +744,20 @@ impl Behaviours { { return InteractionResult::Pass; } + // CandleCakeBlock.useItemOn: an empty hand on the lit candle (`candleHit`: + // above half a block) snuffs it, as `AbstractCandleBlock.extinguish`. + if item.is_none() + && self.data(state).chain.contains(&Class::CandleCake) + && hit.y - f64::from(pos.y) > 0.5 + && self.table.bool(state, self.props.lit) + { + let mut sim = self.sim(level, ticks); + let next = self.table.set_bool(state, self.props.lit, false); + sim.set_block(pos, next, 11); + sim.sound(pos, &"minecraft:block.candle.extinguish".into(), 1.0, 1.0); + sim.game_event(pos, "minecraft:block_change"); + return InteractionResult::Success; + } self.use_without_item(level, ticks, pos, face, hit, player) } From 50a1911cea491bc3d2fc654d3af6345ace141415 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:03:22 +0100 Subject: [PATCH 086/968] Play the creaking heart idle sound at night with logs on all sides A non-uprooted creaking heart with pale oak logs on six sides never made its idle creak; CreakingHeartBlock.animateTick (CreakingHeartBlock.java:65) plays it 1 time in 16 while CREAKING_ACTIVE is on. --- .../examples/probe/marker_particles_probe.rs | 1 + crates/horizon-client/src/animate_tick.rs | 40 +++++++++++++++++++ crates/horizon-client/src/game/render_tick.rs | 4 ++ 3 files changed, 45 insertions(+) diff --git a/crates/horizon-client/examples/probe/marker_particles_probe.rs b/crates/horizon-client/examples/probe/marker_particles_probe.rs index 6025a563cd..8dc48c45c7 100644 --- a/crates/horizon-client/examples/probe/marker_particles_probe.rs +++ b/crates/horizon-client/examples/probe/marker_particles_probe.rs @@ -76,6 +76,7 @@ pub fn main() -> Result<()> { raining_at: &|_| false, height_no_leaves: &|_| 0, firefly_bush_sounds: false, + creaking_active: false, sky_darken: 0, marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)), model_offset: &|_, _, _| None, diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index ac23d329ee..eed6a4d365 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -101,6 +101,8 @@ enum Animate { DeadBush, /// `ShortDryGrassBlock` and `TallDryGrassBlock`. DryGrass, + /// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`). + CreakingHeart, } fn step(facing: &str) -> [i32; 3] { match facing { @@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { "firefly_bush" => Animate::FireflyBush, "open_eyeblossom" => Animate::OpenEyeblossom, "pale_hanging_moss" => Animate::PaleHangingMoss, + "creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => { + Animate::CreakingHeart + } "dead_bush" => Animate::DeadBush, "short_dry_grass" | "tall_dry_grass" => Animate::DryGrass, "sculk_sensor" | "calibrated_sculk_sensor" @@ -442,6 +447,8 @@ pub struct AnimateContext<'a> { pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32, /// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline). pub firefly_bush_sounds: bool, + /// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline). + pub creaking_active: bool, /// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`. pub sky_darken: i32, /// Creative main-hand marker block and a resolver for each placed state's sprite. @@ -1259,6 +1266,39 @@ fn block_animate_tick( } } } + Animate::CreakingHeart => { + // CreakingHeartBlock.animateTick: the idle creak is drawn only while + // the attribute is on, then needs pale oak logs on all six sides + // (`CreakingHeartBlock.isSurroundedByLogs`). + if ctx.creaking_active + && next_int(engine.random(), 16) == 0 + && [ + [-1, 0, 0], + [1, 0, 0], + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], + ] + .iter() + .all(|d| { + let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]; + tables + .pale_oak_logs + .get(ctx.state(p).0 as usize) + .copied() + .unwrap_or(false) + }) + { + sound( + engine, + "minecraft:block.creaking_heart.idle", + [fx, fy, fz], + 1.0, + 1.0, + ); + } + } Animate::DeadBush => { // AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds. if next_int(engine.random(), 130) == 0 { diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 8589569a1e..70fd0ba0a8 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -339,6 +339,9 @@ impl Game<'_> { // The day timeline: firefly bush sounds from 12600 to 23400. let firefly_bush_sounds = self.dim == Dim::Overworld && (12600..23401).contains(&self.world.day_time.rem_euclid(24000)); + // EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night. + let creaking_active = self.dim == Dim::Overworld + && (12600..23401).contains(&self.world.day_time.rem_euclid(24000)); let tables = &self.pack.context.tables; let model_offset = |state, x, z| tables.offset(state, x, z); let gateway_particles = |state, p| { @@ -358,6 +361,7 @@ impl Game<'_> { raining_at: &raining_at, height_no_leaves: &height_no_leaves, firefly_bush_sounds, + creaking_active, sky_darken: self.sky_darken(), marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)), model_offset: &model_offset, From dd649971c8a041007c0876e278af4526212e4a99 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:46:57 +0100 Subject: [PATCH 087/968] Pick the potted plant instead of an empty flower pot Middle-click on a flower pot with a plant gave an empty pot. FlowerPotBlock.getCloneItemStack returns new ItemStack(potted) when the pot is not empty (FlowerPotBlock.java:115). --- crates/horizon-client/src/game/block_items.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs index 80e75c5206..0aa48df9e8 100644 --- a/crates/horizon-client/src/game/block_items.rs +++ b/crates/horizon-client/src/game/block_items.rs @@ -8,6 +8,7 @@ use horizon_client::{ items::ItemStack, save, }; use horizon_world::{ + level::classes::Class, nbt::{Compound, Tag}, registry::BlockStateId, }; @@ -45,7 +46,17 @@ impl Game<'_> { if &*facts.item == "minecraft:air" { return None; } - let mut stack = ItemStack::new(&facts.item, 1); + // FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant + // (`new ItemStack(potted)`), an empty pot as the pot itself. + let behaviours = self.res.behaviours(); + let plant = behaviours.data(state); + let plant_item = plant + .is(Class::FlowerPot) + .then_some(plant.link) + .flatten() + .map(|block| behaviours.block_data(block).item.clone()) + .filter(|item| &**item != "minecraft:air"); + let mut stack = ItemStack::new(plant_item.as_ref().unwrap_or(&facts.item), 1); let mut patch = Compound::new(); // BeehiveBlock.getCloneItemStack: the honey level as the item's block state. if include_data From bc3161c6191f39698c9fd4584a07a2abc68c16fb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:47:36 +0100 Subject: [PATCH 088/968] Ring a bell hit by a wind charge burst A wind charge burst that reached a bell sent a use on its top face, which the bell rejected, so it stayed silent. BellBlock.onExplosionHit calls attemptToRing with no direction, so the bell rings on its facing (BellBlock.java:197). --- crates/horizon-client/src/game/wind.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index 4137d098ec..5e3df4a7b5 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -517,6 +517,16 @@ impl Game<'_> { if !triggers(&name) { return Ok(()); } + // BellBlock.onExplosionHit: attemptToRing with no direction, so the bell + // rings toward its facing, not the face the burst came from, + // powered or not. + if &*name == "minecraft:bell" { + self.with_level(|b, level, ticks| { + b.bell_attempt_to_ring(level, ticks, BlockPos::new(pos[0], pos[1], pos[2])) + }); + self.process_level_events()?; + return Ok(()); + } // Doors react in their lower half only; powered ones and pressed // buttons stay as they are. if name.ends_with("_door") && prop(self, "half").as_deref() != Some("lower") { From e1c7a297bc40efff8edafb05337fc0d661de67ac Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:49:03 +0100 Subject: [PATCH 089/968] Pick a decorated pot with its sherds Middle-click on a decorated pot gave an empty pot. DecoratedPotBlock.getCloneItemStack returns createDecoratedPotInstance(decorations), so the sherds come with the pick; the pot's item only comes with include_data (DecoratedPotBlock.java:211). --- crates/horizon-client/src/game/block_items.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs index 0aa48df9e8..4f89016030 100644 --- a/crates/horizon-client/src/game/block_items.rs +++ b/crates/horizon-client/src/game/block_items.rs @@ -9,7 +9,7 @@ use horizon_client::{ }; use horizon_world::{ level::classes::Class, - nbt::{Compound, Tag}, + nbt::{Compound, JavaString, Tag}, registry::BlockStateId, }; @@ -79,10 +79,17 @@ impl Game<'_> { self.world.block_entities.get(&key), Some(BlockEntity::Banner(_)) ); - if (include_data || banner) + // DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the + // sherds only; the pot's item comes with include_data. + let pot = &*facts.name == "minecraft:decorated_pot"; + if (include_data || banner || pot) && let Some(saved) = self.saved_block_entity(key) { - patch.extend(block_entity_item::pick(&saved)); + let mut picked = block_entity_item::pick(&saved); + if pot && !include_data { + picked.retain(|k, _| *k == JavaString::from("minecraft:pot_decorations")); + } + patch.extend(picked); } if !patch.is_empty() { let mut all = components(&stack); From cbf57eb6eeef5a5bbb66181551f25ad84be92f21 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:49:37 +0100 Subject: [PATCH 090/968] Keep a copper golem statue's pose when it is picked Middle-click on a statue gave it standing, whatever its pose. CopperGolemStatueBlock.getCloneItemStack sets the block_state component to the pose (CopperGolemStatueBlockEntity.getItem, CopperGolemStatueBlock.java:134). --- crates/horizon-client/src/game/block_items.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs index 4f89016030..3b750c178f 100644 --- a/crates/horizon-client/src/game/block_items.rs +++ b/crates/horizon-client/src/game/block_items.rs @@ -73,6 +73,21 @@ impl Game<'_> { Tag::compound([("honey_level", Tag::String(level.as_str().into()))]), ); } + // CopperGolemStatueBlock.getCloneItemStack: the statue's pose as the item's + // block_state (`CopperGolemStatueBlockEntity.getItem`), data or not. + if let Some(pose) = self + .pack + .assets + .registry + .state(state) + .filter(|s| s.name.ends_with("copper_golem_statue")) + .and_then(|s| s.properties.get("copper_golem_pose")) + { + patch.insert( + "minecraft:block_state".into(), + Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]), + ); + } // AbstractBannerBlock.getCloneItemStack: the banner's own item // (BannerBlockEntity.getItem), data or not. let banner = matches!( From c37cab6b6aafcbf36ef4627406c9ffda4d62eedc Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:51:46 +0100 Subject: [PATCH 091/968] Show vault connection particles for every connected player Only the local player got the connection particles from a vault, so guests connected to it saw none. VaultBlockEntity.Client.emitConnectionParticlesForNearbyPlayers loops over every connected player (VaultBlockEntity.java:172). --- .../horizon-client/src/game/trial_visuals.rs | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/game/trial_visuals.rs b/crates/horizon-client/src/game/trial_visuals.rs index cac28b5cc3..e3a69bd9dd 100644 --- a/crates/horizon-client/src/game/trial_visuals.rs +++ b/crates/horizon-client/src/game/trial_visuals.rs @@ -279,19 +279,22 @@ impl Game<'_> { }; let me = self.level.player_uuid.unwrap_or_default(); let range = vault.connected_particles_range; - let (Some(p), true) = (self.player.as_ref(), vault.connected_players.contains(&me)) else { - return; - }; - let block = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32); - let d = [0, 1, 2].map(|i| f64::from(block[i] - pos[i])); - if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > range * range { + // VaultSharedData.getConnectedPlayers, each through getPlayerByUUID: the + // host and the guests, with their position and height. + let mut players = Vec::new(); + for uuid in &vault.connected_players { + if *uuid == me { + if let Some(p) = self.player.as_ref() { + let at = [p.position.x, p.position.y, p.position.z]; + players.push((at, p.dimensions.height)); + } + } else if let Some(g) = self.lan_present().into_iter().find(|g| g.uuid == *uuid) { + players.push((g.position, g.height)); + } + } + if players.is_empty() { return; } - let target = [ - p.position.x, - p.position.y + f64::from(p.dimensions.height / 2.0), - p.position.z, - ]; let facing = self .pack .assets @@ -310,12 +313,21 @@ impl Game<'_> { f64::from(pos[1]) + 1.75, f64::from(pos[2]) + 0.5 + step[1] * 0.5, ]; - let direction: [f64; 3] = std::array::from_fn(|i| target[i] - keyhole[i]); - let random = self.particles.level_random(); - let count = random.next_int_bound(4).unwrap_or(0) + 2; - for _ in 0..count { - let v = offset_random(direction, self.particles.level_random(), 1.0); - self.particle(T::VaultConnection, keyhole, v); + for (at, height) in players { + // isWithinConnectionRange: player.blockPosition().distSqr(vault) in range. + let block = at.map(|v| v.floor() as i32); + let d = [0, 1, 2].map(|i| f64::from(block[i] - pos[i])); + if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > range * range { + continue; + } + let target = [at[0], at[1] + f64::from(height / 2.0), at[2]]; + let direction: [f64; 3] = std::array::from_fn(|i| target[i] - keyhole[i]); + let random = self.particles.level_random(); + let count = random.next_int_bound(4).unwrap_or(0) + 2; + for _ in 0..count { + let v = offset_random(direction, self.particles.level_random(), 1.0); + self.particle(T::VaultConnection, keyhole, v); + } } } } From 2711a4a00508ba7bcac917a946bc1961b2c5e92c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:53:13 +0100 Subject: [PATCH 092/968] Break pointed dripstone hit by a fast thrown trident A thrown trident faster than 0.6 that stuck in pointed dripstone left it standing. SpeleothemBlock.onProjectileHit destroys it, reading the deltaMovement from before the hit (SpeleothemBlock.java:181, AbstractArrow.onHitBlock). --- crates/horizon-client/src/arrows.rs | 5 +++++ crates/horizon-client/src/game/creatures.rs | 16 +++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index c3269831f6..fd5e6b5d51 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -184,6 +184,8 @@ pub struct ArrowState { /// The arrow's own random (`Entity.random`). pub random: LegacyRandom, pub events: Vec, + /// The speed (`deltaMovement.length()`) it hit a block at, before it stuck. + pub hit_speed: f64, } impl fmt::Debug for ArrowState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -227,6 +229,7 @@ impl ArrowState { removed: false, random, events: Vec::new(), + hit_speed: 0.0, } } /// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo @@ -840,6 +843,8 @@ impl Ticker<'_, '_, '_, '_> { /// it moved on, stop, and stick. fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) { self.st.last_state = Some(state); + // BlockState.onProjectileHit still sees the deltaMovement it hit at. + self.st.hit_speed = self.p.velocity.length(); // Projectile.onHitBlock: BlockState.onProjectileHit. if self.st.on_fire() && let Some(reaction) = self.block_reaction(state) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index c323e7bf50..587fef8559 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2667,11 +2667,12 @@ impl Game<'_> { } } } - /// `onProjectileHit` of chorus flowers and decorated pots: an impact - /// projectile (`#impact_projectiles`) breaks the block when - /// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes - /// the `projectiles_can_break_blocks` rule and a shooter that may break - /// blocks (`canBreakBlockInAdventureMode`: never a mob, never an + /// `onProjectileHit` of chorus flowers, decorated pots and speleothems + /// (pointed dripstone, sulfur spikes): an impact projectile + /// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract` + /// and `Projectile.mayBreak` allow it, which takes the + /// `projectiles_can_break_blocks` rule and a shooter that may break blocks + /// (`canBreakBlockInAdventureMode`: never a mob, never an /// adventure player). A pot is cracked first (flags 260), so it shatters. fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) { use horizon_client::command::args::GameMode; @@ -2699,6 +2700,11 @@ impl Game<'_> { let at = BlockPos::new(pos[0], pos[1], pos[2]); match &*self.res.facts().get(state).name { "minecraft:chorus_flower" => {} + // SpeleothemBlock.onProjectileHit (pointed dripstone and sulfur spikes): a + // thrown trident faster than 0.6 breaks it. + "minecraft:pointed_dripstone" | "minecraft:sulfur_spike" + if matches!(p.kind, ProjectileKind::Trident) + && p.arrow.as_ref().is_some_and(|a| a.hit_speed > 0.6) => {} "minecraft:decorated_pot" => { let registry = &self.pack.assets.registry; let Some(mut pot) = registry.state(state).cloned() else { From 33f3444a498b4295df4dc82a3c7d9d9f16330254 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:55:39 +0100 Subject: [PATCH 093/968] Chain a hanging sign onto a wall hanging sign Right-clicking the side of a wall hanging sign with a hanging sign opened the sign instead of chaining. WallHangingSignBlock.useItemOn passes the use on when the hit is not on the editable faces (WallHangingSignBlock.java:53). --- crates/horizon-world/src/level/interact.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 0d867d527e..93f12f09c7 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -744,6 +744,18 @@ impl Behaviours { { return InteractionResult::Pass; } + // WallHangingSignBlock.useItemOn: a hanging sign aimed at the sign's side + // (not its editable faces, `isHittingEditableSide`) chains onto it. Sign + // text has no click commands here, so `canExecuteClickCommands` is false. + if self.data(state).is(Class::WallHangingSign) + && item + .and_then(|i| self.item_binding(i)) + .is_some_and(|binding| binding.kind == super::data::ItemKind::HangingSign) + && face != self.facing(state) + && face != self.facing(state).opposite() + { + return InteractionResult::Pass; + } // CandleCakeBlock.useItemOn: an empty hand on the lit candle (`candleHit`: // above half a block) snuffs it, as `AbstractCandleBlock.extinguish`. if item.is_none() From 296fd3d8395eb1c92b5744392a51acd1686533de Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:03:46 +0100 Subject: [PATCH 094/968] Break one turtle egg at a time when a survival player mines a stack Mining a stack of 2 to 4 turtle eggs removed the whole stack; TurtleEggBlock.playerDestroy (TurtleEggBlock.java:138, decreaseEggs) plays the break and keeps the rest. --- crates/horizon-client/src/game.rs | 10 ++++++++++ crates/horizon-world/src/level/hooks.rs | 26 +++++++++++++++++++++++++ crates/horizon-world/src/level/mod.rs | 12 ++++++++++++ 3 files changed, 48 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..043ef54aee 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4286,6 +4286,16 @@ impl Game<'_> { xp, ); } + // TurtleEggBlock.playerDestroy: after the drops, one egg of a + // mined stack is taken and the rest stay. + self.with_level(|b, level, ticks| { + b.turtle_egg_destroyed_by_player( + level, + ticks, + BlockPos::new(pos[0], pos[1], pos[2]), + broken, + ); + }); } // IceBlock.playerDestroy: the ice melts back into water (the // #prevents_ice_melting tool tag holds only silk touch). diff --git a/crates/horizon-world/src/level/hooks.rs b/crates/horizon-world/src/level/hooks.rs index 405057a3d4..721c78b467 100644 --- a/crates/horizon-world/src/level/hooks.rs +++ b/crates/horizon-world/src/level/hooks.rs @@ -455,6 +455,32 @@ impl Sim<'_, L> { self.game_event(pos, "minecraft:block_destroy"); self.remove_block(pos, false) } + /// `TurtleEggBlock.playerDestroy` (after the drops): `decreaseEggs` plays the + /// break and puts back all but one egg of the mined stack. `destroyed` is the + /// state that was broken. + pub(crate) fn turtle_egg_destroyed_by_player( + &mut self, + pos: BlockPos, + destroyed: BlockStateId, + ) { + let b = self.b; + if !b.data(destroyed).is(Class::TurtleEgg) { + return; + } + let pitch = 0.9 + self.random().next_float() * 0.2; + self.sound( + pos, + &std::sync::Arc::from("minecraft:block.turtle_egg.break"), + 0.7, + pitch, + ); + let eggs = b.table.int(destroyed, b.props.eggs); + if eggs > 1 { + self.set_block(pos, b.table.set_int(destroyed, b.props.eggs, eggs - 1), 2); + self.game_event(pos, "minecraft:block_destroy"); + self.level_event(2001, pos, destroyed.0 as i32); + } + } /// `IceBlock.playerDestroy` (after the drops): mined ice turns into water /// when it sits over a solid block or a liquid, unless the tool prevents /// ice melting (silk touch). In the nether, where water evaporates, the diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index c7d4db8fcb..23fce31745 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -685,6 +685,18 @@ impl Behaviours { self.sim(level, ticks) .ice_destroyed_by_player(pos, destroyed, prevents_melting); } + /// `TurtleEggBlock.playerDestroy` after the drops: a mined stack of eggs + /// loses one egg and the rest stay. `destroyed` is the state that was broken. + pub fn turtle_egg_destroyed_by_player( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + destroyed: BlockStateId, + ) { + self.sim(level, ticks) + .turtle_egg_destroyed_by_player(pos, destroyed); + } /// `BaseFireBlock.getState(level, pos)`: soul fire on its base blocks, /// otherwise fire shaped by its neighbours. pub fn fire_state_for( From a68a897af5ba2e8be2ab96ea89b228d772ece59d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:05:09 +0100 Subject: [PATCH 095/968] Break cushions struck by lightning A bolt's box left cushions intact. Cushion.thunderHit (Cushion.java:138) kills the cushion and drops its item, as break_cushion does. --- crates/horizon-client/src/game/lightning.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 23454c57a6..ff8ca037c8 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -21,6 +21,9 @@ pub(super) struct Bolt { converted: Vec, /// The mobs it has struck (`hitEntities`). hit: Vec, + /// Items a cushion dropped under it, spared for 20 ticks + /// (`Cushion.dropItem`: `setInvulnerableTime(20)`). + spared: Vec, /// Blocks it set on fire (`blocksSetOnFire`). fires: i32, /// The player who channelled it (`LightningBolt.cause`): `Some(None)` for @@ -219,6 +222,7 @@ impl Game<'_> { visual_only, converted: Vec::new(), hit: Vec::new(), + spared: Vec::new(), fires: 0, cause: None, }); @@ -240,6 +244,7 @@ impl Game<'_> { visual_only: true, converted: Vec::new(), hit: Vec::new(), + spared: Vec::new(), fires: 0, cause: None, }); @@ -505,6 +510,17 @@ impl Game<'_> { if !events.is_empty() { let _ = self.handle_mob_events(events); } + // Cushion.thunderHit: a cushion in the box breaks and drops its item + // (the bolt is no player, so no creative spare). Last first, since + // break_cushion removes the entry. + for i in (0..self.cushions.len()).rev() { + let p = self.cushions[i].position; + if inside([p.x, p.y, p.z]) { + let before = self.dropped.len(); + self.break_cushion(i, false); + b.spared.extend(self.dropped[before..].iter().map(|e| e.id)); + } + } // Every player in the box, the server's players too. let strike = move |g: &mut Self| { if let Some(p) = g.player.as_ref().map(|p| p.position) @@ -527,6 +543,7 @@ impl Game<'_> { self.dropped.retain(|e| { !inside([e.position.x, e.position.y, e.position.z]) || e.stack.item.contains("netherite") + || (e.age < 20 && b.spared.contains(&e.id)) }); struck.into_iter().map(|(_, view)| view).collect() } From bb7b1b27ad38708f733bdedb529a2e26f7181dc4 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:05:20 +0100 Subject: [PATCH 096/968] Play the dried ghast place sound when it is put down Placing a dried ghast was silent. DriedGhastBlock.setPlacedBy (DriedGhastBlock.java:189) plays block.dried_ghast.place, or place_in_water when waterlogged. --- crates/horizon-world/src/level/place.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/horizon-world/src/level/place.rs b/crates/horizon-world/src/level/place.rs index 2ec2f02ec5..97c3828d9f 100644 --- a/crates/horizon-world/src/level/place.rs +++ b/crates/horizon-world/src/level/place.rs @@ -1426,6 +1426,14 @@ impl Sim<'_, L> { } } else if data.is(Class::Door) { self.set_block_and_update(above(pos), b.table.set_text(state, p.half, "upper")); + } else if data.is(Class::DriedGhast) { + // DriedGhastBlock.setPlacedBy: the place sound, the water one when waterlogged. + let sound = if b.table.bool(state, p.waterlogged) { + "minecraft:block.dried_ghast.place_in_water" + } else { + "minecraft:block.dried_ghast.place" + }; + self.sound(pos, &sound.into(), 1.0, 1.0); } else if data.is(Class::PitcherCrop) { } else if data.is(Class::SmallDripleaf) { let above_pos = above(pos); From 6529206304aad8a0dea3d4e62820c2ca79ad9e08 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:05:27 +0100 Subject: [PATCH 097/968] Keep the light level when a light block is picked Pick-block on a light gave a level 0 light. LightBlock.getCloneItemStack and setLightOnStack (LightBlock.java:100,104) put the level into the item's block state. --- crates/horizon-client/src/game/block_items.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs index 3b750c178f..58b23ab8bc 100644 --- a/crates/horizon-client/src/game/block_items.rs +++ b/crates/horizon-client/src/game/block_items.rs @@ -88,6 +88,21 @@ impl Game<'_> { Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]), ); } + // LightBlock.getCloneItemStack, setLightOnStack: the light's level as + // the item's block state, whether or not the block data is picked. + if let Some(level) = self + .pack + .assets + .registry + .state(state) + .filter(|s| s.name.as_str() == "minecraft:light") + .and_then(|s| s.properties.get("level")) + { + patch.insert( + "minecraft:block_state".into(), + Tag::compound([("level", Tag::String(level.as_str().into()))]), + ); + } // AbstractBannerBlock.getCloneItemStack: the banner's own item // (BannerBlockEntity.getItem), data or not. let banner = matches!( From f6df8b8339d552e1da90e08d0a892e8879ba72e6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:05:59 +0100 Subject: [PATCH 098/968] Show the red dust burst when a redstone ore is touched Hitting, stepping on or right-clicking a redstone ore lit it with no particles. RedStoneOreBlock.interact (RedStoneOreBlock.java:61) calls spawnParticles each time, lit or not. --- crates/horizon-client/src/game/hand.rs | 2 + crates/horizon-client/src/game/remote.rs | 12 +++++ crates/horizon-client/src/game/sculk.rs | 61 ++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c241208948..f3b608cf06 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -569,6 +569,8 @@ impl HandWorld for GameHands<'_, '_> { true } fn attack_block(&mut self, pos: [i32; 3]) { + // RedStoneOreBlock.interact: the dust comes whether or not the ore was lit. + self.game.redstone_ore_particles(pos); let result = self.game.attack_block(pos); self.keep(result); } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index a88570f380..a24b327f7d 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4778,6 +4778,9 @@ impl Game<'_> { sequence: seq, })); self.tutorial_destroy(state, 0.0); + // startDestroyBlock: BlockState.attack on the client, so a redstone + // ore's dust (RedStoneOreBlock.attack) comes before the break. + self.redstone_ore_particles(hit.pos); if progress >= 1.0 { // startDestroyBlock: instant survival breaks have no destroy delay. self.predict_break(hit.pos, state, seq); @@ -4987,6 +4990,15 @@ impl Game<'_> { (result, DryUse::default()) }; let reacts = result.consumes_action(); + // RedStoneOreBlock.useItemOn: the clicking client's own dust, unless + // a sneak with something in hand skips useItemOn. + if hand == Hand::Main && !spectator { + let sneaking = self.keys.contains(&KeyCode::ShiftLeft); + let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some(); + if !(sneaking && hands_full) { + self.redstone_ore_particles(hit.pos); + } + } let seq = self.next_sequence(); let cursor = [ (hit.point[0] - f64::from(hit.pos[0])) as f32, diff --git a/crates/horizon-client/src/game/sculk.rs b/crates/horizon-client/src/game/sculk.rs index 2d2c45b69c..3d4ec58257 100644 --- a/crates/horizon-client/src/game/sculk.rs +++ b/crates/horizon-client/src/game/sculk.rs @@ -1542,7 +1542,7 @@ impl Game<'_> { .map(|i| [i.position.x, i.position.y, i.position.z]) .collect(); for at in items { - self.step_on_redstone_ore(at, false); + self.step_on_redstone_ore(at, false, false); } } @@ -1560,7 +1560,7 @@ impl Game<'_> { if let Some((at, on_ground, health, flying)) = player { let crouch = self.keys.contains(&KeyCode::ShiftLeft); if on_ground && !self.spectator() { - self.step_on_redstone_ore(at, crouch); + self.step_on_redstone_ore(at, crouch, true); } self.mount_events(Who::Player, self.riding, true); let in_water = self.in_water_at(at); @@ -1581,9 +1581,57 @@ impl Game<'_> { } } + /// `RedStoneOreBlock.spawnParticles`: a red dust puff for each face of the + /// ore that is not against a solid block. Non-axis coordinates draw from + /// the client level random, x then y then z. It is `level.addParticle`, so + /// only the local player's own click or step draws it, not a guest's turn. + pub(super) fn redstone_ore_particles(&mut self, pos: [i32; 3]) { + if self.lan_acting.is_some() + || !matches!( + &*self.block_name(pos), + "minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore" + ) + { + return; + } + // Direction.values(): down, up, north, south, west, east. + const FACES: [[i32; 3]; 6] = [ + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], + [-1, 0, 0], + [1, 0, 0], + ]; + for step in FACES { + let next = [pos[0] + step[0], pos[1] + step[1], pos[2] + step[2]]; + let state = self.world.block(next[0], next[1], next[2]); + if self.res.facts().get(state).solid_render { + continue; + } + let mut at = [0.0f64; 3]; + for axis in 0..3 { + at[axis] = if step[axis] != 0 { + 0.5 + 0.5625 * f64::from(step[axis]) + } else { + f64::from(self.particles.level_random().next_float()) + }; + } + self.particles.add( + &horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0), + [ + f64::from(pos[0]) + at[0], + f64::from(pos[1]) + at[1], + f64::from(pos[2]) + at[2], + ], + [0.0; 3], + ); + } + } + /// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the /// entity is stepping carefully (`attack_block` only lights an unlit ore). - fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) { + fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool, burst: bool) { if carefully { return; } @@ -1597,6 +1645,10 @@ impl Game<'_> { &*self.block_name(pos), "minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore" ) { + // The dust is the local player's client effect, not a mob's or an item's. + if burst { + self.redstone_ore_particles(pos); + } let _ = self.attack_block(pos); } } @@ -1615,6 +1667,7 @@ impl Game<'_> { ) { return Ok(false); } + self.redstone_ore_particles(pos); self.attack_block(pos)?; let block_item = held.is_some_and(|s| { self.res @@ -1644,7 +1697,7 @@ impl Game<'_> { .collect(); for (id, at, on_ground, in_water, species, damaged, vehicle) in snapshot { if on_ground { - self.step_on_redstone_ore(at, false); + self.step_on_redstone_ore(at, false, false); } // A mob first seen already riding raised its event when it spawned. let seen = self.sculk.steps.contains_key(&Who::Mob(id)); From 56b327bd94baf161d7a57daaf136603e14188605 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:06:23 +0100 Subject: [PATCH 099/968] Puff red dust when a lever is switched on Switching a lever on showed no dust puff at the lever. LeverBlock.useWithoutItem (LeverBlock.java:56) calls makeParticle at full scale on the clicking client when the lever ends up powered. --- crates/horizon-client/src/game.rs | 39 ++++++++++++++++++++++++ crates/horizon-client/src/game/remote.rs | 4 +++ 2 files changed, 43 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 043ef54aee..2742cf1b1c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3085,6 +3085,44 @@ impl Game<'_> { } self.process_level_events() } + /// `LeverBlock.makeParticle` at full scale: a powered lever puffs one red + /// dust on the clicking client (`LeverBlock.useWithoutItem`). + fn lever_puff(&mut self, pos: [i32; 3]) { + // The server's side of a guest's pull draws nothing. + if self.lan_acting.is_some() { + return; + } + let state = self.world.block(pos[0], pos[1], pos[2]); + let Some(registry_state) = self.pack.assets.registry.state(state) else { + return; + }; + let prop = |key: &str| registry_state.properties.get(key).map(String::as_str); + if prop("powered") != Some("true") { + return; + } + // FACING is horizontal; getConnectedDirection is the face's direction. + let facing = match prop("facing") { + Some("north") => [0, 0, -1], + Some("south") => [0, 0, 1], + Some("west") => [-1, 0, 0], + Some("east") => [1, 0, 0], + _ => return, + }; + let connected = match prop("face") { + Some("ceiling") => [0, -1, 0], + Some("floor") => [0, 1, 0], + _ => facing, + }; + let at = |base: i32, i: usize| { + f64::from(base) + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i]) + }; + let p = [at(pos[0], 0), at(pos[1], 1), at(pos[2], 2)]; + self.particles.add( + &horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0), + p, + [0.0; 3], + ); + } /// Track which chunks are ticking (Ticks needs chunk load/unload). fn sync_tick_chunks(&mut self) { if !self.tick_chunks.synchronize(&self.world.chunks) { @@ -4543,6 +4581,7 @@ impl Game<'_> { match &*name { "minecraft:note_block" => self.stats.custom("tune_noteblock", 1), "minecraft:bell" => self.stats.custom("bell_ring", 1), + "minecraft:lever" => self.lever_puff(hit.pos), _ => {} } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index a24b327f7d..e431aca1a1 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5031,6 +5031,10 @@ impl Game<'_> { } if reacts { self.predict_use(&changes, seq); + // LeverBlock.useWithoutItem on the client: the predicted pull puffs. + if &*self.block_name(hit.pos) == "minecraft:lever" { + self.lever_puff(hit.pos); + } self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2])); // MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT // (a chest, barrel, furnace, …). CONSUME does not swing. From 0e42a29fdab904a6f52f57fe25cf2a274f55ba2b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:05:35 +0100 Subject: [PATCH 100/968] Cycle the wool of a jeb_ sheep through the dye colours A sheep named jeb_ kept its dyed colour; SheepRenderState.getWoolColor fades it through ColorLerper.getLerpedColor(SHEEP) (SheepRenderer.java, MobRenderer.checkMagicName). --- crates/horizon-client/src/mobs/render.rs | 33 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 29d299bf88..f0c88e177a 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -99,6 +99,23 @@ pub fn sheep_wool_rgb(color: u8) -> u32 { let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255); f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255) } +/// `ColorLerper.getLerpedColor(ColorLerper.Type.SHEEP, tick)`: a `jeb_` sheep's +/// wool holds each dye for 25 ticks, blending into the next one. +pub fn jeb_wool_rgb(tick: f32) -> u32 { + let whole = tick.floor() as i32; + let value = (whole / 25) as usize; + let sub_step = ((whole % 25) as f32 + (tick - tick.floor())) / 25.0; + let (from, to) = ( + sheep_wool_rgb((value % 16) as u8), + sheep_wool_rgb(((value + 1) % 16) as u8), + ); + // ARGB.srgbLerp: Mth.lerpInt per channel, p0 + floor(alpha * (p1 - p0)). + let channel = |shift: u32| { + let (a, b) = ((from >> shift & 255) as i32, (to >> shift & 255) as i32); + (a + (sub_step * (b - a) as f32).floor() as i32) as u32 + }; + channel(16) << 16 | channel(8) << 8 | channel(0) +} pub const VILLAGER_TYPES: [&str; 7] = [ "plains", "desert", "jungle", "savanna", "snow", "swamp", "taiga", ]; @@ -459,7 +476,7 @@ pub fn body_color(m: &Mob, alpha: f32) -> u32 { } } /// Extra render layers drawn over the body with the same pose. -pub fn layers(m: &Mob) -> Vec { +pub fn layers(m: &Mob, alpha: f32) -> Vec { use Species as S; let mut v = Vec::new(); if avatar(m) { @@ -656,7 +673,15 @@ pub fn layers(m: &Mob) -> Vec { // SlimeOuterLayer: the translucent outer cube. S::Slime => push(&mut v, "slime#outer", "entity/slime/slime", white, false), S::Sheep if !m.extra.sheared => { - let color = abgr(sheep_wool_rgb(m.extra.color)); + // SheepRenderer.extractRenderState: `MobRenderer.checkMagicName(entity, "jeb_")`. + let jeb = m.custom_name.as_deref() == Some("jeb_"); + // SheepRenderState.getWoolColor: a jeb_ sheep cycles through the dyes. + let wool = if jeb { + jeb_wool_rgb(m.age as f32 + alpha) + } else { + sheep_wool_rgb(m.extra.color) + }; + let color = abgr(wool); if !m.is_baby() && m.extra.color != 0 { push( &mut v, @@ -1368,7 +1393,7 @@ fn render_visible( for (part, uv, color) in extra { draw(part, base, &poses, *uv, *color, light, overlay, out); } - let mut all_layers = layers(m); + let mut all_layers = layers(m, alpha); // WardenRenderer: the translucent emissive layers fade with the animations. if m.species() == Species::Warden { for (model, texture, a) in super::warden::emissive_layers(m, alpha) { @@ -1735,7 +1760,7 @@ mod tests { m.extra.age = if baby { -24000 } else { 0 }; (m.extra.variant, m.extra.variant2, m.extra.variant3) = (t as u8, p as u8, 5); - let layers = layers(&m); + let layers = layers(&m, 0.0); let group = if baby { "/baby/" } else { "/type/" }; assert!( layers.iter().any(|l| l.texture.contains(group)), From 2010ef412f1a86f58795cae5015f96a8b79b5628 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:05:44 +0100 Subject: [PATCH 101/968] Draw the wool undercoat on a white jeb_ sheep A white sheep named jeb_ had no undercoat; SheepWoolUndercoatLayer.submit draws it for isJebSheep as well as for a dyed wool. --- crates/horizon-client/src/mobs/render.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index f0c88e177a..a5a96865b3 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -682,7 +682,8 @@ pub fn layers(m: &Mob, alpha: f32) -> Vec { sheep_wool_rgb(m.extra.color) }; let color = abgr(wool); - if !m.is_baby() && m.extra.color != 0 { + // SheepWoolUndercoatLayer.submit: a jeb_ sheep is drawn over even when white. + if !m.is_baby() && (jeb || m.extra.color != 0) { push( &mut v, "sheep#wool_undercoat", From 89d4b1ce315b056e04cc6fb7ee40f831fe3ccd1e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:06:03 +0100 Subject: [PATCH 102/968] Tilt the iron golem body side to side while it walks Only the arms and legs swung; IronGolemRenderer.setupRotations also rotates the body about Z by 6.5 degrees of a triangle wave of the walk position. --- crates/horizon-client/src/mobs/render.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index a5a96865b3..e025cf693b 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -971,6 +971,16 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 base *= glam::Mat4::from_translation(glam::Vec3::new(0.0, m.height() + 0.1, 0.0)) * glam::Mat4::from_rotation_z(PI); } + // IronGolemRenderer.setupRotations, after LivingEntityRenderer's: the body + // tilts side to side while it walks. + if m.species() == Species::IronGolem { + let s = anim::state(m, alpha); + if s.speed >= 0.01 { + let wp = s.pos + 6.0; + let triangle_wave = ((wp % 13.0 - 6.5).abs() - 3.25) / 3.25; + base *= glam::Mat4::from_rotation_z((6.5 * triangle_wave).to_radians()); + } + } // AvatarRenderer.setupRotations, after LivingEntityRenderer's. if m.extra.fall_flying { let ticks = m.extra.fall_fly_ticks as f32 + alpha; From 6528d566a1fc4b80cdaaffd2bd4f16745686e75b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:07:03 +0100 Subject: [PATCH 103/968] Hide spell particles near the eye while scoping with a spyglass Spell particles stayed visible in the spyglass view; SpellParticle.isCloseToScopingPlayer sets their alpha to 0 within 3 blocks of the eye in first person while isScoping (SpellParticle.java). --- crates/horizon-client-render/src/particles.rs | 26 ++++++++++++++++++- crates/horizon-client/src/game/render_tick.rs | 14 ++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client-render/src/particles.rs b/crates/horizon-client-render/src/particles.rs index 7d8fcd5e64..d91e43b73c 100644 --- a/crates/horizon-client-render/src/particles.rs +++ b/crates/horizon-client-render/src/particles.rs @@ -981,6 +981,8 @@ pub struct ParticleEngine { level_random: LegacyRandom, /// `SpellParticle.RANDOM`. spell_random: LegacyRandom, + /// The local player's eye while it looks through a spyglass in first person. + scope_eye: Option<[f64; 3]>, /// Seeds for each particle's own random (`RandomSource.create()`). seeds: LegacyRandom, /// When set, particles ask the level for other particles here instead @@ -1019,6 +1021,7 @@ impl ParticleEngine { random: LegacyRandom::new(0x7061_7274), level_random: LegacyRandom::new(0x6c65_7665), spell_random: LegacyRandom::new(0x7370_656c), + scope_eye: None, seeds: LegacyRandom::new(0x7365_6564), requested: None, names: Vec::new(), @@ -1365,6 +1368,16 @@ impl ParticleEngine { } } } + /// Set each frame to the local player's eye while it scopes in first person. + pub fn set_scope_eye(&mut self, eye: Option<[f64; 3]>) { + self.scope_eye = eye; + } + /// `SpellParticle.isCloseToScopingPlayer`: within 3 blocks of that eye. + fn close_to_scoping_player(&self, pos: [f64; 3]) -> bool { + self.scope_eye.is_some_and(|eye| { + (eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0 + }) + } /// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`. pub fn send_particles( &mut self, @@ -2447,6 +2460,12 @@ impl ParticleEngine { q.size *= 0.75; q.lifetime = (8.0 / (f64::from(r.next_float()) * 0.8 + 0.2)) as i32; q.has_physics = false; + // SpellParticle's constructor: setAlpha(0.0F), which also sets originalAlpha. + // MobEffectProvider sets the option's alpha after it, so an entity + // effect starts visible and the first tick hides it. + if self.close_to_scoping_player(q.position) { + q.rgba[3] = 0.0; + } match ty { T::Witch => { let b = self.random.next_float() * 0.5 + 0.35; @@ -3462,7 +3481,12 @@ impl ParticleEngine { Behaviour::Spell { original_alpha } => { p.base_tick(level); self.sprite_from_age(p); - p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05; + // SpellParticle.tick: alpha 0 while close, else eased back to originalAlpha. + if self.close_to_scoping_player(p.position) { + p.rgba[3] = 0.0; + } else { + p.rgba[3] += (original_alpha - p.rgba[3]) * 0.05; + } } Behaviour::Cloud => { p.base_tick(level); diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 8589569a1e..cd1ffe1622 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -465,6 +465,20 @@ impl Game<'_> { }, ); } + // SpellParticle.isCloseToScopingPlayer: the eye of a local player looking + // through a spyglass in first person. + let scope_eye = self + .player + .as_ref() + .filter(|_| self.camera_type.first_person() && self.scoping()) + .map(|p| { + [ + p.position.x, + p.position.y + f64::from(p.dimensions.eye_height), + p.position.z, + ] + }); + self.particles.set_scope_eye(scope_eye); let level = ParticleLevel { world, facts, From 245ea982b966645ca9310771c46d875c4ec01c9d Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:08:15 +0100 Subject: [PATCH 104/968] Show oak plank crumbs when an armor stand breaks A stand broken by a creative hand or the second hit made no plank particles; ArmorStand.showBreakingParticles sends ten oak plank block particles from the box (ArmorStand.java:369). --- .../horizon-client/src/game/armor_stands.rs | 43 +++++++++++++++++++ crates/horizon-client/src/game/creatures.rs | 2 +- .../horizon-client/src/game/lan_particles.rs | 7 ++- 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 4d8a9fd31a..a6633f7d1b 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -525,6 +525,37 @@ impl Game<'_> { } hurt } + /// `ArmorStand.showBreakingParticles`: ten oak plank crumbs from the box, + /// spread by a quarter of its width and height, sent like `sendParticles`. + fn show_breaking_particles(&mut self, at: [f64; 3], width: f64, height: f64) { + let Some(state) = self + .pack + .assets + .registry + .default_state("minecraft:oak_planks") + else { + return; + }; + let block = [ + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ]; + let Some((sprite, tint)) = self.particle_sprite(state, block) else { + return; + }; + let options = horizon_client::particles::ParticleOptions::block( + horizon_client::particles::ParticleType::Block, + sprite, + tint, + ); + // Entity.getY(2/3): the box's height is scaled by 2/3 from the feet. + let centre = [at[0], at[1] + height * (2.0 / 3.0), at[2]]; + let spread = [width / 4.0, height / 4.0, width / 4.0]; + self.particles + .send_particles(&options, centre, spread, 0.05, 10); + self.lan_level_particles(&options, centre, spread, 0.05, 10, Some(state)); + } /// `ArmorStand.hurtServer` for a player's attack (also a sweep's): a /// creative player breaks it, otherwise the first hit wobbles it and a /// second within five ticks breaks it. @@ -533,9 +564,11 @@ impl Game<'_> { let at = self.armor_stands[i].position; let at = [at.x, at.y, at.z]; if self.creative { + let (width, height) = stand_box(&self.armor_stands[i]); self.armor_stands.remove(i); self.audio .play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0); + self.show_breaking_particles(at, f64::from(width), f64::from(height)); return true; } if time - self.armor_stands[i].last_hit > 5 { @@ -547,8 +580,10 @@ impl Game<'_> { } // brokenByPlayer: the stand item, then brokenByAnything: the equipment. let a = self.armor_stands.remove(i); + let (width, height) = stand_box(&a); self.audio .play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0); + self.show_breaking_particles(at, f64::from(width), f64::from(height)); let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]]; let stand = ItemStack { item: Arc::from("minecraft:armor_stand"), @@ -983,6 +1018,14 @@ impl Game<'_> { } } } +/// `ArmorStand.getBbWidth` and `getBbHeight`: a marker has no box, a small stand is half size. +fn stand_box(a: &ArmorStand) -> (f32, f32) { + match (a.marker, a.small) { + (true, _) => (0.0, 0.0), + (false, true) => (0.25, 0.9875), + (false, false) => (0.5, 1.975), + } +} /// `AbstractSkullBlock.getType` of a skull item. pub(super) fn skull_kind(item: &str) -> Option<&'static str> { Some(match item { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index c323e7bf50..f5023ff9a0 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1173,7 +1173,7 @@ impl Game<'_> { let options = horizon_client::particles::ParticleOptions::simple(ty); self.particles .send_particles(&options, at, spread, speed, count); - self.lan_level_particles(&options, at, spread, speed, count); + self.lan_level_particles(&options, at, spread, speed, count, None); } } MobEvent::Sound(name, at, volume, pitch) diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index fe10118044..b28270916b 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -83,7 +83,8 @@ impl Game<'_> { } /// `ServerLevel.sendParticles` of a mob's particles: a packet to each - /// player within 32 blocks. The window draws its own copy. + /// player within 32 blocks. The window draws its own copy. `state` is the + /// block of a block particle. pub(super) fn lan_level_particles( &self, options: &ParticleOptions, @@ -91,8 +92,10 @@ impl Game<'_> { spread: [f64; 3], speed: f64, count: u32, + state: Option, ) { - let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else { + let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, state)) + else { return; }; let packet = C::LevelParticles(play::LevelParticles { From c2bd40f2709a716aac66d23c519b5a8e6e582da7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 18:46:42 +0100 Subject: [PATCH 105/968] Show the pointing hand over chat insertion text while Shift is held Hovering a chat part with an insertion never asked for the pointing hand; vanilla requests it in ChatComponent.java:672 when Shift is held (ChatScreen passes insertionClickMode). --- crates/horizon-client/src/game/chat.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 9086be49cf..eaebe904af 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -976,6 +976,13 @@ impl Game<'_> { let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1)); let wrap = (batch.width as i32 / 2).max(200); if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) { + // DrawingFocusedGraphicsAccess.handleMessage: the pointing hand over + // insertion text, which ChatScreen only asks for while Shift is held. + if style.insertion.is_some() && self.has_shift_down() { + horizon_client::widgets::request_cursor( + horizon_client::widgets::Cursor::PointingHand, + ); + } if let Some(hover) = style.hover { let lines = self.wrapped_rows(&hover, wrap); self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); From 8ef51c4b69bc641a8c60ca237e9b629bdead86bd Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:03:20 +0100 Subject: [PATCH 106/968] Sweep a brush in use like vanilla A brush in use was held still. FirstPersonHandsAndItemsRenderer.applyBrushTransform (FirstPersonHandsAndItemsRenderer.java:286) swings it back and forth over the use. --- crates/horizon-client/src/first_person.rs | 33 +++++++++++++++++++++++ crates/horizon-client/src/game.rs | 5 ++++ 2 files changed, 38 insertions(+) diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index 586e6bfdc7..248d884aef 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -28,6 +28,12 @@ pub enum ItemPose { Bow { ticks_used: f32, }, + /// A brush in use (`ItemUseAnimation.BRUSH`): the ticks left of the use + /// and the partial tick. + Brush { + remaining: f32, + partial: f32, + }, /// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and /// the ticks since its last hit's feedback. Spear { @@ -78,6 +84,9 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) - if matches!(pose, ItemPose::Using) { return m; } + if let ItemPose::Brush { remaining, partial } = pose { + return m * brush_transform(left, remaining, partial); + } // The spear's own place in the hand, without the equip height. if let ItemPose::Spear { kinetic, @@ -177,6 +186,30 @@ impl MapHold { } } +/// `FirstPersonHandsAndItemsRenderer.applyBrushTransform`: the brush in use, +/// swept back and forth by the ticks left of its use (`remaining`). +fn brush_transform(left: bool, remaining: f32, partial: f32) -> glam::Mat4 { + use glam::{Mat4, Vec3}; + use horizon_core::geometry::trig::Mth; + let ticks = remaining % 10.0; + let delta = ticks - partial + 1.0; + let scaled = 1.0 - delta / 10.0; + let swipe = -15.0 + 75.0 * (scaled * 2.0 * std::f32::consts::PI).mcos(); + let sweep = Mat4::from_rotation_x(swipe.to_radians()); + if left { + Mat4::from_translation(Vec3::new(0.1, 0.83, 0.35)) + * Mat4::from_rotation_x((-80.0f32).to_radians()) + * Mat4::from_rotation_y((-90.0f32).to_radians()) + * sweep + * Mat4::from_translation(Vec3::new(-0.3, 0.22, 0.35)) + } else { + Mat4::from_translation(Vec3::new(-0.25, 0.22, 0.35)) + * Mat4::from_rotation_x((-80.0f32).to_radians()) + * Mat4::from_rotation_y(90.0f32.to_radians()) + * sweep + } +} + /// `renderPlayerArm` up to `renderPlayerHand`: the pose of the bare `left` /// or right arm. pub fn player_arm_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 { diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..2f88f9518a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10983,6 +10983,11 @@ impl Game<'_> { } } "minecraft:trident" if using => ItemPose::Trident { ticks_used }, + // ItemUseAnimation.BRUSH: applyBrushTransform. + "minecraft:brush" if using => ItemPose::Brush { + remaining: owner.use_item_remaining_ticks as f32, + partial: a, + }, _ if using => { let components = self .pack From 75b37506abacc96a4470b042a1bfcfb839a62174 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:03:54 +0100 Subject: [PATCH 107/968] Draw skulls worn in the mob head slot Zombie, skeleton, creeper and player heads in a mob's head slot were not drawn. CustomHeadLayer.submit (CustomHeadLayer.java:44) puts the skull model on the head, and resolveSkullRenderType (CustomHeadLayer.java:69) takes a player head's skin from its profile. --- crates/horizon-client/src/game/mob_blocks.rs | 101 +++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 64e72769b0..0673ca4990 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -150,6 +150,7 @@ impl Game<'_> { self.captain_banner_geometry(mob, camera, alpha, light, out); self.held_item_geometry(mob, camera, alpha, light, true, out); self.worn_block_geometry(mob, camera, alpha, light, out); + self.worn_skull_geometry(mob, camera, alpha, light, out); self.golem_flower_geometry(mob, camera, alpha, light, out); if mob.species() == Species::Enderman && let Some(state) = mob.extra.carried @@ -271,6 +272,106 @@ impl Game<'_> { } } } + /// `CustomHeadLayer.submit`: a skull worn in the head slot is drawn on the + /// head. A player head takes its skin from the worn stack's profile + /// (`CustomHeadLayer.resolveSkullRenderType`). + fn worn_skull_geometry( + &self, + mob: &Mob, + camera: glam::DVec3, + alpha: f64, + light: u32, + out: &mut Vec, + ) { + let Some(kind) = mob + .extra + .head + .as_deref() + .and_then(super::armor_stands::skull_kind) + else { + return; + }; + // Only the renderers that add a `CustomHeadLayer` draw it: the + // humanoid mobs, the illagers, the villagers, the copper golem and + // the avatars. The layer also draws on an invisible mob. + if !(&*mob.kind.id == "minecraft:player" + || matches!( + mob.species(), + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::Skeleton + | Species::Stray + | Species::Bogged + | Species::Parched + | Species::WitherSkeleton + | Species::Piglin + | Species::PiglinBrute + | Species::ZombifiedPiglin + | Species::Pillager + | Species::Vindicator + | Species::Evoker + | Species::Illusioner + | Species::Villager + | Species::WanderingTrader + | Species::CopperGolem + | Species::Mannequin + )) + { + return; + } + let Some(head) = + render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head") + else { + return; + }; + // translateToHead, the renderer's `skullYOffset` (the villager's + // `CUSTOM_HEAD_TRANSFORMS`), then the skull's SKULL_SCALE. + let y_offset = if mob.species() == Species::Villager { + -0.074_218_75 + } else { + 0.0 + }; + let pose = head + * glam::Mat4::from_translation(glam::Vec3::new(0.0, y_offset, 0.0)) + * glam::Mat4::from_scale(glam::Vec3::splat(1.1875)); + let special = horizon_assets::item_model::SpecialModel { + kind: if kind == "player" { + horizon_assets::item_model::SpecialKind::PlayerHead + } else { + horizon_assets::item_model::SpecialKind::Head { + kind: kind.to_owned(), + texture: None, + animation: 0.0, + } + }, + }; + let profile = mob.extra.armor[0] + .as_ref() + .filter(|stack| mob.extra.head.as_deref() == Some(&*stack.item)) + .and_then(|stack| stack.extra.as_deref()) + .and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile")) + .map(|(_, t)| horizon_client::scoreboard::tag_to_json(t)) + .unwrap_or(serde_json::Value::Null); + let mut sink = horizon_client::item_render::WorldSink { + out, + light, + lights: Some(horizon_client::block_entity_render::diffuse_lights( + self.dim == super::Dim::Nether, + )), + glint: self.glint(), + }; + self.pack.item_renderer.submit_special( + &special, + &profile, + false, + pose, + &self.gui.atlas.sprites, + &self.item_host(), + &mut sink, + ); + } /// `ItemInHandLayer`: both hands through the item model pipeline; `lit` /// applies the level's diffuse lighting (off for GUI previews, which /// light the projected quads themselves). From 994820e69748732dde7ec9461ce83c90e79aee83 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:06:03 +0100 Subject: [PATCH 108/968] Hold a spyglass to the eye for other players A spyglass in use was drawn in the hand, not at the eye. PlayerItemInHandLayer.renderItemHeldToEye (PlayerItemInHandLayer.java:40) clamps the head pitch to -30 to 90 degrees and holds the item on the head. --- crates/horizon-client/src/game/mob_blocks.rs | 16 ++++++++-- crates/horizon-client/src/mobs/render.rs | 33 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 0673ca4990..01ca7eee86 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -388,9 +388,19 @@ impl Game<'_> { for (item, off_hand) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] { let Some(item) = item else { continue }; let left = off_hand != mob.main_arm_left(); - let Some(t) = + // PlayerItemInHandLayer.submitArmWithItem (avatars only): a spyglass + // in use, with no swing in progress, is held to the eye (`heldOnHead`). + let spyglass_to_eye = &**item == "minecraft:spyglass" + && (&*mob.kind.id == "minecraft:player" || mob.species() == Species::Mannequin) + && off_hand == mob.extra.using_off_hand + && mob.extra.using_item > 0 + && horizon_client::mobs::anim::state(mob, alpha as f32).swing < 1.0e-5; + let t = if spyglass_to_eye { + render::held_to_eye_matrix(mob, alpha as f32, camera, self.res.mob_data(), left) + } else { render::held_item_transform(mob, alpha as f32, camera, self.res.mob_data(), left) - else { + }; + let Some(t) = t else { continue; }; let slot = if off_hand { @@ -444,6 +454,8 @@ impl Game<'_> { // PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies // on the ground. let (context, ordinal) = match (mob.species(), left) { + // The spyglass held to the eye is the HEAD display. + _ if spyglass_to_eye => (Context::Head, 5), (Species::Panda | Species::Fox, _) => (Context::Ground, 7), (_, true) => (Context::ThirdPersonLeftHand, 1), (_, false) => (Context::ThirdPersonRightHand, 2), diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index e025cf693b..a2c498b536 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1644,6 +1644,39 @@ pub fn part_transform( anim::setup(m, &s, &mut poses, &data.animations); part_matrix(&model, base, &poses, part, 0.0) } +/// `PlayerItemInHandLayer.renderItemHeldToEye` up to the item: the root and +/// the head with its pitch clamped to -30 to 90 degrees, `translateToHead` +/// (`CustomHeadLayer.translateToHead`), then the hand's side of the eye. +pub fn held_to_eye_matrix( + m: &Mob, + alpha: f32, + camera: glam::DVec3, + data: &MobData, + left: bool, +) -> Option { + use glam::{Mat4, Vec3}; + let base = base_transform(m, alpha, camera); + let s = anim::state(m, alpha); + let (model_key, _) = body(m); + let model = m + .kind + .layer(&model_key) + .cloned() + .unwrap_or_else(|| m.kind.model.clone()); + let mut poses = Poses::new(&model); + anim::setup(m, &s, &mut poses, &data.animations); + let pitch = + poses.get("head")[3].clamp(-std::f32::consts::FRAC_PI_6, std::f32::consts::FRAC_PI_2); + poses.xrot("head", pitch); + let head = part_matrix(&model, base, &poses, "head", 0.0)?; + let side = if left { -2.5 / 16.0 } else { 2.5 / 16.0 }; + Some( + head * Mat4::from_translation(Vec3::new(0.0, -0.25, 0.0)) + * Mat4::from_rotation_y(std::f32::consts::PI) + * Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625)) + * Mat4::from_translation(Vec3::new(side, -0.0625, 0.0)), + ) +} /// `FoxHeldItemLayer`: the item sits at the head's position under the render /// state's head roll, yaw and pitch (not the posed head's, which differ while /// it sleeps), pushed out to the mouth. From a1454e00a20093b86d7e32aa4c502b23dcc2d3c8 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:08:55 +0100 Subject: [PATCH 109/968] Show the entity tooltip for chat hover events with advanced tooltips Hovering a show_entity chat part showed nothing. With advancedItemTooltips on, vanilla lists the name, the entity type and the UUID (GuiGraphicsExtractor.componentHoverEffect; HoverEvent.java:84). --- crates/horizon-client-core/src/component.rs | 5 ++- crates/horizon-client-core/src/text.rs | 13 +++++-- crates/horizon-client/src/game/chat.rs | 43 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client-core/src/component.rs b/crates/horizon-client-core/src/component.rs index 81651f9c80..d27f49d47c 100644 --- a/crates/horizon-client-core/src/component.rs +++ b/crates/horizon-client-core/src/component.rs @@ -236,7 +236,7 @@ fn object_style(o: &serde_json::Map) -> Style { _ => None, }; } - // HoverEvent.ShowText and ShowItem. + // HoverEvent.ShowText, ShowItem and ShowEntity. 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") => { @@ -247,6 +247,9 @@ fn object_style(o: &serde_json::Map) -> Style { Some("show_item") if e.get("id").is_some_and(Value::is_string) => { style.hover_item = Some(Box::new(Value::Object(e.clone()))); } + Some("show_entity") if e.get("id").is_some_and(Value::is_string) => { + style.hover_entity = Some(Box::new(Value::Object(e.clone()))); + } _ => {} } } diff --git a/crates/horizon-client-core/src/text.rs b/crates/horizon-client-core/src/text.rs index ac2136dcd5..b45da3dcad 100644 --- a/crates/horizon-client-core/src/text.rs +++ b/crates/horizon-client-core/src/text.rs @@ -94,6 +94,8 @@ pub struct Style { pub hover: Option>, /// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object. pub hover_item: Option>, + /// `HoverEvent.ShowEntity`: the event's `{id, uuid, name?}` object. + pub hover_entity: Option>, /// `Style.insertion`: the text a Shift-click puts in the chat box. pub insertion: Option, /// The font (`FontDescription.Resource`), a full id like @@ -124,11 +126,12 @@ impl Style { if self.click.is_none() { self.click.clone_from(&base.click); } - if self.hover.is_none() { + // One HoverEvent: its three kinds are inherited together, so a style + // with its own event never picks up another kind from `base`. + if self.hover.is_none() && self.hover_item.is_none() && self.hover_entity.is_none() { self.hover.clone_from(&base.hover); - } - if self.hover_item.is_none() { self.hover_item.clone_from(&base.hover_item); + self.hover_entity.clone_from(&base.hover_entity); } if self.insertion.is_none() { self.insertion.clone_from(&base.insertion); @@ -584,6 +587,10 @@ fn style_json(style: &Style) -> Vec<(String, Value)> { let mut event = item.clone(); event.insert("action".into(), "show_item".into()); out.push(("hover_event".to_owned(), Value::Object(event))); + } else if let Some(Value::Object(entity)) = style.hover_entity.as_deref() { + let mut event = entity.clone(); + event.insert("action".into(), "show_entity".into()); + out.push(("hover_event".to_owned(), Value::Object(event))); } if let Some(insertion) = &style.insertion { out.push(("insertion".to_owned(), insertion.clone().into())); diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index eaebe904af..03520c2431 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -926,6 +926,41 @@ impl Game<'_> { .chat .draw(batch, &self.gui.atlas, self.ticks, focused); } + /// `HoverEvent.EntityTooltipInfo.getTooltipLines`: the name when the + /// event has one, the entity type and the UUID. + fn entity_tooltip_lines(entity: &serde_json::Value) -> Vec<(Text, u32)> { + let mut lines = Vec::new(); + if let Some(name) = entity.get("name") { + let name = horizon_client::component::to_text(name, &mut |s| Text::literal(s)); + lines.push((name, u32::MAX)); + } + let id = entity + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let kind = Text::translate(&format!("entity.{}", id.replace(':', ".")), &[]); + lines.push(( + Text::translate("gui.entity_tooltip.type", &[kind]), + u32::MAX, + )); + if let Some(uuid) = entity.get("uuid").and_then(Self::entity_uuid) { + lines.push((Text::literal(uuid), u32::MAX)); + } + lines + } + /// The UUID of a ShowEntity event: its string, or the int array + /// `UUIDUtil.CODEC` writes (most significant word first). + fn entity_uuid(v: &serde_json::Value) -> Option { + if let Some(s) = v.as_str() { + return Some(s.to_owned()); + } + let words = v.as_array().filter(|w| w.len() == 4)?; + let mut bits = 0u128; + for w in words { + bits = (bits << 32) | u128::from(w.as_i64()? as u32); + } + Some(horizon_protocol::crypto::uuid_string(bits)) + } pub(super) fn draw_chat_screen(&mut self, batch: &mut HudBatch) { let Some(mut screen) = self.ui.chat_screen.take() else { return; @@ -993,6 +1028,14 @@ impl Game<'_> { .filter(|s| self.res.items().get(&s.item).is_some()) { self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1); + } else if let Some(entity) = style + .hover_entity + .as_deref() + .filter(|_| self.settings.file.bool("advancedItemTooltips")) + { + // ChatScreen: a ShowEntity tooltip only with advanced tooltips on. + let lines = Self::entity_tooltip_lines(entity); + self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); } } else if self.ui.chat.indicator_at(height, cx, cy) { // GuiMessageTag.systemSinglePlayer's text, split at 210. From f250e84ecf5f15caebcaef3cc184693dbf57e919 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:51:36 +0100 Subject: [PATCH 110/968] Refuse item counts above the max stack in item commands item replace/fill/override accepted counts up to 99 above an item's max stack. ItemInput.createItemStack (ItemInput.java:21) throws arguments.item.overstacked instead. --- crates/horizon-client/src/game/commands/items.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/commands/items.rs b/crates/horizon-client/src/game/commands/items.rs index e80cd99bb2..a936d61913 100644 --- a/crates/horizon-client/src/game/commands/items.rs +++ b/crates/horizon-client/src/game/commands/items.rs @@ -310,12 +310,19 @@ impl Game<'_> { None => (0, None), }; let count = Self::int(ctx, "count").unwrap_or(1).max(1) as u32; - Ok(Some(ItemStack { + let stack = ItemStack { item: id.as_str().into(), count, damage, extra, - })) + }; + // ItemInput.createItemStack: a count above the stack's max (its + // max_stack_size component, else the item's) is refused. + let max_stack = self.stack_max_size(&stack); + if count > max_stack { + return Err(err("arguments.item.overstacked", &[&id, &max_stack])); + } + Ok(Some(stack)) } /// One item modifier (a typed object, a list or a datapack reference) /// applied to a stack; `None` when the stack ends up empty. From d21886b2f3ed313f80406d878ec040803cf5283f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:06:46 +0100 Subject: [PATCH 111/968] Snuff a lit candle with an empty hand An empty hand on a lit candle did nothing; only arrows lit them. CandleBlock.useItemOn (CandleBlock.java:75) calls AbstractCandleBlock.extinguish: unlit, wick smoke, candle extinguish sound, block_change. --- crates/horizon-client/src/game.rs | 38 ++++++++++++++++++++++ crates/horizon-world/src/level/interact.rs | 20 ++++++++++++ 2 files changed, 58 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2742cf1b1c..ee51432a6b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3860,6 +3860,44 @@ impl Game<'_> { [note / 24.0, 0.0, 0.0], ); } + // AbstractCandleBlock.extinguish: the smoke from each wick, at + // CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1. + // `level.addParticle`: only the local player's own click draws it. + LevelEvent::BlockEvent { + kind: "candle_smoke", + .. + } if self.lan_acting.is_some() => {} + LevelEvent::BlockEvent { + pos, + state, + kind: "candle_smoke", + } => { + use horizon_client::particles::{ParticleOptions, ParticleType}; + let candles = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("candles")?.parse::().ok()) + .unwrap_or(1); + const OFFSETS: [&[[u8; 3]]; 4] = [ + &[[8, 8, 8]], + &[[6, 7, 8], [10, 8, 7]], + &[[8, 5, 10], [6, 7, 8], [9, 8, 7]], + &[[7, 5, 9], [10, 7, 9], [6, 7, 6], [9, 8, 6]], + ]; + for o in OFFSETS[candles.clamp(1, 4) - 1] { + self.particles.add( + &ParticleOptions::simple(ParticleType::Smoke), + [ + f64::from(pos.x) + f64::from(o[0]) / 16.0, + f64::from(pos.y) + f64::from(o[1]) / 16.0, + f64::from(pos.z) + f64::from(o[2]) / 16.0, + ], + [0.0, 0.1, 0.0], + ); + } + } LevelEvent::GrowTree { .. } | LevelEvent::PlaceFeature { .. } | LevelEvent::BlockEvent { .. } => {} diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index 93f12f09c7..c563de321e 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -770,6 +770,26 @@ impl Behaviours { sim.game_event(pos, "minecraft:block_change"); return InteractionResult::Success; } + // CandleBlock.useItemOn: an empty hand snuffs a lit candle (`mayBuild`). + if item.is_none() + && player.may_build + && self.data(state).chain.contains(&Class::Candle) + && self.table.bool(state, self.props.lit) + { + // AbstractCandleBlock.extinguish: unlit, the wick smoke, the + // extinguish sound, then the block-change game event. + let next = self.table.set_bool(state, self.props.lit, false); + let mut sim = self.sim(level, ticks); + sim.set_block_and_update(pos, next); + sim.emit(LevelEvent::BlockEvent { + pos, + state: next, + kind: "candle_smoke", + }); + sim.sound(pos, &"minecraft:block.candle.extinguish".into(), 1.0, 1.0); + sim.game_event_state(pos, "minecraft:block_change", next); + return InteractionResult::Success; + } self.use_without_item(level, ticks, pos, face, hit, player) } From 90db82ba0e598bab01980f4510576954ab625a08 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:53:20 +0100 Subject: [PATCH 112/968] Hold a witch's potion at her nose instead of in her hand A drinking witch's potion was drawn in the hand with no potion contents. WitchItemLayer.applyTranslation (WitchItemLayer.java:17) holds the potion at the nose as a potion item. --- crates/horizon-client/src/game/mob_blocks.rs | 8 +++++- crates/horizon-client/src/mobs/render.rs | 26 ++++++++++++++++++++ crates/horizon-client/src/mobs/witch.rs | 13 ++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 01ca7eee86..376fdfa85b 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -421,6 +421,12 @@ impl Game<'_> { continue; }; stack.clone() + } else if mob.species() == Species::Witch + && horizon_client::mobs::witch::holds_potion(item) + && &**item != "minecraft:potion" + { + // A drinking witch's main hand holds the potion's type, drawn as a potion item. + ItemStack::new("minecraft:potion", 1).with_potion(item) } else { ItemStack::new(item, 1) }; @@ -456,7 +462,7 @@ impl Game<'_> { let (context, ordinal) = match (mob.species(), left) { // The spyglass held to the eye is the HEAD display. _ if spyglass_to_eye => (Context::Head, 5), - (Species::Panda | Species::Fox, _) => (Context::Ground, 7), + (Species::Panda | Species::Fox | Species::Witch, _) => (Context::Ground, 7), (_, true) => (Context::ThirdPersonLeftHand, 1), (_, false) => (Context::ThirdPersonRightHand, 2), }; diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index a2c498b536..6ff5ca4826 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1747,6 +1747,32 @@ pub fn held_item_transform( .unwrap_or_else(|| m.kind.model.clone()); let mut poses = Poses::new(&model); anim::setup(m, &s, &mut poses, &data.animations); + // WitchItemLayer.applyTranslation: a potion is held at the nose, any other + // item as CrossedArmsItemLayer holds it; only the main hand's item is drawn. + if m.species() == Species::Witch { + let item = m.extra.main_hand.as_deref()?; + if left != m.main_arm_left() { + return None; + } + if super::witch::holds_potion(item) { + let nose = part_matrix(&model, base, &poses, "nose", 0.0)?; + return Some( + nose * glam::Mat4::from_translation(glam::Vec3::new(0.0625, 0.25, 0.0)) + * glam::Mat4::from_rotation_z(180.0f32.to_radians()) + * glam::Mat4::from_rotation_x(140.0f32.to_radians()) + * glam::Mat4::from_rotation_z(10.0f32.to_radians()) + * glam::Mat4::from_rotation_x(180.0f32.to_radians()), + ); + } + // CrossedArmsItemLayer.applyTranslation: translateToArms, then this. + let arms = part_matrix(&model, base, &poses, "arms", 0.0)?; + return Some( + arms * glam::Mat4::from_rotation_x(0.75) + * glam::Mat4::from_scale(glam::Vec3::splat(1.07)) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.13, -0.34)) + * glam::Mat4::from_rotation_x(std::f32::consts::PI), + ); + } let mut t = hand_base(m, &model, base, &poses, left)?; // ItemInHandLayer: a STAB swing of this arm thrusts its item out // (SpearAnimations.thirdPersonAttackItem, the main hand's forward diff --git a/crates/horizon-client/src/mobs/witch.rs b/crates/horizon-client/src/mobs/witch.rs index 05d4441254..4fbef3c149 100644 --- a/crates/horizon-client/src/mobs/witch.rs +++ b/crates/horizon-client/src/mobs/witch.rs @@ -320,6 +320,19 @@ fn drink(m: &mut Mob, cx_events: &mut Vec, target_far: bool) { )); } } +/// `WitchRenderer.extractRenderState`'s `isHoldingPotion`: the main hand holds +/// a potion, either a drunk one (kept by the potion's name, `drink`) or a +/// plain potion item. +pub fn holds_potion(item: &str) -> bool { + matches!( + item, + "minecraft:potion" + | "minecraft:water_breathing" + | "minecraft:fire_resistance" + | "minecraft:healing" + | "minecraft:swiftness" + ) +} /// WitchModel.setupAnim (VillagerModel base with a wobbling nose). pub fn anim(m: &Mob, s: &State, p: &mut Poses) { p.yrot("head", s.y_rot.to_radians()); From 131458ee0ff83d90a9b4930d06252b4edf2e57bb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:07:25 +0100 Subject: [PATCH 113/968] Play the head's sound from a note block under a custom head A note block with a player head on top always clicked, ignoring the head's note sound. NoteBlock.getCustomSoundId (NoteBlock.java:167) takes the sound from the skull above; no skull or sound means silence. --- crates/horizon-client/src/game.rs | 19 +++++++++++++++++++ crates/horizon-world/src/level/redstone.rs | 11 ++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index ee51432a6b..f3f49f228b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3860,6 +3860,25 @@ impl Game<'_> { [note / 24.0, 0.0, 0.0], ); } + // NoteBlock.getCustomSoundId: the skull above's note block sound, + // played at volume 3 (vanilla's pitch for a head is 1). + LevelEvent::BlockEvent { + pos, + kind: "note_custom_sound", + .. + } => { + if let Some(BlockEntity::Skull(skull)) = + self.world.block_entities.get(&(pos.x, pos.y + 1, pos.z)) + && let Some(sound) = skull.note_block_sound.as_deref() + { + let at = [ + f64::from(pos.x) + 0.5, + f64::from(pos.y) + 0.5, + f64::from(pos.z) + 0.5, + ]; + self.audio.play(sound, Some(at), 3.0, 1.0); + } + } // AbstractCandleBlock.extinguish: the smoke from each wick, at // CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1. // `level.addParticle`: only the local player's own click draws it. diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 6676242e12..9fd17e1d52 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -1201,8 +1201,17 @@ impl Sim<'_, L> { }); horizon_core::jmath::pow(2.0, f64::from(note - 12) / 12.0) as f32 }; + // NoteBlock.getCustomSoundId: the sound saved in the skull above is + // read by the client; no skull or no sound means no sound. + if instrument == "custom_head" { + self.emit(LevelEvent::BlockEvent { + pos, + state, + kind: "note_custom_sound", + }); + return; + } let sound = match instrument.as_str() { - "custom_head" => "minecraft:ui.button.click".to_owned(), "dragon" => "minecraft:block.note_block.imitate.ender_dragon".to_owned(), name if mob_head => format!("minecraft:block.note_block.imitate.{name}"), name => format!("minecraft:block.note_block.{name}"), From e0a1b471ffb6f732d09c4706a22253f3af4315a2 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:57:31 +0100 Subject: [PATCH 114/968] Draw the block in a copper golem's antenna The block item in a copper golem's antenna slot was never drawn. BlockDecorationLayer.submit (BlockDecorationLayer.java:33) draws it at CopperGolemModel.applyBlockOnAntennaTransform (CopperGolemModel.java:311). --- crates/horizon-client/src/game/mob_blocks.rs | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 376fdfa85b..4eb99913fb 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -152,6 +152,7 @@ impl Game<'_> { self.worn_block_geometry(mob, camera, alpha, light, out); self.worn_skull_geometry(mob, camera, alpha, light, out); self.golem_flower_geometry(mob, camera, alpha, light, out); + self.antenna_block_geometry(mob, camera, alpha, light, out); if mob.species() == Species::Enderman && let Some(state) = mob.extra.carried && let Some((_, tint)) = self.block_face_sprite(state) @@ -169,6 +170,43 @@ impl Game<'_> { self.block_model_vertices_tinted(state, 42, t, light, true, tint, out); } } + /// `BlockDecorationLayer.submit`: the block item in a copper golem's + /// antenna slot (a flower, or any block item), drawn at the antenna by + /// `CopperGolemModel.applyBlockOnAntennaTransform` with `NO_OVERLAY`. Only + /// the block's default state is drawn, not the item's block state component. + fn antenna_block_geometry( + &self, + mob: &Mob, + camera: glam::DVec3, + alpha: f64, + light: u32, + out: &mut Vec, + ) { + use glam::{Mat4, Vec3}; + if mob.species() != Species::CopperGolem || horizon_client::mobs::trader::is_invisible(mob) + { + return; + } + let Some(stack) = mob.saddle_item() else { + return; + }; + let Some(state) = self.pack.assets.registry.default_state(&stack.item) else { + return; + }; + let Some(head) = + render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head") + else { + return; + }; + // UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5) + // then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies. + let unit = Mat4::from_translation(Vec3::new(-0.5, 0.0, -0.5)) + * Mat4::from_translation(Vec3::splat(0.5)) + * Mat4::from_rotation_z(180.0f32.to_radians()) + * Mat4::from_translation(Vec3::splat(-0.5)); + let t = head * Mat4::from_translation(Vec3::new(0.0, -1.75, 0.0)) * unit; + self.block_model_vertices(state, 42, t, light, true, out); + } /// `IronGolemFlowerLayer`: the poppy in the right arm while the golem /// offers it (`getOfferFlowerTick() > 0`), drawn with `NO_OVERLAY`. fn golem_flower_geometry( From 45e1d44be255fbd6c97a5eeca2b1fb7703c74286 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 17:22:55 +0100 Subject: [PATCH 115/968] Draw the candle snuff smoke on the clicking client In single player the server side skips the wick smoke, so the client draws it itself, as CandleBlock.useItemOn does on the client (AbstractCandleBlock.extinguish). --- crates/horizon-client/src/game.rs | 59 ++++++++++++------------ crates/horizon-client/src/game/remote.rs | 13 +++++- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index f3f49f228b..e17f7873ff 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3085,6 +3085,35 @@ impl Game<'_> { } self.process_level_events() } + /// `AbstractCandleBlock.extinguish`: the smoke from each wick, at + /// CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1. + fn candle_smoke(&mut self, pos: [i32; 3], state: BlockStateId) { + use horizon_client::particles::{ParticleOptions, ParticleType}; + let candles = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("candles")?.parse::().ok()) + .unwrap_or(1); + const OFFSETS: [&[[u8; 3]]; 4] = [ + &[[8, 8, 8]], + &[[6, 7, 8], [10, 8, 7]], + &[[8, 5, 10], [6, 7, 8], [9, 8, 7]], + &[[7, 5, 9], [10, 7, 9], [6, 7, 6], [9, 8, 6]], + ]; + for o in OFFSETS[candles.clamp(1, 4) - 1] { + self.particles.add( + &ParticleOptions::simple(ParticleType::Smoke), + [ + f64::from(pos[0]) + f64::from(o[0]) / 16.0, + f64::from(pos[1]) + f64::from(o[1]) / 16.0, + f64::from(pos[2]) + f64::from(o[2]) / 16.0, + ], + [0.0, 0.1, 0.0], + ); + } + } /// `LeverBlock.makeParticle` at full scale: a powered lever puffs one red /// dust on the clicking client (`LeverBlock.useWithoutItem`). fn lever_puff(&mut self, pos: [i32; 3]) { @@ -3879,8 +3908,6 @@ impl Game<'_> { self.audio.play(sound, Some(at), 3.0, 1.0); } } - // AbstractCandleBlock.extinguish: the smoke from each wick, at - // CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1. // `level.addParticle`: only the local player's own click draws it. LevelEvent::BlockEvent { kind: "candle_smoke", @@ -3890,33 +3917,7 @@ impl Game<'_> { pos, state, kind: "candle_smoke", - } => { - use horizon_client::particles::{ParticleOptions, ParticleType}; - let candles = self - .pack - .assets - .registry - .state(state) - .and_then(|s| s.properties.get("candles")?.parse::().ok()) - .unwrap_or(1); - const OFFSETS: [&[[u8; 3]]; 4] = [ - &[[8, 8, 8]], - &[[6, 7, 8], [10, 8, 7]], - &[[8, 5, 10], [6, 7, 8], [9, 8, 7]], - &[[7, 5, 9], [10, 7, 9], [6, 7, 6], [9, 8, 6]], - ]; - for o in OFFSETS[candles.clamp(1, 4) - 1] { - self.particles.add( - &ParticleOptions::simple(ParticleType::Smoke), - [ - f64::from(pos.x) + f64::from(o[0]) / 16.0, - f64::from(pos.y) + f64::from(o[1]) / 16.0, - f64::from(pos.z) + f64::from(o[2]) / 16.0, - ], - [0.0, 0.1, 0.0], - ); - } - } + } => self.candle_smoke([pos.x, pos.y, pos.z], state), LevelEvent::GrowTree { .. } | LevelEvent::PlaceFeature { .. } | LevelEvent::BlockEvent { .. } => {} diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e431aca1a1..6c4bbaff77 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4990,13 +4990,22 @@ impl Game<'_> { (result, DryUse::default()) }; let reacts = result.consumes_action(); - // RedStoneOreBlock.useItemOn: the clicking client's own dust, unless - // a sneak with something in hand skips useItemOn. + // useItemOn on this client, unless a sneak with something in hand + // skips it: RedStoneOreBlock's dust, and the wick smoke of a lit + // candle an empty hand snuffs (CandleBlock.useItemOn). if hand == Hand::Main && !spectator { let sneaking = self.keys.contains(&KeyCode::ShiftLeft); let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some(); if !(sneaking && hands_full) { self.redstone_ore_particles(hit.pos); + let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); + let lit_candle = self.block_name(hit.pos).ends_with("candle") + && self.pack.assets.registry.state(state).is_some_and(|s| { + s.properties.get("lit").map(String::as_str) == Some("true") + }); + if held.is_none() && self.may_build() && lit_candle { + self.candle_smoke(hit.pos, state); + } } } let seq = self.next_sequence(); From 9a8455d017d24fc053a89ee57c41a214ab807fd8 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Thu, 29 Oct 2026 22:16:46 +0100 Subject: [PATCH 116/968] Launcher: copy ATLauncher instances into Java instances --- launcher/src/other_launchers.rs | 66 ++++++++++++++++++++++++++- launcher/src/other_launchers/tests.rs | 35 ++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/launcher/src/other_launchers.rs b/launcher/src/other_launchers.rs index aacaaab411..179c2f06b1 100644 --- a/launcher/src/other_launchers.rs +++ b/launcher/src/other_launchers.rs @@ -23,8 +23,12 @@ //! instance's game folder in `profiles//`. //! , //! +//! - ATLauncher: its folder keeps each instance in `Instances//`, the game folder itself +//! beside an `instance.json` (the game's version in `id`, the loader in `launcher.loaderVersion`, +//! a `type` and a `version`, and the name in `launcher.name`). +//! //! -//! Neither app describes where it records an instance's Minecraft version, so the player names +//! Neither the CurseForge app nor the Modrinth App describes where it records an instance's Minecraft version, so the player names //! it when copying one. use crate::instances::{self, Instance}; use crate::loaders; @@ -81,6 +85,7 @@ const SKIPPED: [&str; 4] = [ ]; const CURSEFORGE: &str = "the CurseForge app"; const MODRINTH_APP: &str = "the Modrinth App"; +const ATLAUNCHER: &str = "ATLauncher"; fn loader_of_uid(uid: &str) -> Option<&'static str> { match uid { @@ -282,6 +287,57 @@ pub fn modrinth_app_profiles(root: &Path) -> Vec { game_folders(&root.join("profiles"), MODRINTH_APP) } +/// One ATLauncher instance folder: its `instance.json` names the Minecraft version (`id`), the +/// loader (`launcher.loaderVersion`, whose `type` is `Fabric`, `Quilt`, `NeoForge` or `Forge`) +/// and the instance (`launcher.name`). Its game folder is the folder itself. +pub fn atlauncher_instance(dir: &Path) -> Option { + let json: Value = + serde_json::from_str(&fs::read_to_string(dir.join("instance.json")).ok()?).ok()?; + let minecraft = json["id"].as_str().filter(|v| !v.is_empty())?.to_string(); + let loader = &json["launcher"]["loaderVersion"]; + let loader = match (loader["type"].as_str(), loader["version"].as_str()) { + (Some(kind), Some(version)) => ["fabric", "quilt", "neoforge", "forge"] + .into_iter() + .find(|n| n.eq_ignore_ascii_case(kind)) + .map(|name| Loader { + name, + version: version.to_string(), + }), + _ => None, + }; + let folder = dir.file_name()?.to_string_lossy().into_owned(); + let name = json["launcher"]["name"] + .as_str() + .map(str::trim) + .filter(|n| !n.is_empty()) + .map(String::from) + .unwrap_or(folder); + Some(Found { + launcher: ATLAUNCHER, + name, + minecraft: Some(minecraft), + loader, + game: dir.to_path_buf(), + shared: false, + }) +} + +/// The ATLauncher instances in its folder `root` (or in its `Instances/` itself). +pub fn atlauncher_instances(root: &Path) -> Vec { + let dir = if root.join("Instances").is_dir() { + root.join("Instances") + } else { + root.to_path_buf() + }; + let mut found: Vec = fs::read_dir(&dir) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| atlauncher_instance(&e.path())) + .collect(); + found.sort_by_key(|f| f.name.to_lowercase()); + found +} /// What one folder holds: an official launcher's profiles, a Prism or MultiMC root (or its /// `instances/`), the CurseForge app's modding folder, the Modrinth App's folder, or a single /// instance folder. @@ -297,7 +353,14 @@ pub fn in_folder(dir: &Path, launcher: Option<&'static str>) -> Vec { .filter(|f| f.game.file_name() == name) .collect(); } + if dir.join("instance.json").is_file() { + return atlauncher_instance(dir).into_iter().collect(); + } if dir.join("Instances").is_dir() { + let atlauncher = atlauncher_instances(dir); + if !atlauncher.is_empty() { + return atlauncher; + } return curseforge_instances(dir); } if dir.join("profiles").is_dir() { @@ -334,6 +397,7 @@ pub fn scan(os: Os, folders: &Folders) -> Vec { OFFICIAL => found.extend(official_profiles(&dir)), "Prism Launcher" | "MultiMC" => found.extend(prism_instances(&dir, launcher)), "Modrinth App" => found.extend(modrinth_app_profiles(&dir)), + ATLAUNCHER => found.extend(atlauncher_instances(&dir)), _ => {} } } diff --git a/launcher/src/other_launchers/tests.rs b/launcher/src/other_launchers/tests.rs index 9d5601d441..a3cd5d7b79 100644 --- a/launcher/src/other_launchers/tests.rs +++ b/launcher/src/other_launchers/tests.rs @@ -247,3 +247,38 @@ fn curseforge_and_modrinth_app_instances_are_their_game_folders() { ); let _ = fs::remove_dir_all(&root); } + +#[test] +fn atlauncher_instances_are_read_from_their_instance_json() { + let root = scratch("atl"); + let fabric = json!({"id": "1.20.1", "launcher": {"name": "My Pack", + "loaderVersion": {"type": "Fabric", "version": "0.16.9"}}}); + let neo = json!({"id": "1.21.1", "launcher": {"name": "Neo", + "loaderVersion": {"type": "NeoForge", "version": "21.1.77"}}}); + let plain = json!({"id": "1.21.1", "launcher": {"name": ""}}); + write( + &root.join("Instances/Pack1/instance.json"), + &fabric.to_string(), + ); + write(&root.join("Instances/Pack1/mods/a.jar"), "x"); + write(&root.join("Instances/Neo/instance.json"), &neo.to_string()); + write( + &root.join("Instances/Plain/instance.json"), + &plain.to_string(), + ); + write(&root.join("Instances/NotOne/readme.txt"), "x"); + let found = atlauncher_instances(&root); + let names: Vec<_> = found.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["My Pack", "Neo", "Plain"]); + let pack = &found[0]; + assert_eq!(pack.launcher, "ATLauncher"); + assert_eq!(pack.minecraft.as_deref(), Some("1.20.1")); + assert_eq!(pack.loader.as_ref().unwrap().name, "fabric"); + assert_eq!(pack.loader.as_ref().unwrap().version, "0.16.9"); + assert_eq!(pack.game, root.join("Instances/Pack1")); + assert_eq!(found[1].loader.as_ref().unwrap().name, "neoforge"); + assert!(found[2].loader.is_none()); + assert_eq!(in_folder(&root, None).len(), 3); + assert_eq!(in_folder(&root.join("Instances/Pack1"), None).len(), 1); + let _ = fs::remove_dir_all(&root); +} From 65967c4324bc6f8e6888e6dde5b6d0dccacd1f31 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 22:18:23 +0100 Subject: [PATCH 117/968] Make the maps-villager-trades fixes build on current main --- crates/horizon-client/src/mobs.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 6baad06088..e945576d82 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -808,6 +808,10 @@ pub struct Extra { pub charged: bool, /// Held item ids (main hand, off hand) and head item. pub main_hand: Option>, + /// `ItemStack.getCount` of the main hand, which `Mob.pickUpItem` can fill + /// with a whole stack (a dolphin, a panda, a zombie with a free hand); + /// 0 or 1 is a single item. Read it with the `main_hand_count` method. + pub main_hand_count: u32, pub off_hand: Option>, pub head: Option>, /// Worn armour below the head (humanoid mobs). From a9e1f452df3d6d8d93cde6257d2cf8ffa107f6f7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:40:15 +0100 Subject: [PATCH 118/968] Break a lily pad that a boat drives over A boat passing over a lily pad left it in place. Vanilla LilyPadBlock.entityInside (LilyPadBlock.java:27) destroys the pad with its drops for an AbstractBoat on a server level. --- crates/horizon-client/src/game.rs | 20 ++++++++++++++++++++ crates/horizon-world/src/level/hooks.rs | 10 ++++++++++ crates/horizon-world/src/level/mod.rs | 9 +++++++++ 3 files changed, 39 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..52262e8bf7 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -704,6 +704,11 @@ impl GameLevel<'_> { .map(|s| s.bounds()), ) } + /// `LilyPadBlock.entityInside`: the boxes of the boats that can break a pad. + fn boat_trigger_boxes(&self) -> impl Iterator + '_ { + let b = self.blockers; + b.boats.iter().map(boats::Boat::bounds) + } /// The non-living entities that trigger plates and tripwires (boats, /// crystals, primed TNT, falling blocks, item entities, experience orbs and /// projectiles). @@ -3251,6 +3256,21 @@ impl Game<'_> { } } self.with_level(|b, level, ticks| { + // LilyPadBlock.entityInside: a boat inside a lily pad breaks it. + let mut boat_cells: Vec = Vec::new(); + for bounds in level.boat_trigger_boxes() { + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + for x in (lo.x + 1e-7).floor() as i32..=(hi.x - 1e-7).floor() as i32 { + for y in (lo.y + 1e-7).floor() as i32..=(hi.y - 1e-7).floor() as i32 { + for z in (lo.z + 1e-7).floor() as i32..=(hi.z - 1e-7).floor() as i32 { + boat_cells.push(BlockPos::new(x, y, z)); + } + } + } + } + for pos in boat_cells { + b.boat_inside(level, ticks, pos); + } // The mobs and the other entities (items, orbs, projectiles...) too. let mut touched: Vec = Vec::new(); for bounds in level.mob_trigger_boxes().chain(level.other_trigger_boxes()) { diff --git a/crates/horizon-world/src/level/hooks.rs b/crates/horizon-world/src/level/hooks.rs index 405057a3d4..de42d56861 100644 --- a/crates/horizon-world/src/level/hooks.rs +++ b/crates/horizon-world/src/level/hooks.rs @@ -507,6 +507,16 @@ impl Sim<'_, L> { } } + /// `LilyPadBlock.entityInside` for a boat: the lily pad at `pos` is + /// destroyed with its drops (`Level.destroyBlock(pos, true, entity)`). + pub(crate) fn boat_inside(&mut self, pos: BlockPos) { + let b = self.b; + let state = self.state(pos); + if b.data(state).is(Class::LilyPad) { + self.destroy_block(pos, true, super::flags::UPDATE_LIMIT); + } + } + // ---- misc helpers ---- /// `BaseFireBlock.onPlace`: a fire inside an empty obsidian frame (in the diff --git a/crates/horizon-world/src/level/mod.rs b/crates/horizon-world/src/level/mod.rs index c7d4db8fcb..c988538363 100644 --- a/crates/horizon-world/src/level/mod.rs +++ b/crates/horizon-world/src/level/mod.rs @@ -802,6 +802,15 @@ impl Behaviours { ) { self.sim(level, ticks).entity_inside(pos); } + /// `LilyPadBlock.entityInside` for a boat whose box is inside `pos`. + pub fn boat_inside( + &self, + level: &mut L, + ticks: &mut Ticks, + pos: BlockPos, + ) { + self.sim(level, ticks).boat_inside(pos); + } /// `DaylightDetectorBlock.tickEntity` of the daylight detector at `pos`: /// its signal follows the sky every 20 game ticks. The detector's ticker /// exists only where the dimension has sky light. From c61911ffcd233f7a13151080656b11bc568f3f5e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:40:30 +0100 Subject: [PATCH 119/968] Play the dried ghast sound when it takes water A dried ghast filled with water made no place-in-water sound. Vanilla DriedGhastBlock.placeLiquid (DriedGhastBlock.java:174) plays DRIED_GHAST_PLACE_IN_WATER after scheduling the fluid tick. --- crates/horizon-world/src/level/fluids.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/horizon-world/src/level/fluids.rs b/crates/horizon-world/src/level/fluids.rs index 47558e52df..bf265f711c 100644 --- a/crates/horizon-world/src/level/fluids.rs +++ b/crates/horizon-world/src/level/fluids.rs @@ -240,6 +240,15 @@ impl Sim<'_, L> { self.set_block_and_update(pos, next); let delay = self.fluid_tick_delay(fluid.kind()); self.schedule_fluid_tick(pos, fluid.kind(), delay); + // DriedGhastBlock.placeLiquid: its own place-in-water sound. + if data.is(Class::DriedGhast) { + self.emit(super::LevelEvent::Sound { + pos, + sound: "minecraft:block.dried_ghast.place_in_water".into(), + volume: 1.0, + pitch: 1.0, + }); + } true } /// `BucketPickup.pickupBlock`: true when a fluid was taken. From 68cfbd61dad401e59e4441adb23b04cd395f8ddf Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:44:52 +0100 Subject: [PATCH 120/968] Make dolphins flee guardians and ignore their hits Dolphins ignored guardians and took their hits as attacks; Dolphin.registerGoals (Dolphin.java:158) adds AvoidEntity for Guardian and HurtByTargetGoal ignoring Guardian. --- crates/horizon-client/src/mobs/aquatic.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index a5e2bee3cd..8da82d463a 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -135,7 +135,23 @@ pub fn register(m: &mut Mob) { g.add(5, LookAtPlayer::new(6.0)); g.add(5, super::dolphin::Jump::new(10)); g.add(6, MeleeAttack::new(1.2, true)); - m.ai.targets.add(1, HurtByTarget::new().alert()); + // `Dolphin.registerGoals`: AvoidEntityGoal(Guardian.class, 8, 1, 1). + g.add( + 9, + AvoidEntity::new( + Avoid::Species(vec![S::Guardian, S::ElderGuardian]), + 8.0, + 1.0, + 1.0, + ), + ); + // `HurtByTargetGoal(this, Guardian.class)`: guardians' hits are ignored. + m.ai.targets.add( + 1, + HurtByTarget::new() + .alert() + .ignoring(&[S::Guardian, S::ElderGuardian]), + ); m.ai.mov.kind = MoveKind::SmoothSwimming { max_turn_x: 85.0, max_turn_y: 10.0, From 17383e6fe0bbba6c1225821f9ba864319605eb5c Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:45:31 +0100 Subject: [PATCH 121/968] Hold items in an allay's own hand transform Allay items were placed with the humanoid hand offset; AllayModel.translateToHand (AllayModel.java:120) uses the body, the right arm's pitch, a 0.7 scale and a shift. --- crates/horizon-client/src/mobs/render.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 29d299bf88..3024ced6a0 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1537,6 +1537,24 @@ pub fn part_matrix( .iter() .find_map(|c| part_matrix(c, m, poses, name, x_offset)) } +fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> { + if part.name == name { + return Some(part); + } + part.children.iter().find_map(|c| find_part(c, name)) +} +/// `AllayModel.translateToHand`: the root and body, then the right arm's +/// pitch, a 0.7 scale and a shift. Both hands use the right arm's pitch. +fn allay_hand(model: &Part, base: glam::Mat4, poses: &Poses) -> Option { + let body = part_matrix(model, base, poses, "body", 0.0)?; + let x_rot = poses.pose(find_part(model, "right_arm")?)[3]; + Some( + body * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.0625, 0.1875)) + * glam::Mat4::from_rotation_x(x_rot) + * glam::Mat4::from_scale(glam::Vec3::splat(0.7)) + * glam::Mat4::from_translation(glam::Vec3::new(0.0625, 0.0, 0.0)), + ) +} /// The hand of a mob in model space, up to the item's own transform /// (`ItemInHandLayer.submitArmWithItem`). fn hand_base( @@ -1564,7 +1582,11 @@ fn hand_base( } else { 0.0 }; - let mut t = part_matrix(model, base, poses, arm, offset)?; + let mut t = if m.species() == Species::Allay { + allay_hand(model, base, poses)? + } else { + part_matrix(model, base, poses, arm, offset)? + }; if m.species() == Species::Vex { // `VexModel.translateToHand`: the held item is drawn at 0.55 scale. t *= glam::Mat4::from_scale(glam::Vec3::splat(0.55)); From 8aeb55f0cff5853683059d67a1b53eeeffcda220 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:41:18 +0100 Subject: [PATCH 122/968] Apply x_rotation and y_rotation ranges in entity selectors Selectors parsed the rotation ranges but never filtered on them, so @e[y_rotation=...] matched regardless of facing. EntitySelectorParser.createRotationPredicate (EntitySelectorParser.java:188) checks the wrapped pitch and yaw. --- crates/horizon-client/src/game/commands.rs | 36 ++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 861ade06d2..fe5ad12f8b 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -87,6 +87,30 @@ fn java_float(v: f64) -> String { fn format_double(v: f64) -> String { format!("{v:.6}") } +/// `EntitySelectorParser.createRotationPredicate`: the wrapped angle lies in +/// the wrapped range, which runs through 180 when its minimum is above its +/// maximum (defaults 0 and 359). +fn rotation_in((lo, hi): (Option, Option), angle: f32) -> bool { + let min = wrap_degrees(lo.unwrap_or(0.0) as f32); + let max = wrap_degrees(hi.unwrap_or(359.0) as f32); + let angle = wrap_degrees(angle); + if min > max { + angle >= min || angle <= max + } else { + angle >= min && angle <= max + } +} +/// `Mth.wrapDegrees`. +fn wrap_degrees(angle: f32) -> f32 { + let wrapped = angle % 360.0; + if wrapped >= 180.0 { + wrapped - 360.0 + } else if wrapped < -180.0 { + wrapped + 360.0 + } else { + wrapped + } +} impl Game<'_> { /// Commands.performCommand for a line without its slash. @@ -655,6 +679,18 @@ impl Game<'_> { { return None; } + // x_rotation is the pitch, y_rotation the yaw (getXRot, getYRot). + let [yaw, pitch] = self.target_rotation(t); + if let Some(range) = s.x_rotation + && !rotation_in(range, pitch) + { + return None; + } + if let Some(range) = s.y_rotation + && !rotation_in(range, yaw) + { + return None; + } if s.delta.iter().any(Option::is_some) { let d = s.delta.map(|v| v.unwrap_or(0.0)); for i in 0..3 { From 925580fe3da7d411140dcc2e7c7c4684c6959beb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:42:14 +0100 Subject: [PATCH 123/968] Name the mob a renamed spawn egg spawns A spawn egg renamed in an anvil spawned an unnamed mob; the stack's custom name was never applied. Entity.applyImplicitComponents (Entity.java:4281) sets the custom name from the item on spawn. --- crates/horizon-client/src/game/creatures.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index c323e7bf50..22e3c88921 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -4390,8 +4390,14 @@ impl Game<'_> { f64::from(p[1]), f64::from(p[2]) + 0.5, ]; - if self.spawn_mob_spawned(&kind, at).is_none() { + let Some(mob_id) = self.spawn_mob_spawned(&kind, at) else { return Ok(false); + }; + // Entity.applyImplicitComponents: a name from an anvil names the mob. + if let Some(name) = stack.custom_name() + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob_id) + { + m.custom_name = Some(name.to_owned()); } self.swing_arm(); if !self.creative From 6ec46d1a8176bcbabf23633332f77d5c6e850fa0 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:44:08 +0100 Subject: [PATCH 124/968] Pick the piston a piston head belongs to Pick block on a piston head gave nothing, since the head has no item form. PistonHeadBlock.getCloneItemStack (PistonHeadBlock.java:116) gives a sticky or plain piston by the head's type. --- crates/horizon-client/src/game/block_items.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs index 80e75c5206..4ffbe03e9c 100644 --- a/crates/horizon-client/src/game/block_items.rs +++ b/crates/horizon-client/src/game/block_items.rs @@ -42,10 +42,22 @@ impl Game<'_> { let key = (pos[0], pos[1], pos[2]); let state = self.world.block(pos[0], pos[1], pos[2]); let facts = self.res.facts().get(state); - if &*facts.item == "minecraft:air" { + // PistonHeadBlock.getCloneItemStack: the piston the head belongs to. + let head = self + .pack + .assets + .registry + .state(state) + .filter(|s| s.name.as_str() == "minecraft:piston_head") + .map(|s| match s.properties.get("type") { + Some(t) if t == "sticky" => "minecraft:sticky_piston", + _ => "minecraft:piston", + }); + let item = head.unwrap_or(&*facts.item); + if item == "minecraft:air" { return None; } - let mut stack = ItemStack::new(&facts.item, 1); + let mut stack = ItemStack::new(item, 1); let mut patch = Compound::new(); // BeehiveBlock.getCloneItemStack: the honey level as the item's block state. if include_data From dc5695dfa7d16181c1b95b3849d47dedfc7a8328 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:45:44 +0100 Subject: [PATCH 125/968] Mark worlds another running copy has open as locked The world list showed a world open in another instance as an ordinary world. DirectoryLock.isLocked (DirectoryLock.java:66) and LevelSummary.createInfo (LevelSummary.java:154) show it as Locked in red and it cannot be joined. --- crates/horizon-client/src/save.rs | 13 +++++++++++++ crates/horizon-client/src/title.rs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 09024ad697..d111466d30 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -155,6 +155,19 @@ impl SessionLock { file.flush()?; Ok(Self(file)) } + /// `DirectoryLock.isLocked`: another copy holds the world's `session.lock`. + /// A world without the file is not locked; one whose file may not be opened + /// is. + pub fn is_locked(world: &Path) -> bool { + let file = match fs::OpenOptions::new() + .write(true) + .open(world.join("session.lock")) + { + Ok(file) => file, + Err(e) => return e.kind() == std::io::ErrorKind::PermissionDenied, + }; + matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock)) + } } /// Bring a world last saved by an older game version to the current format, as /// 26.3 does when it opens one: the file fixes and level.dat first diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index c38b35504d..a1802d3aa1 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -91,6 +91,8 @@ pub struct WorldEntry { pub modified: std::time::SystemTime, pub commands: bool, pub version: String, + /// `LevelSummary.isLocked`: another running copy has the world open. + pub locked: bool, } #[derive(Clone, Debug)] pub enum Mode { @@ -251,6 +253,7 @@ pub fn list_worlds(saves: &Path) -> Vec { version: data.get("Version").and_then(|v| v.get("Name")) .and_then(|v| match v { horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()), _ => None }) .unwrap_or_else(|| lang::get_or("selectWorld.versionUnknown", "Unknown")), + locked: crate::save::SessionLock::is_locked(&path), }) }) .collect(); @@ -260,6 +263,16 @@ pub fn list_worlds(saves: &Path) -> Vec { /// LevelSummary.createInfo: a hardcore world's mode in red (CommonColors.RED), /// else `gameMode.` + its GameType name, then commands and the version. fn world_info(world: &WorldEntry) -> crate::text::Text { + // LevelSummary.createInfo: a locked world says so in red (ChatFormatting.RED). + if world.locked { + return crate::text::Text::colored( + lang::get_or( + "selectWorld.locked", + "Locked by another running instance of Minecraft.", + ), + 0xFF5555, + ); + } let mode = if world.hardcore { crate::text::Text::colored(lang::get_or("gameMode.hardcore", "Hardcore Mode"), 0xFF0000) } else { @@ -645,6 +658,10 @@ impl Title { /// (`createWorldAccess`); then `openWorldCheckVersionCompatibility`: a world /// whose file fixes are due asks for a backup first; any other opens at once. fn play(&mut self, world: WorldEntry) -> Action { + // LevelSummary.primaryActionActive: a world another copy has open cannot be joined. + if world.locked { + return Action::None; + } if crate::save::SessionLock::acquire(&self.saves.join(&world.folder)).is_err() { self.toast = Some(( crate::toasts::Slide::default(), From c97f4da629e033139e5f559494b67914f9509009 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:47:22 +0100 Subject: [PATCH 126/968] Make allays pick up only the items they can see An allay walked to a matching item behind a wall. NearestItemSensor.doTick (NearestItemSensor.java:24) keeps only items the mob has a line of sight to. --- crates/horizon-client/src/game/allays.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index 6173265231..7c7e48e522 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -43,14 +43,34 @@ impl Game<'_> { .items() .get(&held) .map_or(64, |i| i.max_stack as i32); - // GoToWantedItem / wantsToPickUp: the same item within 32. + // GoToWantedItem / wantsToPickUp: the same item within 32 that the + // allay sees (NearestItemSensor's hasLineOfSight, eye to eye). let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 { + let w = self.mob_world_base(&[]); + let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z); + // The sensor looks in the allay's box grown by (32, 16, 32). + let b = self.mobs[i].bounds(); + let (low, high) = (b.min_position().y - 16.0, b.max_position().y + 16.0); self.dropped .iter() .enumerate() .filter(|(_, e)| e.stack.item == held && e.pickup_delay == 0) .map(|(j, e)| (j, e.position.distance_squared(pos))) - .filter(|(_, d)| *d <= 32.0 * 32.0) + .filter(|(_, d)| *d < 32.0 * 32.0) + .filter(|(j, _)| { + let y = self.dropped[*j].position.y; + y <= high && y + 0.25 >= low + }) + .filter(|(j, _)| { + // ItemEntity's eye height is 0.2125. + let e = &self.dropped[*j]; + let at = horizon_core::geometry::Vec3::new( + e.position.x, + e.position.y + 0.2125, + e.position.z, + ); + w.line_of_sight(eye, at) + }) .min_by(|a, b| a.1.total_cmp(&b.1)) } else { None From c8477304a36e6bd5605848f3714ff7a48c008098 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:48:59 +0100 Subject: [PATCH 127/968] Let mobs slide down honey blocks Mobs fell through honey blocks; only the player slid. HoneyBlock.entityInside (HoneyBlock.java:75) slows any living entity beside the block, with the slide sound and the entity event 53 particles. --- crates/horizon-client/src/game/lan.rs | 5 ++ crates/horizon-client/src/mobs/ai.rs | 69 ++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50c1b2f21d..8fde7e7161 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1877,6 +1877,11 @@ impl Game<'_> { if event == 21 && self.options.execution_role.is_client() { self.start_guardian_sound(id as u32); } + // Event 53: a mob sliding down a honey block sheds honey particles + // (HoneyBlock.showSlideParticles), drawn by the client that runs the world. + if event == 53 && self.options.execution_role.is_client() { + self.honey_slide_particles(at); + } } /// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's /// emitter, and an animation for the attacker's client and observers once diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index dc93e2b662..b316a910c4 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -517,7 +517,7 @@ pub fn tick( } else { ai_step(m, w, level_random, events); } - effects_from_blocks(m, w); + effects_from_blocks(m, w, level_random, events); if m.species() == Species::Rabbit { super::rabbit::post_tick(m); } @@ -554,12 +554,18 @@ pub fn tick( true } /// `LivingEntity.aiStep` -> `Entity.applyEffectsFromBlocks` for the blocks -/// that hurt or hold a mob: the cactus (`CactusBlock.entityInside`, 1), the -/// cobweb (stuck at 0.25, 0.05, 0.25) and the sweet berry bush +/// that hurt, hold or slide a mob: the cactus (`CactusBlock.entityInside`, 1), +/// the cobweb (stuck at 0.25, 0.05, 0.25), the sweet berry bush /// (`SweetBerryBushBlock.entityInside`: stuck at 0.8, 0.75, 0.8, and a grown bush pricks for 1 when the mob moved at least 0.003 on X -/// or Z this tick). The blocks are those the mob's box touches anywhere along -/// its move this tick, from where it started to where it ended. -fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { +/// or Z this tick) and the honey block (`honey_slide`). The blocks are those +/// the mob's box touches anywhere along its move this tick, from where it +/// started to where it ended. +fn effects_from_blocks( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { let mut visited: Vec<[i32; 3]> = Vec::new(); let travel = m.position.subtract(m.previous); let longest = travel.x.abs().max(travel.y.abs()).max(travel.z.abs()); @@ -613,6 +619,7 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { m.damage(1.0, Damage::of(DamageKind::SweetBerryBush)); } } + "minecraft:honey_block" => honey_slide(m, p, level_random, events), _ => continue, } visited.push(p); @@ -621,6 +628,56 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) { } } } +/// `HoneyBlock.entityInside` for a mob: a falling mob beside the honey block at +/// `p` slides down it (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects`). +fn honey_slide( + m: &mut Mob, + p: [i32; 3], + level_random: &mut LegacyRandom, + events: &mut Vec, +) { + let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08; + let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32); + let [x, y, z] = p.map(f64::from); + let pos = m.position; + if m.on_ground || pos.y > y + 0.9375 - 1.0e-7 || old_delta_y(m.velocity.y) >= -0.08 { + return; + } + let dx = (x + 0.5 - pos.x).abs(); + let dz = (z + 0.5 - pos.z).abs(); + let overlap = 0.4375 + f64::from(m.width()) / 2.0; + if dx + 1.0e-7 <= overlap && dz + 1.0e-7 <= overlap { + return; + } + // doSlideMovement + let v = m.velocity; + let old = old_delta_y(v.y); + m.velocity = if old < -0.13 { + let factor = -0.05 / old; + Vec3::new(v.x * factor, new_delta_y(-0.05), v.z * factor) + } else { + Vec3::new(v.x, new_delta_y(-0.05), v.z) + }; + m.fall_distance = 0.0; + // maybeDoSlideEffects: the slide sound (one time in five, unless silent), + // then the server's entity event 53 (one time in five). + let sound = level_random.next_int_bound(5).unwrap_or(1) == 0; + if sound && !m.silent { + events.push(MobEvent::Sound( + "block.honey_block.slide", + [pos.x, pos.y, pos.z], + 1.0, + 1.0, + )); + } + if level_random.next_int_bound(5).unwrap_or(1) == 0 { + events.push(MobEvent::EntityEvent { + mob: m.id, + event: 53, + at: [pos.x, pos.y, pos.z], + }); + } +} /// LivingEntity.updateSwingTime. pub fn tick_swing(m: &mut Mob) { let duration = m.swing_duration(); From 739185e2c9355d4463ab0ba7c2ec0526f92123b3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:49:29 +0100 Subject: [PATCH 128/968] Make piglins walk only to wanted items they can see Piglins took the nearest wanted item within 9 blocks even behind a wall. NearestItemSensor.doTick (NearestItemSensor.java:24) takes the nearest visible one within 32, then GoToWantedItem walks to it within 9 (PiglinAi). --- crates/horizon-client/src/game/piglins.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/piglins.rs b/crates/horizon-client/src/game/piglins.rs index edfd9a3a07..7a177768cf 100644 --- a/crates/horizon-client/src/game/piglins.rs +++ b/crates/horizon-client/src/game/piglins.rs @@ -200,14 +200,25 @@ impl Game<'_> { continue; } let pos = m.position; + // NearestItemSensor: the nearest wanted item within 32 that the piglin + // sees (eye to eye); GoToWantedItem then walks to it within 9 blocks. + let w = self.mob_world_base(&[]); + let eye = Vec3::new(pos.x, m.eye_y(), pos.z); let best = self .dropped .iter() .enumerate() .map(|(j, e)| (j, e.position.distance_squared(pos))) - .filter(|(_, d)| *d <= 81.0) + .filter(|(_, d)| *d < 32.0 * 32.0) .filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack)) - .min_by(|a, b| a.1.total_cmp(&b.1)); + .filter(|&(j, _)| { + // ItemEntity's eye height is 0.2125. + let e = &self.dropped[j]; + let at = Vec3::new(e.position.x, e.position.y + 0.2125, e.position.z); + w.line_of_sight(eye, at) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .filter(|(_, d)| *d <= 81.0); let Some((j, _)) = best else { self.mobs[i].extra.point = None; continue; From 54bb09a261df8430f0617bab55a86a7d430c2a85 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:50:45 +0100 Subject: [PATCH 129/968] Name an armor stand placed from a renamed item A renamed armor stand item placed an unnamed stand. EntityType.createDefaultStackConfig (EntityType.java:171) applies the stack's components, custom name included, through Entity.applyImplicitComponents (Entity.java:4281). --- crates/horizon-client/src/game/armor_stands.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 4d8a9fd31a..6a315e70bc 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -336,7 +336,11 @@ impl Game<'_> { ); let yaw = (((self.yaw - 180.0 + 180.0).rem_euclid(360.0) - 180.0 + 22.5) / 45.0).floor() * 45.0; - let stand = ArmorStand::new(self.next_entity + 1, foot, yaw); + let mut stand = ArmorStand::new(self.next_entity + 1, foot, yaw); + // Entity.applyImplicitComponents: a name from an anvil names the stand. + if let Some(name) = stack.custom_name() { + stand.custom_name = Some(horizon_client::text::Text::literal(name)); + } let b = stand.bounds(); // Touching the floor is not a collision. let (lo, hi) = (b.min_position(), b.max_position()); From 7f19d9f95bb345f4c76e97a4ebbbb40b0e6df9d5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:56:49 +0100 Subject: [PATCH 130/968] Name mobs and armor stands a dispenser places from a renamed item The dispenser paths for spawn eggs and armor stands skipped the stack's custom name that the hand-placed paths now apply. Entity.applyImplicitComponents (Entity.java:4281) names the entity on every spawn from an item. --- crates/horizon-client/src/game/dispenser.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index f973bfd56d..40d3606fc2 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -908,6 +908,12 @@ impl Game<'_> { let Some(id) = self.spawn_mob_at(&kind, front, facing != Direction::Up) else { return Ok((Some(stack), CLICK)); }; + // Entity.applyImplicitComponents: a name from an anvil names the mob. + if let Some(name) = stack.custom_name() + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.custom_name = Some(name.to_owned()); + } self.mob_ambient(id)?; // SpawnEggItemBehavior: the entity was placed by the dispenser. self.post_block_game_event("minecraft:entity_place", [pos.0, pos.1, pos.2], None, None); @@ -1051,8 +1057,12 @@ impl Game<'_> { // EntityType.create rolls a yaw from the level random before the stack config turns it. let _ = self.world.random.next_float(); self.next_entity += 1; - let stand = + let mut stand = horizon_client::entities::ArmorStand::new(self.next_entity, foot, y_rot(facing)); + // Entity.applyImplicitComponents: a name from an anvil names the stand. + if let Some(name) = stack.custom_name() { + stand.custom_name = Some(horizon_client::text::Text::literal(name)); + } self.world.modified.insert((front.0 >> 4, front.2 >> 4)); self.armor_stands.push(stand); (shrink(stack), CLICK) From fecb4044855511f22acda4634ad1b47df7226260 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:47:38 +0100 Subject: [PATCH 131/968] Hide saddle reins on a horse nobody rides A saddled horse with no rider drew its left and right saddle lines. EquineSaddleModel.setupAnim (EquineSaddleModel.java:64) shows the riding parts only while the horse is ridden. --- crates/horizon-client/src/game/remote_vehicles.rs | 5 +++++ crates/horizon-client/src/mobs/anim.rs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 5ece948c0f..fbfb75e471 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -108,6 +108,11 @@ impl Game<'_> { }; let vehicle_id = vehicle as u32; for m in &mut self.mobs { + // `Entity.isVehicle` (the saddle reins of a ridden horse): the + // server sends no rider on the mob itself, only this list. + if m.id == vehicle_id { + m.extra.rider = passengers.first().map(|&p| p as u32); + } if m.extra.mount == Some(vehicle_id) && !passengers.contains(&(m.id as i32)) { m.extra.mount = None; m.extra.passenger = false; diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index 25fe9801d7..e10e9365db 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -861,6 +861,11 @@ pub fn setup( p.visible(part, false); } } + // EquineSaddleModel.setupAnim: the reins show only while ridden + // (`isRidden` is `Entity.isVehicle`: a player or a mob rides it). + for part in ["left_saddle_line", "right_saddle_line"] { + p.visible(part, m.rider.is_some() || m.extra.rider.is_some()); + } } Anim::Hoglin => super::piglin::hoglin_anim(m, s, p), Anim::Blaze => super::nether::blaze_anim(s, p), From 1f21c74b0151117a95cadb3914392191639943df Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:47:38 +0100 Subject: [PATCH 132/968] Hide bogged mushrooms once it is sheared A sheared bogged kept its mushrooms on the head. BoggedModel.setupAnim (BoggedModel.java:62) sets the mushrooms part visible only while the bogged is not sheared. --- crates/horizon-client/src/game/lan.rs | 8 ++++++++ crates/horizon-client/src/game/remote_mob_data.rs | 2 ++ crates/horizon-client/src/mobs/anim.rs | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 8fde7e7161..54bb555f4c 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -812,6 +812,14 @@ fn mob_meta( } defaults.push((16, serializer::BOOLEAN, Value::Bool(false))); } + // Bogged.DATA_SHEARED (the first entry after Mob's): the mushrooms + // on its head are gone. + "minecraft:bogged" => { + if e.sheared { + meta.push((16, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((16, serializer::BOOLEAN, Value::Bool(false))); + } // AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two). "minecraft:wandering_trader" => unhappy_counter(m, meta, defaults), // Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 5cb862b651..03a2486f70 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -119,6 +119,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti } // SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on. (Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0, + // Bogged.DATA_SHEARED: the mushrooms on its head are gone. + (Species::Bogged, 16, Value::Bool(sheared)) => m.extra.sheared = *sheared, // MushroomCow.DATA_TYPE: 0 red, 1 brown. (Species::Mooshroom, 17, Value::Int(variant)) => { m.extra.variant = (*variant == 1) as u8 diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index e10e9365db..3b4f1ebd95 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -840,6 +840,10 @@ pub fn setup( bob(p, "right_arm", s.age, 1.0); bob(p, "left_arm", s.age, -1.0); } + // BoggedModel.setupAnim: the mushrooms on the head come off once sheared. + if m.species() == super::Species::Bogged { + p.visible("mushrooms", !m.extra.sheared); + } } Anim::Witch => super::witch::anim(m, s, p), Anim::Villager => super::villager::anim(m, s, p), From 0a353d57c92b29e432e64ed3c8d26fa2b7a3852b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:51:08 +0100 Subject: [PATCH 133/968] Chime amethyst blocks when a projectile hits them A projectile striking an amethyst block played nothing; vanilla plays block.amethyst_block.chime at the block with a random pitch (AmethystBlock.java:18). --- crates/horizon-client/src/game/creatures.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 22e3c88921..774b5b5081 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2667,6 +2667,23 @@ impl Game<'_> { } } } + /// `AmethystBlock.onProjectileHit`: a projectile that strikes an amethyst + /// block rings `block.amethyst_block.chime` there, pitched by the level random. + fn amethyst_projectile_hit(&mut self, pos: [i32; 3]) { + let state = self.world.block(pos[0], pos[1], pos[2]); + if &*self.res.facts().get(state).name != "minecraft:amethyst_block" { + return; + } + let pitch = 0.5 + self.world.random.next_float() * 1.2; + self.level_events + .push(horizon_world::level::LevelEvent::Sound { + pos: BlockPos::new(pos[0], pos[1], pos[2]), + sound: "minecraft:block.amethyst_block.chime".into(), + volume: 1.0, + pitch, + }); + let _ = self.process_level_events(); + } /// `onProjectileHit` of chorus flowers and decorated pots: an impact /// projectile (`#impact_projectiles`) breaks the block when /// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes @@ -2878,6 +2895,7 @@ impl Game<'_> { && face != [0; 3] { self.bell_projectile_hit(p, pos, at, face); + self.amethyst_projectile_hit(pos); self.projectile_breaks_block(p, pos); } self.combat.projectile_owner = match p.owner { From d77cca177ade994fd24a6f17561b93f453b8e210 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:54:21 +0100 Subject: [PATCH 134/968] Show the white trail of a spectral arrow in flight A spectral arrow in flight left no trail; vanilla adds a white spell particle each tick while it is not in the ground (SpectralArrow.java:34). --- .../examples/probe/arrow_probe.rs | 1 + crates/horizon-client/src/arrows.rs | 10 ++++++++++ crates/horizon-client/src/game/creatures.rs | 7 ++++++- .../src/game/remote_projectiles.rs | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/examples/probe/arrow_probe.rs b/crates/horizon-client/examples/probe/arrow_probe.rs index eca28f1f5d..27d855483c 100644 --- a/crates/horizon-client/examples/probe/arrow_probe.rs +++ b/crates/horizon-client/examples/probe/arrow_probe.rs @@ -48,6 +48,7 @@ fn particle(k: ArrowParticle) -> &'static str { ArrowParticle::Bubble => "bubble", ArrowParticle::Crit => "crit", ArrowParticle::Splash => "splash", + ArrowParticle::Spectral => "spectral", } } pub fn main() -> Result<()> { diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index c3269831f6..65320f262d 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -123,6 +123,8 @@ pub enum ArrowParticle { Bubble, Crit, Splash, + /// `SpectralArrow.tick`'s white spell particle in flight. + Spectral, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ArrowSound { @@ -627,6 +629,14 @@ impl Ticker<'_, '_, '_, '_> { self.p.position = end; self.apply_effects_from_blocks(original, end); } + // SpectralArrow.tick: after the move, while not in the ground. + if !self.st.in_ground && &*self.st.pickup_item.item == "minecraft:spectral_arrow" { + self.st.events.push(ArrowEvent::Particle { + kind: ArrowParticle::Spectral, + position: self.position(), + velocity: Vec3::ZERO, + }); + } self.finish(); hit } diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 774b5b5081..b515f77390 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2739,6 +2739,7 @@ impl Game<'_> { ArrowParticle::Bubble => ParticleType::Bubble, ArrowParticle::Crit => ParticleType::Crit, ArrowParticle::Splash => ParticleType::Splash, + ArrowParticle::Spectral => ParticleType::Effect, }; match event { ArrowEvent::Particle { @@ -2746,7 +2747,11 @@ impl Game<'_> { position, velocity, } => self.particles.add( - &ParticleOptions::simple(kind(k)), + &match k { + // SpellParticleOption.create(EFFECT, -1, 1.0F): white, power 1. + ArrowParticle::Spectral => ParticleOptions::colored(kind(k), [1.0; 4]), + _ => ParticleOptions::simple(kind(k)), + }, [position.x, position.y, position.z], [velocity.x, velocity.y, velocity.z], ), diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 409c28c88d..11be1e9b9f 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -253,6 +253,12 @@ impl Game<'_> { .llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]); } let mut p = Projectile::new(k, at, None); + // The type id is all a client knows of which arrow this is. + if kind == "minecraft:spectral_arrow" + && let Some(a) = p.arrow.as_mut() + { + a.set_item(&horizon_client::items::ItemStack::new(kind, 1)); + } p.id = id; p.velocity = velocity; p.yaw = yaw; @@ -479,6 +485,19 @@ impl Game<'_> { .map(|p| p.position) .collect(); self.smoke_trails(trails); + // SpectralArrow.tick on a client: a white spell particle each tick + // while the arrow is not in the ground. + let spectral = ParticleOptions::colored(ParticleType::Effect, [1.0; 4]); + for p in &self.creatures.projectiles { + if !p.stuck + && p.arrow + .as_ref() + .is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow") + { + let at = [p.position.x, p.position.y, p.position.z]; + self.particles.add(&spectral, at, [0.0; 3]); + } + } // FireworkRocketEntity.tick: a rocket attached to an entity follows it // and boosts it client-side too (attachedToEntity.isFallFlying), and // every rocket leaves its spark trail on a client. From f448660e4ce258d3886398828b8a7165fcd535d1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:57:56 +0100 Subject: [PATCH 135/968] Let rabbits, foxes and silverfish climb out of powder snow Mobs stuck in powder snow never jumped out: the goal was missing from their registrations. Vanilla ClimbOnTopOfPowderSnowGoal jumps when the block above is powder snow or open (ClimbOnTopOfPowderSnowGoal.java:23). --- crates/horizon-client/src/mobs/ai.rs | 2 +- crates/horizon-client/src/mobs/fox.rs | 7 +++-- crates/horizon-client/src/mobs/goals.rs | 37 +++++++++++++++++++++++ crates/horizon-client/src/mobs/hostile.rs | 5 +-- crates/horizon-client/src/mobs/rabbit.rs | 4 ++- 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index b316a910c4..55b91ef23a 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -883,7 +883,7 @@ fn freeze_tick(m: &mut Mob, w: &MobWorld) { } /// Whether the mob's box (shrunk by `1.0E-5`, as in `Entity.checkInsideBlocks`) /// touches powder snow. -fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool { +pub(super) fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool { let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5)); let (min, max) = (b.min_position(), b.max_position()); for x in min.x.floor() as i32..=max.x.floor() as i32 { diff --git a/crates/horizon-client/src/mobs/fox.rs b/crates/horizon-client/src/mobs/fox.rs index 7d4948c30d..f2fedfa80e 100644 --- a/crates/horizon-client/src/mobs/fox.rs +++ b/crates/horizon-client/src/mobs/fox.rs @@ -7,9 +7,9 @@ use super::{ Mob, MobEvent, MobWorld, Seen, Species, goals::{ - Avoid, AvoidEntity, Breed, Cx, Float, FollowParent, Goal, JUMP, LOOK, LeapAtTarget, - LookAtPlayer, MOVE, MeleeAttack, MoveToBlock, NearestTarget, Panic, Prey, Stroll, - goal_clone, hide_pos, reduced, + Avoid, AvoidEntity, Breed, ClimbOnTopOfPowderSnow, Cx, Float, FollowParent, Goal, JUMP, + LOOK, LeapAtTarget, LookAtPlayer, MOVE, MeleeAttack, MoveToBlock, NearestTarget, Panic, + Prey, Stroll, goal_clone, hide_pos, reduced, }, living::wrap, }; @@ -66,6 +66,7 @@ pub fn register(m: &mut Mob) { let countdown = m.random.next_int_bound(reduced(140)).unwrap_or(0); let g = &mut m.ai.goals; g.add(0, Wrap::new(Float).on_start(clear_states)); + g.add(0, ClimbOnTopOfPowderSnow); g.add(1, Faceplant { countdown: 0 }); g.add(2, Panic::new(2.2)); g.add(3, Wrap::new(Breed::new(1.0)).on_start(clear_states)); diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 0ac9b0cff2..70fdd0f63b 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -681,6 +681,43 @@ impl Goal for Float { } } } +/// `ClimbOnTopOfPowderSnowGoal`: a powder-snow walker standing in powder snow +/// jumps while the block above is powder snow or has no collision. +#[derive(Clone, Debug)] +pub struct ClimbOnTopOfPowderSnow; +impl Goal for ClimbOnTopOfPowderSnow { + goal_clone!(); + fn flags(&self) -> u8 { + JUMP + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // wasInPowderSnow || isInPowderSnow: the freeze tick has read the + // powder snow the mob's box touches before the goals run. + if !super::ai::in_powder_snow(m, cx.w) + || !cx + .w + .data + .entity_has("minecraft:powder_snow_walkable_mobs", &m.kind.id) + { + return false; + } + // Mob.blockPosition().above() + let above = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32 + 1, + m.position.z.floor() as i32, + ]; + cx.w.block_name(above[0], above[1], above[2]) == "minecraft:powder_snow" + || cx.w.collision_bounds(above).is_none() + } + fn every_tick(&self) -> bool { + true + } + /// `ClimbOnTopOfPowderSnowGoal.tick`: ask the mob to jump. + fn tick(&mut self, m: &mut Mob, _: &mut Cx) { + m.ai.jump = true; + } +} /// PanicGoal: flee to a random position after panic-causing damage (or /// towards water while burning). #[derive(Clone, Debug)] diff --git a/crates/horizon-client/src/mobs/hostile.rs b/crates/horizon-client/src/mobs/hostile.rs index 1e3df33a95..3ce92ad63d 100644 --- a/crates/horizon-client/src/mobs/hostile.rs +++ b/crates/horizon-client/src/mobs/hostile.rs @@ -3,8 +3,8 @@ use super::{ Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who, goals::{ - Avoid, AvoidEntity, Cx, Float, Goal, HurtByTarget, LeapAtTarget, LookAtPlayer, MOVE, - MeleeAttack, NearestTarget, RandomLookAround, Stroll, goal_clone, + Avoid, AvoidEntity, ClimbOnTopOfPowderSnow, Cx, Float, Goal, HurtByTarget, LeapAtTarget, + LookAtPlayer, MOVE, MeleeAttack, NearestTarget, RandomLookAround, Stroll, goal_clone, }, }; use horizon_core::random::RandomSource; @@ -57,6 +57,7 @@ pub fn register(m: &mut Mob) { S::Silverfish => { let g = &mut m.ai.goals; g.add(1, Float); + g.add(1, ClimbOnTopOfPowderSnow); g.add(3, SilverfishWakeUpFriends); g.add(4, MeleeAttack::new(1.0, false)); g.add(5, SilverfishMergeWithStone::default()); diff --git a/crates/horizon-client/src/mobs/rabbit.rs b/crates/horizon-client/src/mobs/rabbit.rs index 66ec891f90..3b71c7aa10 100644 --- a/crates/horizon-client/src/mobs/rabbit.rs +++ b/crates/horizon-client/src/mobs/rabbit.rs @@ -3,7 +3,8 @@ use super::{ Mob, MobEvent, MobWorld, Species, control::Operation, goals::{ - Avoid, AvoidEntity, Breed, Cx, Float, Goal, LookAtPlayer, Panic, Stroll, Tempt, goal_clone, + Avoid, AvoidEntity, Breed, ClimbOnTopOfPowderSnow, Cx, Float, Goal, LookAtPlayer, Panic, + Stroll, Tempt, goal_clone, }, }; use horizon_core::{ @@ -34,6 +35,7 @@ pub fn register(m: &mut Mob) { .collect::>(); let g = &mut m.ai.goals; g.add(1, Float); + g.add(1, ClimbOnTopOfPowderSnow); g.add(1, Panic::new(2.2)); g.add(2, Breed::new(0.8)); g.add(3, Tempt::new(1.0, food, false)); From 0db93570be403a9971b05317a2718389109ae020 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:38:28 +0100 Subject: [PATCH 136/968] Apply spectator fog to powder snow Spectators saw normal powder snow fog; vanilla PowderedSnowFogEnvironment.setupFog uses start -8 and half the render distance for spectators. --- crates/horizon-client-render/src/fog.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client-render/src/fog.rs b/crates/horizon-client-render/src/fog.rs index e8ef5106e1..1bd769001a 100644 --- a/crates/horizon-client-render/src/fog.rs +++ b/crates/horizon-client-render/src/fog.rs @@ -311,10 +311,14 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog fog.cloud_end = fog.environmental_end; } FogType::PowderSnow => { - fog.environmental_start = 0.0; - fog.environmental_end = 2.0; - fog.sky_end = 2.0; - fog.cloud_end = 2.0; + // PowderedSnowFogEnvironment.setupFog. + (fog.environmental_start, fog.environmental_end) = if input.spectator { + (-8.0, rd * 0.5) + } else { + (0.0, 2.0) + }; + fog.sky_end = fog.environmental_end; + fog.cloud_end = fog.environmental_end; } _ if input.blindness.is_some() => { let (duration, infinite) = input.blindness.unwrap_or((0, true)); From 0c9e6293ee1c4ffbc21295ba786548ece0b99b44 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:42:19 +0100 Subject: [PATCH 137/968] Roll stuck arrows while their shake timer runs A stuck arrow kept a fixed model pose; vanilla ArrowModel.setupAnim (ArrowRenderer) rolls the root by -sin(shake*3)*shake degrees while shakeTime lasts. --- crates/horizon-client/src/game/creatures.rs | 22 +++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index c323e7bf50..a4b978369a 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -21,7 +21,7 @@ use horizon_client::{ text::Text, }; use horizon_core::{ - geometry::{Aabb, Direction, Vec3, traverse_blocks}, + geometry::{Aabb, Direction, Vec3, traverse_blocks, trig::Mth}, position::BlockPos, random::RandomSource, }; @@ -4657,10 +4657,28 @@ impl Game<'_> { let m = glam::Mat4::from_translation(c) * glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians()) * glam::Mat4::from_rotation_z(a.pitch.to_radians()); + // ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim + // rolls the root while the shake timer runs. + let shake = a + .arrow + .as_ref() + .map_or(0.0, |s| s.shake as f32 - alpha as f32); + let roll = if shake > 0.0 { + -(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0) + } else { + 0.0 + }; + let pose_for = |part: &horizon_client::mobs::Part| { + let mut pose = part.pose; + if std::ptr::eq(part, model) { + pose[5] += roll; + } + pose + }; horizon_client::mobs::render_model( model, m, - &|part| part.pose, + &pose_for, [r.u0, r.v0, r.u1, r.v1], packed, 0, From 69d060efe6e8145698d34d169ede178e715d1076 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:42:20 +0100 Subject: [PATCH 138/968] Hang an allay's lead at 0.6 of its eye height Allay.getLeashOffset (Allay.java:484) puts the rope at 0.6 of the eye height and 0.1 of the width; the lead used the default offset. --- crates/horizon-client/src/mobs/leash.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/horizon-client/src/mobs/leash.rs b/crates/horizon-client/src/mobs/leash.rs index 3bd0513d79..3383ebd937 100644 --- a/crates/horizon-client/src/mobs/leash.rs +++ b/crates/horizon-client/src/mobs/leash.rs @@ -336,6 +336,9 @@ pub fn leash_offset(m: &Mob) -> [f64; 3] { if super::llama::is_llama(m.species()) { // Llama.getLeashOffset. [0.0, 0.75 * m.eye_height(), f64::from(m.width()) * 0.5] + } else if m.species() == Species::Allay { + // Allay.getLeashOffset. + [0.0, m.eye_height() * 0.6, f64::from(m.width()) * 0.1] } else { [0.0, m.eye_height(), f64::from(m.width() * 0.4)] } From 05595032585701db32537c53e832f0167c372056 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:44:40 +0100 Subject: [PATCH 139/968] Fire thrown-item pickup advancements for allay deliveries Taking an item an entity threw never fired thrown_item_picked_up_by_player and allay deliveries had no thrower. Vanilla ServerPlayer.onItemPickup and BehaviorUtils.throwItem (PickedUpItemTrigger) do both. --- crates/horizon-client/src/game.rs | 6 +++++ .../horizon-client/src/game/advancements.rs | 25 +++++++++++++++++++ crates/horizon-client/src/game/allays.rs | 7 +++++- crates/horizon-client/src/game/lan_actions.rs | 5 ++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..5a8c6d25f7 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2881,13 +2881,16 @@ impl Game<'_> { if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) { let before = item.stack.count; let kind = item.stack.item.clone(); + let thrower = item.thrower; match self.give(item.stack.clone()) { None => { self.stats.add("picked_up", &kind, before as i32); + let picked = item.stack.clone(); self.add_pickup( pickups::Taken::Item(Box::new(item)), pickups::Collector::Player, ); + self.trigger_thrown_pickup(thrower, &picked); let pitch = ((self.world.random.next_float() - self.world.random.next_float()) * 0.7 @@ -2905,6 +2908,9 @@ impl Game<'_> { pickups::Taken::Item(Box::new(item.clone())), pickups::Collector::Player, ); + // ItemEntity.playerTouch: the entity keeps what did not fit, + // and `onItemPickup` hands that stack to the trigger. + self.trigger_thrown_pickup(thrower, &rest); // handleTakeItemEntity: the random pitch of a full pickup. let pitch = ((self.world.random.next_float() - self.world.random.next_float()) diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index a85b99ac12..a16f884adc 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -1089,6 +1089,31 @@ impl Game<'_> { let it = item_view(stack); self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w)); } + /// `ServerPlayer.onItemPickup`: the acting player takes an item that an + /// entity threw (`ItemEntity.getOwner`), which fires + /// `thrown_item_picked_up_by_player` with that entity and the stack the + /// item entity holds. A thrower that is neither this player nor a mob in + /// the level (another player) fires nothing. + pub(super) fn trigger_thrown_pickup(&mut self, thrower: Option<[i32; 4]>, stack: &ItemStack) { + let Some(uuid) = thrower else { + return; + }; + let name = "minecraft:thrown_item_picked_up_by_player"; + if uuid == self.acting_player_uuid() { + let me = self.player_entity(); + self.trigger_item_entity(name, stack, &me); + return; + } + let Some(m) = self + .mobs + .iter() + .find(|m| self.persist.uuids.get(&m.id) == Some(&uuid)) + else { + return; + }; + let target = self.mob_entity(m); + self.trigger_item_entity(name, stack, &target); + } /// `ConstructBeaconTrigger`: the pyramid levels of the new beacon. pub(super) fn trigger_construct_beacon(&mut self, levels: i32) { let me = self.player_entity(); diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index 6173265231..ac72849a98 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -179,8 +179,13 @@ impl Game<'_> { let m = &mut self.mobs[i]; m.extra.ints[1] -= 1; m.extra.ints[3] = 60; - // ItemEntity.setDefaultPickUpDelay. + // ItemEntity.setDefaultPickUpDelay; BehaviorUtils.throwItem sets the allay as the thrower. self.spawn_item(ItemStack::new(&item, 1), from, v, 10); + let id = self.mobs[i].id; + let thrower = self.mob_uuid(id); + if let Some(e) = self.dropped.last_mut() { + e.thrower = Some(thrower); + } if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 { const PITCHES: [f32; 16] = [ 0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5, diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index e1c35a83c9..68e2feb918 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -3092,16 +3092,21 @@ impl Game<'_> { let mut taken = Vec::new(); for i in near.into_iter().rev() { let (stack, item_id) = (self.dropped[i].stack.clone(), self.dropped[i].id); + let thrower = self.dropped[i].thrower; let before = stack.count; + let whole = stack.clone(); let rest = self.as_guest(host, conn, |g| g.give(stack)).flatten(); match rest { None => { let item = self.dropped.remove(i); self.guest_pickup(host, id, item); + // ServerPlayer.onItemPickup: the stack the entity held. + self.as_guest(host, conn, |g| g.trigger_thrown_pickup(thrower, &whole)); taken.push((item_id, before)); } Some(rest) if rest.count < before => { self.guest_pickup(host, id, self.dropped[i].clone()); + self.as_guest(host, conn, |g| g.trigger_thrown_pickup(thrower, &rest)); self.dropped[i].stack = rest; taken.push((item_id, before - self.dropped[i].stack.count)); } From 6fb33260aef8de3f849b1b7cbe107917aa99bed6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:45:55 +0100 Subject: [PATCH 140/968] Let empty buckets scoop bubble columns An empty bucket on a bubble column did nothing; vanilla BubbleColumnBlock.pickupBlock removes the column and returns a water bucket. --- crates/horizon-world/src/level/buckets.rs | 2 ++ crates/horizon-world/src/level/fluids.rs | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/crates/horizon-world/src/level/buckets.rs b/crates/horizon-world/src/level/buckets.rs index 5bffd2e57e..219142f9fe 100644 --- a/crates/horizon-world/src/level/buckets.rs +++ b/crates/horizon-world/src/level/buckets.rs @@ -43,6 +43,8 @@ impl Sim<'_, L> { "minecraft:water_bucket" } else if b.is_block(state, b.named.powder_snow) { "minecraft:powder_snow_bucket" + } else if b.is_block(state, b.named.bubble_column) { + "minecraft:water_bucket" } else { return None; }; diff --git a/crates/horizon-world/src/level/fluids.rs b/crates/horizon-world/src/level/fluids.rs index 47558e52df..adb5058e3b 100644 --- a/crates/horizon-world/src/level/fluids.rs +++ b/crates/horizon-world/src/level/fluids.rs @@ -264,6 +264,11 @@ impl Sim<'_, L> { } return false; } + if b.is_block(state, b.named.bubble_column) { + // BubbleColumnBlock.pickupBlock: the column is removed, the bucket is water. + self.set_block(pos, b.air(), 11); + return true; + } if b.is_block(state, b.named.powder_snow) { // PowderSnowBlock.pickupBlock: the block breaks with its particles and sound. self.set_block(pos, b.air(), 11); From 720381e3cfdebd4026b6d354132e86c2657ff081 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:47:12 +0100 Subject: [PATCH 141/968] Crack skulls, shulker boxes, bells, lecterns and statues while broken Only chests and banners showed the breaking overlay. Vanilla SkullBlockRenderer, ShulkerBoxRenderer, BellRenderer, LecternRenderer and CopperGolemStatueBlockRenderer submit their models for crumbling too. --- .../src/block_entity_render.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client-render/src/block_entity_render.rs b/crates/horizon-client-render/src/block_entity_render.rs index 913b2def9c..04aeafe7bb 100644 --- a/crates/horizon-client-render/src/block_entity_render.rs +++ b/crates/horizon-client-render/src/block_entity_render.rs @@ -1104,7 +1104,11 @@ impl BlockEntityRenderer { pub fn renders(&self, state: u32) -> bool { !matches!(self.kind(state), Kind::None) } - /// ChestRenderer and BannerRenderer submit their animated models for crumbling. + /// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit, + /// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit, + /// BellRenderer.submit, LecternRenderer.submit (the book) and + /// CopperGolemStatueBlockRenderer.submit: the model drawn again under the + /// destroy stage. pub fn render_breaking( &self, scene: &Scene, @@ -1113,7 +1117,16 @@ impl BlockEntityRenderer { stage: [f32; 2], out: &mut Vec, ) { - if !matches!(self.kind(state), Kind::Banner { .. } | Kind::Chest { .. }) { + if !matches!( + self.kind(state), + Kind::Banner { .. } + | Kind::Chest { .. } + | Kind::Skull { .. } + | Kind::Shulker { .. } + | Kind::Bell + | Kind::Lectern { .. } + | Kind::CopperGolem { .. } + ) { return; } let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) From e701c9f4582b2d4b7cb8499837e086914c29622a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:52:29 +0100 Subject: [PATCH 142/968] Trail tipped arrows with their potion colour Tipped arrows left no entity-effect particles in flight or stuck in a block. Vanilla Arrow.tick and Arrow.makeParticle draw two per tick in flight and one every fifth tick in the ground (Arrow.java). --- .../examples/probe/arrow_probe.rs | 3 ++ crates/horizon-client/src/arrows.rs | 37 +++++++++++++++++++ crates/horizon-client/src/game/creatures.rs | 9 +++++ 3 files changed, 49 insertions(+) diff --git a/crates/horizon-client/examples/probe/arrow_probe.rs b/crates/horizon-client/examples/probe/arrow_probe.rs index eca28f1f5d..fc0fd58c1d 100644 --- a/crates/horizon-client/examples/probe/arrow_probe.rs +++ b/crates/horizon-client/examples/probe/arrow_probe.rs @@ -153,6 +153,9 @@ pub fn main() -> Result<()> { bits(position), bits(velocity) )), + ArrowEvent::EffectParticle { color, position } => { + events.push(format!("effect {color:08x} {}", bits(position))) + } ArrowEvent::Sound { sound, volume, diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index c3269831f6..471d66b36a 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -94,6 +94,12 @@ pub enum ArrowEvent { position: Vec3, velocity: Vec3, }, + /// `Arrow.makeParticle`: an entity-effect particle in the potion's colour, + /// placed by the arrow's own random. + EffectParticle { + color: u32, + position: Vec3, + }, Sound { sound: ArrowSound, volume: f32, @@ -521,6 +527,31 @@ impl Ticker<'_, '_, '_, '_> { fn trident(&self) -> bool { matches!(self.p.kind, ProjectileKind::Trident) } + /// `Arrow.getColor`: the potion's colour, None for a plain arrow + /// (`PotionContents.EMPTY`). + fn effect_color(&self) -> Option { + let potion = self.st.pickup_item.potion()?; + Some(crate::brewing::potion_color(Some(potion))) + } + /// `Arrow.makeParticle`: `amount` entity-effect particles in the arrow's + /// box (`getRandomX(0.5)`, `getRandomY()`, `getRandomZ(0.5)`), none for a + /// plain arrow. + fn make_particle(&mut self, amount: u32) { + let Some(color) = self.effect_color() else { + return; + }; + for _ in 0..amount { + let at = self.position(); + let width = 0.5; + let rx = (2.0 * self.st.random.next_double() - 1.0) * 0.5; + let ry = self.st.random.next_double(); + let rz = (2.0 * self.st.random.next_double() - 1.0) * 0.5; + self.st.events.push(ArrowEvent::EffectParticle { + color, + position: Vec3::new(at.x + width * rx, at.y + 0.5 * ry, at.z + width * rz), + }); + } + } fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit { // ThrownTrident.tick. if self.trident() && self.st.in_ground_time > 4 { @@ -568,6 +599,10 @@ impl Ticker<'_, '_, '_, '_> { let at = self.position(); self.apply_effects_from_blocks(at, at); } + // Arrow.tick: one particle every fifth tick while it is in the ground. + if self.st.in_ground_time % 5 == 0 { + self.make_particle(1); + } return Hit::None; } self.st.in_ground_time = 0; @@ -628,6 +663,8 @@ impl Ticker<'_, '_, '_, '_> { self.apply_effects_from_blocks(original, end); } self.finish(); + // Arrow.tick: two particles per tick in flight. + self.make_particle(2); hit } /// The end of a tick in flight: drag and gravity, then `Projectile.tick` diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index a4b978369a..3f0501e88b 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2733,6 +2733,15 @@ impl Game<'_> { [position.x, position.y, position.z], [velocity.x, velocity.y, velocity.z], ), + ArrowEvent::EffectParticle { color, position } => { + // Arrow.makeParticle: an entity-effect particle in the potion's colour. + let c = |shift: u32| (color >> shift & 255) as f32 / 255.0; + self.particles.add( + &ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0]), + [position.x, position.y, position.z], + [0.0; 3], + ); + } ArrowEvent::Sound { sound, volume, From 0b2e9da8549e499cfced367f6f802c76ca90321b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:52:42 +0100 Subject: [PATCH 143/968] Burst tipped arrows and drop the potion after 600 ticks A tipped arrow left stuck for 600 ticks kept its potion and never burst. Vanilla Arrow.tick sends entity event 0 (20 particles in the potion colour) and sets a plain arrow as the pickup item. --- crates/horizon-client/src/arrows.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 471d66b36a..51627d1389 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -603,6 +603,13 @@ impl Ticker<'_, '_, '_, '_> { if self.st.in_ground_time % 5 == 0 { self.make_particle(1); } + // Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends + // entity event 0 (the client's burst of 20 particles in its colour, + // handleEntityEvent) and turns into a plain arrow. + if self.st.in_ground_time >= 600 && self.effect_color().is_some() { + self.make_particle(20); + self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1); + } return Hit::None; } self.st.in_ground_time = 0; From 0d598edee8df67b8fbaaf0e3b55e350e7f3902de Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:07:12 +0100 Subject: [PATCH 144/968] Draw tipped arrow particles and the stuck-arrow shake on the client side The window and LAN guests are clients of the server thread, so the arrow effects added for Arrow.tick, Arrow.handleEntityEvent and ArrowModel.setupAnim never reached them: the server's arrow drew its particles into a world nobody sees, and a client's copy of an arrow never held a shake timer. The server no longer draws or randomises Arrow.makeParticle (it is the client's `Level.isClientSide` branch). It sends Arrow.ID_EFFECT_COLOR with a tipped arrow's entity data and entity event 0 when the arrow has been stuck for 600 ticks. A client ticks the particles itself (two in flight, one every fifth tick in a block, the client's own random) and bursts twenty on event 0. A client's arrow now starts its shake when it lands (AbstractArrow .onSyncedDataUpdated) and runs it down each tick, so the roll shows. --- .../examples/probe/arrow_probe.rs | 4 +- crates/horizon-client/src/arrows.rs | 67 +++++++-------- crates/horizon-client/src/game/creatures.rs | 12 +-- .../horizon-client/src/game/lan_entities.rs | 17 +++- crates/horizon-client/src/game/remote.rs | 4 + .../src/game/remote_projectiles.rs | 81 +++++++++++++++++++ 6 files changed, 134 insertions(+), 51 deletions(-) diff --git a/crates/horizon-client/examples/probe/arrow_probe.rs b/crates/horizon-client/examples/probe/arrow_probe.rs index fc0fd58c1d..ccd3c38b19 100644 --- a/crates/horizon-client/examples/probe/arrow_probe.rs +++ b/crates/horizon-client/examples/probe/arrow_probe.rs @@ -153,8 +153,8 @@ pub fn main() -> Result<()> { bits(position), bits(velocity) )), - ArrowEvent::EffectParticle { color, position } => { - events.push(format!("effect {color:08x} {}", bits(position))) + ArrowEvent::PotionPuff { id, color } => { + events.push(format!("potion-puff {id} {color:08x}")) } ArrowEvent::Sound { sound, diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 51627d1389..9ccd40a494 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -94,11 +94,12 @@ pub enum ArrowEvent { position: Vec3, velocity: Vec3, }, - /// `Arrow.makeParticle`: an entity-effect particle in the potion's colour, - /// placed by the arrow's own random. - EffectParticle { + /// `Level.broadcastEntityEvent(arrow, 0)`: a tipped arrow stuck for 600 + /// ticks. The clients burst its particles (`Arrow.handleEntityEvent`); + /// the arrow itself is a plain one from now on. + PotionPuff { + id: u32, color: u32, - position: Vec3, }, Sound { sound: ArrowSound, @@ -162,6 +163,9 @@ pub struct ArrowState { /// Ticks stuck (`life`): gone at 1200. pub life: i32, pub shake: i32, + /// `Arrow.ID_EFFECT_COLOR` as a client was sent it (a server's arrow + /// reads its potion instead), None for a plain arrow. + pub synced_color: Option, /// The block it stuck in (`lastState`). pub last_state: Option, pub pickup: Pickup, @@ -209,12 +213,14 @@ impl PartialEq for ArrowState { } } impl ArrowState { + /// `AbstractArrow`'s fields at spawn, with its own `Entity.random`. pub fn new(random: LegacyRandom) -> Self { Self { in_ground: false, in_ground_time: 0, life: 0, shake: 0, + synced_color: None, last_state: None, pickup: Pickup::Disallowed, pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1), @@ -235,6 +241,14 @@ impl ArrowState { events: Vec::new(), } } + /// `Arrow.getColor`: the potion's colour, None for a plain arrow + /// (`PotionContents.EMPTY`). + pub fn effect_color(&self) -> Option { + self.synced_color.or_else(|| { + let potion = self.pickup_item.potion()?; + Some(crate::brewing::potion_color(Some(potion))) + }) + } /// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo /// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`) /// makes the arrow CREATIVE_ONLY; vanilla takes the component off the @@ -527,31 +541,6 @@ impl Ticker<'_, '_, '_, '_> { fn trident(&self) -> bool { matches!(self.p.kind, ProjectileKind::Trident) } - /// `Arrow.getColor`: the potion's colour, None for a plain arrow - /// (`PotionContents.EMPTY`). - fn effect_color(&self) -> Option { - let potion = self.st.pickup_item.potion()?; - Some(crate::brewing::potion_color(Some(potion))) - } - /// `Arrow.makeParticle`: `amount` entity-effect particles in the arrow's - /// box (`getRandomX(0.5)`, `getRandomY()`, `getRandomZ(0.5)`), none for a - /// plain arrow. - fn make_particle(&mut self, amount: u32) { - let Some(color) = self.effect_color() else { - return; - }; - for _ in 0..amount { - let at = self.position(); - let width = 0.5; - let rx = (2.0 * self.st.random.next_double() - 1.0) * 0.5; - let ry = self.st.random.next_double(); - let rz = (2.0 * self.st.random.next_double() - 1.0) * 0.5; - self.st.events.push(ArrowEvent::EffectParticle { - color, - position: Vec3::new(at.x + width * rx, at.y + 0.5 * ry, at.z + width * rz), - }); - } - } fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit { // ThrownTrident.tick. if self.trident() && self.st.in_ground_time > 4 { @@ -599,15 +588,17 @@ impl Ticker<'_, '_, '_, '_> { let at = self.position(); self.apply_effects_from_blocks(at, at); } - // Arrow.tick: one particle every fifth tick while it is in the ground. - if self.st.in_ground_time % 5 == 0 { - self.make_particle(1); - } // Arrow.tick on the server: a tipped arrow stuck for 600 ticks sends - // entity event 0 (the client's burst of 20 particles in its colour, - // handleEntityEvent) and turns into a plain arrow. - if self.st.in_ground_time >= 600 && self.effect_color().is_some() { - self.make_particle(20); + // entity event 0 (the clients' burst of particles in its colour, + // `handleEntityEvent`) and turns into a plain arrow. The flight + // and ground particles are the clients' own (`Level.isClientSide`). + if self.st.in_ground_time >= 600 + && let Some(color) = self.st.effect_color() + { + self.st.events.push(ArrowEvent::PotionPuff { + id: self.p.id, + color, + }); self.st.pickup_item = crate::items::ItemStack::new("minecraft:arrow", 1); } return Hit::None; @@ -670,8 +661,6 @@ impl Ticker<'_, '_, '_, '_> { self.apply_effects_from_blocks(original, end); } self.finish(); - // Arrow.tick: two particles per tick in flight. - self.make_particle(2); hit } /// The end of a tick in flight: drag and gravity, then `Projectile.tick` diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3f0501e88b..60575fd589 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2733,14 +2733,10 @@ impl Game<'_> { [position.x, position.y, position.z], [velocity.x, velocity.y, velocity.z], ), - ArrowEvent::EffectParticle { color, position } => { - // Arrow.makeParticle: an entity-effect particle in the potion's colour. - let c = |shift: u32| (color >> shift & 255) as f32 / 255.0; - self.particles.add( - &ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0]), - [position.x, position.y, position.z], - [0.0; 3], - ); + // Arrow.tick: `broadcastEntityEvent(this, 0)`. The window and the + // guests burst the particles themselves (`remote_arrow_puff`). + ArrowEvent::PotionPuff { id, .. } => { + self.lan_entity_event(id as i32, 0, [at.x, at.y, at.z]); } ArrowEvent::Sound { sound, diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 21a5d74b41..ddcb5d569c 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -416,6 +416,7 @@ impl Game<'_> { a.is_some_and(|a| a.no_physics), a.map_or(0, |a| a.pierce), a.is_some_and(|a| a.in_ground) || p.stuck, + a.and_then(|a| a.effect_color()), )); } // ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on @@ -427,6 +428,7 @@ impl Game<'_> { a.is_some_and(|a| a.no_physics), 0, a.is_some_and(|a| a.in_ground) || p.stuck, + None, )); if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) { e.meta.push((12, serializer::BOOLEAN, Value::Bool(true))); @@ -470,7 +472,7 @@ impl Game<'_> { Some(g) => g.entity_id, None => owner(Some(horizon_client::mobs::Who::Player)), }; - e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground); + e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground, None); // ThrownTrident.ID_LOYALTY and ID_FOIL. if t.loyalty != 0 { e.meta @@ -573,12 +575,15 @@ impl Game<'_> { } /// `AbstractArrow`'s ID_FLAGS (critical 1, no physics 2), PIERCE_LEVEL and -/// IN_GROUND, when they differ from the defaults. +/// IN_GROUND, and `Arrow.ID_EFFECT_COLOR` of a tipped arrow, when they differ +/// from the defaults. The colour is not sent back to -1 once the arrow turns +/// plain: the clients end it on entity event 0. fn arrow_meta( critical: bool, no_physics: bool, pierce: u8, in_ground: bool, + color: Option, ) -> Vec<(u8, i32, Value)> { let mut meta = Vec::new(); let flags = i8::from(critical) | i8::from(no_physics) << 1; @@ -591,6 +596,14 @@ fn arrow_meta( if in_ground { meta.push((10, serializer::BOOLEAN, Value::Bool(true))); } + if let Some(color) = color { + // PotionContents.getColor is opaque ARGB. + meta.push(( + 11, + serializer::INT, + Value::Int((0xFF00_0000 | color) as i32), + )); + } meta } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index a88570f380..ed78a28b6c 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2480,6 +2480,10 @@ impl Game<'_> { self.remote_firework_explosion(p.id); } self.remote_fangs_event(p.id as u32, p.event); + // Arrow.handleEntityEvent 0: a tipped arrow's burst of particles. + if p.event == 0 { + self.remote_arrow_puff(p.id as u32); + } // LivingEntity.handleEntityEvent 2: a spear's hit felt. if p.event == 2 { self.remote_kinetic_hit(p.id); diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 409c28c88d..88b3d1d149 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -113,6 +113,11 @@ fn projectile_kind(kind: &str) -> Option { fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) { p.previous = p.position; p.age += 1; + // The shake timer runs down, and `inGroundTime` counts the ticks in a block. + if let Some(a) = p.arrow.as_mut() { + a.shake = (a.shake - 1).max(0); + a.in_ground_time = if p.stuck { a.in_ground_time + 1 } else { 0 }; + } if p.stuck { return; } @@ -125,6 +130,12 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) { p.position = at; p.velocity = Vec3::ZERO; p.stuck = true; + // AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true starts the shake. + if let Some(a) = p.arrow.as_mut() + && a.shake <= 0 + { + a.shake = 7; + } return; } p.position = to; @@ -163,6 +174,40 @@ impl Game<'_> { } } + /// `Arrow.makeParticle`: `amount` entity-effect particles in the potion's + /// colour inside the arrow's 0.5 box (`getRandomX(0.5)`, `getRandomY()`, + /// `getRandomZ(0.5)`), placed by the client's random. + pub(super) fn arrow_effect_particles(&mut self, at: Vec3, color: u32, amount: u32) { + let c = |shift: u32| (color >> shift & 255) as f32 / 255.0; + let options = + ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0]); + for _ in 0..amount { + let r = self.particles.random(); + let x = (2.0 * r.next_double() - 1.0) * 0.5; + let y = r.next_double(); + let z = (2.0 * r.next_double() - 1.0) * 0.5; + self.particles.add( + &options, + [at.x + 0.5 * x, at.y + 0.5 * y, at.z + 0.5 * z], + [0.0; 3], + ); + } + } + + /// `Arrow.handleEntityEvent(0)`: a tipped arrow stuck for 600 ticks bursts + /// into twenty particles in its colour. The server turns it into a plain + /// arrow (and does not send the colour again), so it stops here. + pub(super) fn remote_arrow_puff(&mut self, id: u32) { + let Some(p) = self.creatures.projectiles.iter_mut().find(|p| p.id == id) else { + return; + }; + let Some(color) = p.arrow.as_mut().and_then(|a| a.synced_color.take()) else { + return; + }; + let at = p.position; + self.arrow_effect_particles(at, color, 20); + } + /// `handleAddEntity` for these types; false when `kind` isn't one. pub(super) fn spawn_remote_projectile( &mut self, @@ -368,8 +413,24 @@ impl Game<'_> { *critical = flags & 1 != 0; } (ProjectileKind::Arrow { .. }, 10, Value::Bool(in_ground)) => { + // AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true after + // the first tick starts the shake. + if *in_ground + && !p.stuck + && p.age > 0 + && let Some(a) = p.arrow.as_mut() + && a.shake <= 0 + { + a.shake = 7; + } p.stuck = *in_ground; } + // Arrow.ID_EFFECT_COLOR: -1 for a plain arrow. + (ProjectileKind::Arrow { .. }, 11, Value::Int(color)) => { + if let Some(a) = p.arrow.as_mut() { + a.synced_color = (*color != -1).then_some(*color as u32); + } + } // WitherSkull.DATA_DANGEROUS. (ProjectileKind::WitherSkull { dangerous }, 8, Value::Bool(d)) => { *dangerous = *d; @@ -471,6 +532,26 @@ impl Game<'_> { } self.creatures.projectiles = projectiles; self.tridents = tridents; + // Arrow.tick on a client: a tipped arrow's particles, two per tick in + // flight and one every fifth tick in a block. + let puffs: Vec<(Vec3, u32, u32)> = self + .creatures + .projectiles + .iter() + .filter_map(|p| { + let a = p.arrow.as_deref()?; + let color = a.effect_color()?; + let amount = if p.stuck { + u32::from(a.in_ground_time % 5 == 0) + } else { + 2 + }; + (amount > 0).then_some((p.position, color, amount)) + }) + .collect(); + for (at, color, amount) in puffs { + self.arrow_effect_particles(at, color, amount); + } let trails: Vec = self .creatures .projectiles From ed279f27d54acd819123ddadd62b12098567f6b3 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:44:36 +0100 Subject: [PATCH 145/968] Draw the leather overlay on worn armour and armour stands EquipmentLayerRenderer.renderLayers draws every layer of an equipment asset; only layer 0 was loaded, so leather armour lost its overlay layer (leather.json, EquipmentLayerRenderer.java:51). --- .../horizon-client/src/game/armor_stands.rs | 90 +++++++++++-------- .../src/game/entity_model_loading_tests.rs | 2 +- crates/horizon-client/src/game/perspective.rs | 7 +- 3 files changed, 58 insertions(+), 41 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 4d8a9fd31a..944e7ceea0 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -39,8 +39,8 @@ pub(super) struct ArmorStandAssets { pub layers: HashMap<(&'static str, bool), Part>, /// The player's armour models by slot (helmet, chestplate, leggings, boots). pub player: HashMap<&'static str, Part>, - /// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour). - pub equipment: HashMap)>>, + /// asset id -> layer kind (humanoid / humanoid_leggings) -> each layer's (texture, undyed colour), in order. + pub equipment: HashMap)>>>, /// asset id -> `trim_overrides`. pub trim_overrides: HashMap>, pub trims: TrimRegistry, @@ -84,18 +84,24 @@ impl ArmorStandAssets { let Ok(v) = v else { continue }; let mut kinds = HashMap::new(); for kind in ["humanoid", "humanoid_leggings", "wings"] { - if let Some(layer) = v["layers"][kind].get(0) { - let tex = layer["texture"] - .as_str() - .unwrap_or(name) - .trim_start_matches("minecraft:"); - let undyed = layer["dyeable"]["color_when_undyed"] - .as_i64() - .map(|c| c as u32 & 0xFF_FFFF); - kinds.insert( - kind.to_owned(), - (format!("entity/equipment/{kind}/{tex}"), undyed), - ); + // Every layer of the kind: leather's overlay is layer 1. + let layers: Vec<(String, Option)> = v["layers"][kind] + .as_array() + .into_iter() + .flatten() + .map(|layer| { + let tex = layer["texture"] + .as_str() + .unwrap_or(name) + .trim_start_matches("minecraft:"); + let undyed = layer["dyeable"]["color_when_undyed"] + .as_i64() + .map(|c| c as u32 & 0xFF_FFFF); + (format!("entity/equipment/{kind}/{tex}"), undyed) + }) + .collect(); + if !layers.is_empty() { + kinds.insert(kind.to_owned(), layers); } } trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v)); @@ -190,19 +196,21 @@ impl Game<'_> { } else { "humanoid" }; - let Some((texture, undyed)) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) - else { + let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else { continue; }; - let (Some(r), Some(part)) = ( - self.gui.atlas.sprites.get(texture.as_str()), - assets.player.get(layer), - ) else { + let Some(part) = assets.player.get(layer) else { continue; }; - let color = layer_color(*undyed, stack); - out.push((part, [r.u0, r.v0, r.u1, r.v1], color)); - // EquipmentLayerRenderer: the trim layer over the armour. + // EquipmentLayerRenderer.renderLayers: every layer of the asset, + // the leather overlay too, then the trim layer over the armour. + for (texture, undyed) in layers { + let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else { + continue; + }; + let color = layer_color(*undyed, stack); + out.push((part, [r.u0, r.v0, r.u1, r.v1], color)); + } if let Some(r) = stack .and_then(|s| assets.trim_texture(&asset, kind, s)) .and_then(|t| self.gui.atlas.sprites.get(&t.sprite())) @@ -725,16 +733,12 @@ impl Game<'_> { } else { "humanoid" }; - let Some((texture, undyed)) = - assets.equipment.get(&asset).and_then(|k| k.get(kind)) - else { + let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else { continue; }; - let (Some(uv), Some(part)) = (uv_of(texture), assets.layers.get(&(layer, a.small))) - else { + let Some(part) = assets.layers.get(&(layer, a.small)) else { continue; }; - let color = layer_color(*undyed, Some(stack)); // ArmorStandRenderer's HumanoidArmorLayer: the armour glint // when `stack.hasFoil()`, as on a living entity. let item_glint = self @@ -752,16 +756,24 @@ impl Game<'_> { // ArmorStandArmorModel.setupAnim: the same pose as the stand. let mut layer_poses = Poses::new(part); apply_pose(&mut layer_poses, &a.pose); - horizon_client::mobs::render::draw( - part, - base, - &layer_poses, - uv, - color, - packed, - overlay, - out, - ); + // EquipmentLayerRenderer: every layer of the asset, the leather overlay + // too; the shader glint goes on the first layer only (renderShaderGlint). + for (i, (texture, undyed)) in layers.iter().enumerate() { + let Some(uv) = uv_of(texture) else { + continue; + }; + let color = layer_color(*undyed, Some(stack)); + horizon_client::mobs::render::draw( + part, + base, + &layer_poses, + uv, + color, + packed, + if i == 0 { overlay } else { 0 }, + out, + ); + } // EquipmentLayerRenderer: the trim layer over the armour. if let Some(trim) = assets .trim_texture(&asset, kind, stack) diff --git a/crates/horizon-client/src/game/entity_model_loading_tests.rs b/crates/horizon-client/src/game/entity_model_loading_tests.rs index 61ed814912..a00ab96abb 100644 --- a/crates/horizon-client/src/game/entity_model_loading_tests.rs +++ b/crates/horizon-client/src/game/entity_model_loading_tests.rs @@ -219,7 +219,7 @@ fn armor_equipment_loads_without_an_entity_model_document() { assert!(assets.layers.is_empty()); assert_eq!( assets.equipment["minecraft:leather"]["humanoid"], - ("entity/equipment/humanoid/leather".into(), Some(0x123456)) + vec![("entity/equipment/humanoid/leather".into(), Some(0x123456))] ); assert!(!assets.equipment.contains_key("minecraft:invalid")); } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 42f568eddd..e0cbd4d6bd 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -679,7 +679,12 @@ impl Game<'_> { return; }; let assets = &self.armor_stand_assets; - let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else { + let Some((texture, _)) = assets + .equipment + .get(asset) + .and_then(|k| k.get("wings")) + .and_then(|layers| layers.first()) + else { return; }; // WingsLayer.getPlayerElytraTexture: a player's cape texture has its elytra. From 8d3541a364f73f8582b94603578d58781e692eba Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:45:10 +0100 Subject: [PATCH 146/968] Save and load a mooshroom's colour under Type MushroomCow.addAdditionalSaveData and readAdditionalSaveData store the colour as Type; only foxes wrote it, so a brown mooshroom reloaded red (MushroomCow.java:214, :221). --- crates/horizon-client/src/entity_nbt.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index cca903fd1a..cb20e4afb1 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -324,6 +324,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { ); } match id { + // MushroomCow.addAdditionalSaveData: the colour under Type. + "minecraft:mooshroom" => { + let kind = if m.extra.variant == 1 { "brown" } else { "red" }; + map.insert("Type".into(), string(kind)); + } // Fox.addAdditionalSaveData; Horizon models only the type. "minecraft:fox" => { let kind = if m.extra.variant == 1 { "snow" } else { "red" }; @@ -1182,6 +1187,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - crate::mobs::Species::Fox => { m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow")); } + crate::mobs::Species::Mooshroom => { + // MushroomCow.readAdditionalSaveData: an unknown Type is red. + m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown")); + } crate::mobs::Species::Rabbit => { // Rabbit.Variant.byId: an unknown id is brown. m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) { From 1f9fdd9129429ffc3b7515fe12118e95305b0ea7 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:45:27 +0100 Subject: [PATCH 147/968] Pick a mooshroom calf's colour like MushroomCow A mooshroom calf took its first parent's colour every time; MushroomCow.getOffspringVariant gives either parent's colour, and the other one 1 in 1024 times when both match (MushroomCow.java:275). --- crates/horizon-client/src/game/creatures.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 60575fd589..1679e9ceb0 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2087,6 +2087,17 @@ impl Game<'_> { { child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant); } + // MushroomCow.getOffspringVariant: either parent's colour, and the + // other colour 1 time in 1024 when both parents have the same one. + if p.species() == Species::Mooshroom { + let mate = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant); + child.extra.variant = if coin { p.extra.variant } else { mate }; + if p.extra.variant == mate + && self.world.random.next_int_bound(1024).unwrap_or(1) == 0 + { + child.extra.variant = if p.extra.variant == 1 { 0 } else { 1 }; + } + } if p.species() == Species::Sheep { child.extra.color = p.extra.color; } From a8a6485243f45700b12c5a01d458fa166eba9d7a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:46:37 +0100 Subject: [PATCH 148/968] Send LAN guests the new difficulty on /difficulty MinecraftServer.setDifficulty sends ChangeDifficulty to every player; /difficulty changed only the host, so guests kept the old difficulty until they rejoined (MinecraftServer.java:1307). --- crates/horizon-client/src/game/commands.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 861ade06d2..efaa04481e 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -28,6 +28,7 @@ use horizon_client::{ text::{self, Style, Text}, }; use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_protocol::packets::play::{self, Clientbound as C}; use horizon_world::registry::BlockStateId; pub(super) type R = Result; @@ -2612,6 +2613,15 @@ impl Game<'_> { // Peaceful removes hostile mobs. self.mobs.retain(|m| !m.hostile()); } + // MinecraftServer.setDifficulty: every player, guests included, is + // sent the new difficulty. + if let Some(host) = self.lan.as_ref() { + host.hub + .broadcast(&C::ChangeDifficulty(play::ChangeDifficulty { + difficulty: i32::from(i), + locked: self.level.difficulty_locked, + })); + } self.feedback(Text::translate("commands.difficulty.success", &[name(i)])); Ok(0) } From e9808d0d134245eafbe5c69c5a72e62d43bbd3b5 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 19:46:55 +0100 Subject: [PATCH 149/968] Move online guests to the default mode when game mode is forced MinecraftServer.enforceGameTypeForPlayers sets every player to the server mode when it is forced; guests already online kept their own mode and only got the default at join (MinecraftServer.java:1412). --- crates/horizon-client/src/game/lan.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 50c1b2f21d..0774100bb0 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1359,6 +1359,25 @@ impl Game<'_> { host.settings.force_game_mode = force; host.hub.config.guest_commands = effective; host.hub.config.force_game_mode = force; + // MinecraftServer.enforceGameTypeForPlayers: the guests online + // take the default mode (`setGameMode` does nothing when it is + // already theirs). A world open to LAN is an IntegratedServer, + // whose `updateGameModeBasedOnPermission` leaves out the players + // who have game master permission; a dedicated server does not. + if force { + let mode = host.hub.config.default_game_mode; + let spares_operators = !host.hub.config.dedicated || host.hub.config.integrated; + let stale: Vec = host + .hub + .guests + .iter() + .filter(|g| g.game_mode != mode && !(spares_operators && g.permission >= 2)) + .map(|g| g.conn) + .collect(); + for conn in stale { + host.hub.set_game_mode(conn, mode); + } + } host.hub.resend_commands(); } } From f4c6f8dc29e8544a36c6bf4c322ef19f6ac8e808 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Thu, 29 Oct 2026 22:24:22 +0100 Subject: [PATCH 150/968] Skins and maps upload again after a resource pack reload Refs #3122, #3694 --- crates/horizon-client/src/game.rs | 5 +- crates/horizon-client/src/game/atlas_slots.rs | 73 +++++++++++++++++++ crates/horizon-client/src/game/maps.rs | 40 +++++----- crates/horizon-client/src/game/packs.rs | 4 + crates/horizon-client/src/game/remote.rs | 20 ++--- 5 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 crates/horizon-client/src/game/atlas_slots.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 431df41192..5331f8f6df 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6,6 +6,7 @@ mod agent; mod agent_settings; mod allays; mod armor_stands; +mod atlas_slots; mod banners; mod beacon; mod beds; @@ -1830,7 +1831,7 @@ struct Game<'a> { /// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads /// and mannequins) resolve through the services in the background. skin_profiles: RefCell, - skin_slots: Vec<(String, usize)>, + skin_slots: atlas_slots::AtlasSlots, /// Atlas trim slots in use: (sprite name, slot), least recently drawn first. trim_slots: Vec<(String, usize)>, cart_models: minecarts::CartModels, @@ -14464,7 +14465,7 @@ impl<'a> Game<'a> { .join("assets/skins"), )), skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()), - skin_slots: Vec::new(), + skin_slots: atlas_slots::AtlasSlots::new(horizon_client::hud::SKIN_SLOTS), trim_slots: Vec::new(), cart_models: prepared.cart_models, riding: None, diff --git a/crates/horizon-client/src/game/atlas_slots.rs b/crates/horizon-client/src/game/atlas_slots.rs new file mode 100644 index 0000000000..aaa32c5379 --- /dev/null +++ b/crates/horizon-client/src/game/atlas_slots.rs @@ -0,0 +1,73 @@ +//! The GUI atlas's fixed slots for runtime images (`horizon/skin{N}`, +//! `horizon/map{N}`): which image each slot holds, least recently shown +//! first. A new atlas (a resource pack reload) has every slot blank, so the +//! table is forgotten with it and each image uploads again. + +pub(super) struct AtlasSlots { + count: usize, + /// (image, slot), most recently shown last. + held: Vec<(K, usize)>, +} + +impl AtlasSlots { + pub(super) fn new(count: usize) -> Self { + Self { + count, + held: Vec::new(), + } + } + /// The slot holding `key`, marking it recently shown. + pub(super) fn show(&mut self, key: &Q) -> Option + where + K: std::borrow::Borrow, + { + let i = self.held.iter().position(|e| e.0.borrow() == key)?; + let e = self.held.remove(i); + let slot = e.1; + self.held.push(e); + Some(slot) + } + /// A slot for `key`: a free one, else the least recently shown, returned + /// with the image it held. + pub(super) fn claim(&mut self, key: K) -> (usize, Option) { + let (slot, evicted) = if self.held.len() < self.count { + (self.held.len(), None) + } else { + let (old, slot) = self.held.remove(0); + (slot, Some(old)) + }; + self.held.push((key, slot)); + (slot, evicted) + } + /// The atlas was replaced: every slot is blank. + pub(super) fn forget(&mut self) { + self.held.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_least_recently_shown_image_gives_up_its_slot() { + let mut slots = AtlasSlots::new(2); + assert_eq!(slots.claim("a"), (0, None)); + assert_eq!(slots.claim("b"), (1, None)); + assert_eq!(slots.show(&"a"), Some(0)); + assert_eq!(slots.claim("c"), (1, Some("b"))); + assert_eq!(slots.show(&"b"), None); + } + + #[test] + fn a_new_atlas_shows_no_stored_skin() { + // #3385: a skin stored before a pack reload must upload again, not + // name a sprite the new atlas does not have. + let mut slots = AtlasSlots::new(4); + slots.claim("skins/abc".to_string()); + assert_eq!(slots.show("skins/abc"), Some(0)); + slots.forget(); + assert_eq!(slots.show("skins/abc"), None); + assert_eq!(slots.claim("skins/abc".to_string()), (0, None)); + } +} diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index b6d0b604ea..eb819e3003 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -284,15 +284,24 @@ impl MapData { }) } } -#[derive(Default)] pub(super) struct Maps { pub data: HashMap, /// The highest map id handed out (`maps/last_id.dat`), loaded on first use. last_id: Option, - /// (map id, atlas slot), most recently shown last. - slots: Vec<(i32, usize)>, + /// The atlas slots holding map textures. + pub slots: super::atlas_slots::AtlasSlots, missing: std::collections::HashSet, } +impl Default for Maps { + fn default() -> Self { + Self { + data: HashMap::new(), + last_id: None, + slots: super::atlas_slots::AtlasSlots::new(MAP_SLOTS), + missing: Default::default(), + } + } +} /// `MapColor.getColorFromPackedId` as RGBA bytes (NONE is transparent). fn packed_rgba(packed: u8) -> [u8; 4] { let id = usize::from(packed >> 2); @@ -1180,23 +1189,16 @@ impl Game<'_> { /// The atlas slot showing map `id`, its texture uploaded when it changed. pub(super) fn map_texture(&mut self, id: i32) -> Option { self.map(id)?; - let slots = &mut self.maps.slots; - if let Some(i) = slots.iter().position(|e| e.0 == id) { - let e = slots.remove(i); - slots.push(e); - } else { - // A free slot, else the least recently shown one. - let slot = if slots.len() < MAP_SLOTS { - slots.len() - } else { - slots.remove(0).1 - }; - slots.push((id, slot)); - if let Some(m) = self.maps.data.get_mut(&id) { - m.texture_dirty = true; + let slot = match self.maps.slots.show(&id) { + Some(slot) => slot, + None => { + let (slot, _) = self.maps.slots.claim(id); + if let Some(m) = self.maps.data.get_mut(&id) { + m.texture_dirty = true; + } + slot } - } - let slot = self.maps.slots.last()?.1; + }; let rect = self .gui .atlas diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 2096e8c0e7..e13779e343 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -226,6 +226,10 @@ impl Game<'_> { let context = pack.context.clone(); self.pack = pack; self.gui = gui; + // The new atlas's skin and map slots are blank: players and maps + // upload into it again instead of naming sprites it does not have. + self.skin_slots.forget(); + self.maps.slots.forget(); // The atlas's sprites moved: the particle engine re-reads every rect // and the packs' sprite sets, or every particle draws a stale region. self.particles diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 82268627da..5f8bdf0e27 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3486,14 +3486,10 @@ impl Game<'_> { /// Puts a downloaded skin in an atlas skin slot: a free one, else the /// least recently shown. pub(super) fn store_skin(&mut self, location: String, rgba: Vec) { - use horizon_client::hud::SKIN_SLOTS; - let slot = if self.skin_slots.len() < SKIN_SLOTS { - self.skin_slots.len() - } else { - let (old, slot) = self.skin_slots.remove(0); + let (slot, evicted) = self.skin_slots.claim(location.clone()); + if let Some(old) = evicted { self.gui.atlas.sprites.remove(&old); - slot - }; + } let Some(mut rect) = self .gui .atlas @@ -3515,17 +3511,11 @@ impl Game<'_> { if location.starts_with("capes/") { rect.v1 = (rect.v0 + rect.v1) / 2.0; } - self.gui.atlas.sprites.insert(location.clone(), rect); - self.skin_slots.push((location, slot)); + self.gui.atlas.sprites.insert(location, rect); } /// Whether a downloaded skin is in the atlas, marking it recently shown. fn show_skin(&mut self, location: &str) -> bool { - let Some(i) = self.skin_slots.iter().position(|e| e.0 == location) else { - return false; - }; - let e = self.skin_slots.remove(i); - self.skin_slots.push(e); - true + self.skin_slots.show(location).is_some() } /// `show_skin`, or — its slot evicted — the image queued for upload again. fn again_skin(&mut self, location: &str) -> bool { From 80c9404cf44737c60f2cf5b1c0c8c87b074a4c96 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:24:24 +0500 Subject: [PATCH 151/968] Launcher: native mods' own crates, Horizon's lock as their allow list, post-build steps A bundle's [[crate]] (staged by horizon-mixins 0.6.0 as native-crates///) may use only the registry crates Horizon's Cargo.lock has: the build checks the staged lock's downloaded packages against Horizon's and fetches with --locked, saying plainly when a feature would add a crate. After the game compiles, each [[post_build]] bin is built with the same profile and run in the new version folder with HORIZON_NATIVE_{MOD,VERSION_DIR,DATA_DIR,CACHE_DIR}; a failure fails the build. Progress steps are translated. MIXINS_REV moves to horizon-mixins#5's head. A test bundle (launcher/tests/fixtures/native-crate-demo) uses all of it. Co-Authored-By: Claude Opus 5.5 --- docs/native-mods.md | 126 ++++++- launcher/lang/de.json | 10 +- launcher/lang/es.json | 10 +- launcher/lang/fr.json | 10 +- launcher/lang/ru.json | 10 +- launcher/lang/uk.json | 10 +- launcher/lang/zh.json | 10 +- launcher/src/native_mods.rs | 175 +++++++-- launcher/src/native_mods/crates.rs | 349 ++++++++++++++++++ .../native-crate-demo/data/greeting.txt | 1 + .../native-crate-demo/engine/Cargo.toml | 9 + .../native-crate-demo/engine/data/salt.txt | 1 + .../native-crate-demo/engine/src/bin/bake.rs | 22 ++ .../native-crate-demo/engine/src/lib.rs | 13 + .../tests/fixtures/native-crate-demo/frame.rs | 24 ++ .../native-crate-demo/horizon-mixin.toml | 25 ++ 16 files changed, 766 insertions(+), 39 deletions(-) create mode 100644 launcher/src/native_mods/crates.rs create mode 100644 launcher/tests/fixtures/native-crate-demo/data/greeting.txt create mode 100644 launcher/tests/fixtures/native-crate-demo/engine/Cargo.toml create mode 100644 launcher/tests/fixtures/native-crate-demo/engine/data/salt.txt create mode 100644 launcher/tests/fixtures/native-crate-demo/engine/src/bin/bake.rs create mode 100644 launcher/tests/fixtures/native-crate-demo/engine/src/lib.rs create mode 100644 launcher/tests/fixtures/native-crate-demo/frame.rs create mode 100644 launcher/tests/fixtures/native-crate-demo/horizon-mixin.toml diff --git a/docs/native-mods.md b/docs/native-mods.md index c63c8d3340..a830b82f9f 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -39,6 +39,106 @@ the game's items and recipes (`native_recipes`, `native_known_recipes`, `native_ recipe viewer needs, in builds with or without the `mods` feature. A WebAssembly mod's panel shows only while no native mod shows one. +## A bundle + +A bundle is a folder with a `horizon-mixin.toml`. Besides its helpers and modules (horizon-mixins' README) it can +have its own crate, data files and a post-build step (horizon-mixins 0.6.0). The test bundle +[`launcher/tests/fixtures/native-crate-demo`](../launcher/tests/fixtures/native-crate-demo) uses all of them: + +``` +native-crate-demo/ + horizon-mixin.toml + frame.rs # helper, compiled into the game + data/greeting.txt # the bundle's data: native_data!("crate_demo", "greeting.txt") + engine/ # the mod's own crate + Cargo.toml # [dependencies] crc32fast = "1" + data/salt.txt # the crate's data: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/salt.txt")) + src/lib.rs + src/bin/bake.rs # the post-build step +``` + +```toml +[mod] +id = "crate_demo" +version = "0.1.0" + +[[injection]] +id = "frame" +hook = "frame" # a name from native-hooks.json, instead of `target` +hook_version = 1 +kind = "HEAD" +helper = "frame.rs" +bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }] + +[[crate]] +id = "engine" +path = "engine" # holds Cargo.toml +# dependent = "horizon-client" (the default: the workspace package that depends on it) + +[[post_build]] +crate = "engine" +bin = "bake" +args = [] +``` + +### Named hook points + +`native-hooks.json` at the root of the source names each hook point and the version of its contract (signature, +when it is called, what it does): `{"name": "hud", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_hud"}`. +An injection names `hook` and `hook_version` instead of `target`, and horizon-mixins resolves it from the catalog +of the source it applies to, so a hook point can move in the source without breaking the mods that use it. A name +the catalog lacks is `E_HOOK_UNKNOWN` (the error lists the names it has), a version it lacks `E_HOOK_VERSION`. A +contract is never changed: an incompatible change adds a version. Helpers (`native_text`, `native_player`…) are not +in the catalog; they are kept stable as the hook points are. A test (`catalog_matches_hook_points`) keeps the +catalog and `native_hooks.rs` in step. + +### The mod's crate + +A `[[crate]]` is a Cargo package of the bundle's, staged as `native-crates///` and added to the +workspace's members and to the `[dependencies]` of its `dependent`, so helpers call it as +`crate_demo_engine::checksum(…)`. Cargo compiles it once and keeps it: editing a helper, or switching another mod +on or off, recompiles `horizon-client` but not the mod's crate, so the heavy code of a big mod belongs there and +only the glue in helpers. A crate can't depend on `horizon-client` (Cargo forbids the cycle) or on any path or git +crate, and has no `Cargo.lock`, `[workspace]` or build of its own. + +**Dependencies come from Horizon's `Cargo.lock`.** A crate names registry crates by version requirement +(`crc32fast = "1"`, `{ version, features, default-features, optional }`); each must already be in Horizon's lock +file in a version that matches, and builds in exactly that version. The lock file is the allow list: horizon-mixins +refuses a crate the lock lacks (`E_CRATE_DEPENDENCY`) and writes the mod's crates into the staged lock without +changing any other byte, so the game still builds with `--locked`. The launcher checks it again (every downloaded +package of the staged lock as in Horizon's: name, version, source, checksum), and a feature that turns on a +dependency the lock lacks fails at `cargo fetch --locked` with a message saying so. The build stops with: + +``` +horizon-mixins failed (exit status: 1). The end of its output, the whole of it in …/native/build.log: +E_CRATE_DEPENDENCY: crate_lock needs rand ^0.9, which isn't in Horizon's Cargo.lock (…/horizon-mixin.toml) +``` + +### Data files + +A bundle's `data/` folder (any files) is staged as `native-data//` at the root of the workspace. A helper or +module compiles a file in with `horizon_client::native_data!("", "path/in/data")` (`&'static [u8; N]`, +`crate::native_data!` inside the lib), since hook points API 3. A mod's crate keeps its own data in its folder and +reads it with `include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/…"))`. The registry's `.hnmod` may carry +both (`mixins//data/…`, `mixins///data/…`). + +### Post-build steps + +A `[[post_build]]` names a bin of one of the mod's crates. After the game compiles, the launcher builds it with the +same profile into the same `native/target/`, then runs it (at low priority, in mod order) in the new build's version +folder with: + +| Variable | What | +|---|---| +| `HORIZON_NATIVE_MOD` | the mod's id | +| `HORIZON_NATIVE_VERSION_DIR` | the new build's version folder (also the working directory) | +| `HORIZON_NATIVE_DATA_DIR` | `/native-data//`, created: what the game reads at run time with `native_built_data("")` (or `horizon_client::native_data::built`) | +| `HORIZON_NATIVE_CACHE_DIR` | `/native/cache//`, created, kept from one build to the next | + +Its output goes to `native/build.log`. When it fails, so does the build ("crate_demo's post-build step failed …") +and the version folder is removed. It runs on the player's computer like the rest of the mod's code: only the +launcher runs it, never horizon-mixins. + ## What the launcher does In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads @@ -67,6 +167,7 @@ trusting native code is the player's decision, never an agent's. 4. **Mixins.** The bundles are copied first, so the build and its key use that copy whatever happens to the folder meanwhile. `horizon-mixins apply` stages the modded source; the launcher copies only the files that changed into `native/stage/`, so Cargo, which goes by file times, recompiles only the crates the mods touch. + With mods' crates, their dependencies are checked against Horizon's `Cargo.lock` ("The mod's crate"). 5. **Compile**: `cargo build --locked --profile fast [--features horizon-client/mods] -p horizon-client`, into `native/target/`. `fast` is the workspace's own profile: the release's optimizations without its link-time ones (`lto = false`, `codegen-units = 16`, incremental), so a rebuild recompiles and links only what changed. @@ -77,7 +178,9 @@ trusting native code is the player's decision, never an agent's. on Linux, nice 19 on macOS, below-normal priority on Windows), so the desktop and a game in play stay smooth; on an otherwise idle computer they still use every core. The log is `native/build.log`; a failure shows its last lines (a horizon-mixins diagnostic in one line). -6. **Install**: a new version, `native--`, a copy of the starting version's folder (resources +6. **Post-build**: each mod's post-build program is built; it runs in the new version folder once that is copied + (next step), before the version counts as installed ("Post-build steps"). +7. **Install**: a new version, `native--`, a copy of the starting version's folder (resources included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json` saying which release or Nightly it started from (`base`) and which commit, mods, horizon-mixins and Rust made it. The instance the mods are installed in plays it, the default **Horizon** instance included: the build @@ -119,6 +222,16 @@ source downloaded first): | That mod switched on again | under 1 s: the kept build plays again | | Nothing changed, rebuild | 1 s, nothing compiled | +The test bundle `native-crate-demo` (its own crate on `crc32fast`, data files, the `frame` hook by name, a +post-build step), measured the same way on 2026-10-30 (Rust already downloaded): + +| What | Time | Compiled | +|---|---|---| +| First build (horizon-mixins, the crates, the whole game) | 3 min 27 s; 2 min 24 s compiling the game | 343 crates | +| A helper's code changed | 48 s (43 s compiling) | `horizon-client` only: the mod's crate is kept | +| The mod's crate changed | 11 s (6 s compiling) | `crate-demo-engine`, then `horizon-client` | +| Nothing changed | under 1 s | nothing | + The same set with the `release` profile took 7 min 6 s for the first build and 2 min 34 s per rebuild (2 min 28 s of compiling `horizon-client`, mostly its link-time optimization). In play, a `fast` game drew the same frames per second as the Nightly's own `release` build (vsync-bound, about 112 FPS at 120 Hz), with about 0.1 ms more CPU per @@ -163,7 +276,7 @@ each was built or last used): switching a mod off and on again plays the kept bu Horizon's own. - **The registry** (horizon-rust/horizon-mods) lists them as their own kind, `native-mod`, only after a maintainer has read the source: the `.hnmod` contains `.rs` and `.toml` files and may contain data files under - `mixins//data/`. The launcher checks the bundle without running anything. Native code is trusted. + `mixins//data/` or a crate's `data/`. The launcher checks the bundle without running anything. Native code is trusted. The file is pinned by SHA-256 like every registry file. An update is reviewed again; `revoked.toml` takes one down. A WebAssembly mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the @@ -185,12 +298,3 @@ each was built or last used): switching a mod off and on again plays the kept bu - Rebuild after the mods change, and offer **Update** when `update_for` finds a newer Horizon (asking first, as it compiles for minutes). - A prebuilt horizon-mixins from its CI instead of building it on the player's computer. - -## Named hook catalog (draft) - -`native-hooks.json` maps stable hook names and contract versions to the current -source targets. A contract includes the signature, call timing and behavior. -Keep an existing contract or add a new version when it changes incompatibly. -The catalog does not cover helper methods or arbitrary private game code. -Named injection resolution needs a matching horizon-mixins change; existing raw -targets and the `hooks` metadata continue to work as before. diff --git a/launcher/lang/de.json b/launcher/lang/de.json index c31dd48145..46d3eb6bbc 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -192,5 +192,13 @@ "Keep": "Behalten", "Move to Trash": "In den Papierkorb", "Install and turn on native mods": "Installieren und native Mods einschalten", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.", + "Downloading Rust {version} (once): {done}%": "Rust {version} wird heruntergeladen (einmalig): {done} %", + "Building horizon-mixins (once)": "horizon-mixins wird gebaut (einmalig)", + "Downloading Horizon's source at {revision}": "Horizons Quellcode bei {revision} wird heruntergeladen", + "Applying the mixins": "Mixins werden angewendet", + "Checking the mods' crates against Horizon's Cargo.lock": "Crates der Mods werden mit Horizons Cargo.lock abgeglichen", + "Compiling the game: {count} crates, now {name}": "Spiel wird kompiliert: {count} Crates, gerade {name}", + "Running {name}'s post-build step": "Post-Build-Schritt von {name} wird ausgeführt", + "Installing the build": "Build wird installiert" } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 4c350b3926..dc6c2722b3 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -192,5 +192,13 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.", "Install and turn on native mods": "Instalar y activar los mods nativos", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.", + "Downloading Rust {version} (once): {done}%": "Descargando Rust {version} (una sola vez): {done} %", + "Building horizon-mixins (once)": "Compilando horizon-mixins (una sola vez)", + "Downloading Horizon's source at {revision}": "Descargando el código fuente de Horizon en {revision}", + "Applying the mixins": "Aplicando los mixins", + "Checking the mods' crates against Horizon's Cargo.lock": "Comprobando los crates de los mods con el Cargo.lock de Horizon", + "Compiling the game: {count} crates, now {name}": "Compilando el juego: {count} crates, ahora {name}", + "Running {name}'s post-build step": "Ejecutando el paso posterior a la compilación de {name}", + "Installing the build": "Instalando la compilación" } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 9b69bd8420..8cba5334d2 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -192,5 +192,13 @@ "Delete…": "Supprimer…", "Keep": "Garder", "Move to Trash": "Mettre à la corbeille", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", + "Downloading Rust {version} (once): {done}%": "Téléchargement de Rust {version} (une seule fois) : {done} %", + "Building horizon-mixins (once)": "Compilation de horizon-mixins (une seule fois)", + "Downloading Horizon's source at {revision}": "Téléchargement du code source de Horizon à {revision}", + "Applying the mixins": "Application des mixins", + "Checking the mods' crates against Horizon's Cargo.lock": "Vérification des crates des mods avec le Cargo.lock de Horizon", + "Compiling the game: {count} crates, now {name}": "Compilation du jeu : {count} crates, en cours : {name}", + "Running {name}'s post-build step": "Exécution de l'étape post-compilation de {name}", + "Installing the build": "Installation de la version compilée" } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 5b1986d52a..d01e729bc5 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -192,5 +192,13 @@ "Delete…": "Удалить…", "Keep": "Оставить", "Move to Trash": "В корзину", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", + "Downloading Rust {version} (once): {done}%": "Загрузка Rust {version} (один раз): {done}%", + "Building horizon-mixins (once)": "Сборка horizon-mixins (один раз)", + "Downloading Horizon's source at {revision}": "Загрузка исходного кода Horizon на {revision}", + "Applying the mixins": "Применение миксинов", + "Checking the mods' crates against Horizon's Cargo.lock": "Проверка крейтов модов по Cargo.lock Horizon", + "Compiling the game: {count} crates, now {name}": "Компиляция игры: {count} крейтов, сейчас {name}", + "Running {name}'s post-build step": "Выполнение шага после сборки мода {name}", + "Installing the build": "Установка сборки" } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index cf4b3497d4..9536d878f8 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -192,5 +192,13 @@ "Keep": "Залишити", "Move to Trash": "Перемістити до кошика", "Install and turn on native mods": "Встановити й увімкнути нативні моди", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.", + "Downloading Rust {version} (once): {done}%": "Завантаження Rust {version} (один раз): {done}%", + "Building horizon-mixins (once)": "Збирання horizon-mixins (один раз)", + "Downloading Horizon's source at {revision}": "Завантаження вихідного коду Horizon на {revision}", + "Applying the mixins": "Застосування міксинів", + "Checking the mods' crates against Horizon's Cargo.lock": "Перевірка крейтів модів за Cargo.lock Horizon", + "Compiling the game: {count} crates, now {name}": "Компіляція гри: {count} крейтів, зараз {name}", + "Running {name}'s post-build step": "Виконання кроку після збирання мода {name}", + "Installing the build": "Встановлення збірки" } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index d0bc4fbbf7..2bb4729eff 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -192,5 +192,13 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "要将 {name} 连同其世界和设置一起移到回收站吗?你之后可以从那里恢复。", "Install and turn on native mods": "安装并启用原生模组", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。" + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。", + "Downloading Rust {version} (once): {done}%": "正在下载 Rust {version}(仅一次):{done}%", + "Building horizon-mixins (once)": "正在构建 horizon-mixins(仅一次)", + "Downloading Horizon's source at {revision}": "正在下载 Horizon 在 {revision} 的源代码", + "Applying the mixins": "正在应用 mixin", + "Checking the mods' crates against Horizon's Cargo.lock": "正在对照 Horizon 的 Cargo.lock 检查模组的 crate", + "Compiling the game: {count} crates, now {name}": "正在编译游戏:已完成 {count} 个 crate,当前 {name}", + "Running {name}'s post-build step": "正在运行 {name} 的构建后步骤", + "Installing the build": "正在安装构建" } diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 390325ec58..0e41d01c35 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -15,6 +15,7 @@ //! its next build stays on the same revision ([`base_for`]) until the player asks to update //! ([`update_for`]), so a rebuild compiles only what the mods changed. use crate::backend; +use crate::i18n::{tr, trf}; use crate::instances::{self, Instance}; use crate::rust_toolchain::{self, Toolchain}; use sha2::{Digest, Sha256}; @@ -23,10 +24,12 @@ use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +mod crates; + const PREFIX: &str = "native-"; /// horizon-mixins, for bundles that don't name the revision they were tested with. const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins"; -pub const MIXINS_REV: &str = "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"; +pub const MIXINS_REV: &str = "b74802d8acdf3968a520ccb11b5749cf9fcfc386"; /// The profile builds use: the workspace's `fast` (the release's optimizations without its /// link-time ones, incremental), so a rebuild recompiles and links only what the mods changed. /// A revision without it builds with `release` ([`profile_of`]). @@ -480,26 +483,38 @@ pub enum Step { Tool, Source(String), Mixins, + /// The mods' own crates checked against Horizon's `Cargo.lock`. + Crates, /// Crates compiled so far, and the last one. Compile(usize, String), + /// A mod's post-build step, built and run. + PostBuild(String), Install, } impl Step { pub fn label(&self) -> String { match self { - Self::Rust(done) => format!( - "Downloading Rust {} (once): {:.0}%", - rust_toolchain::VERSION, - done * 100.0 + Self::Rust(done) => trf( + "Downloading Rust {version} (once): {done}%", + &[ + ("version", rust_toolchain::VERSION), + ("done", &format!("{:.0}", done * 100.0)), + ], ), - Self::Tool => "Building horizon-mixins (once)".into(), - Self::Source(revision) => { - format!("Downloading Horizon's source at {}", &revision[..7]) - } - Self::Mixins => "Applying the mixins".into(), - Self::Compile(n, name) => format!("Compiling the game: {n} crates, now {name}"), - Self::Install => "Installing the build".into(), + Self::Tool => tr("Building horizon-mixins (once)").into(), + Self::Source(revision) => trf( + "Downloading Horizon's source at {revision}", + &[("revision", &revision[..7])], + ), + Self::Mixins => tr("Applying the mixins").into(), + Self::Crates => tr("Checking the mods' crates against Horizon's Cargo.lock").into(), + Self::Compile(n, name) => trf( + "Compiling the game: {count} crates, now {name}", + &[("count", &n.to_string()), ("name", name)], + ), + Self::PostBuild(id) => trf("Running {name}'s post-build step", &[("name", id)]), + Self::Install => tr("Installing the build").into(), } } } @@ -591,15 +606,8 @@ fn apply(rust: &Toolchain, tool: &Path, source: &Path, mods: &Path, out: &Path) /// outside any Cargo configuration of the player's ([`rust_toolchain::outside_configs`]), so only /// the workspace's own (`stage/.cargo/config.toml`, when it has one) applies. fn compile(rust: &Toolchain, stage: &Path, target: &Path) -> Command { - let mut cmd = rust.command(&rust.cargo()); - rust_toolchain::low_priority(&mut cmd); + let mut cmd = cargo(rust, stage, "build"); cmd.env("CARGO_TARGET_DIR", target); - cmd.arg("build"); - let config = stage.join(".cargo").join("config.toml"); - if config.is_file() { - cmd.arg("--config").arg(config); - } - cmd.arg("--manifest-path").arg(stage.join("Cargo.toml")); cmd.args(["--locked", "--profile", profile_of(stage)]); if has_mods_feature(stage) { cmd.args(["--features", FEATURES]); @@ -608,6 +616,82 @@ fn compile(rust: &Toolchain, stage: &Path, target: &Path) -> Command { cmd } +/// The launcher's cargo running `subcommand` on the workspace `dir`, at low priority, with only +/// the workspace's own Cargo configuration ([`compile`]). +fn cargo(rust: &Toolchain, dir: &Path, subcommand: &str) -> Command { + let mut cmd = rust.command(&rust.cargo()); + rust_toolchain::low_priority(&mut cmd); + cmd.arg(subcommand); + let config = dir.join(".cargo").join("config.toml"); + if config.is_file() { + cmd.arg("--config").arg(config); + } + cmd.arg("--manifest-path").arg(dir.join("Cargo.toml")); + cmd +} + +/// Checks the mods' crates (`native-crates/`) against `source`'s `Cargo.lock`, Horizon's own. +/// horizon-mixins already wrote them into `stage`'s lock (and refused a crate the lock lacks, +/// `E_CRATE_DEPENDENCY`), leaving every other package as it was: this checks that again, a crate +/// they name must be in Horizon's lock and every package downloaded exactly as there. Then the +/// crates are fetched, so a feature turning on a dependency the lock lacks fails here, readably. +fn lock_crates(rust: &Toolchain, source: &Path, stage: &Path) -> Result<(), String> { + let read = |dir: &Path| { + let path = dir.join("Cargo.lock"); + fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display())) + }; + let horizon = crates::downloaded(&read(source)?)?; + let staged = crates::downloaded(&read(stage)?)?; + if let Some(why) = crates::refused(&horizon, Some(&staged), &crates::wanted(stage)?) { + return Err(why); + } + // Only this computer's crates: the build needs no others. + let mut fetch = cargo(rust, stage, "fetch"); + fetch.arg("--locked"); + if let Some(host) = rust_toolchain::host() { + fetch.args(["--target", host.target]); + } + run(fetch, "Downloading the crates", |_| {}).map_err(crates::locked) +} + +/// Builds the mods' post-build programs with the game's profile in `target`; each step and its +/// program, in mod order. +fn post_build_programs( + rust: &Toolchain, + stage: &Path, + target: &Path, + progress: &impl Fn(Step), +) -> Result, String> { + let steps = crates::post_builds(stage)?; + let profile = profile_of(stage); + let mut out = Vec::new(); + for step in steps { + progress(Step::PostBuild(step.mod_id.clone())); + let mut cmd = cargo(rust, stage, "build"); + cmd.env("CARGO_TARGET_DIR", target); + cmd.args([ + "--locked", + "--profile", + profile, + "-p", + &step.package, + "--bin", + &step.bin, + ]); + run( + cmd, + &format!("Building {}'s post-build step", step.mod_id), + |_| {}, + )?; + let program = + target + .join(profile) + .join(format!("{}{}", step.bin, std::env::consts::EXE_SUFFIX)); + out.push((step, program)); + } + Ok(out) +} + /// Horizon's source at commit `revision`: GitHub's archive of it, unpacked once, with the commit in /// `.horizon-revision` (bundles pin it). A Nightly's commit stays downloadable after the Nightly /// is gone: its `nightly-source/…` tag keeps it (docs/release-flow.md). Links are left out: @@ -745,6 +829,10 @@ pub fn build( let stage = real.join("stage"); sync(&fresh, &stage)?; let _ = fs::remove_dir_all(&fresh); + if stage.join(crates::DIR).is_dir() { + progress(Step::Crates); + lock_crates(&rust, &source, &stage)?; + } let target = real.join("target"); let profile = profile_of(&stage); let mut crates = 0; @@ -758,7 +846,9 @@ pub fn build( progress(Step::Compile(crates, name.to_owned())); } }, - )?; + ) + .map_err(crates::locked)?; + let post = post_build_programs(&rust, &stage, &target, &progress)?; progress(Step::Install); let about = serde_json::json!({ "base": origin, @@ -770,7 +860,14 @@ pub fn build( "mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::>(), }); let binary = target.join(profile).join(backend::CLIENT); - install(base, &version, &binary, &about)?; + install(base, &version, &binary, &about, |dir| { + for (step, program) in &post { + progress(Step::PostBuild(step.mod_id.clone())); + let cmd = crates::post_build(program, step, dir, &real.join("cache"))?; + run(cmd, &format!("{}'s post-build step", step.mod_id), |_| {})?; + } + Ok(()) + })?; } else { // Used again: it counts as the newest of the builds kept (`prune`). let _ = fs::write(backend::version_dir(&version).join(".installed"), ""); @@ -783,11 +880,14 @@ pub fn build( /// Installs `binary` as version `version`: a copy of installed version `base`'s folder (its /// resources included; cloned where the disk can) with the built game in place of `base`'s. +/// `finish` (the mods' post-build steps) runs in its folder before it counts as installed; when +/// it fails, the folder is removed. fn install( base: &str, version: &str, binary: &Path, about: &serde_json::Value, + finish: impl FnOnce(&Path) -> Result<(), String>, ) -> Result<(), String> { let from = backend::version_dir(base); let partial = backend::version_dir(&format!(".{version}.partial")); @@ -822,6 +922,10 @@ fn install( let dir = backend::version_dir(version); let _ = fs::remove_dir_all(&dir); fs::rename(&partial, &dir).map_err(|e| e.to_string())?; + if let Err(e) = finish(&dir) { + let _ = fs::remove_dir_all(&dir); + return Err(e); + } fs::write(dir.join(".installed"), "").map_err(|e| e.to_string()) } @@ -1189,6 +1293,33 @@ mod tests { assert_ne!(first, key(A)); } + /// The test bundle (`tests/fixtures/native-crate-demo`): its crate's sources and data, and its + /// own data folder, are all in the build's key. + #[test] + fn the_key_covers_a_bundles_crate_and_data_files() { + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/native-crate-demo"); + let d = dir("crate-key"); + backend::copy_dir(&fixture, &d).unwrap(); + let found = bundles(&d).unwrap(); + assert_eq!(found[0].id, "crate_demo"); + let key = || version_for(A, B, &bundles(&d).unwrap()).unwrap(); + let mut seen = vec![key()]; + for file in [ + "engine/src/lib.rs", + "engine/data/salt.txt", + "engine/Cargo.toml", + "data/greeting.txt", + ] { + let mut bytes = fs::read(d.join(file)).unwrap(); + bytes.extend_from_slice(b"\n"); + fs::write(d.join(file), bytes).unwrap(); + let now = key(); + assert!(!seen.contains(&now), "{file} is in the key"); + seen.push(now); + } + } + #[test] fn only_changed_files_are_written_again() { let (from, to) = (dir("sync-from"), dir("sync-to")); diff --git a/launcher/src/native_mods/crates.rs b/launcher/src/native_mods/crates.rs new file mode 100644 index 0000000000..754252f63e --- /dev/null +++ b/launcher/src/native_mods/crates.rs @@ -0,0 +1,349 @@ +//! Native mods' own crates and post-build steps (docs/native-mods.md, "Crates" and +//! "Post-build steps"). horizon-mixins stages a mod's `[[crate]]` as +//! `native-crates///`, a workspace member, and lists its `[[post_build]]` steps in +//! `native-post-build.json`. A mod's crate may use only what Horizon's own `Cargo.lock` has: the +//! lock file is the allow list, so a build never downloads a crate Horizon doesn't already ship. +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Where horizon-mixins stages the mods' crates, in the workspace it writes. +pub(super) const DIR: &str = "native-crates"; +/// The post-build steps it found, in mod order. +const POST_BUILD: &str = "native-post-build.json"; + +/// A package of a `Cargo.lock` from a registry (or git): the ones a build would download. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct Locked { + pub name: String, + pub version: String, + pub source: String, + pub checksum: Option, +} + +/// The downloaded packages of the lock file `text`; the workspace's own (no `source`) are left +/// out. +pub(super) fn downloaded(text: &str) -> Result, String> { + let lock: toml::Table = text.parse().map_err(|e| format!("Cargo.lock: {e}"))?; + let packages = lock.get("package").and_then(|p| p.as_array()); + let field = + |p: &toml::Value, name: &str| p.get(name).and_then(|v| v.as_str()).map(String::from); + Ok(packages + .into_iter() + .flatten() + .filter_map(|p| { + Some(Locked { + source: field(p, "source")?, + name: field(p, "name")?, + version: field(p, "version")?, + checksum: field(p, "checksum"), + }) + }) + .collect()) +} + +/// A crate a mod's crate asks for in its `Cargo.toml`: under its own name or `package`, in any +/// dependency table (dev and build ones too: the lock has them all). +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) struct Wanted { + pub mod_id: String, + pub name: String, + pub version: String, +} + +/// What every crate of `stage/native-crates///` asks for. +pub(super) fn wanted(stage: &Path) -> Result, String> { + let mut out = Vec::new(); + for (mod_id, manifest) in manifests(&stage.join(DIR))? { + let text = + fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; + let value: toml::Table = text + .parse() + .map_err(|e| format!("{}: {e}", manifest.display()))?; + let mut tables: Vec<&toml::Value> = Vec::new(); + let kinds = ["dependencies", "dev-dependencies", "build-dependencies"]; + tables.extend(kinds.iter().filter_map(|k| value.get(*k))); + for target in value + .get("target") + .and_then(|t| t.as_table()) + .into_iter() + .flat_map(|t| t.values()) + { + tables.extend(kinds.iter().filter_map(|k| target.get(*k))); + } + for (key, dep) in tables.iter().filter_map(|t| t.as_table()).flatten() { + let get = |name: &str| dep.get(name).and_then(|v| v.as_str()); + out.push(Wanted { + mod_id: mod_id.clone(), + name: get("package").unwrap_or(key).to_owned(), + version: dep.as_str().or(get("version")).unwrap_or("*").to_owned(), + }); + } + } + out.sort(); + Ok(out) +} + +/// `(mod id, its crate's Cargo.toml)` under `native-crates/`, sorted. +fn manifests(dir: &Path) -> Result, String> { + let mut out = Vec::new(); + let read = |d: &Path| -> Result, String> { + let mut paths: Vec = fs::read_dir(d) + .map_err(|e| format!("{}: {e}", d.display()))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + paths.sort(); + Ok(paths) + }; + for mod_dir in read(dir)? { + let id = mod_dir + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + // A folder left empty by an earlier build (the stage keeps folders) is no crate. + for krate in read(&mod_dir)? { + let manifest = krate.join("Cargo.toml"); + if manifest.is_file() { + out.push((id.clone(), manifest)); + } + } + } + Ok(out) +} + +/// Why a mod's crates can't build with Horizon's lock file `horizon`: a crate they name that it +/// lacks (checked before Cargo, which would look for it online), or a package the updated lock +/// `staged` has that Horizon's lacks or has in another version. None when they fit. +pub(super) fn refused( + horizon: &BTreeSet, + staged: Option<&BTreeSet>, + wanted: &[Wanted], +) -> Option { + let why = |mod_id: &str, name: &str, version: &str| { + format!("{mod_id} needs {name} {version}, which isn't in Horizon's Cargo.lock. A native mod's crate can use only the crates Horizon itself uses, in the versions its Cargo.lock has.") + }; + if let Some(w) = wanted + .iter() + .find(|w| !horizon.iter().any(|l| l.name == w.name)) + { + return Some(why(&w.mod_id, &w.name, &w.version)); + } + let added = staged?.difference(horizon).next()?; + // A crate asked for by name is that mod's; one pulled in by another names every mod with crates. + let mut mods: Vec<&str> = wanted + .iter() + .filter(|w| w.name == added.name) + .map(|w| w.mod_id.as_str()) + .collect(); + if mods.is_empty() { + mods = wanted.iter().map(|w| w.mod_id.as_str()).collect(); + } + mods.dedup(); + Some(why(&mods.join(", "), &added.name, &added.version)) +} + +/// A Cargo error (`run`'s) said plainly when it is `--locked` refusing to change the lock file: +/// a mod's crate turned on, with a feature, a dependency Horizon's `Cargo.lock` lacks. +pub(super) fn locked(error: String) -> String { + if !error.contains("--locked was passed") { + return error; + } + format!("A native mod's crate needs a crate that isn't in Horizon's Cargo.lock, through one of its dependencies' features. A native mod's crate can use only the crates Horizon itself uses, with the features that need no others.\n\n{error}") +} + +/// A mod's post-build step: a program of one of its crates, run after the game compiles. +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +pub(super) struct PostBuild { + #[serde(rename = "mod")] + pub mod_id: String, + pub package: String, + pub bin: String, + #[serde(default)] + pub args: Vec, +} + +/// The post-build steps horizon-mixins listed in `stage` (none without the file: an older +/// horizon-mixins, or no mod has one). +pub(super) fn post_builds(stage: &Path) -> Result, String> { + let path = stage.join(POST_BUILD); + match fs::read_to_string(&path) { + Ok(text) => serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display())), + Err(_) => Ok(Vec::new()), + } +} + +/// Step `step`'s program `program` set up to run in the new build's version folder `version`, +/// with the mod's folders in its environment: `HORIZON_NATIVE_DATA_DIR` (what the game reads at +/// run time, in the version folder) and `HORIZON_NATIVE_CACHE_DIR` (kept across builds, under +/// `cache`). Both are created. +pub(super) fn post_build( + program: &Path, + step: &PostBuild, + version: &Path, + cache: &Path, +) -> Result { + let data = version.join("native-data").join(&step.mod_id); + let cache = cache.join(&step.mod_id); + for dir in [&data, &cache] { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let mut cmd = Command::new(program); + crate::rust_toolchain::low_priority(&mut cmd); + cmd.args(&step.args) + .current_dir(version) + .env("HORIZON_NATIVE_MOD", &step.mod_id) + .env("HORIZON_NATIVE_VERSION_DIR", version) + .env("HORIZON_NATIVE_DATA_DIR", &data) + .env("HORIZON_NATIVE_CACHE_DIR", &cache); + Ok(cmd) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK: &str = r#" +version = 4 + +[[package]] +name = "horizon-client" +version = "0.1.0" + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def" +"#; + + fn wants(mod_id: &str, name: &str, version: &str) -> Wanted { + Wanted { + mod_id: mod_id.into(), + name: name.into(), + version: version.into(), + } + } + + #[test] + fn a_mods_crate_may_use_only_what_horizons_lock_has() { + let horizon = downloaded(LOCK).unwrap(); + assert_eq!( + horizon.len(), + 2, + "the workspace's own packages are left out" + ); + let ok = [wants("bake", "serde_json", "1")]; + assert_eq!(refused(&horizon, None, &ok), None); + assert_eq!(refused(&horizon, Some(&horizon), &ok), None); + // Asked for by name, and not in the lock: refused before Cargo looks online. + let err = refused(&horizon, None, &[wants("bake", "rand", "0.9")]).unwrap(); + assert!( + err.starts_with("bake needs rand 0.9, which isn't in Horizon's Cargo.lock"), + "{err}" + ); + // In the lock, but the update had to change its version. + let newer = LOCK.replace("1.0.140", "1.0.145"); + let err = refused(&horizon, Some(&downloaded(&newer).unwrap()), &ok).unwrap(); + assert!(err.starts_with("bake needs serde_json 1.0.145,"), "{err}"); + // Pulled in by another crate: every mod with crates is named. + let added = format!( + "{LOCK}\n[[package]]\nname = \"ryu\"\nversion = \"1.0.20\"\nsource = \"registry+x\"\n" + ); + let two = [wants("bake", "serde_json", "1"), wants("zoom", "itoa", "1")]; + let err = refused(&horizon, Some(&downloaded(&added).unwrap()), &two).unwrap(); + assert!(err.starts_with("bake, zoom needs ryu 1.0.20,"), "{err}"); + // A feature that needs another crate: Cargo's --locked refusal, said plainly. + let cargo = "error: cannot update the lock file /s/Cargo.lock because --locked was passed to prevent this".to_string(); + assert!(locked(cargo.clone()).starts_with("A native mod's crate needs a crate")); + assert!(locked(cargo).ends_with("to prevent this")); + assert_eq!(locked("other".into()), "other"); + // Another checksum is another package. + let other = LOCK.replace("\"abc\"", "\"abd\""); + assert!(refused(&horizon, Some(&downloaded(&other).unwrap()), &ok).is_some()); + } + + #[test] + fn every_dependency_table_of_every_mod_crate_is_read() { + let stage = + std::env::temp_dir().join(format!("horizon-native-crates-{}", std::process::id())); + let _ = fs::remove_dir_all(&stage); + let krate = stage.join(DIR).join("bake").join("engine"); + fs::create_dir_all(&krate).unwrap(); + fs::write( + krate.join("Cargo.toml"), + "[package]\nname = \"bake-engine\"\nversion = \"0.1.0\"\n\ + [dependencies]\nserde_json = \"1\"\nhash = { package = \"sha2\", version = \"0.10\" }\n\ + [build-dependencies]\nitoa = { version = \"1\" }\n\ + [target.'cfg(unix)'.dev-dependencies]\nlibc = \"0.2\"\n", + ) + .unwrap(); + assert_eq!( + wanted(&stage).unwrap(), + [ + wants("bake", "itoa", "1"), + wants("bake", "libc", "0.2"), + wants("bake", "serde_json", "1"), + wants("bake", "sha2", "0.10"), + ] + ); + fs::create_dir_all(stage.join(DIR).join("gone/engine/src")).unwrap(); + assert_eq!( + wanted(&stage).unwrap().len(), + 4, + "an emptied crate folder is skipped" + ); + assert_eq!(post_builds(&stage).unwrap(), []); + } + + #[test] + fn post_build_steps_run_in_the_version_folder_with_the_mods_folders() { + let json = r#"[{"mod":"bake","package":"bake-engine","bin":"bake","args":["--fast"]}, + {"mod":"zoom","package":"zoom-tables","bin":"tables"}]"#; + let root = std::env::temp_dir().join(format!("horizon-native-post-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join(POST_BUILD), json).unwrap(); + let steps = post_builds(&root).unwrap(); + assert_eq!(steps.len(), 2); + assert_eq!(steps[1].args, Vec::::new()); + let version = root.join("versions/native-abc"); + let cmd = post_build( + Path::new("/target/fast/bake"), + &steps[0], + &version, + &root.join("cache"), + ) + .unwrap(); + assert_eq!(cmd.get_current_dir(), Some(version.as_path())); + assert_eq!(cmd.get_args().collect::>(), ["--fast"]); + let env = |name: &str| { + cmd.get_envs() + .find(|(k, _)| *k == name) + .and_then(|(_, v)| v) + .map(PathBuf::from) + }; + assert_eq!(env("HORIZON_NATIVE_MOD"), Some("bake".into())); + assert_eq!(env("HORIZON_NATIVE_VERSION_DIR"), Some(version.clone())); + assert_eq!( + env("HORIZON_NATIVE_DATA_DIR"), + Some(version.join("native-data/bake")) + ); + assert_eq!( + env("HORIZON_NATIVE_CACHE_DIR"), + Some(root.join("cache/bake")) + ); + assert!(version.join("native-data/bake").is_dir() && root.join("cache/bake").is_dir()); + fs::write(root.join(POST_BUILD), "{}").unwrap(); + assert!(post_builds(&root).is_err()); + } +} diff --git a/launcher/tests/fixtures/native-crate-demo/data/greeting.txt b/launcher/tests/fixtures/native-crate-demo/data/greeting.txt new file mode 100644 index 0000000000..aa725498aa --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/data/greeting.txt @@ -0,0 +1 @@ +Hello from crate_demo's data folder diff --git a/launcher/tests/fixtures/native-crate-demo/engine/Cargo.toml b/launcher/tests/fixtures/native-crate-demo/engine/Cargo.toml new file mode 100644 index 0000000000..e1bacb1fb8 --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/engine/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "crate-demo-engine" +version = "0.1.0" +edition = "2024" +publish = false + +# Only crates in Horizon's Cargo.lock, by version requirement. +[dependencies] +crc32fast = "1" diff --git a/launcher/tests/fixtures/native-crate-demo/engine/data/salt.txt b/launcher/tests/fixtures/native-crate-demo/engine/data/salt.txt new file mode 100644 index 0000000000..06a710d47a --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/engine/data/salt.txt @@ -0,0 +1 @@ +crate_demo salt diff --git a/launcher/tests/fixtures/native-crate-demo/engine/src/bin/bake.rs b/launcher/tests/fixtures/native-crate-demo/engine/src/bin/bake.rs new file mode 100644 index 0000000000..1529f15061 --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/engine/src/bin/bake.rs @@ -0,0 +1,22 @@ +//! crate_demo's post-build step: the launcher runs it once the game is built, in the new build's +//! folder. What it writes in HORIZON_NATIVE_DATA_DIR, the game reads (`native_built_data`); +//! HORIZON_NATIVE_CACHE_DIR stays from one build to the next. +use std::{env, fs, path::PathBuf}; + +fn main() { + let dir = + |name: &str| PathBuf::from(env::var_os(name).unwrap_or_else(|| panic!("{name} unset"))); + let (data, cache) = ( + dir("HORIZON_NATIVE_DATA_DIR"), + dir("HORIZON_NATIVE_CACHE_DIR"), + ); + let runs = fs::read_to_string(cache.join("runs")) + .ok() + .and_then(|r| r.trim().parse().ok()) + .unwrap_or(0u32) + + 1; + fs::write(cache.join("runs"), runs.to_string()).expect("the cache folder"); + let baked = format!("{:08x} (run {runs})", crate_demo_engine::checksum(b"baked")); + fs::write(data.join("baked.txt"), &baked).expect("the data folder"); + println!("crate_demo baked {baked}"); +} diff --git a/launcher/tests/fixtures/native-crate-demo/engine/src/lib.rs b/launcher/tests/fixtures/native-crate-demo/engine/src/lib.rs new file mode 100644 index 0000000000..4315ac5fd0 --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/engine/src/lib.rs @@ -0,0 +1,13 @@ +//! crate_demo's own crate: the mod's logic, compiled apart from the game and kept when only the +//! mod's helpers change. + +/// The crate's data file, compiled in. +const SALT: &[u8] = include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/data/salt.txt")); + +/// CRC-32 of the salt then `bytes`. +pub fn checksum(bytes: &[u8]) -> u32 { + let mut hasher = crc32fast::Hasher::new(); + hasher.update(SALT); + hasher.update(bytes); + hasher.finalize() +} diff --git a/launcher/tests/fixtures/native-crate-demo/frame.rs b/launcher/tests/fixtures/native-crate-demo/frame.rs new file mode 100644 index 0000000000..677db76caa --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/frame.rs @@ -0,0 +1,24 @@ +// The first frame: shows that the crate, both data folders and the post-build step reached the +// game, in the log and in the mod's folder (`horizon-mods/.horizon-mod-data/crate_demo/proof.txt`). +fn crate_demo_frame(game: &mut crate::game::Game<'_>) { + use std::sync::atomic::{AtomicBool, Ordering}; + static DONE: AtomicBool = AtomicBool::new(false); + if DONE.swap(true, Ordering::Relaxed) { + return; + } + // The bundle's data/greeting.txt, compiled in. + let greeting = horizon_client::native_data!("crate_demo", "greeting.txt"); + // What the post-build step wrote when the launcher built this game. + let baked = game.native_built_data("crate_demo").join("baked.txt"); + let baked = std::fs::read_to_string(baked).unwrap_or_else(|e| format!("missing ({e})")); + let line = format!( + "crate_demo: {} checksum {:08x}, baked {}", + String::from_utf8_lossy(greeting).trim(), + crate_demo_engine::checksum(greeting), + baked.trim() + ); + eprintln!("{line}"); + let dir = game.native_data_dir("crate_demo"); + let _ = + std::fs::create_dir_all(&dir).and_then(|()| std::fs::write(dir.join("proof.txt"), &line)); +} diff --git a/launcher/tests/fixtures/native-crate-demo/horizon-mixin.toml b/launcher/tests/fixtures/native-crate-demo/horizon-mixin.toml new file mode 100644 index 0000000000..1885378a1c --- /dev/null +++ b/launcher/tests/fixtures/native-crate-demo/horizon-mixin.toml @@ -0,0 +1,25 @@ +# A native mod that uses every part of the bundle format (docs/native-mods.md): its own crate with +# a dependency from Horizon's Cargo.lock, data files, a named hook point and a post-build step. +[mod] +id = "crate_demo" +version = "0.1.0" + +# The hook point by its name in native-hooks.json, not its path in the source. +[[injection]] +id = "frame" +hook = "frame" +hook_version = 1 +kind = "HEAD" +helper = "frame.rs" +bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }] + +# engine/ (its own Cargo.toml): a workspace member that horizon-client depends on, compiled and +# cached on its own. +[[crate]] +id = "engine" +path = "engine" + +# Built after the game, then run in the new build's folder. +[[post_build]] +crate = "engine" +bin = "bake" From 89b737d88aeb315ea6cb20d96d09332e2551d814 Mon Sep 17 00:00:00 2001 From: unknown <162871325+WhyFovius@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:24:28 +0300 Subject: [PATCH 152/968] Streaming: light and mesh jobs bounded in flight, nearest first The light and mesh stages of Streaming::update submitted every ready column's jobs at once, in hash order. At a wide render distance the queue - and lighting, and meshing, and each job's 3x3 region snapshot - grew with the number of loaded columns, requeue re-ranked that whole heap under the queue lock each time the player crossed a chunk border, and the workers ground through jobs whose columns were long gone, their results dropped only after the work was done (#3629: at render distance 1000 the backlog survived lowering the distance, and chunks around the player waited behind it). Both stages now hand the workers only as much as they can chew, chosen from everything that is ready each update, nearest first: what stays dirty is asked for again, with the current centre, as capacity frees - the way vanilla's chunk task queues hold bounded per-worker work and follow the current ticket levels. Generation (workers * 3) and decoration (workers * 2) already had such bounds. Fixes #3629 Co-Authored-By: OpenCode Agent (GLM) --- crates/horizon-client-render/src/streaming.rs | 167 +++++++++++++++++- 1 file changed, 162 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs index d9b093df9e..283fa1431e 100644 --- a/crates/horizon-client-render/src/streaming.rs +++ b/crates/horizon-client-render/src/streaming.rs @@ -873,6 +873,21 @@ impl Streaming { } Some(Box::new(region)) } + /// Whether `light_region` would find all nine columns: the same walk + /// without building the snapshot, so the scheduler can rank the ready + /// columns before any region is cloned. A proto neighbour whose light + /// view turns out empty is skipped when its region is built, and asked + /// for again, as `light_region` returning `None` always was. + fn light_region_ready(&self, world: &World, x: i32, z: i32) -> bool { + light_region_ready( + world, + |x, z| self.in_range(x, z, 1), + &self.decorated, + &self.protos, + x, + z, + ) + } fn region(world: &World, x: i32, z: i32) -> Option> { let mut region: Region = Default::default(); for dz in 0..3 { @@ -1243,13 +1258,29 @@ impl Streaming { } } self.schedule_generation(world, workers); - // Lighting once all neighbours exist. - let light: Vec<_> = world.dirty_light.iter().copied().collect(); - for (x, z) in light { + // Lighting once all neighbours exist, nearest first and only as + // much of it as the workers can chew. Vanilla's light engine also + // hands a bounded queue to its workers and reschedules what did + // not fit by distance; submitted whole, the light jobs of every + // unlit column at once — each carrying a 3x3 region snapshot — + // grew with the render distance and outlived the columns they + // were queued for (#3629). What stays in `dirty_light` is asked + // for again, from the current centre, as capacity frees. + let mut light: Vec<(i64, i32, i32)> = Vec::new(); + let mut gone: Vec<(i32, i32)> = Vec::new(); + for &(x, z) in world.dirty_light.iter() { if !world.chunks.contains_key(&(x, z)) { - world.dirty_light.remove(&(x, z)); - continue; + gone.push((x, z)); + } else if self.light_region_ready(world, x, z) { + light.push((self.distance(x, z), x, z)); } + } + for key in gone { + world.dirty_light.remove(&key); + } + let room = (self.threads.len() * 4).saturating_sub(self.lighting.len()); + nearest_first(&mut light, room); + for (_, x, z) in light { let Some(region) = self.light_region(world, x, z) else { continue; }; @@ -1295,6 +1326,7 @@ impl Streaming { } // Column by column: the checks below hold for all of a column's // sections, and columns waiting for light cost one look per frame. + let mut candidates: Vec<(i64, i32, i32, u32)> = Vec::new(); let columns: Vec<_> = world.dirty_sections.columns().collect(); for ((x, z), sections) in columns { if !world.chunks.contains_key(&(x, z)) { @@ -1323,6 +1355,18 @@ impl Streaming { if !ready { continue; } + candidates.push((self.distance(x, z), x, z, sections)); + } + // Nearest first, and only as many sections as the workers can chew. + // Every ready column used to be submitted at once, so at a wide + // render distance the queue — and `meshing`, and each job's 3x3 + // region snapshot — grew with the number of loaded columns and the + // jobs went stale long before a worker reached them (#3629). What + // stays dirty is asked for again, from the current centre, as + // capacity frees; `requeue` then has a small queue to keep close. + let room = (self.threads.len() * 32).saturating_sub(self.meshing.len()); + nearest_first(&mut candidates, room); + for (_, x, z, sections) in candidates { let Some(region) = Self::region(world, x, z) else { continue; }; @@ -1910,6 +1954,46 @@ fn rank_again(queue: &mut BinaryHeap, distance: impl Fn(i32, i32) -> i64 } *queue = BinaryHeap::from(jobs); } +/// Whether the 3x3 a light job for (x, z) reads is all there: every +/// neighbour a finished column, or a decorated proto chunk past the area +/// (the last FULL ring lights against neighbours at FEATURES, as +/// `Streaming::light_region` reads them). +fn light_region_ready( + world: &World, + in_range: impl Fn(i32, i32) -> bool, + decorated: &FxHashSet<(i32, i32)>, + protos: &FxHashMap<(i32, i32), Option>, + x: i32, + z: i32, +) -> bool { + for dz in 0..3 { + for dx in 0..3 { + let n = (x + dx - 1, z + dz - 1); + if world.chunks.contains_key(&n) + || (!in_range(n.0, n.1) + && decorated.contains(&n) + && matches!(protos.get(&n), Some(Some(_)))) + { + continue; + } + return false; + } + } + true +} +/// The nearest `room` of the `candidates`, ordered: the light and mesh +/// stages submit bounded work, chosen from everything that is ready each +/// `update`, so what runs is always the nearest of what is left — chosen +/// again, from the current centre, as capacity frees, the way vanilla's +/// chunk task queues follow the current ticket levels. The selection is +/// O(n); the sort orders only what is submitted. +fn nearest_first(candidates: &mut Vec, room: usize) { + if candidates.len() > room { + candidates.select_nth_unstable(room); + candidates.truncate(room); + } + candidates.sort_unstable(); +} #[cfg(test)] mod tests { use super::*; @@ -1999,6 +2083,79 @@ mod tests { assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0))); } + /// The light and mesh stages submit bounded work, the nearest first: + /// what exceeds the room stays dirty, to be chosen again — from the + /// current centre — as capacity frees. Submitted whole, the queue once + /// held every ready column's jobs and grew with the render distance + /// (#3629); ties break by position, so the choice is deterministic. + #[test] + fn bounded_submission_takes_the_nearest_first() { + let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32) + .map(|i| { + let (x, z) = (i / 8 - 2, i % 8 - 3); + ( + i64::from((x - 3).pow(2) + (z - 2).pow(2)), + x, + z, + 1 << (i % 24), + ) + }) + .collect(); + let mut expected = candidates.clone(); + expected.sort_unstable(); + let mut none = candidates.clone(); + nearest_first(&mut none, 0); + assert!(none.is_empty()); + let mut nearest = candidates; + nearest_first(&mut nearest, 5); + assert_eq!(nearest, expected[..5]); + let mut past = expected.clone(); + nearest_first(&mut past, expected.len() + 10); + assert_eq!(past, expected); + } + + /// What the scheduler counts as ready to light: every neighbour a + /// finished column, or a decorated proto chunk past the area — the + /// walk `light_region` makes without its snapshot. + #[test] + fn light_readiness_follows_the_region_a_light_job_reads() { + let mut world = World::new(horizon_world::registry::BlockStateId(0), 0); + let chunk = Arc::new(Chunk::empty(0, 0)); + let nine = (-1..=1).flat_map(|dz| (-1..=1).map(move |dx| (dx, dz))); + for key in nine { + world.chunks.insert(key, chunk.clone()); + } + // The kept area is the cross around (0, 0); the corners of the + // 3x3 light reads lie past it. + let in_range = |x: i32, z: i32| x.abs() + z.abs() <= 1; + let none = FxHashSet::<(i32, i32)>::default(); + let empty = FxHashMap::<(i32, i32), Option>::default(); + assert!(light_region_ready(&world, in_range, &none, &empty, 0, 0)); + // A corner is missing, and nothing stands in for it yet. + world.chunks.remove(&(1, 1)); + assert!(!light_region_ready(&world, in_range, &none, &empty, 0, 0)); + // A decorated, checked-in proto chunk past the area lights against. + let decorated: FxHashSet<(i32, i32)> = [(1, 1)].into_iter().collect(); + let mut protos: FxHashMap<(i32, i32), Option> = Default::default(); + protos.insert((1, 1), Some(Box::new(()) as Proto)); + assert!(light_region_ready( + &world, in_range, &decorated, &protos, 0, 0 + )); + // The proto is checked out to a job: it cannot be read. + protos.insert((1, 1), None); + assert!(!light_region_ready( + &world, in_range, &decorated, &protos, 0, 0 + )); + // A neighbour inside the area waits for the chunk itself. + world.chunks.remove(&(1, 0)); + let mut inside = decorated.clone(); + inside.insert((1, 0)); + protos.insert((1, 0), Some(Box::new(()) as Proto)); + assert!(!light_region_ready( + &world, in_range, &inside, &protos, 0, 0 + )); + } + #[test] fn view_distance_is_vanillas_shape() { // ChunkTrackingView.isInViewDistance at render distance 12: a column From 263ca3d80a4798f907124869d006e95cb7205f76 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:32:05 +0500 Subject: [PATCH 153/968] Docs: a native mod crate's features apply to the game too Co-Authored-By: Claude Opus 5.5 --- docs/native-mods.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/native-mods.md b/docs/native-mods.md index a830b82f9f..5bdf4a7d68 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -114,6 +114,11 @@ horizon-mixins failed (exit status: 1). The end of its output, the whole of it i E_CRATE_DEPENDENCY: crate_lock needs rand ^0.9, which isn't in Horizon's Cargo.lock (…/horizon-mixin.toml) ``` +**Features are shared with the game.** Cargo builds each locked crate once, with the union of the features everyone +asks for, so a feature a mod's crate turns on also applies to the game's own use of that crate. `serde_json`'s +`preserve_order`, for example, would change the key order of every JSON file the game writes. Ask only for +features that add something (new functions, new types), never ones that change existing behaviour. + ### Data files A bundle's `data/` folder (any files) is staged as `native-data//` at the root of the workspace. A helper or From 0a8ac2e89c245c68add6c6ff47dc6d0c0b5791b3 Mon Sep 17 00:00:00 2001 From: Senjin Date: Wed, 28 Oct 2026 14:30:53 +0100 Subject: [PATCH 154/968] HDR: a shortcut key to its settings, unbound by default Graphics: HDR gets a Shortcut row, bound like the sort key on Extras: click it, press a key or mouse button (Escape unbinds). The key lives in horizon-graphics.txt as hdrKey, never in options.txt, which stays vanilla's. Pressed in the game where the display offers HDR, it opens Graphics: HDR straight away, and Done goes back to the game: tune paper white and peak while looking at the world. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/graphics.rs | 18 +++++++++++- .../horizon-client-render/src/graphics_ui.rs | 28 ++++++++++++++++++- .../horizon-client-render/src/options_ui.rs | 16 +++++++++-- crates/horizon-client/src/game.rs | 4 +-- crates/horizon-client/src/game/tweaks.rs | 24 ++++++++++++++++ docs/graphics.md | 2 +- 6 files changed, 85 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client-render/src/graphics.rs b/crates/horizon-client-render/src/graphics.rs index 5b64cf5883..c91f435e00 100644 --- a/crates/horizon-client-render/src/graphics.rs +++ b/crates/horizon-client-render/src/graphics.rs @@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool { REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed) } +/// The name the options screen waits on to bind `Graphics::hdr_key`. +pub const HDR_KEY: &str = "horizon.hdrSettings"; static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); /// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer /// opens its surface; the settings open HDR's screen only then. @@ -391,6 +393,8 @@ pub struct Graphics { /// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the /// first-person hand stays at the world's. pub hdr_ui_brightness: u32, + /// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default. + pub hdr_key: String, /// Battery mode: at most 60 frames per second in a world and 30 in menus /// or behind other windows, and half the worker threads. Off by default; /// it changes when frames are drawn, never what they show or the ticks. @@ -429,6 +433,7 @@ impl Default for Graphics { hdr_peak: 0, hdr_black_level: 0, hdr_ui_brightness: 100, + hdr_key: "key.keyboard.unknown".into(), battery: false, api: GraphicsApi::Auto, shader_pack: String::new(), @@ -507,6 +512,7 @@ impl Graphics { "hdrUiBrightness" => { g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100) } + "hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(), "batteryMode" => g.battery = flag(g.battery), "graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(), "pickedShaderPack" => g.shader_pack = value.to_owned(), @@ -524,7 +530,7 @@ impl Graphics { } pub fn serialize(&self) -> String { format!( - "preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n", + "preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n", self.preset.name(), self.lod_distance, self.far_detail.name(), @@ -556,11 +562,20 @@ impl Graphics { self.hdr_peak, self.hdr_black_level, self.hdr_ui_brightness, + self.hdr_key, bool_text(self.battery), self.api.name(), self.shader_pack, ) } + /// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name. + pub fn set_key(&mut self, name: &str, value: String) -> bool { + match name { + HDR_KEY => self.hdr_key = value, + _ => return false, + } + true + } pub fn save(&self, dir: &Path) { let _ = fs::write(dir.join(FILE), self.serialize()); } @@ -691,6 +706,7 @@ mod tests { hdr_peak: 1400, hdr_black_level: 12, hdr_ui_brightness: 60, + hdr_key: "key.keyboard.h".into(), battery: true, api: GraphicsApi::Dx12, shader_pack: "Golden Hour".into(), diff --git a/crates/horizon-client-render/src/graphics_ui.rs b/crates/horizon-client-render/src/graphics_ui.rs index 3bb558ab1d..1d0d9cfe47 100644 --- a/crates/horizon-client-render/src/graphics_ui.rs +++ b/crates/horizon-client-render/src/graphics_ui.rs @@ -43,6 +43,8 @@ pub enum H { /// Opens `Level::Hdr`. HdrSettings, HdrBlackLevel, + /// Binds `Graphics::hdr_key`. + HdrKey, HdrPeak, HdrPaperWhite, HdrUiBrightness, @@ -153,6 +155,11 @@ pub fn rows(level: Level) -> Vec { None, "How bright the HUD and menus are, against paper white.", ), + h( + H::HdrKey, + None, + "A key that opens this screen from the game. Not bound at first.", + ), ], Level::Advanced => vec![ Row::Header("Rendering"), @@ -454,6 +461,9 @@ impl H { format!("{:.2} nits", g.hdr_black_level as f32 / 100.0), ), H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)), + H::HdrKey => { + Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key)) + } H::BatteryMode => labelled("Battery Mode", on_off(g.battery)), H::GraphicsApi => labelled("Graphics API", g.api.label()), H::Overlay => labelled( @@ -686,6 +696,8 @@ pub enum Action { OpenAdvanced, OpenSimple, OpenHdr, + /// Wait for a key or mouse button to bind to this name. + BindKey(&'static str), Done, } @@ -708,6 +720,8 @@ pub struct GraphicsUi { /// The list's scroll amount and its held scrollbar (`AbstractScrollArea`). scroll: ui::Scroll, dragging: Option, + /// The key binding the options screen is waiting on (`Action::BindKey`). + pub binding: Option<&'static str>, } impl GraphicsUi { @@ -840,7 +854,13 @@ impl GraphicsUi { if !shown(wd.y, 20.0) { continue; } - let label = item.label(s); + let mut label = item.label(s); + if item == Item::Horizon(H::HdrKey) && self.binding.is_some() { + // KeyBindsScreen: "> key <" while it waits. + label = Text::literal("Shortcut: > ") + .append(crate::keybinds::display_name(&s.horizon.hdr_key)) + .append(" <"); + } let blocked = item.blocked(s); let live = blocked.is_none(); if hover && let Some(why) = blocked { @@ -964,6 +984,12 @@ impl GraphicsUi { Target::Item(Item::Horizon(H::HdrSettings)) => { vec![Action::ButtonClick, Action::OpenHdr] } + Target::Item(Item::Horizon(H::HdrKey)) => { + vec![ + Action::ButtonClick, + Action::BindKey(crate::graphics::HDR_KEY), + ] + } Target::Item(item) => { if item.slider(s).is_some() { self.dragging = Some(item); diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 92ac28a953..f556696eda 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -2404,6 +2404,7 @@ impl OptionsUi { return; } if let Some(level) = Self::graphics_level(self.page()) { + self.graphics.binding = self.selected_key; self.graphics .draw(level, s, batch, atlas, cursor, self.in_world); return; @@ -2655,6 +2656,15 @@ impl OptionsUi { } if let Some(level) = Self::graphics_level(self.page()) { use crate::graphics_ui::Action; + // Waiting on a key: a mouse button binds, as on Key Binds. + if self.selected_key.is_some() && !drag { + let button = if right { + winit::event::MouseButton::Right + } else { + winit::event::MouseButton::Left + }; + return self.bind_mouse(s, button); + } let mut events = Vec::new(); for action in self .graphics @@ -2666,6 +2676,7 @@ impl OptionsUi { Action::SliderDrag => events.push(Event::SliderDrag), Action::OpenAdvanced => self.push(Page::GraphicsAdvanced), Action::OpenHdr => self.push(Page::GraphicsHdr), + Action::BindKey(name) => self.selected_key = Some(name), Action::OpenSimple => { if self.stack.len() >= 2 && self.stack[self.stack.len() - 2] == Page::Graphics @@ -3079,7 +3090,7 @@ impl OptionsUi { keys.insert(key.clone(), value); return vec![Event::Changed]; } - if !s.extras.set_key(name, value.clone()) { + if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) { s.keys.set(name, &value); } vec![Event::Changed] @@ -4381,7 +4392,7 @@ mod tests { MouseButton::Back, MouseButton::Forward, ] { - for name in [SORT_KEY, "key.jump"] { + for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] { let mut ui = OptionsUi::default(); let mut s = Settings::default(); ui.selected_key = Some(name); @@ -4390,6 +4401,7 @@ mod tests { let value = keybinds::mouse_name(button); match name { SORT_KEY => assert_eq!(s.extras.sort_key, value), + crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value), _ => assert_eq!(s.keys.get(name), value), } assert_eq!(Extras::parse(&s.extras.serialize()), s.extras); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..04e5bdc9f8 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -12575,7 +12575,7 @@ impl Game<'_> { } return Ok(()); } - if self.sort_key_press(key) { + if self.sort_key_press(key) || self.hdr_key_press(key) { return Ok(()); } if self.remote.is_some() && self.remote_key(key)? { @@ -13524,7 +13524,7 @@ impl ApplicationHandler for Game<'_> { // Neither edge of a bound button is an ordinary slot click. return Ok(()); } - if pressed && self.sort_mouse_press(button) { + if pressed && (self.sort_mouse_press(button) || self.hdr_mouse_press(button)) { return Ok(()); } match self.screen { diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index 648073874e..adb32c0a81 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -373,6 +373,30 @@ impl Game<'_> { pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool { self.sort_input(&keybinds::mouse_name(button)) } + /// The HDR settings key (`Graphics::hdr_key`): opens Graphics: HDR from the game, where + /// the display offers HDR; Done goes back to the game. + pub(super) fn hdr_key_press(&mut self, key: KeyCode) -> bool { + keybinds::key_name(key).is_some_and(|name| self.hdr_input(name)) + } + pub(super) fn hdr_mouse_press(&mut self, button: MouseButton) -> bool { + self.hdr_input(&keybinds::mouse_name(button)) + } + fn hdr_input(&mut self, name: &str) -> bool { + if self.screen != Screen::Playing + || self.settings.horizon.hdr_key != name + || !horizon_client::graphics::hdr_offered() + { + return false; + } + if self.title.is_none() { + self.capture(false); + } + self.open_options_at( + self.title.is_some(), + horizon_client::options_ui::Page::GraphicsHdr, + ); + true + } fn sort_input(&mut self, name: &str) -> bool { let e = &self.settings.extras; if !e.sort || e.sort_key != name || self.recipe_search_focused() { diff --git a/docs/graphics.md b/docs/graphics.md index b173f80ad3..bef25301f4 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -22,7 +22,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi | | sky, fog, weather, animated textures | `horizon-graphics.txt` | | Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` | | | resolution scale, anti-aliasing (FXAA or TAA), TAA upscaling, present mode, frame pacing | `horizon-graphics.txt` | -| HDR (its own screen, from Display) | HDR, black level, peak brightness, paper white, UI brightness | `horizon-graphics.txt` | +| HDR (its own screen, from Display) | HDR, black level, peak brightness, paper white, UI brightness, a shortcut key to the screen | `horizon-graphics.txt` | | Performance | battery mode, worker threads, upload budget per frame | `horizon-graphics.txt` | | Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` | From 7b98a22fe1f83d6e18718fa4f850bb9090b35242 Mon Sep 17 00:00:00 2001 From: Senjin Date: Thu, 29 Oct 2026 01:40:23 +0100 Subject: [PATCH 155/968] HDR shortcut: Done goes back to the game, not the pause menu The key opens Graphics: HDR straight from the game, so closing it returns there and takes the mouse again (options_to_game, like options_to_world_options for Chat Settings). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 3 +++ crates/horizon-client/src/game/options.rs | 7 ++++++- crates/horizon-client/src/game/tweaks.rs | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 04e5bdc9f8..397066990e 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1923,6 +1923,8 @@ struct Game<'a> { world_options: Option, /// Chat Settings opened from RestrictionsScreen: Done returns to World Options. options_to_world_options: bool, + /// HDR's settings opened with their key from the game: Done returns to the game. + options_to_game: bool, command_block_screen: Option, /// KeyboardHandler's debug key state (game/debug_keys.rs), the physical /// keys held, and the debug renderers F3+B and F3+G switch on. @@ -14532,6 +14534,7 @@ impl<'a> Game<'a> { serving: None, world_options: None, options_to_world_options: false, + options_to_game: false, command_block_screen: None, friends: None, debug_keys: Default::default(), diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 52710e4d48..e1cf85098d 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -106,6 +106,7 @@ impl Game<'_> { self.keys.clear(); self.screen = Screen::Options; self.options_from_title = from_title; + self.options_to_game = false; let ui = &mut self.ui.options; ui.open_at(!from_title && self.title.is_none(), page); let root = self.options.resources.clone(); @@ -216,13 +217,17 @@ impl Game<'_> { Event::Close if self.ui.options.resource_error.is_some() => {} Event::Close => { self.settings.save(&Self::options_path()); + let to_game = std::mem::take(&mut self.options_to_game); self.screen = if std::mem::take(&mut self.options_to_world_options) { Screen::WorldOptions - } else if self.options_from_title { + } else if self.options_from_title || to_game { Screen::Playing } else { Screen::Paused }; + if to_game { + self.capture(true); + } self.dragging = false; } Event::Language(code) => { diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index adb32c0a81..7cd9c8ba90 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -395,6 +395,7 @@ impl Game<'_> { self.title.is_some(), horizon_client::options_ui::Page::GraphicsHdr, ); + self.options_to_game = self.title.is_none(); true } fn sort_input(&mut self, name: &str) -> bool { From 79360c58679c24c901eca75977851a3882e3c5f8 Mon Sep 17 00:00:00 2001 From: Amadeus Date: Fri, 30 Oct 2026 00:37:45 +0300 Subject: [PATCH 156/968] Reduce sunrise fan intersection work with projected sector selection Keep the full barycentric reference and seam fallbacks; add CPU and GPU equivalence checks plus shader-pack helper isolation coverage. Co-Authored-By: OpenAI Codex --- .../src/gfx/sunrise_fan.rs | 121 ++++++++++- crates/horizon-client-render/src/terrain.wgsl | 46 ++++- .../tests/sunrise_shader.rs | 189 ++++++++++++++++++ 3 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 crates/horizon-client-render/tests/sunrise_shader.rs diff --git a/crates/horizon-client-render/src/gfx/sunrise_fan.rs b/crates/horizon-client-render/src/gfx/sunrise_fan.rs index 423f810442..613b2d4763 100644 --- a/crates/horizon-client-render/src/gfx/sunrise_fan.rs +++ b/crates/horizon-client-render/src/gfx/sunrise_fan.rs @@ -5,7 +5,7 @@ //! `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 +//! 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 @@ -15,9 +15,9 @@ use glam::Vec3; pub const TRIANGLES: usize = 16; -/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight +/// 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`. +/// 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]; @@ -55,9 +55,9 @@ mod tests { 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 { + for (i, triangle) in normals.iter().take(TRIANGLES).enumerate() { let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES)); - let w = normals[i][3]; + let w = triangle[3]; if k0 * w <= 0.0 && k1 * w >= 0.0 { let apex = d.dot(n(i)); let s = apex + k1 - k0; @@ -75,7 +75,116 @@ mod tests { best } - /// The shader's former per-pixel Möller-Trumbore over the same vertices. + // Projecting the fan onto the YZ plane leaves uniformly spaced rim + // angles after undoing its Y squash. Test both ray directions because + // the projection plane goes through the camera. + fn sector_weight(alpha: f32, sun: Vec3, d: Vec3) -> f32 { + let normals = normals(alpha, sun); + if d.y * d.y + d.z * d.z < 1e-10 { + return weight(&normals, d); + } + let n = |i: usize| Vec3::from_slice(&normals[i][..3]); + let apex = d.dot(n(0)); + if apex * normals[0][3] < -1e-4 { + return 0.0; + } + let y = d.dot(n(16)); + let x = -d.dot(n(20)); + let ax = x.abs(); + let ay = y.abs(); + let t = std::f32::consts::SQRT_2 - 1.0; + let q = if ay > ax { + if ax < ay * t { 3 } else { 2 } + } else if ay > ax * t { + 1 + } else { + 0 + }; + let sector = if x >= 0.0 { + if y >= 0.0 { q } else { 15 - q } + } else if y >= 0.0 { + 7 - q + } else { + 8 + q + }; + let mut best = 0.0f32; + let k0 = d.dot(n(16 + sector as usize)); + let k1 = d.dot(n(16 + ((sector + 1) % 16) as usize)); + let offsets: &[i32] = if k0.abs().min(k1.abs()) < ax.max(ay) * 0.001 { + &[0, 8, -1, 1, 7, 9] + } else { + &[0, 8] + }; + for &offset in offsets { + let i = (sector + offset).rem_euclid(16) as usize; + let k0 = d.dot(n(16 + i)); + let k1 = d.dot(n(16 + (i + 1) % 16)); + let w = normals[i][3]; + let apex = d.dot(n(i)); + let sum = apex + k1 - k0; + if sum.abs() >= 1e-6 + && sum * w > 0.0 + && apex * sum >= 0.0 + && k1 * sum >= 0.0 + && k0 * sum <= 0.0 + { + best = best.max(apex / sum); + } + } + best + } + #[test] + fn angular_sector_matches_random_and_exact_rim_directions() { + let mut state = 19u32; + let mut random = || { + state = state.wrapping_mul(1664525).wrapping_add(1013904223); + (state >> 8) as f32 / 16777216.0 + }; + for _ in 0..1000 { + let alpha = 0.0011 + random() * 0.9989; + let z = if random() > 0.5 { 0.3 } else { -0.2 }; + let sun = Vec3::new(0.0, (1.0f32 - z * z).sqrt(), z); + let normals = normals(alpha, sun); + let (_, rim) = vertices(alpha, sun); + let random_ray = Vec3::new( + random() * 2.0 - 1.0, + random() * 2.0 - 1.0, + random() * 2.0 - 1.0, + ) + .normalize(); + for ray in rim.into_iter().chain([random_ray]) { + let d = ray.normalize(); + let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d)); + assert!( + (got - want).abs() < 1e-4, + "{alpha} {sun} {d}: {got} != {want}" + ); + } + } + } + + #[test] + fn angular_sector_matches_all_triangle_search() { + for alpha in [0.0011f32, 0.05, 0.6, 1.0] { + for z in [-0.2f32, 0.3] { + let sun = Vec3::new(0.0, (1.0 - z * z).sqrt(), z); + let normals = normals(alpha, sun); + for yaw in 0..360 { + for pitch in -89..90 { + let (y, p) = ((yaw as f32).to_radians(), (pitch as f32).to_radians()); + let d = Vec3::new(y.cos() * p.cos(), p.sin(), y.sin() * p.cos()); + let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d)); + assert!( + (got - want).abs() < 1e-4, + "alpha={alpha} z={z} d={d} {got} != {want}" + ); + } + } + } + } + } + + /// 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; diff --git a/crates/horizon-client-render/src/terrain.wgsl b/crates/horizon-client-render/src/terrain.wgsl index 30c5e937e0..5d2b4ca089 100644 --- a/crates/horizon-client-render/src/terrain.wgsl +++ b/crates/horizon-client-render/src/terrain.wgsl @@ -203,7 +203,7 @@ fn sky_fog(color: vec3, dist: vec2) -> vec3 { // Weight of the fan centre where the ray crosses the sunrise fan (0 when missed): // Moller-Trumbore from the origin along d, over normals built once a frame // (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det. -fn sunrise_weight(d: vec3) -> f32 { +fn sunrise_weight_full(d: vec3) -> f32 { if globals.sunrise.a <= 0.001 { return 0.0; } var best = 0.0; var k0 = dot(d, globals.sunrise_fan[16].xyz); @@ -223,6 +223,50 @@ fn sunrise_weight(d: vec3) -> f32 { } return best; } +// The rim's YZ projection is an ellipse. Comparing its unsquashed X/Y +// magnitudes with tan(PI/8) selects one of sixteen sectors without atan2. +fn sunrise_triangle_weight(d: vec3, i: u32) -> f32 { + let k0 = dot(d, globals.sunrise_fan[16u+i].xyz); + let k1 = dot(d, globals.sunrise_fan[16u+(i+1u)%16u].xyz); + let triangle = globals.sunrise_fan[i]; + let apex = dot(d, triangle.xyz); + let s = apex + k1 - k0; + if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0 + && k1 * s >= 0.0 && k0 * s <= 0.0 { + return apex / s; + } + return 0.0; +} +fn sunrise_weight(d: vec3) -> f32 { + if globals.sunrise.a <= 0.001 { return 0.0; } + if dot(d.yz, d.yz) < 1e-10 { return sunrise_weight_full(d); } + // The camera lies in the rim plane. A forward ray can reach the fan + // only on the apex side. Keep a guard for rounded coplanar normals. + let apex = dot(d, globals.sunrise_fan[0].xyz); + if apex * globals.sunrise_fan[0].w < -1e-4 { return 0.0; } + let y = dot(d, globals.sunrise_fan[16].xyz); + let x = -dot(d, globals.sunrise_fan[20].xyz); + let ax = abs(x); + let ay = abs(y); + let tangent = 0.414213562373095; + let quadrant_sector = select(select(0u, 1u, ay > ax * tangent), + select(2u, 3u, ax < ay * tangent), ay > ax); + let sector = select(select(8u + quadrant_sector, 7u - quadrant_sector, y >= 0.0), + select(15u - quadrant_sector, quadrant_sector, y >= 0.0), x >= 0.0); + var best = max(sunrise_triangle_weight(d, sector), + sunrise_triangle_weight(d, (sector + 8u) % 16u)); + // Test adjacent sectors only at a rounded boundary; their exact rim + // normals remain the authority for hit tests and interpolation weights. + let k0 = dot(d, globals.sunrise_fan[16u+sector].xyz); + let k1 = dot(d, globals.sunrise_fan[16u+(sector+1u)%16u].xyz); + if min(abs(k0), abs(k1)) < max(ax, ay) * 0.001 { + best = max(best, sunrise_triangle_weight(d, (sector + 15u) % 16u)); + best = max(best, sunrise_triangle_weight(d, (sector + 1u) % 16u)); + best = max(best, sunrise_triangle_weight(d, (sector + 7u) % 16u)); + best = max(best, sunrise_triangle_weight(d, (sector + 9u) % 16u)); + } + return best; +} // The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input: // gfx/native_sky.rs builds that stage from this section). fn sky_along_ray(d: vec3) -> vec3 { diff --git a/crates/horizon-client-render/tests/sunrise_shader.rs b/crates/horizon-client-render/tests/sunrise_shader.rs new file mode 100644 index 0000000000..7dbd3a093e --- /dev/null +++ b/crates/horizon-client-render/tests/sunrise_shader.rs @@ -0,0 +1,189 @@ +#[path = "../src/gfx/sunrise_fan.rs"] +mod sunrise_fan; + +#[path = "../src/gfx/wgsl_prefix.rs"] +mod wgsl_prefix; + +fn sunrise_shader() -> String { + let terrain = include_str!("../src/terrain.wgsl"); + let start = terrain.find("fn sunrise_weight_full").unwrap(); + let end = terrain[start..].find("// The sky's colour").unwrap() + start; + let mut source = String::from( + "struct Globals { sunrise: vec4, sunrise_fan: array,32> }\n\ + @group(0) @binding(0) var globals: Globals;\n\ + @group(0) @binding(1) var rays: array>;\n\ + @group(0) @binding(2) var results: array>;\n", + ); + source.push_str(&terrain[start..end]); + source.push_str( + "@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) {\n\ + if id.x < arrayLength(&rays) {\n\ + let d = rays[id.x].xyz;\n\ + results[id.x] = vec2(sunrise_weight_full(d), sunrise_weight(d));\n\ + }\n\ + }\n", + ); + source +} + +#[test] +fn extracted_sunrise_shader_validates() { + let module = naga::front::wgsl::parse_str(&sunrise_shader()).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); +} + +// Execute the actual optimized and reference WGSL on a host GPU. +// This opt-in test compares shipped WGSL with the full fan search, not FPS. +#[test] +#[ignore = "requires a host GPU; run with --ignored --nocapture"] +fn sunrise_sectors_match_reference_on_real_gpu() { + use wgpu::util::DeviceExt; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + eprintln!("Sunrise GPU correctness: {}", adapter.get_info().name); + let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(sunrise_shader().into()), + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: None, + layout: None, + module: &module, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + let mut rays = Vec::<[f32; 4]>::new(); + for yaw in 0..360 { + for pitch in -89..90 { + let (sy, cy) = (yaw as f32).to_radians().sin_cos(); + let (sp, cp) = (pitch as f32).to_radians().sin_cos(); + rays.push([sy * cp, sp, cy * cp, 0.]); + } + } + rays.extend([ + [1., 0., 0., 0.], + [-1., 0., 0., 0.], + [0., 1., 0., 0.], + [0., -1., 0., 0.], + ]); + let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents: bytemuck::cast_slice(&rays), + usage: wgpu::BufferUsages::STORAGE, + }); + let size = (rays.len() * 8) as u64; + let output = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let read = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + for alpha in [0.01, 0.2, 0.65, 1.] { + for hemisphere in [-1., 1.] { + let mut globals = vec![[1., 1., 1., alpha]]; + globals.extend(sunrise_fan::normals( + alpha, + glam::Vec3::new(0., 0., hemisphere), + )); + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents: bytemuck::cast_slice(&globals), + usage: wgpu::BufferUsages::UNIFORM, + }); + let bindings = [&uniform, &input, &output]; + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &bindings + .iter() + .enumerate() + .map(|(i, b)| wgpu::BindGroupEntry { + binding: i as u32, + resource: b.as_entire_binding(), + }) + .collect::>(), + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_compute_pass(&Default::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &group, &[]); + pass.dispatch_workgroups((rays.len() as u32).div_ceil(64), 1, 1); + } + encoder.copy_buffer_to_buffer(&output, 0, &read, 0, size); + queue.submit([encoder.finish()]); + read.slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + { + let mapped = read.slice(..).get_mapped_range().unwrap(); + let pairs: &[[f32; 2]] = bytemuck::cast_slice(&mapped); + for (i, pair) in pairs.iter().enumerate() { + assert!( + (pair[0] - pair[1]).abs() < 1e-4, + "alpha={alpha} hemisphere={hemisphere} ray={:?} values={pair:?}", + rays[i] + ); + } + } + read.unmap(); + } + } +} + +#[test] +fn native_pack_sky_keeps_sunrise_helpers_private() { + use wgsl_prefix::{TERRAIN, prefixed, section}; + // Use the same complete terrain sections and prefixer as native_sky.rs. + let sky = prefixed( + &[ + section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"), + section(TERRAIN, "fn linear_fog(", "fn apply_fog("), + section(TERRAIN, "struct SkyOut {", "// rendertype_lines"), + ] + .concat(), + "horizon_sky_", + ); + for name in [ + "sunrise_weight", + "sunrise_weight_full", + "sunrise_triangle_weight", + ] { + assert!(sky.contains(&format!("fn horizon_sky_{name}("))); + } + // A native pack may declare the same helper names as the renderer. + let own = "fn sunrise_weight(d: vec3) -> f32 { return d.x; }\n\ + fn sunrise_weight_full(d: vec3) -> f32 { return d.y; }\n\ + fn sunrise_triangle_weight(d: vec3, i: u32) -> f32 { return d.z + f32(i); }\n"; + let module = naga::front::wgsl::parse_str(&format!("{own}\n{sky}")).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); +} + +#[test] +fn complete_terrain_shader_still_validates_for_fallback_sky() { + let module = naga::front::wgsl::parse_str(wgsl_prefix::TERRAIN).unwrap(); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .unwrap(); +} From 4b004cf7d620e69fdbf5f69bbd8bb144c319ef5b Mon Sep 17 00:00:00 2001 From: Senjin Date: Thu, 29 Oct 2026 22:40:32 +0100 Subject: [PATCH 157/968] HDR: Auto on Windows, from the luminances the OS reports Where no Wayland compositor answers, Auto takes what wgpu reads from the OS (Surface::display_hdr_info): on Windows DXGI's MaxLuminance as the peak and the SDR white level of the SDR content brightness slider as Paper White, both in absolute nits, HDR output's scale there. No event says they changed, so they're read again every 2 seconds while HDR output is on, and logged once per change. Untested on Windows so far. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/gfx.rs | 103 ++++++++++++++++++++++-- docs/graphics.md | 2 +- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 179b05f67e..a06b51bf20 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -3331,7 +3331,8 @@ impl Renderer { // Only HDR output asks the system about the display, so with HDR off the // game never waits on the compositor for it. self.display = if output.is_some() { - let (watch, now) = DisplayWatch::new(&self.window); + let (watch, now) = + DisplayWatch::new(&self.window, self.surface.as_ref(), &self.adapter); crate::graphics::report(now); self.display_fresh = true; watch @@ -3370,7 +3371,7 @@ impl Renderer { if std::mem::take(&mut self.display_fresh) { return true; } - let Some(now) = self.display.changed() else { + let Some(now) = self.display.changed(self.surface.as_ref(), &self.adapter) else { return false; }; crate::graphics::report(now); @@ -5732,6 +5733,38 @@ mod tests { assert!(!drawable_size(984, 65496, 32768)); assert!(!drawable_size(984, u32::MAX, 32768)); } + + #[test] + fn os_luminances_become_auto_values() { + use super::os_reported; + use crate::graphics::Reported; + let info = |max, white| wgpu::DisplayHdrInfo { + luminance: Some(wgpu::DisplayLuminance { + max_nits: max, + sdr_white_nits: white, + ..Default::default() + }), + ..Default::default() + }; + // Windows' SDR content brightness slider runs 80..480 nits. + assert_eq!( + os_reported(&info(Some(998.4), Some(240.0))), + Some(Reported { + paper_white: 240, + peak: 998 + }) + ); + assert_eq!( + os_reported(&info(Some(120.0), Some(1000.0))), + Some(Reported { + paper_white: 480, + peak: 200 + }) + ); + assert_eq!(os_reported(&info(Some(1000.0), None)), None); + assert_eq!(os_reported(&info(Some(f32::NAN), Some(80.0))), None); + assert_eq!(os_reported(&wgpu::DisplayHdrInfo::default()), None); + } #[test] fn surface_is_8_bit_and_not_srgb() { use super::surface_format; @@ -6156,32 +6189,64 @@ mod sprite_upload_tests { } /// What the system says about the window's display, kept up to date: on Wayland, the -/// compositor's preferred image description of the surface. +/// compositor's preferred image description of the surface; elsewhere what wgpu reads +/// from the OS (on Windows, DXGI's luminances and the SDR white level). #[derive(Default)] struct DisplayWatch { #[cfg(target_os = "linux")] wayland: Option, + /// Without Wayland's event, what wgpu last read and when to read it again: the OS + /// sends no event for a moved window or a changed SDR brightness slider. + polled: Option<(Option, std::time::Instant)>, } +/// How often [`DisplayWatch`] reads the OS's values again where no event says they changed. +const DISPLAY_POLL: std::time::Duration = std::time::Duration::from_secs(2); impl DisplayWatch { /// Starts watching, with what the system reports now. - fn new(window: &Window) -> (Self, Option) { + fn new( + window: &Window, + surface: Option<&wgpu::Surface>, + adapter: &wgpu::Adapter, + ) -> (Self, Option) { #[cfg(target_os = "linux")] if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) { let watch = Self { wayland: Some(wayland), + polled: None, }; return (watch, now.map(reported)); } let _ = window; - (Self::default(), None) + let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter))); + log_os_reported(now); + let watch = Self { + polled: Some((now, std::time::Instant::now() + DISPLAY_POLL)), + ..Self::default() + }; + (watch, now) } /// What the system reports, if that changed since the last call. - fn changed(&mut self) -> Option> { + fn changed( + &mut self, + surface: Option<&wgpu::Surface>, + adapter: &wgpu::Adapter, + ) -> Option> { #[cfg(target_os = "linux")] if let Some(wayland) = &mut self.wayland { return wayland.changed().map(|now| now.map(reported)); } - None + let (last, next) = self.polled.as_mut()?; + let time = std::time::Instant::now(); + if time < *next { + return None; + } + *next = time + DISPLAY_POLL; + let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter))); + (now != *last).then(|| { + log_os_reported(now); + *last = now; + now + }) } } @@ -6232,6 +6297,30 @@ fn flush_display_log() { (log.last, log.at) = (line, Some(now)); } } +fn log_os_reported(reported: Option) { + log_display(match reported { + Some(r) => format!( + "Horizon renderer: the system reports SDR white at {} nits and a peak of {} nits", + r.paper_white, r.peak + ), + None => "Horizon renderer: the system doesn't report the display's luminance".into(), + }); +} +/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR +/// white level the brightness slider sets), which is HDR output's scale there: SDR +/// white is Paper White, the display's peak is Peak Brightness. Both are kept within +/// the settings' ranges. The OS's figures often come from the display's EDID and may +/// run optimistic; the player's own value always wins. +fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option { + let luminance = info.luminance?; + let usable = |n: f32| n.is_finite() && n > 0.0; + let peak = luminance.max_nits.filter(|&n| usable(n))?; + let white = luminance.sdr_white_nits.filter(|&n| usable(n))?; + Some(crate::graphics::Reported { + paper_white: (white.round() as u32).clamp(80, 480), + peak: (peak.round() as u32).clamp(200, 10_000), + }) +} /// What a compositor's luminances mean in HDR output's scale. /// diff --git a/docs/graphics.md b/docs/graphics.md index b173f80ad3..ebb7e31b6a 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -30,7 +30,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi **Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are. -**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 by default, for an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain. +**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 by default, for an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain. Which settings each screen shows, their descriptions and costs are in `crates/horizon-client-render/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them. From 5aa2da49608f64dc9f56ed32345cecd06db59cbc Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:38:08 +0700 Subject: [PATCH 158/968] Applying a resource pack no longer freezes the window The pack screen, F3+T and options-set staged the whole reload (overlay root, models, atlas, pack tables, mip chains) on the render thread, so a large pack froze the window for seconds. They now run it on a worker under the progress overlay, as server packs already did (Minecraft. reloadResourcePacks under a LoadingOverlay); a reload asked for while one runs waits for it (pendingReload). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 14 +- crates/horizon-client/src/game/packs.rs | 237 +++++++++++++++--- .../horizon-client/src/game/server_packs.rs | 100 ++------ 3 files changed, 225 insertions(+), 126 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..d4dbc7b1f1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1445,6 +1445,9 @@ struct Game<'a> { pending_entry: Option, world_loader: Option, pending_pack_reload: bool, + /// A pack reload staged on a worker (`LoadingOverlay`): the frame draws + /// its progress until `drive_pack_reload` applies it. + pack_reload: Option, /// A server's resource packs (game/server_packs.rs). server_packs: server_packs::ServerPackState, /// The link a chat click asks to open (`ConfirmLinkScreen`). @@ -12021,9 +12024,9 @@ impl Game<'_> { } return Ok(false); } - // A server pack's reload covers the frame with its progress - // (`LoadingOverlay`) until `tick_server_packs` applies it. - if let Some(reload) = &self.server_packs.reload { + // A pack reload covers the frame with its progress + // (`LoadingOverlay`) until `drive_pack_reload` applies it. + if let Some(reload) = &self.pack_reload { if let Some(renderer) = &mut self.renderer { let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); horizon_client::level_loading::draw_progress( @@ -13191,8 +13194,8 @@ impl ApplicationHandler for Game<'_> { return; } // The pack reload's progress overlay takes no input - // (`LoadingOverlay`): `start_pack_reload` released what was held. - if self.server_packs.reload.is_some() + // (`LoadingOverlay`): `spawn_pack_reload` released what was held. + if self.pack_reload.is_some() && !matches!( &event, WindowEvent::CloseRequested @@ -14227,6 +14230,7 @@ impl<'a> Game<'a> { pending_entry: None, world_loader: None, pending_pack_reload: false, + pack_reload: None, server_packs: Default::default(), link_prompt: None, code_of_conduct: None, diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 2096e8c0e7..6707270430 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -136,45 +136,94 @@ impl Game<'_> { /// models and sprites (every section is meshed again, /// `LevelRenderer.allChanged`), the shared atlas (GUI, item, entity, /// particle and font textures), item models, sounds, languages, GUI - /// metadata and the cloud and world border textures. + /// metadata and the cloud and world border textures. The loading runs on + /// a worker under the progress overlay (`LoadingOverlay` over + /// `ReloadableResourceManager.createReload`'s background executor), so + /// the window keeps drawing; `drive_pack_reload` applies it. pub(super) fn reload_packs(&mut self) { // Pack reload updates tail-owned mob metadata. Keep menus responsive // until those resources exist, then apply the latest selection once. - if self.rest_loader.is_some() || self.world_loader.is_some() { + // A reload already under its overlay finishes first (vanilla's + // `pendingReload`, which `runTick` starts once the overlay is gone). + if reload_waits( + self.rest_loader.is_some() || self.world_loader.is_some(), + self.pack_reload.is_some(), + ) { self.pending_pack_reload = true; return; } - if let Err(e) = self.reload_selected_packs() { - log::warn!("Horizon: resource reload failed: {e}"); - if self.screen != super::Screen::Options { - self.open_options_at( - self.title.is_some(), - horizon_client::options_ui::Page::Packs, - ); - } - self.ui.options.resource_error = Some(e.to_string()); + if let Err(e) = self.spawn_pack_reload(ReloadKind::Selection) { + self.selection_reload_failed(&format!("could not start the pack reload: {e}")); } } - /// The CPU half of the reload, safe on a worker (`PackReload` runs it): - /// `apply_staged_pack` finishes the GPU half and the swap. - pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result { + /// `reloadResourcePacks`' failure: the pack screen again, with the error. + fn selection_reload_failed(&mut self, error: &str) { + log::warn!("Horizon: resource reload failed: {error}"); + if self.screen != super::Screen::Options { + self.open_options_at( + self.title.is_some(), + horizon_client::options_ui::Page::Packs, + ); + } + self.ui.options.resource_error = Some(error.to_string()); + } + /// Stages the selection with the server's active packs on top on a + /// worker (`createReload`'s background executor); the frame draws its + /// progress meanwhile, and the overlay takes no input (`LoadingOverlay`), + /// so what is held is released (`KeyMapping.releaseAll`). + pub(super) fn spawn_pack_reload(&mut self, kind: ReloadKind) -> std::io::Result<()> { + self.keys.clear(); + self.physical_keys.clear(); + self.left = false; + self.right = false; let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); } - stage_pack( - &vanilla, - &self.settings.resource_packs, - &self.server_packs.active, - &self.settings.mods.enabled, - &self.options.dev_mods, - &self.pack.assets, - &self.res, - &self.pack.context, - self.settings.force_unicode, - self.settings.japanese_glyphs, - &super::Progress::new(), - ) + let (selected, server, mods, dev_mods) = ( + self.settings.resource_packs.clone(), + self.server_packs.active.clone(), + self.settings.mods.enabled.clone(), + self.options.dev_mods.clone(), + ); + let (assets, res, context) = ( + self.pack.assets.clone(), + self.res.clone(), + self.pack.context.clone(), + ); + let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); + self.pack_reload = Some(PackReload::spawn(kind, move |progress| { + stage_pack( + &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode, + japanese, progress, + ) + .map_err(|e| e.to_string()) + })?); + Ok(()) + } + /// `LoadingOverlay.tick` once the reload is done: the staged pack is + /// applied on the render thread, and its outcome goes to whoever asked + /// (`reloadResourcePacks`' callback, or `PackReloadConfig.Callbacks`). + pub(super) fn drive_pack_reload(&mut self) { + let Some((kind, staged)) = self.pack_reload.as_mut().and_then(PackReload::finished) else { + return; + }; + self.pack_reload = None; + let result = + staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())); + match kind { + ReloadKind::Selection => { + if let Err(e) = result { + self.selection_reload_failed(&e); + } + } + ReloadKind::Server => { + if let Err(e) = &result { + log::warn!("Horizon: server resource pack reload failed: {e}"); + } + self.finish_pack_reload(result.is_ok()); + } + } } /// The render-thread half: the renderer on the new pack's textures, then /// the swap (`ReloadInstance`'s application listeners). @@ -257,16 +306,10 @@ impl Game<'_> { self.creative_screen.forget_items(); Ok(()) } - /// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then - /// applied (`PackReload` runs the same two phases across frames). - pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { - let staged = self.stage_selected_packs()?; - self.apply_staged_pack(staged) - } } -/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a -/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay +/// The CPU half of a pack reload (`PackReload` runs it on a worker; +/// `apply_staged_pack` does the GPU half and the swap): the overlay /// root, the models and sprites, the menu bundle, the pack's tables and its /// CPU mip chains. pub(super) fn stage_pack( @@ -320,3 +363,127 @@ pub(super) struct StagedPack { pub pack: PackResources, pub textures: PreparedTextures, } + +/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. +const RELOAD_LINES: [&str; 4] = [ + "Preparing resources", + "Loading assets", + "Loading pack tables", + "Applying resources", +]; +/// Whether a reload asked for now waits: for the world's resources, or for +/// the reload already under its overlay (`Minecraft.reloadResourcePacks` +/// hands back `pendingReload` while a `LoadingOverlay` is up). +fn reload_waits(world_loading: bool, in_flight: bool) -> bool { + world_loading || in_flight +} +/// Who asked for a pack reload: its outcome goes back there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ReloadKind { + /// The player's own selection (`Minecraft.reloadResourcePacks`). + Selection, + /// A server's packs (`DownloadedPackSource.startReload`). + Server, +} +type StageResult = std::result::Result; +/// A pack reload whose CPU half runs on a worker while `progress` feeds the +/// loading overlay; `Game::drive_pack_reload` applies it on the render thread. +pub(super) struct PackReload { + pub(super) progress: super::Progress, + kind: ReloadKind, + handle: Option>, +} +impl PackReload { + /// Starts `stage` on its own thread and returns at once. + fn spawn( + kind: ReloadKind, + stage: impl FnOnce(&super::Progress) -> StageResult + Send + 'static, + ) -> std::io::Result { + let progress = super::Progress::with(&RELOAD_LINES); + let worker = progress.clone(); + let handle = std::thread::Builder::new() + .name("pack-reload".into()) + .spawn(move || stage(&worker))?; + Ok(Self { + progress, + kind, + handle: Some(handle), + }) + } + pub(super) fn kind(&self) -> ReloadKind { + self.kind + } + /// The staged pack once the worker is done (taken once); none while it runs. + fn finished(&mut self) -> Option<(ReloadKind, StageResult)> { + if !self.handle.as_ref()?.is_finished() { + return None; + } + let staged = self + .handle + .take()? + .join() + .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); + Some((self.kind, staged)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::mpsc, time::Duration}; + + fn wait(reload: &mut PackReload) -> (ReloadKind, StageResult) { + for _ in 0..500 { + if let Some(done) = reload.finished() { + return done; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("the reload worker never finished"); + } + + #[test] + fn a_reload_stages_on_a_worker_while_the_caller_goes_on() { + // The stage blocks until the caller lets it go: `spawn` returning at + // all means the window thread is free to draw the overlay. + let (release, blocked) = mpsc::channel::<()>(); + let (thread, ran_on) = mpsc::channel(); + let mut reload = PackReload::spawn(ReloadKind::Selection, move |progress| { + blocked.recv().unwrap(); + progress.set(2); + thread + .send(std::thread::current().name().map(str::to_owned)) + .unwrap(); + Err("staged".into()) + }) + .unwrap(); + assert!(reload.finished().is_none(), "still staging"); + assert_eq!(reload.progress.line(), "Preparing resources"); + release.send(()).unwrap(); + let (kind, staged) = wait(&mut reload); + assert_eq!(kind, ReloadKind::Selection); + assert_eq!(staged.err().as_deref(), Some("staged")); + assert_eq!(reload.progress.line(), "Loading pack tables"); + assert_eq!(ran_on.recv().unwrap().as_deref(), Some("pack-reload")); + assert!(reload.finished().is_none(), "the outcome is taken once"); + } + + #[test] + fn a_panicking_stage_fails_the_reload() { + let mut reload = PackReload::spawn(ReloadKind::Server, |_| panic!("bad pack")).unwrap(); + let (kind, staged) = wait(&mut reload); + assert_eq!(kind, ReloadKind::Server); + assert_eq!( + staged.err().as_deref(), + Some("the pack reload worker panicked") + ); + } + + #[test] + fn a_reload_waits_for_world_resources_and_the_one_in_flight() { + assert!(!reload_waits(false, false)); + assert!(reload_waits(true, false)); + assert!(reload_waits(false, true)); + assert!(reload_waits(true, true)); + } +} diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 6dead96044..a663ecf771 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -3,7 +3,7 @@ //! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the //! server list's setting, downloads into `downloads/`, and a resource reload //! with the server's packs on top of the player's own. -use super::{Game, Progress}; +use super::{Game, packs::ReloadKind}; use horizon_client::{ Result, confirm_screen::Confirm, @@ -89,9 +89,6 @@ pub(super) struct ServerPackState { pub(super) active: Vec, /// The download batches' notifiers, oldest first. notifiers: Vec, - /// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the - /// frame draws its progress until `tick_server_packs` applies it. - pub(super) reload: Option, } /// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its /// own `SystemToast`. @@ -201,21 +198,6 @@ pub(super) fn saves() -> PathBuf { std::env::current_dir().unwrap_or_default().join("saves") } -/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. -const RELOAD_LINES: [&str; 4] = [ - "Preparing resources", - "Loading assets", - "Loading pack tables", - "Applying resources", -]; -/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a -/// worker while `progress` feeds the loading screen, then the apply lands -/// on the render thread. -pub(super) struct PackReload { - pub(super) progress: Progress, - handle: std::thread::JoinHandle>, -} - impl Game<'_> { /// `ConnectScreen`: the server's setting decides what its packs do /// (`configureForServerControl`); single player's own server takes them @@ -342,6 +324,13 @@ impl Game<'_> { return; } self.drive_pack_reload(); + // One reload at a time: the player's own waits under its overlay + // before the server's packs can ask for theirs (`ServerPacks` keeps + // its own reload from overlapping). + let selection_reloading = self + .pack_reload + .as_ref() + .is_some_and(|r| r.kind() == ReloadKind::Selection); let finished = self .server_packs .queue @@ -351,7 +340,7 @@ impl Game<'_> { for batch in finished { self.server_packs.packs.downloaded(batch); } - while self.server_packs.packs.wants_tick() { + while !selection_reloading && self.server_packs.packs.wants_tick() { let tick = self.server_packs.packs.tick(); if !tick.download.is_empty() { self.start_pack_downloads(tick.download); @@ -415,75 +404,14 @@ impl Game<'_> { self.finish_pack_reload(false); return; }; - self.server_packs.active = server.clone(); - // The overlay swallows input until the apply (`LoadingOverlay`): - // release what is held so nothing keeps acting while it covers the - // frame (`KeyMapping.releaseAll` when a screen opens). - self.keys.clear(); - self.physical_keys.clear(); - self.left = false; - self.right = false; - let vanilla = self.vanilla_root(); - if self.ui.vanilla_root.as_os_str().is_empty() { - self.ui.vanilla_root = vanilla.clone(); + self.server_packs.active = server; + if let Err(e) = self.spawn_pack_reload(ReloadKind::Server) { + log::error!("Horizon: could not start the pack reload worker: {e}"); + self.finish_pack_reload(false); } - let (selected, mods, dev_mods) = ( - self.settings.resource_packs.clone(), - self.settings.mods.enabled.clone(), - self.options.dev_mods.clone(), - ); - let (assets, res, context) = ( - self.pack.assets.clone(), - self.res.clone(), - self.pack.context.clone(), - ); - let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); - let progress = Progress::with(&RELOAD_LINES); - let worker = progress.clone(); - let handle = std::thread::Builder::new() - .name("server-pack-reload".into()) - .spawn(move || { - super::packs::stage_pack( - &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, - unicode, japanese, &worker, - ) - .map_err(|e| e.to_string()) - }); - match handle { - Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), - Err(e) => { - log::error!("Horizon: could not start the pack reload worker: {e}"); - self.finish_pack_reload(false); - } - } - } - /// `PackReloadConfig.Callbacks`: applies the staged reload once its - /// worker is done; on failure the packs active before reload again. - fn drive_pack_reload(&mut self) { - let Some(reload) = &self.server_packs.reload else { - return; - }; - if !reload.handle.is_finished() { - return; - } - let reload = self.server_packs.reload.take().unwrap(); - let staged = reload - .handle - .join() - .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); - let ok = match staged - .and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())) - { - Ok(()) => true, - Err(e) => { - log::warn!("Horizon: server resource pack reload failed: {e}"); - false - } - }; - self.finish_pack_reload(ok); } /// `ServerPacks::reloaded` and the recovery reload it can ask for. - fn finish_pack_reload(&mut self, ok: bool) { + pub(super) fn finish_pack_reload(&mut self, ok: bool) { if let Some(recovery) = self.server_packs.packs.reloaded(ok) { self.start_pack_reload(&recovery); } From 2017653aff160ad86e4aac7773343b45733a6e92 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Thu, 29 Oct 2026 22:44:39 +0100 Subject: [PATCH 159/968] bench: one script that runs Horizon, vanilla or Pumpkin under the same horizon-bots load --- bench/bench.py | 452 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_bench.py | 83 ++++++++ 2 files changed, 535 insertions(+) create mode 100644 bench/bench.py create mode 100644 tests/test_bench.py diff --git a/bench/bench.py b/bench/bench.py new file mode 100644 index 0000000000..f028a4451b --- /dev/null +++ b/bench/bench.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin. + + python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50 + python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345 + python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50 + +Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the +server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds: + +- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second; +- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window; +- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS); +- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second, when the bots report them + (they do not yet: the field is null until issue S-6); join time: the bots' `joined_after_ms`. + +It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1). + +Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released +binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the +config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key +differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs +repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots +""" + +import argparse +import datetime +import json +import os +from pathlib import Path +import platform +import re +import shutil +import socket +import statistics +import subprocess +import sys +import tempfile +import threading +import time + +ROOT = Path(__file__).resolve().parent.parent +EXE = '.exe' if os.name == 'nt' else '' +SERVERS = ('horizon', 'vanilla', 'pumpkin') + +PROPERTIES = { + 'level-seed': '12345', + 'view-distance': '10', + 'simulation-distance': '10', + 'online-mode': 'false', + 'white-list': 'false', + 'gamemode': 'creative', + 'force-gamemode': 'true', + 'spawn-protection': '0', + 'max-players': '200', + 'difficulty': 'easy', + 'server-ip': '127.0.0.1', + 'enable-query': 'false', + 'enable-rcon': 'false', +} + +# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off. +PUMPKIN_CONFIGURATION = '''\ +server_address = "127.0.0.1:{port}" +seed = "{seed}" +max_players = 200 +view_distance = {view} +simulation_distance = {view} +online_mode = false +default_difficulty = "Easy" +allow_chat_reports = false +encryption = false +lan_broadcast = false + +[networking.bedrock] +enabled = false + +[networking.lan_broadcast] +enabled = false + +[networking.query] +enabled = false + +[networking.rcon] +enabled = false +''' +PUMPKIN_FEATURES = '''\ +[telemetry] +enabled = false + +[plugins] +enabled = false +''' + +DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE) +TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms') +TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms') +GAMETIME = re.compile(r'The time is (-?\d+)') + + +def free_port(): + with socket.socket() as s: + s.bind(('127.0.0.1', 0)) + return s.getsockname()[1] + + +def parse_tick_query(lines): + """MSPT numbers from the lines `tick query` printed, or None until both lines are there.""" + average = percentiles = None + for line in lines: + if (m := TICK_AVERAGE.search(line)) and average is None: + average = float(m.group(1)) + if (m := TICK_PERCENTILES.search(line)) and percentiles is None: + percentiles = tuple(float(g) for g in m.groups()) + if average is None or percentiles is None: + return None + return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]} + + +def parse_gametime(lines): + """The game time `time query gametime` printed, or None.""" + for line in lines: + if m := GAMETIME.search(line): + return int(m.group(1)) + return None + + +def tps(start_ticks, end_ticks, seconds): + """Game ticks per wall-clock second over the window, or None when a query went unanswered.""" + if start_ticks is None or end_ticks is None or seconds <= 0: + return None + return round((end_ticks - start_ticks) / seconds, 2) + + +def percentile(values, p): + if not values: + return None + ordered = sorted(values) + return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))] + + +def cpu_seconds(text): + """ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss].""" + days, _, rest = text.rpartition('-') + seconds = 0.0 + for part in rest.split(':'): + seconds = seconds * 60 + float(part) + return seconds + (float(days) * 86400 if days else 0) + + +def tree_usage(root): + """RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`.""" + try: + out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout + except OSError: + return None + rows = {} + for line in out.splitlines(): + parts = line.split() + if len(parts) == 4: + rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3])) + tree, frontier = {root}, [root] + while frontier: + parent = frontier.pop() + for pid, (ppid, _, _) in rows.items(): + if ppid == parent and pid not in tree: + tree.add(pid) + frontier.append(pid) + return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows) + + +def prepare(kind, directory, port, args): + """Writes the server's settings into its scratch folder and returns the command that starts it.""" + directory = Path(directory) + if args.pre_generated: + shutil.copytree(args.pre_generated, directory / 'world') + props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed), + 'view-distance': str(args.view_distance), + 'simulation-distance': str(args.view_distance)}) + if kind == 'pumpkin': + config = directory / 'config' + config.mkdir() + (config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format( + port=port, seed=args.seed, view=args.view_distance)) + (config / 'features.toml').write_text(PUMPKIN_FEATURES) + if args.pumpkin_config: + with (config / 'configuration.toml').open('a') as f: + f.write('\n' + Path(args.pumpkin_config).read_text()) + return [Path(args.exe).resolve()] + (directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items())) + (directory / 'eula.txt').write_text('eula=true\n') + if kind == 'vanilla': + return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui'] + server = Path(args.exe).resolve() + subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True, + capture_output=True) + toml = directory / 'server.toml' + # No web panel in a bench. + toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text())) + return [server, '--dir', directory, 'run'] + + +class Server: + """A server with its console on stdin and its output collected.""" + + def __init__(self, command, directory, log_path): + self.lines = [] + self.lock = threading.Lock() + self.log = log_path.open('w', encoding='utf-8') + env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'), + HORIZON_LAUNCHER_NO_SELF_UPDATE='1') + self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + encoding='utf-8', errors='replace', env=env) + threading.Thread(target=self._read, daemon=True).start() + + def _read(self): + for line in self.process.stdout: + with self.lock: + self.lines.append(line.rstrip('\n')) + self.log.write(line) + self.log.flush() + + def count(self): + with self.lock: + return len(self.lines) + + def since(self, n): + with self.lock: + return self.lines[n:] + + def wait_for(self, regex, timeout): + deadline = time.time() + timeout + while time.time() < deadline: + if any(regex.search(line) for line in self.since(0)): + return + if self.process.poll() is not None: + raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:])) + time.sleep(0.1) + raise RuntimeError(f'the server was not up within {timeout} s') + + def ask(self, command, parse, timeout=5): + """Sends a console command and returns parse(its output lines), or None if nothing parsed in time.""" + start = self.count() + try: + self.process.stdin.write(command + '\n') + self.process.stdin.flush() + except (BrokenPipeError, OSError): + return None + deadline = time.time() + timeout + while time.time() < deadline: + if (value := parse(self.since(start))) is not None: + return value + time.sleep(0.05) + return None + + def stop(self): + if self.process.poll() is None: + try: + self.process.stdin.write('stop\n') + self.process.stdin.flush() + self.process.wait(timeout=60) + except (BrokenPipeError, OSError, subprocess.TimeoutExpired): + self.process.kill() + self.process.wait() + self.log.close() + + +def bots_command(args, port, total): + return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots), + '--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)), + '--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-'] + + +def split_bot_output(lines): + """The per-second samples (JSON lines with a `t`) and the final summary the bots printed.""" + samples, rest = [], [] + for line in lines: + try: + sample = json.loads(line) + except ValueError: + sample = None + if isinstance(sample, dict) and 't' in sample: + samples.append(sample) + else: + rest.append(line) + text = ''.join(rest) + return samples, (json.loads(text) if text.strip() else {}) + + +def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds): + mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None + joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0] + players = max(summary.get('joined') or 0, 1) + # None until the bots count bytes (issue S-6). + bandwidth = None + totals = [b for b in summary.get('per_bot', []) if 'bytes_in' in b] + if totals and run_seconds > 0: + bandwidth = {k: round(sum(b.get(k, 0) for b in totals) / players / run_seconds) + for k in ('bytes_in', 'bytes_out')} + return { + 'server': kind, + 'bots': args.bots, + 'behaviour': args.behaviour, + 'start_seconds': start_seconds, + 'tps': tps(*gametime, window_seconds), + 'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')}, + 'mspt_worst_p99': max((t['p99'] for t in ticks), default=None), + 'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None}, + 'cpu_percent': mean(cpu), + 'bytes_per_player_second': bandwidth, + 'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)}, + 'bots_joined': summary.get('joined'), + 'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')], + 'chunks_total': summary.get('chunks'), + 'tick_queries': len(ticks), + } + + +def run_once(kind, args, out): + scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-')) + result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour} + server = None + try: + port = free_port() + command = prepare(kind, scratch, port, args) + started = time.time() + server = Server(command, scratch, out / f'{kind}-{args.bots}.log') + server.wait_for(DONE, args.start_timeout) + start_seconds = round(time.time() - started, 2) + total = args.bots * args.join_every + args.warmup + args.seconds + bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True) + bot_lines = [] + threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start() + time.sleep(args.bots * args.join_every + args.warmup) + first_time = server.ask('time query gametime', parse_gametime) + began = time.time() + rss, cpu, ticks = [], [], [] + usage = tree_usage(server.process.pid) + last_cpu, last_time = (usage[1] if usage else 0), time.time() + next_query = time.time() + while time.time() - began < args.seconds: + time.sleep(1) + now = time.time() + if usage and (u := tree_usage(server.process.pid)): + rss.append(u[0]) + cpu.append((u[1] - last_cpu) / (now - last_time) * 100) + last_cpu, last_time = u[1], now + if now >= next_query: + if (t := server.ask('tick query', parse_tick_query)) is not None: + ticks.append(t) + next_query = now + 5 + window = time.time() - began + last = server.ask('time query gametime', parse_gametime) + bot_process.wait(timeout=total + 120) + time.sleep(0.5) + _, summary = split_bot_output(list(bot_lines)) + result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds, + total)) + except Exception as e: # a failed run is reported, and the next one still runs + result['error'] = str(e) + finally: + if server: + server.stop() + shutil.rmtree(scratch, ignore_errors=True) + return result + + +def table(runs): + """The Markdown table of the design: one column per server, one row per measure.""" + ok = [r for r in runs if 'error' not in r] + head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n' + def cell(r, path): + value = r + for key in path: + value = value.get(key) if isinstance(value, dict) else None + return '-' if value is None else str(value) + rows = [ + ('Players', ('bots',)), + ('TPS', ('tps',)), + ('MSPT average', ('mspt', 'average')), + ('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')), + ('MSPT p99 (worst query)', ('mspt_worst_p99',)), + ('RSS mean MB', ('rss_mb', 'mean')), + ('CPU %', ('cpu_percent',)), + ('Bytes in per player and s', ('bytes_per_player_second', 'bytes_in')), + ('Join p50 ms', ('join_ms', 'p50')), + ('Join worst ms', ('join_ms', 'worst')), + ('Start s', ('start_seconds',)), + ] + body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows) + failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r) + return head + body + failed + + +def check(args): + for kind in args.server: + if kind not in SERVERS: + sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}') + if 'vanilla' in args.server and not args.jar: + sys.exit('vanilla needs --jar, your own server.jar') + if any(k != 'vanilla' for k in args.server) and not args.exe: + sys.exit('horizon and pumpkin need --exe, the server binary') + if not Path(args.bots_bin).exists(): + sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots') + if args.pre_generated and not Path(args.pre_generated).is_dir(): + sys.exit(f'--pre-generated {args.pre_generated} is not a folder') + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) + parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'], + help='horizon, vanilla or pumpkin (comma-separated, one run each)') + parser.add_argument('--exe', help='the horizon-server or Pumpkin binary') + parser.add_argument('--jar', help="vanilla's server.jar (your own copy)") + parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}')) + parser.add_argument('--bots', type=int, default=50) + parser.add_argument('--behaviour', default='mixed') + parser.add_argument('--join-every', type=float, default=0.5) + parser.add_argument('--warmup', type=int, default=30) + parser.add_argument('--seconds', type=int, default=60) + parser.add_argument('--seed', type=int, default=12345) + parser.add_argument('--view-distance', type=int, default=10) + parser.add_argument('--heap', default='4g', help='the Java heap for vanilla') + parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`') + parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml') + parser.add_argument('--start-timeout', type=int, default=600) + parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs') + args = parser.parse_args(argv) + check(args) + args.out.mkdir(parents=True, exist_ok=True) + runs = [] + for kind in args.server: + print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True) + runs.append(run_once(kind, args, args.out)) + print(json.dumps(runs[-1]), flush=True) + report = { + 'kind': 'horizon-bench', + 'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'), + 'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()}, + 'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()}, + 'runs': runs, + } + path = args.out / 'report.json' + path.write_text(json.dumps(report, indent=2) + '\n') + print('\n' + table(runs) + f'\nWrote {path}') + return 1 if any('error' in r for r in runs) else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_bench.py b/tests/test_bench.py new file mode 100644 index 0000000000..256711f693 --- /dev/null +++ b/tests/test_bench.py @@ -0,0 +1,83 @@ +"""bench/bench.py: the parsers, the report table and the per-server setup (no server is started).""" + +import importlib.util +from pathlib import Path +import tempfile +import types +import unittest + +SPEC = importlib.util.spec_from_file_location('bench', Path(__file__).resolve().parent.parent / 'bench/bench.py') +bench = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(bench) + + +def args(**kw): + base = dict(seed=777, view_distance=8, pre_generated=None, pumpkin_config=None, exe='pumpkin', jar='server.jar', + heap='2g', bots=5, behaviour='mixed', warmup=1, join_every=0.5) + base.update(kw) + return types.SimpleNamespace(**base) + + +class ParserTests(unittest.TestCase): + def test_tick_query(self): + lines = ['[Server] Average time per tick: 12.5 ms', 'Target: 50.0 ms', 'P50: 11.0 ms P95: 20.5 ms P99: 31.0 ms'] + self.assertEqual(bench.parse_tick_query(lines), {'average': 12.5, 'p50': 11.0, 'p95': 20.5, 'p99': 31.0}) + self.assertIsNone(bench.parse_tick_query(lines[:1])) + + def test_gametime_and_tps(self): + self.assertEqual(bench.parse_gametime(['x', 'The time is 4200']), 4200) + self.assertIsNone(bench.parse_gametime(['nothing'])) + self.assertEqual(bench.tps(1000, 2200, 60), 20.0) + self.assertIsNone(bench.tps(None, 2200, 60)) + + def test_cpu_seconds_and_percentile(self): + self.assertEqual(bench.cpu_seconds('1-00:00:01.5'), 86401.5) + self.assertEqual(bench.cpu_seconds('02:03'), 123.0) + self.assertEqual(bench.percentile([5, 1, 3], 50), 3) + self.assertIsNone(bench.percentile([], 50)) + + def test_bot_output_split(self): + samples, summary = bench.split_bot_output(['{"t": 1, "online": 2}\n', '{\n', ' "joined": 2\n', '}\n']) + self.assertEqual(samples, [{'t': 1, 'online': 2}]) + self.assertEqual(summary, {'joined': 2}) + + +class SetupTests(unittest.TestCase): + def test_pumpkin_config_and_pre_generated_world(self): + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + world = tmp / 'src' + world.mkdir() + (world / 'level.dat').write_text('x') + run = tmp / 'run' + run.mkdir() + command = bench.prepare('pumpkin', run, 25599, args(pre_generated=str(world))) + self.assertEqual(len(command), 1) + config = (run / 'config/configuration.toml').read_text() + self.assertIn('127.0.0.1:25599', config) + self.assertIn('[networking.bedrock]\nenabled = false', config) + self.assertIn('enabled = false', (run / 'config/features.toml').read_text()) + self.assertEqual((run / 'world/level.dat').read_text(), 'x') + + def test_vanilla_properties(self): + with tempfile.TemporaryDirectory() as tmp: + command = bench.prepare('vanilla', tmp, 25598, args()) + text = (Path(tmp) / 'server.properties').read_text() + for line in ('server-ip=127.0.0.1', 'server-port=25598', 'level-seed=777', 'online-mode=false'): + self.assertIn(line, text) + self.assertIn('-Xmx2g', command) + self.assertEqual((Path(tmp) / 'eula.txt').read_text(), 'eula=true\n') + + +class TableTests(unittest.TestCase): + def test_table_marks_missing_numbers(self): + run = {'server': 'horizon', 'bots': 5, 'tps': 20.0, 'mspt': {'average': 3.0, 'p99': 9.0}, + 'bytes_per_player_second': None, 'join_ms': {'p50': 80, 'worst': 120}} + text = bench.table([run, {'server': 'pumpkin', 'error': 'no start\nmore'}]) + self.assertIn('| TPS | 20.0 |', text) + self.assertIn('| Bytes in per player and s | - |', text) + self.assertIn('pumpkin failed: no start', text) + + +if __name__ == '__main__': + unittest.main() From f7b77fbc8844e8dd4d49ea1db29c04cce1c2868c Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:41:49 +0700 Subject: [PATCH 160/968] Many mobs no longer grow the renderer's staging memory until it runs out Uploads staged every write through wgpu's StagingBelt, which gives a write larger than its chunk a buffer of exactly that size, keeps it for good and only reuses it for writes that fit. The entity batch outgrows the 1 MiB chunk with a few hundred mobs and grows a little each tick while more appear, so every tick took a new buffer: about 1 GB more per hundred mobs past ~750, never given back, until the game was killed (#2826). Writes larger than the chunk now take a power-of-two slice and copy only their bytes, so the buffers the belt keeps are reused: one per doubling. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client-render/src/gfx/upload.rs | 113 +++++++++++++++++- 1 file changed, 108 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client-render/src/gfx/upload.rs b/crates/horizon-client-render/src/gfx/upload.rs index 0d56c73a03..ec6bf9271c 100644 --- a/crates/horizon-client-render/src/gfx/upload.rs +++ b/crates/horizon-client-render/src/gfx/upload.rs @@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20; pub struct Uploads { belt: wgpu::util::StagingBelt, + chunk: u64, } impl Uploads { pub fn new(device: &wgpu::Device) -> Self { Self::with_chunk(device, CHUNK) } - /// A belt whose staging buffers hold `chunk` bytes (larger writes get - /// a buffer of their own). + /// A belt whose staging buffers hold `chunk` bytes (a larger write gets + /// a buffer of its own, rounded up to a power of two). pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self { Self { belt: wgpu::util::StagingBelt::new(device.clone(), chunk), + chunk, } } + /// A mapped staging slice of at least `size` bytes. The belt gives a + /// write larger than its chunk a buffer of exactly that size, keeps it for + /// good and only reuses it for writes that fit: an upload that grows a + /// little every frame (the entity batch while mobs appear) took a new + /// buffer each frame until the game ran out of memory (#2826). Rounded up + /// to a power of two, the large buffers it keeps are reused, one per + /// doubling. + fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> { + let size = if size.get() > self.chunk { + size.checked_next_power_of_two().unwrap_or(size) + } else { + size + }; + self.belt.allocate(size, alignment) + } /// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip /// level of one array layer, padding rows to the copy alignment. pub fn texture( @@ -41,7 +58,7 @@ impl Uploads { }; let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)) .expect("non-zero alignment"); - let slice = self.belt.allocate(size, alignment); + let slice = self.allocate(size, alignment); { let mut view = slice .get_mapped_range_mut() @@ -80,8 +97,20 @@ impl Uploads { let Some(size) = NonZeroU64::new(bytes.len() as u64) else { return; }; - self.belt - .write_buffer(encoder, target, offset, size) + // The belt's own `write_buffer` checks, kept now that a large write's + // slice is rounded up past them. + assert!( + offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT) + && size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT), + "buffer upload at {offset} of {size} bytes must be 4-byte aligned" + ); + let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment"); + let slice = self.allocate(size, alignment).slice(..size.get()); + encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get()); + slice + .get_mapped_range_mut() + .expect("staging belt slices are mapped") + .slice(..) .copy_from_slice(bytes); } /// Close this frame's staging buffers: they come back for reuse once the @@ -217,6 +246,80 @@ mod tests { } } + /// Staging buffers the belt holds, once every submitted frame is done + /// with them. The belt only reports its buffers through `Debug`. + fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize { + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + // Finishing an empty frame takes back every recalled buffer. + uploads.finish(&device.create_command_encoder(&Default::default())); + let belt = format!("{:?}", uploads.belt); + let count = |field: &str| -> usize { + let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2; + let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next(); + digits.and_then(|d| d.parse().ok()).expect(&belt) + }; + count("active_chunks") + count("closed_chunks") + count("free_chunks") + } + + /// #2826: a write larger than the chunk that grows a little every frame + /// (the entity batch while mobs appear) got a new exact-size buffer each + /// frame, all of them kept, until the game ran out of memory. + #[test] + fn growing_large_writes_reuse_the_belt_buffers() { + let Some((device, queue)) = device() else { + eprintln!("no GPU adapter: skipped"); + return; + }; + const CHUNK: u64 = 1 << 16; + const TARGET: usize = 4 * CHUNK as usize; + let target = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: TARGET as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: TARGET as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut uploads = Uploads::with_chunk(&device, CHUNK); + let mut expected = vec![0u8; TARGET]; + let frames = 64; + for frame in 0..frames { + let mut encoder = device.create_command_encoder(&Default::default()); + // One small write, then the large one, 1.5 to 3.5 chunks long. + uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]); + expected[TARGET - 4..].fill(9); + let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4); + let bytes: Vec = (0..len).map(|i| (i + frame) as u8).collect(); + uploads.buffer(&mut encoder, &target, 0, &bytes); + expected[..len].copy_from_slice(&bytes); + encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64); + uploads.finish(&encoder); + queue.submit([encoder.finish()]); + readback + .slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + // Only the written bytes land: the rest of the target is untouched. + assert!( + *readback.slice(..).get_mapped_range().unwrap() == expected[..], + "frame {frame}" + ); + readback.unmap(); + } + // A chunk for the small writes and a buffer per doubling of the large + // one (2 and 4 chunks), maybe one more where a small write took the + // start of a buffer the large one needed whole: not one per frame. + let kept = kept_buffers(&device, &mut uploads); + assert!( + kept <= 4, + "the belt kept {kept} buffers over {frames} frames" + ); + } + #[test] fn belt_buffer_writes_land_in_order_at_their_offsets() { let Some((device, queue)) = device() else { From 90823415c317b2183080d897e098d95a9162405f Mon Sep 17 00:00:00 2001 From: DUzzL Date: Thu, 29 Oct 2026 22:46:17 +0100 Subject: [PATCH 161/968] horizon-bots: report bytes in/out and join-time percentiles --- crates/horizon-bots/src/main.rs | 89 ++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index 66326a74e9..631ec25550 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -15,10 +15,13 @@ //! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the //! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly"). +//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after +//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds). + use horizon_protocol::{ PROTOCOL_VERSION, buf::{Reader, Writer}, - connection::{ConnWriter, Connection}, + connection::{ConnWriter, Connection, Counters}, crypto::offline_uuid, ids, packets::{ @@ -32,7 +35,7 @@ use serde_json::json; use std::{ collections::HashSet, sync::{ - Arc, Mutex, + Arc, Mutex, OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, }, thread, @@ -156,6 +159,41 @@ struct Bot { teleport: Mutex>, error: Mutex>, joined_after_ms: AtomicU64, + /// The connection's byte counters (wire bytes, after compression and encryption), set once it is open. + counters: OnceLock>, +} + +impl Bot { + fn bytes_in(&self) -> u64 { + self.counters + .get() + .map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64) + } + fn bytes_out(&self) -> u64 { + self.counters + .get() + .map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64) + } +} + +/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty. +fn percentile(sorted: &[u64], p: u64) -> u64 { + if sorted.is_empty() { + return 0; + } + let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize; + sorted[rank - 1] +} + +/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max. +fn join_summary(mut times: Vec) -> serde_json::Value { + times.sort_unstable(); + json!({ + "count": times.len(), + "p50_ms": percentile(×, 50), + "p99_ms": percentile(×, 99), + "max_ms": times.last().copied().unwrap_or(0), + }) } /// A small deterministic generator (SplitMix64), so every run moves the bots the same way. @@ -465,6 +503,7 @@ fn run_bot( } }; let (reader, writer) = connection.split(); + let _ = bot.counters.set(reader.counters.clone()); let writer = Arc::new(Mutex::new(writer)); bot.online.store(true, Ordering::SeqCst); { @@ -659,10 +698,20 @@ fn main() { "resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::(), "pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::(), "waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::(), + "bytes_in": bots.iter().map(|b| b.bytes_in()).sum::(), + "bytes_out": bots.iter().map(|b| b.bytes_out()).sum::(), + "join_time": join_summary( + bots.iter() + .map(|b| b.joined_after_ms.load(Ordering::SeqCst)) + .filter(|&ms| ms > 0) + .collect(), + ), "per_bot": bots.iter().enumerate().map(|(i, b)| json!({ "name": format!("{}{i}", o.name), "behaviour": behaviour_of(&o.behaviour, i).name(), "joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst), + "bytes_in": b.bytes_in(), + "bytes_out": b.bytes_out(), "chunks": b.chunks.load(Ordering::Relaxed), "unique_chunks": b.unique.load(Ordering::Relaxed), "resent": b.resent.load(Ordering::Relaxed), @@ -695,3 +744,39 @@ fn main() { } std::process::exit(if errors.is_empty() { 0 } else { 1 }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percentiles_use_nearest_rank() { + assert_eq!(percentile(&[], 50), 0); + assert_eq!(percentile(&[7], 99), 7); + let v: Vec = (1..=100).collect(); + assert_eq!(percentile(&v, 50), 50); + assert_eq!(percentile(&v, 99), 99); + assert_eq!(percentile(&[10, 20, 30, 40], 50), 20); + } + + #[test] + fn join_summary_sorts_and_reports_max() { + let s = join_summary(vec![300, 100, 200]); + assert_eq!(s["count"], 3); + assert_eq!(s["p50_ms"], 200); + assert_eq!(s["p99_ms"], 300); + assert_eq!(s["max_ms"], 300); + assert_eq!(join_summary(vec![])["count"], 0); + } + + #[test] + fn bot_bytes_come_from_the_connection_counters() { + let bot = Bot::default(); + assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0)); + let counters = Arc::new(Counters::default()); + counters.bytes_in.store(1234, Ordering::Relaxed); + counters.bytes_out.store(56, Ordering::Relaxed); + let _ = bot.counters.set(counters); + assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56)); + } +} From 86ebc77e25e06cf83ba96a827c75f941d92e79db Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:46:24 +0100 Subject: [PATCH 162/968] docs/server-next.md: the next server's design, measured against vanilla and Pumpkin Horizon's dedicated server, vanilla 26.3 and Pumpkin 0.2.1 (black box) with the same horizon-bots at 10/50/100 players; numeric targets; everything in this repo, shared by the integrated server, LAN and --dedicated; parallel ticking that keeps vanilla's order; the network front, tracker deltas, off-thread saving; the Horizon fast path; online mode on by default; the plugin API (with levzzz5154); the plan and the open PRs on the theme. Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 1 + docs/server-next.md | 440 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 441 insertions(+) create mode 100644 docs/server-next.md diff --git a/docs/README.md b/docs/README.md index 12e91fe7b7..e92cd1fdf9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor - [Native client](native-client.md): options, controls, the dedicated server, `horizon-ctl` scripting, packaging, architecture. - [Host a server](hosting.md): install `horizon-server`, prepare the game files, first run, `server.toml`, the commands, the web panel, backups, services and Docker; what works today and what doesn't yet. - [Hosting a server: design](server.md): `horizon-server`, the design and the plan: install, `server.toml`, the CLI, the web panel, backups, services, Docker, and the bot benchmark against vanilla. +- [The next server: design](server-next.md): Horizon's dedicated server against vanilla and Pumpkin (measured), the targets, parallel ticking that keeps vanilla's order, the network front, the Horizon fast path, the plugin API and the plan. - [Agent control](agent-control.md): what AI agents can drive in the launcher and the game, and the design of the control socket, `horizon-ctl` and MCP. - [Graphics](graphics.md): the Simple and Advanced graphics settings, the graphics benchmark and what it measured. - [Benchmark](benchmark.md): Horizon against vanilla 26.3 and vanilla with performance mods, on the same machine, world, path and settings: what is measured, how it is kept fair, how to reproduce it. diff --git a/docs/server-next.md b/docs/server-next.md new file mode 100644 index 0000000000..8cfa105cee --- /dev/null +++ b/docs/server-next.md @@ -0,0 +1,440 @@ +# The next server: design + +This page is the design of Horizon's next server: the fastest server we can build for Horizon players, with +vanilla Java clients welcome and vanilla's rules kept exactly. Two bars: + +1. **Better than Pumpkin on every number we can measure**: players at 20 TPS, MSPT p99, memory, bandwidth and + join time, with Pumpkin's released binary run as a black box against the same bots. +2. **Vanilla-exact where Pumpkin isn't.** Gameplay is Horizon's engine, checked by its 324 vanilla parity + harnesses (each one runs the real 26.3 jars and Horizon on the same inputs and compares the results). + +All of it lives in this repo. Single player's integrated server, LAN, `--dedicated` and the +`horizon-server` supervisor run the same server code (section 3). horizon-rust/horizon-server is only a +download home, for people who want a standalone server. + +Contents: the measured baseline (section 1), the targets (2), where the work goes (3), the design of each part +with the measured cost it removes (4 to 8), the plan (9), and what each open PR on the theme brings +(appendix A). + +What [server.md](server.md) and [hosting.md](hosting.md) already decide and mostly build is not repeated here: +the supervisor, `server.toml`, the panel, backups and the control API. + +**Originality.** Nothing here comes from Pumpkin's, Paper's, Folia's or any other server's code; none of it +was read. About other servers we use only what a user can see: their documentation, their generated config +file, and black-box runs of their released binaries. Vanilla's behaviour comes from the 26.3 jars, as for the +rest of Horizon. + +## 1. Baseline (measured 2026-10-29, Mac mini M4 Pro, 14 cores, 64 GB) + +**Servers.** +- Horizon: `horizon-client --dedicated` at horizon `668419b92`, `fast` profile. This is main *before* the three + tick fixes still in review (#3815, #3834, #3847). +- Vanilla: 26.3 `server.jar` on Temurin 25, `-Xms4G -Xmx4G`. +- Pumpkin: release `0.2.1+26.3-26.51`, `pumpkin-ARM64-macOS`, SHA-256 checked against the release's + `checksums.sha256`. + +**Same settings for all three.** Seed 12345, a fresh world, view and simulation distance 10, offline mode, creative +with forced game mode, max players 200, difficulty easy, bound to 127.0.0.1. Pumpkin also ran with its +Bedrock listeners, LAN broadcast, plugins and telemetry turned off, through its own `pumpkin.toml`. + +**Load.** `horizon-bots --behaviour mixed` (60 % walk within 48 blocks of spawn, 20 % explore outwards at +22 blocks/s, 20 % idle). One bot joins every 0.5 s, then a 30 s warm-up, then a 60 s window. The bots don't fight: +`horizon-bots` has no combat yet (issue S-1, section 9.3). + +**How each number is taken.** The server measures itself, the same way on all three: +- MSPT: the console's `tick query` every 5 s, which reports the average, P50, P95 and P99 over the last + 100 ticks. +- TPS: `time query gametime` at the start and end of the window, as game ticks per wall-clock second. +- Memory: RSS, sampled every second. +- CPU: the server process's CPU seconds divided by the window. +- Bandwidth: the bots' loopback bytes, from macOS `nettop`, per player. +- Join time: from the bot's connect to its spawn position. + +The driver, `sn_run.py`, comes into `tools/server_bench.py` with issue S-2 (#3972). + +**Caveat.** The Mac mini is shared. Its load average was 57 to 132 on 14 cores during the runs (other agents' builds), and +the bots ran on the same machine. So a tick can include waiting for a core, and the numbers carry more noise +than a dedicated machine's would. All three servers ran interleaved under the same load (10, then 50, then +100 bots, each in the order Horizon, vanilla, Pumpkin). Treat differences under about 20 % as noise. + +| Bots | Server | TPS | MSPT avg | p50 | p99 (median / worst 5 s) | CPU cores | RSS MB | KB/s per player | KB per chunk | Chunks sent | Join ms p50 / max | Pulled back | +|---|---|---|---|---|---|---|---|---|---|---|---|---| +| 10 | Horizon | **20.0** | **11.3** | 10.9 | **17.7 / 23.6** | 1.01 | 1165 | 87.9 | **3.55** | **15 272** | 74 / **103** | 1 469 | +| 10 | vanilla | 16.7 | 41.1 | 7.8 | 243 / 352 | 0.79 | 3334 | 61.4 | 6.75 | 5 646 | 3 730 / 5 550 | 1 366 | +| 10 | Pumpkin | 20.0 | 23.0 | 16.6 | 87 / 143 | 1.20 | **538** | 46.3 | 4.50 | 6 397 | **55** / 1 980 | 0 | +| 50 | Horizon | **20.0** | **28.7** | 27.4 | **38.5 / 51.7** | 2.11 | 1535 | 76.5 | **3.64** | **65 431** | 93 / **249** | 12 141 | +| 50 | vanilla | 5.3 | 58.8 | 7.8 | 416 / 537 | 0.70 | 3466 | 38.4 | 7.43 | 16 115 | 4 791 / 15 082 | 2 577 | +| 50 | Pumpkin | 20.0 | 45.8 | 40.2 | 151 / 283 | 1.08 | **869** | 34.5 | 3.77 | 28 427 | **54** / 4 075 | 0 | +| 100 | Horizon | **14.3** | 79.2 | 83.8 | **108 / 138** | 4.42 | 2054 | 95.4 | 3.70 | **160 042** | 83 / **244** | 14 638 | +| 100 | vanilla | 2.2 | 108.3 | 63.5 | 539 / 556 | 1.37 | 3699 | 45.0 | 5.18 | 53 786 | 933 / 10 524 | 14 790 | +| 100 | Pumpkin | 13.6 | **78.6** | 73.3 | 231 / 348 | 2.54 | **1421** | 51.6 | **3.61** | 88 625 | **43** / 494 | 0 | + +Idle RSS after start (fresh world, nobody on): Horizon 760–780 MB, vanilla 1 550–1 630 MB, **Pumpkin 32–35 MB**. +Start to ready, fresh world: Horizon 9.2–14.1 s, vanilla 25–34 s, **Pumpkin 0.1–0.2 s** (it generates no spawn +area before it accepts players). The raw JSON comes with S-2. + +**What it says.** +- **Up to 50 players**, Horizon and Pumpkin both hold 20 TPS. Horizon's tick is about 40 % shorter (28.7 against + 45.8 ms at 50) and its p99 is 4 to 5 times lower (51.7 against 283 ms at worst). Over the same window it also + sent 2.3 times the chunks. Vanilla falls to 5.3 TPS at 50. +- **At 100 players nobody holds 20 TPS.** Horizon runs at 14.3, Pumpkin at 13.6, vanilla at 2.2. Horizon still has the + lowest p99 (138 against 348 and 556 ms) and sends 1.8 times Pumpkin's chunks. Two of the Horizon fixes in review + (#3815, #3834) took a 100-bot run from 14.5 to 17.8 TPS (different mob counts, so read it as a direction). +- **Memory is Pumpkin's clear lead.** It idles at 32 MB against Horizon's 760 MB, and uses 1.4 GB against 2.05 GB + at 100. Vanilla's heap alone is 4 GB. +- **Bandwidth per chunk is the same** for Horizon and Pumpkin (3.6–3.7 KB), and 5–7 KB for vanilla. Horizon's + higher KB/s per player is the extra chunks. So bandwidth can't drop much further on vanilla's format. The Horizon + fast path (section 8) is where it can. +- **Join.** Pumpkin's median is lowest (43–55 ms), but its worst reached 4.1 s at 50 bots. Horizon's stays at + 74–93 ms with a worst of 244 ms. Vanilla takes 0.9 to 4.8 s. +- **The work isn't fully equal.** Pumpkin never pulled a bot back (0 against ~14 600 for Horizon and vanilla at + 100): it doesn't run vanilla's "moved too quickly" check, so its explore bots never wait for it. The mob counts + of vanilla and Pumpkin can't be seen from outside; Horizon had 1 834 mobs at the end of the 100-bot window. + Vanilla's self-reported MSPT leaves out the chunk work it runs between ticks, which is why its TPS (from game + time) is the number to read, not its MSPT. + +**Where Horizon's tick goes at 100 bots** (macOS `sample` of the tick thread, from the profiling behind #3815, #3834 and #3847, same machine): + +| Cost | Share of the tick thread | Status | +|---|---|---| +| Mobs: AI and movement | 12–19 % | flat profile (collision scan, AABBs, chunk reads) | +| Natural spawning (`spawn_for_chunk`) | 10–12 % | already tight per position | +| Hub (`tick_lan`): tracking, chunk send, play state | 15–22 % | section encoding is already on helper threads | +| Light views of proto chunks on the tick thread | 18.7 % → 0.3 % | **fixed** in #3815 | +| Scheduled ticks: one `GameLevel` per due tick | tick_blocks 15 → 10 ms at 50 bots | **fixed** in #3834 | +| Spawners: a level built with nobody in range | 1.6 % → 0.5 % | **fixed** in #3847 | +| Random ticks | 7–10 % | vanilla's own block reads | +| Fluids (`slope_distance`, `Vec` allocations) | ~4.5 % | open | + +Everything above runs on **one thread**. The worker pool (generation, light, section encoding) has cores to +spare: the whole server used 1.0 to 4.4 of the 14 cores. Pumpkin's real leads over Horizon are memory and start +time (section 7). On scale the lesson is simple: the next 10× can't come from shaving one +thread. It comes from running the tick on more cores without changing what vanilla would do. + +## 2. Targets + +Each one is measured with the bench from section 1, on a dedicated 8-core machine (the bots on another +machine), with `mixed` bots, view distance 10, and a pre-generated world plus a fresh-world pass. + +| Number | Pumpkin today | Target | Why it is reachable | +|---|---|---|---| +| Players at 20 TPS (walking, one level) | 50 (13.6 TPS at 100) | **200** on 8 cores | parallel phases (4.2) + the hub off the tick thread (5) | +| Players at 20 TPS spread over 3 dimensions | — | **300** | levels in parallel (4.1) | +| MSPT p99 at 100 players | 231 ms (worst 5 s: 348) | **< 35 ms** (p50 < 15) | the same, plus saving off the tick thread (5.5) | +| RSS idle, fresh world | 32 MB | **< 100 MB**. One of the two numbers where we may stay behind: vanilla's registries, worldgen and loot tables are loaded up front | headless engine (7) | +| RSS at 100 players | 1.42 GB | **< 1.0 GB** | per-player state measured and capped (7) | +| Bandwidth, walking vanilla client | 3.6 KB per chunk | **≤ vanilla's bytes per chunk**, compression level picked by measurement | 5.3 | +| Bandwidth, walking Horizon client | — | **−50 %** against vanilla clients on the same server | the fast path (8) | +| Join to spawn, p99 | 43 ms p50, 494 ms max (4.1 s at 50) | **< 300 ms** on loopback, **< 2 s** for a full view | 5.2 | +| Start to ready | 0.1–0.2 s (no spawn area generated first) | **< 1 s**, the other one | headless engine (7), spawn area on the workers | +| Vanilla parity | not checked | **every harness that covers server code passes** on every server PR | 4.3 | + +The 200-player target fits within one tick: at 100 bots, the tick thread's CPU time per tick was 31 ms +(with #3815 and #3834). That leaves 8 cores × 50 ms = 400 core-ms per tick for 200 players, +if most of the work moves to parallel phases. + +## 3. Where the work goes + +### 3.1 One server, in this repo + +Single player runs on the server thread (`game/integrated.rs`, since #1514), so the integrated server, LAN +hosts and `--dedicated` share every line of server code: the tick, the levels, the hub, the entity tracker, +saving. A separate server repo would fork that code or pin it across repos, and either way two copies would +drift. Gameplay is about 300 000 lines (`game/` 133 k, `mobs/` 62 k, `lan/` 15 k, the flat lib 90 k, and +`horizon-world` 124 k), guarded by the 324 harnesses. So everything here lands in horizon: + +| Part | Where | Shared by | +|---|---|---| +| Parallel tick (section 4) | `game/levels.rs`, `game/integrated.rs`, `game/dedicated.rs`, and the scheduler as a new module beside them | integrated server, LAN, dedicated | +| Network front (5) | the hub, `lan/` (`conn.rs`, `hub.rs`, `chunks.rs`, `tracking.rs`); framing in `horizon-protocol` | LAN, Play with Friends, dedicated, the integrated server's memory connection where it applies | +| Saving and light off the tick thread (5.5) | `game/saving.rs`, `save.rs`, `horizon-world` | everyone | +| Fast path (8) | `horizon-protocol` (the `horizon:hello` payloads), `lan/conn.rs` (server), `net.rs` and `game/remote*.rs` (client) | every Horizon client against every Horizon server | +| Operations (9.2) | `crates/horizon-server` (supervisor, panel, control API, MCP), `crates/horizon-server-files` | dedicated | +| Plugin API (W9) | `crates/horizon-mods` and the server hooks from #3706 and #3878 | integrated server, LAN, dedicated | +| Bench (W0) | `tools/server_bench.py`, `crates/horizon-bots`, `benchmarks/` | — | + +What changes for single player: it gets the exact parallel phases (4.1, 4.2 `tick = "vanilla"`), the cheaper +hub and off-thread saving, at no cost in behaviour. `tick = "regions"` is a dedicated-server option and +single player never uses it. + +The crate split carries on as planned. When `Game`'s server half moves from the binary into a library, +that move serves the game's build times and architecture. The server doesn't need it, because it is +already the same binary. + +### 3.2 `--dedicated`, the supervisor and the download repo + +- **`horizon-client --dedicated`** (the `horizon-engine` build without audio and controllers, + [server.md](server.md) §3) stays the server engine and the harnesses' server. With `HORIZON_SERIAL_TICK` it is also the serial + reference every parallel phase is compared against (4.3). +- **`horizon-server`** (the supervisor) stays the program owners run. +- **horizon-rust/horizon-server** holds no code. Its releases repack the engine and the supervisor, built from + this repo's tags, for people who only want a standalone server (W8). + +## 4. Ticking on more cores + +Vanilla ticks one level at a time (`MinecraftServer.tickChildren`: each `ServerLevel.tick` in order, then the +connections). Inside a level it ticks in a fixed order: +1. time and weather; +2. scheduled block and fluid ticks, in their queue's order; +3. raids; +4. chunks: random ticks and natural spawning, in a shuffled chunk order that draws on the level's random; +5. block events; +6. entities, in list order; +7. block entities, in list order. + +Horizon follows the same order (`game/levels.rs` `tick_server`, which swaps each level into the one `Game`). + +### 4.1 Levels in parallel (exact) + +Each level ticks on its own thread. Three things stay serial, in vanilla's level order, at a barrier after the +parallel phase: +- anything that crosses levels: entities and players changing dimension, `execute in`, ender pearls, portal + search into the other level's chunks; +- shared server state: scoreboard, advancements and statistics, game rules, boss bars, the player list, + command functions and `/schedule`, the world border's sync across levels; +- the connections. + +A level's tick writes those effects to an ordered log, which the barrier applies in vanilla's order. + +The one place where vanilla's order is visible across levels is an entity that leaves a level that ticks +earlier for one that ticks later: in vanilla it can tick in both levels during one server tick. The barrier +replays that case serially (the arriving entity's tick runs in the destination's serial slot). A harness that +throws entities through portals every tick checks it against the serial path, tick by tick. +- **Removes:** the levels' sum becomes their maximum. It helps only when players are spread over dimensions; + with everyone in the overworld it gains nothing. That's why 4.2 exists. +- **Engine work (horizon):** levels already have separate state (`levels.rs` parks the inactive ones). The + work is making `Game`'s per-level part a value a thread can own, with the shared part behind the barrier log. + +### 4.2 One level on more cores (exact by default) + +Vanilla's order inside a level can't be split into regions freely. Random ticks and natural spawning draw from +one level random in chunk order. Scheduled ticks run in one global order. Entity IDs come from one counter. +Mobs see the moves of the mobs that ticked before them. Running regions in parallel changes these draw +sequences. No player can see the difference, but the parity harnesses do, and so would anyone comparing seeds +with vanilla. Two modes: + +- **`tick = "vanilla"` (default): exact.** Vanilla's order of effects is kept. Only work that doesn't depend + on that order runs in parallel: + 1. *Per-entity pure work, run ahead in parallel, committed in order.* Pathfinding (`PathFinder` over a + read-only snapshot of the level), sensor scans (nearest player, nearest item, line of sight), collision + shape gathering for `Entity.move`, and brain sensors run on all cores at the start of the entity phase. + None of this draws a random number, and an entity's own draws use its own random (`Entity.random`), + so no level draw moves. The serial + entity loop then uses each result if nothing it read changed since. It checks the blocks and entities in + the result's read set against a per-section change counter, and recomputes in place when they did. Mobs + that don't move or path (most of them: 1 400+ mobs against 100 players in section 1) commit + without any recompute. **Removes:** the AI and movement share (12–19 %), minus the serial commit. + 2. *Hub work after the tick.* Entity tracking, chunk sending and packet encoding read the level once it has + ticked, so they run in parallel per player while the next tick's serial phases start (section 5). + **Removes:** the hub's 15–22 %. + 3. *Random ticks: block reads batched per chunk.* The draws stay serial and in order, but each chunk's + sections are pinned once instead of a map lookup per read. **Removes:** part of the 7–10 %. + 4. *Natural spawning: a per-tick grid of "players near this chunk" answers*, built in parallel. The draws + stay serial. **Removes:** ~2 % (the per-chunk map entry and `Vec` measured while profiling #3847). +- **`tick = "regions"` (opt-in, off by default): parallel regions, vanilla rules, not vanilla's random + sequences.** + - Groups of loaded chunks more than 2 chunks apart, with no entity, ticket or redstone link between them, + tick on separate threads. Each group gets its own random, split from the level's, plus its own entity ID + range and scheduled-tick queue. Groups merge and split at the barrier as players move. + - Every rule stays vanilla's (the harnesses that compare outcomes still pass). What changes is only which + random number a given chunk draws. The config says it in those words. + - Off by default, like every Horizon change to vanilla behaviour ([design-system.md](design-system.md)). +- **What stays serial in both modes:** cross-level effects (4.1), commands and functions, the player list, chat + order, advancements, the scoreboard, raids (one per village, but their spawning draws the level random), + block events (vanilla runs them to a fixed point in one queue), and the order of packets to one player. + +### 4.3 How exact is checked + +Every parallel phase has a switch that runs it serially (`HORIZON_SERIAL_TICK=entities,hub,...`). A harness runs +the same fixture both ways and compares a digest of the level every tick: block states, entity positions and +NBT, the random's state. On top of that, the harnesses that cover server code run with the default mode. A phase that can't pass that comparison doesn't ship in `tick = "vanilla"`. + +## 5. Network and I/O off the tick thread + +**What already exists in the hub:** +- section encoding once per chunk, on helper threads (#623, `lan/chunks.rs`); +- chunk frames compressed once for every player (`SharedFrames`, `lan/conn.rs`); +- vanilla's chunk batch pacing (`ChunkBatchReceived`) and its move checks (#586); +- a reader and a writer thread per connection (#649 made the writer wake once per tick). + +#3036 (hectoreccles) moves batch bodies to helper threads. W1 builds on it rather than redoing it. + +### 5.1 I/O threads instead of two threads per player + +200 players means 400 threads today. The hub moves to a fixed pool of I/O threads (one per two cores) on +`kqueue`/`epoll` through `mio`, with one bounded queue per player. The tick thread only pushes packet bodies. +Framing, compression, encryption and the socket writes happen on the pool. A player whose queue passes its +budget gets its chunk sending throttled first (as vanilla's batch pacing does). Vanilla's timeouts and +disconnects stay unchanged. **Removes:** thread switches and lock traffic per packet. Bench: CPU per player at +200 idle bots. + +### 5.2 Join + +Login and configuration (registries, tags, known packs) are the same for every player. They are encoded once +per server start and sent as prepared frames. The first chunks around the spawn come from the chunk cache +(5.3), not the generator. **Removes:** Horizon already joins in 74–93 ms p50 on loopback, worst 244 ms. Pumpkin's median is lower (43–55 ms) but its worst reached 4.1 s at 50 bots. Prepared frames bring the median under Pumpkin's and keep the worst flat. + +### 5.3 Chunk cache and compression level + +- A chunk's encoded and compressed packet is kept until a block in it changes. Block changes go to the players + holding it as vanilla's section updates, and drop the cached frame. Walking players who re-enter chunks then + cost a memcpy, not an encode. +- Compression uses vanilla's threshold (256) and the level that gives the best bytes per core-ms on the bench. + zlib 6 is what Horizon uses today; Pumpkin's config defaults to 4. Both are vanilla's wire format. +- **Removes:** the hub's encode cost for re-sent chunks. In section 1, Horizon's walking bots received + each chunk several times, and those re-sends are where most of the bandwidth went. + +### 5.4 Entity tracking as deltas + +- Today the tracker rebuilds each tracked entity's state per tick (`lan/tracking.rs`, `Entities::tick`, + `mob_meta` ~3 % of the tick thread). +- Instead, the engine writes a change log as it changes an entity (position, rotation, metadata index, equipment, + passengers). The tracker turns each changed entity into packets once, and sends them to every player who + sees it. +- A grid of players per chunk section finds who sees it. +- Vanilla's update cadence is kept exactly, because clients interpolate on it: `ServerEntity.sendChanges`, + with its update interval, its relative moves, its 60-tick position check, and its rotation and head + thresholds. +- **Removes:** the per-viewer encode (×players) and the full state rebuild for entities that didn't change. + +### 5.5 Saving and light + +- Light already runs on the worker pool. Since #3815 the tick thread no longer builds proto-chunk views + (18.7 % → 0.3 %). +- Saving: at autosave (every 6 000 ticks) and on unload, a chunk is snapshotted on the tick thread (a copy of + its sections and block entities' NBT, which is cheap). The workers serialize, compress and write the region + files, through vanilla's `RegionFile` format (`horizon-world`). +- The bench gets an autosave window so that the spike is measured, not guessed. + +## 6. Who can join + +`server.toml`: + +```toml +online_mode = true # the default: every player's account is checked +[clients] +allow = "any" # "any" (vanilla Java and Horizon) or "horizon" (Horizon clients only) +``` + +- **Online mode is on by default**, as on every server, vanilla's included. With it on, each player's + account is checked by the session service, and the whitelist and bans apply on top. + - `online_mode` stays the owner's setting. hosting.md's warnings about offline mode stay as they are: an + offline server checks no accounts, so keep it on a LAN or a tailnet. + - No other auth providers. Horizon's own sign-in: [microsoft-account.md](microsoft-account.md). +- With `allow = "horizon"`, the server refuses a client that didn't send brand `horizon` and the fast-path + hello (section 8) in configuration. The refusal message names the Horizon launcher. + - **Be honest about what this is:** a convenience filter, not security. Horizon's client is open source, so + a modified client can claim to be Horizon. It keeps vanilla players off a server they can't play fully, + nothing more. Accounts (online mode), the whitelist and bans are the real gate. + +## 7. Memory and start + +- Pumpkin idles at 32 MB and Horizon at 760 MB. The difference is mostly what the dedicated path + loads only for a window: the HUD atlas, the item renderer, block-entity and entity models, the panorama, + the fog and music tables, and the mesh tables. [server.md](server.md) §2 already decided the headless + engine: the dedicated path skips all of them. +- After that, every per-player and per-chunk structure gets a measured size in the bench report (bytes + per loaded chunk, per tracked entity, per player). The target is under 1 GB at 100 players. +- A loaded chunk's lighting and heightmaps are kept packed (#3253's storage primitives, by hectoreccles, are + the base). +- Start: resources load in 1.6 s today. Without the client's tables, and with the registries mapped from one + prepared file, the target is under 1 s. + +## 8. The Horizon fast path + +Vanilla's protocol stays the default and is never changed for a vanilla client. Horizon clients ask for more, +in configuration: + +1. The client sends brand `horizon` (it does today, `net.rs`), then a `horizon:hello` custom payload: + protocol revision plus a capability bit set. +2. The server answers `horizon:hello` with the subset it accepts. Horizon's server already answers a Horizon + brand with `horizon:quiet` (`lan/conn.rs`), and this is the same mechanism. +3. A client that sends no hello, or a capability the server doesn't know, gets vanilla. The fast path is + always negotiated, never assumed. + +| Capability | What it changes | Measured cost it removes | +|---|---|---| +| `chunk-zstd` | chunk packets in Horizon's own section encoding with a shared zstd dictionary, as one `horizon:chunk` payload | bytes per chunk (section 1: 3.6–3.7 KB for Horizon and Pumpkin alike with vanilla's format) | +| `far-terrain` | the server sends LOD tiles beyond view distance from the saved world, at low priority under the send budget. The client's `lod.rs` draws them today from its own generator | the client generating far terrain itself. Players see the server's real world, builds included | +| `entity-batch` | one packet per tick with every tracked entity's change, quantized as vanilla's deltas are | per-packet overhead: dozens of small moves per tick per player | +| `server-stats` | TPS, MSPT and the player's own ping, for the client's debug overlay | none (a feature) | + +Each capability has its own harness. A Horizon client with the capability, and one without, must end up with +identical client state (`horizon-ctl state` digests). The client half lands in horizon behind the same +negotiation, so a Horizon client still plays on every vanilla server. + +## 9. Plan + +### 9.1 Workstreams (in order; each is a few PRs under ~500 lines, with its target and bench) + +| # | Workstream | Target | Proof | +|---|---|---|---| +| W0 | **Bench**: Pumpkin and vanilla runners in `tools/server_bench.py`, a pre-generated world, bots on another machine, the report | ±5 % between two runs of the same binary | the report, published in `benchmarks/` | +| W1 | **Network front**: I/O pool, prepared login frames, chunk cache, compression level by measurement (with #3036) | join p99 < 300 ms, CPU per idle player halved | bench, `lan_e2e`, `dedicated_server` | +| W2 | **Tracker deltas**: the engine's change log, encode once per entity | `tick_lan` share ÷ 3 at 100 bots | bench, `server_entities` | +| W3 | **Parallel levels**, then the exact entity phase (4.2.1), then `tick = "regions"` | 200 players at 20 TPS on 8 cores | bench, the serial-against-parallel digest harness (4.3) | +| W4 | **Headless memory and start** (with #2608) | idle < 100 MB, start < 1 s | bench | +| W5 | **Saving off the tick thread** | no autosave spike in p99 | bench with an autosave window | +| W6 | **Fast path**: hello, `chunk-zstd`, `entity-batch`, `far-terrain` | −50 % bytes for Horizon clients | per-capability harness | +| W7 | **Operations**: metrics endpoint (Prometheus text, loopback/tailnet only), scheduled restarts | the official server's checklist (9.2) | an hour-long soak with 100 bots | +| W8 | **Downloads**: a workflow in horizon-rust/horizon-server repacks the engine and supervisor from this repo's release tags | one archive per OS, the same checksums as horizon's release | the release | +| W9 | **Plugin API**: server-side mods on `horizon-mods`, with events, commands, permissions and a scheduler, on the server hooks (#3706, #3878). Led by levzzz5154, who leads modding; roadmap item `server-mods` | a plugin can do what bunchy7s asks for, measured per-tick cost under 0.1 ms with no plugin loaded | mod harness on the integrated server and `--dedicated` | + +W0 comes first: without a bench that repeats within 5 %, no tick PR can show its gain. W1, W2 and W4 can run +side by side. W3 waits for W0. W9 follows levzzz5154's plan. + +### 9.2 The official server + +horizon.bunchy7s.dev already runs Horizon's own server, hosted by bunchy7s: `horizon-client --dedicated` from the +current release, in online mode, without the `horizon-server` supervisor. It runs no plugins, and +kira-pumpkin was never used on it (his answers, 2026-10-29). So each improvement above reaches it with a +release, and its baseline is the Horizon row of section 1. What he asked for is a plugin API, which is W9. + +Still to settle with him: his backups, his restarts, how he deploys (systemd, Docker), and why he runs without +the supervisor (a missing feature, or no need), so that the supervisor covers what he does by hand. + +### 9.3 Issues anyone can take + +- **S-1 (#3971) bots: a `fight` behaviour.** Bots in survival that attack the nearest mob and each other. + Today's bench has no combat. +- **S-2 (#3972) bench: Pumpkin and vanilla runs in `tools/server_bench.py`**, with `time query gametime` for + TPS, a `--pre-generated` world copy, and a Markdown table from the JSON. +- **S-3 (#3973) bench: bots on another machine**, with the report recording where they ran. +- **S-4 (#3974) the download repo's release workflow** (W8), now that no code lives there. +- **S-5 (#3975) docs: an owner's page** comparing `server.properties` to `server.toml`. +- **S-6 (#3976) bots: report bytes and join time**, so the bench doesn't need `nettop`. + +## Appendix A. Open horizon PRs on the server's theme (2026-10-29) + +Each one is **use** (what the server takes from it), **covered** (by what), or **not for the server**. ★ marks +the ones that should land in horizon first: the server builds on them, so triage should prove them first. + +| PR | Author | Verdict | +|---|---|---| +| ★ #3815, ★ #3847 | ludovic111 | **use**: tick-thread costs (section 1). In the merger; #3834 merged | +| ★ #3036 | hectoreccles | **use**: chunk batch bodies on helper threads, the base of W1 (5.3) | +| ★ #2608 | FusselTV | **use**: nothing kept around spawn while nobody is on. It cuts idle CPU, idle RAM and start time, two of the three numbers where Pumpkin leads. It conflicts since `streaming.rs` moved to `horizon-client-render`; triage carries it if needed, as #2698 did | +| ★ #3756 | ludovic111 | **use**: the server names itself `horizon`, which the fast path (8) builds on | +| ★ #3441 | xKumorio | **use**: guests' known movement as vanilla's; server players' gameplay | +| ★ #3873 | DUzzL | **use**: breeding statistics and advancements credited on the server thread | +| ★ #3300, ★ #3802 | masa-masa-0314, ThatCakeIsAPie | **use**: a world's data packs reach recipes, loot, advancements, tags and block state providers. Servers run packs | +| ★ #2698 (carries #2544, #2586) | FusselTV | **use**: structure mobs and generation animals as vanilla's | +| ★ #2872 | levzzz5154 | **use**: forced chunks get vanilla's rings (`/forceload` farms) | +| #3480–#3490 | ThatCakeIsAPie | **use**: each level's height from its dimension type (tall-world packs), local and over the network. #3489 (uniform sections shared) also feeds the memory target (7). It's a long stack, so it lands at its own pace | +| #3491–#3494 | ThatCakeIsAPie | **use**: data-pack biomes, including the biome registry a server sends (#3493) | +| #3366 | ThatCakeIsAPie | **use**: faster surface rules, i.e. more chunks generated a second for exploring players | +| #3706, #3878 | arismoko | **use** (W9): read-only native world and server-tick hooks, the base of the plugin API | +| #3827 | abit-foggy | **use if proven on the server bench**: incremental tick-chunk membership and random-tick bounds touch `tick_blocks` on the server too. It needs the bench and parity, not only the client's frame times | +| #3868 | spidercraft | **covered** for the server: the dedicated server already runs online mode with the session check. The PR is for LAN and Play with Friends | +| #3750, #3851 | leifiyoo, DUzzL | **use**: block entities replaced as vanilla does; the axolotl test fix (main's failing `pr_ready` test) | +| #3369–#3374 | ThatCakeIsAPie | not for the server core: upgrading 26.2 data packs is an owner's tool, which the supervisor can call later | +| #3201 | damnurmum | not for the server: packet translation for client mods | +| #3276 | dan4ikx | not for the server now: client audio, UDP and mod payloads. Its client-to-server mod channels are a starting point for W9's plugin channels, and must not collide with `horizon:hello` (8) | +| #3869, #3724, #3023 | spidercraft, aangeraakt, Web-mc1 | not for the server: Play with Friends codes, client skins, the launcher's ViaProxy | +| #3669, #3841, #3865, #3881 | various | not for the server: client-side fixes | + +**Server parity fixes from contributors** land on their own merits: they are already the server's code: #3716, #3645, #3686, #2990, #2987, #2577, #3401, #3621, #3675, #3748, #3875, #3908. + +**Closed without merging in the last two weeks**, with a server idea: #628 (packets a second and the server +line on F3) is the client half of the fast path's `server-stats` capability. The other 16 have no server idea, or are against +Mojang's rules (#3537, #3681: offline play, other account providers). From 18da9c988a30eed05284a5b69c6837ddfe2c896e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:50:14 +0100 Subject: [PATCH 163/968] docs/server-next.md: beat Pumpkin on idle memory and start too (< 32 MB, < 0.1 s), and how Co-Authored-By: Claude Opus 5.5 --- docs/server-next.md | 62 +++++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/docs/server-next.md b/docs/server-next.md index 8cfa105cee..a0ce9d705f 100644 --- a/docs/server-next.md +++ b/docs/server-next.md @@ -107,8 +107,8 @@ area before it accepts players). The raw JSON comes with S-2. | Fluids (`slope_distance`, `Vec` allocations) | ~4.5 % | open | Everything above runs on **one thread**. The worker pool (generation, light, section encoding) has cores to -spare: the whole server used 1.0 to 4.4 of the 14 cores. Pumpkin's real leads over Horizon are memory and start -time (section 7). On scale the lesson is simple: the next 10× can't come from shaving one +spare: the whole server used 1.0 to 4.4 of the 14 cores. Pumpkin leads on memory and start +time today; section 7 is the plan to pass it there too. On scale the lesson is simple: the next 10× can't come from shaving one thread. It comes from running the tick on more cores without changing what vanilla would do. ## 2. Targets @@ -121,12 +121,12 @@ machine), with `mixed` bots, view distance 10, and a pre-generated world plus a | Players at 20 TPS (walking, one level) | 50 (13.6 TPS at 100) | **200** on 8 cores | parallel phases (4.2) + the hub off the tick thread (5) | | Players at 20 TPS spread over 3 dimensions | — | **300** | levels in parallel (4.1) | | MSPT p99 at 100 players | 231 ms (worst 5 s: 348) | **< 35 ms** (p50 < 15) | the same, plus saving off the tick thread (5.5) | -| RSS idle, fresh world | 32 MB | **< 100 MB**. One of the two numbers where we may stay behind: vanilla's registries, worldgen and loot tables are loaded up front | headless engine (7) | +| RSS idle, fresh world | 32 MB | **< 32 MB** | nothing loaded while empty, lazy data (7) | | RSS at 100 players | 1.42 GB | **< 1.0 GB** | per-player state measured and capped (7) | | Bandwidth, walking vanilla client | 3.6 KB per chunk | **≤ vanilla's bytes per chunk**, compression level picked by measurement | 5.3 | | Bandwidth, walking Horizon client | — | **−50 %** against vanilla clients on the same server | the fast path (8) | | Join to spawn, p99 | 43 ms p50, 494 ms max (4.1 s at 50) | **< 300 ms** on loopback, **< 2 s** for a full view | 5.2 | -| Start to ready | 0.1–0.2 s (no spawn area generated first) | **< 1 s**, the other one | headless engine (7), spawn area on the workers | +| Start to ready (status ping answered) | 0.1–0.2 s | **< 0.1 s**, fresh or existing world | nothing heavy before the listener opens (7) | | Vanilla parity | not checked | **every harness that covers server code passes** on every server PR | 4.3 | The 200-player target fits within one tick: at 100 bots, the tick thread's CPU time per tick was 31 ms @@ -327,18 +327,48 @@ allow = "any" # "any" (vanilla Java and Horizon) or "horizon" (Horizon cl a modified client can claim to be Horizon. It keeps vanilla players off a server they can't play fully, nothing more. Accounts (online mode), the whitelist and bans are the real gate. -## 7. Memory and start +## 7. Memory and start: below Pumpkin's 32 MB and 0.1 s -- Pumpkin idles at 32 MB and Horizon at 760 MB. The difference is mostly what the dedicated path - loads only for a window: the HUD atlas, the item renderer, block-entity and entity models, the panorama, - the fog and music tables, and the mesh tables. [server.md](server.md) §2 already decided the headless - engine: the dedicated path skips all of them. -- After that, every per-player and per-chunk structure gets a measured size in the bench report (bytes - per loaded chunk, per tracked entity, per player). The target is under 1 GB at 100 players. -- A loaded chunk's lighting and heightmaps are kept packed (#3253's storage primitives, by hectoreccles, are - the base). -- Start: resources load in 1.6 s today. Without the client's tables, and with the registries mapped from one - prepared file, the target is under 1 s. +Today Horizon idles at 760 MB, and a fresh world is ready in 9–14 s. Pumpkin idles at 32 MB and is ready in +0.1–0.2 s. The targets are **idle RSS < 32 MB** and **ready < 0.1 s**, on a fresh world and on an existing one. + +Each step below is one or more PRs. Each one is measured with the bench on three numbers: +- RSS 10 s after ready, with nobody on; +- the time from exec to the first answered status ping, timed the same way for Pumpkin; +- a per-component memory table: `vmmap` summary, plus the engine's own counters for chunks, registries and + pools. + +1. **Nothing loaded while nobody is on** (#2608, FusselTV): no chunks, light, mobs or tickets around the spawn + on an empty server. + - On a fresh world, the initial spawn search (`MinecraftServer.setInitialSpawn`) runs on the workers after + the listener opens. Status pings are answered at once. A login waits until the spawn is found. + - The chosen spawn and every rule stay vanilla's. Only the moment changes: the first player's join pays for + it, not the start. +2. **Headless engine** ([server.md](server.md) §2): the dedicated path skips everything only a window uses: the + HUD and block atlases, item and block-entity models, entity models, the panorama, fog and music tables, the + meshing tables, and client language files beyond the server's own messages. +3. **One server data file, mapped, parsed on first use.** `horizon-prepare` writes a compact, versioned binary + `server.pack` from the owner's own files: block states, registries, tags, worldgen settings, loot tables, + recipes, advancements, structure templates. + - The engine maps it (`mmap`) and decodes an entry the first time it's needed: a loot table at the first + drop, a structure template when it's first placed, a recipe at the first craft. + - Pages nobody reads never count in RSS. Decoding goes through the same types as today, so parity doesn't + move. +4. **World generation built per dimension, on first use.** The noise router, the density-function graph, the + biome source and the structure sets are built when a dimension's first chunk is generated. The Nether and the + End, already parked when empty (`levels.rs`), build nothing until a player goes there. +5. **No idle threads or pools.** The worker pool and the hub's I/O threads start with the first player and + shrink back after the last one leaves. Their stacks are sized to what they use. +6. **Give memory back.** After the last player leaves, unloaded chunks and caches are freed, and the allocator + returns pages to the OS. The bench checks that RSS drops back under the target 60 s after the last leave. +7. **Start < 0.1 s.** With steps 1 to 4, nothing heavy runs before the listener opens: read `server.properties` + and the lists, map `server.pack`, read `level.dat`, bind. A startup span log times each step, and the bench + fails a PR that makes any of them slower. + +With players on, the target is **< 1 GB at 100 players** (Pumpkin: 1.42 GB). Every per-player and per-chunk +structure gets a measured size in the bench report: bytes per loaded chunk, per tracked entity, per player. +Chunk lighting and heightmaps are kept packed, on #3253's storage primitives (hectoreccles), and #3489 shares +uniform sections. ## 8. The Horizon fast path @@ -373,7 +403,7 @@ negotiation, so a Horizon client still plays on every vanilla server. | W1 | **Network front**: I/O pool, prepared login frames, chunk cache, compression level by measurement (with #3036) | join p99 < 300 ms, CPU per idle player halved | bench, `lan_e2e`, `dedicated_server` | | W2 | **Tracker deltas**: the engine's change log, encode once per entity | `tick_lan` share ÷ 3 at 100 bots | bench, `server_entities` | | W3 | **Parallel levels**, then the exact entity phase (4.2.1), then `tick = "regions"` | 200 players at 20 TPS on 8 cores | bench, the serial-against-parallel digest harness (4.3) | -| W4 | **Headless memory and start** (with #2608) | idle < 100 MB, start < 1 s | bench | +| W4 | **Memory and start** (section 7, steps 1–7, with #2608) | idle RSS < 32 MB, ready < 0.1 s, RSS back under 32 MB 60 s after the last player leaves | bench: idle RSS, time to first status ping, per-component table | | W5 | **Saving off the tick thread** | no autosave spike in p99 | bench with an autosave window | | W6 | **Fast path**: hello, `chunk-zstd`, `entity-batch`, `far-terrain` | −50 % bytes for Horizon clients | per-capability harness | | W7 | **Operations**: metrics endpoint (Prometheus text, loopback/tailnet only), scheduled restarts | the official server's checklist (9.2) | an hour-long soak with 100 bots | From d6653e2ee5fbb8be09f5b16aefe888eb2c6dcbe7 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:53:20 +0500 Subject: [PATCH 164/968] Launcher: horizon-mixins at its merged 0.6.0 (horizon-mixins#5) Co-Authored-By: Claude Opus 5.5 --- launcher/src/native_mods.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 0e41d01c35..06cdd8f1e2 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -29,7 +29,7 @@ mod crates; const PREFIX: &str = "native-"; /// horizon-mixins, for bundles that don't name the revision they were tested with. const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins"; -pub const MIXINS_REV: &str = "b74802d8acdf3968a520ccb11b5749cf9fcfc386"; +pub const MIXINS_REV: &str = "cb1ad23022392d88f006cf94cbf6552a73f5df12"; /// The profile builds use: the workspace's `fast` (the release's optimizations without its /// link-time ones, incremental), so a rebuild recompiles and links only what the mods changed. /// A revision without it builds with `release` ([`profile_of`]). From 166ce9296961ba35274a674e143a2d666e5cfeec Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:54:41 +0500 Subject: [PATCH 165/968] Launcher: Polish for the native build steps Co-Authored-By: Claude Opus 5.5 --- launcher/lang/pl.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 0798c0cf5a..e9b9556958 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -192,5 +192,13 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Za pierwszym razem Graj pobiera go od Mojang dla Twojego konta, razem z potrzebną Javą.", "Move to Trash": "Przenieś do kosza", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.", + "Applying the mixins": "Nakładanie mixinów", + "Building horizon-mixins (once)": "Budowanie horizon-mixins (jednorazowo)", + "Checking the mods' crates against Horizon's Cargo.lock": "Sprawdzanie skrzynek (crates) modów z Cargo.lock Horizon", + "Compiling the game: {count} crates, now {name}": "Kompilowanie gry: {count} skrzynek, teraz {name}", + "Downloading Horizon's source at {revision}": "Pobieranie kodu źródłowego Horizon w wersji {revision}", + "Downloading Rust {version} (once): {done}%": "Pobieranie Rust {version} (jednorazowo): {done}%", + "Installing the build": "Instalowanie kompilacji", + "Running {name}'s post-build step": "Uruchamianie kroku po kompilacji moda {name}" } From b495d4931e71872cdda458b2bb0d1f1e1b77d98a Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:09:31 +0700 Subject: [PATCH 166/968] Light a column again when an edit darkens it beside an unlit one Generated worlds saved impossible block light at chunk borders: an air block at 0 beside one at 6 (#2786). A column is lit from the blocks of its whole 3x3, so it holds light from a neighbour whose own light job has not landed yet. An edit in it then runs the incremental relight, which reads only lit columns: its darkening removes that neighbour's light and nothing can put it back, and the neighbour's job landing does not relight the column. The stale border stays, and is saved. Columns whose light an edit (or a replayed edit) changed beside a loaded, unlit column now get a full light job, as vanilla's light engine, with one light storage for every chunk, never loses it. Fixes #2786 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/light.rs | 81 +++++++++++++++++++ crates/horizon-client-render/src/streaming.rs | 10 +++ 2 files changed, 91 insertions(+) diff --git a/crates/horizon-client-render/src/light.rs b/crates/horizon-client-render/src/light.rs index 42f2f8892b..bb0d01d083 100644 --- a/crates/horizon-client-render/src/light.rs +++ b/crates/horizon-client-render/src/light.rs @@ -828,6 +828,23 @@ pub fn relight( relight_region(&mut region, tables, sky_light, positions, force, changed); region.finish(); } +/// The columns `changed` touched that border a loaded column not lit yet +/// (`unlit`). `relight` reads and writes only lit columns, so an edit's +/// darkening can take away light such a neighbour sends in and never put it +/// back, and nothing relights the column when that neighbour's own light +/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine +/// keeps one light storage for every chunk, so its edits and its chunk +/// lighting see each other. These columns need a full light job, which +/// lights them from the blocks of their whole 3x3, that neighbour's included. +pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> { + let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect(); + columns.sort_unstable(); + columns.dedup(); + columns.retain(|&(x, z)| { + (-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz))) + }); + columns +} fn relight_region( world: &mut Region, tables: &LightTables, @@ -1072,6 +1089,70 @@ mod tests { // Without the shape test the room would be lit (slabs have no dampening). assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light"); } + /// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0) + /// was lit from the 3x3's blocks, its torch's light and the far torch's + /// in (1, 0) both. Taking its torch away darkens everything the torch + /// lit, the far torch's light at the border too, and `relight` cannot + /// bring that back from a column it doesn't read. Columns like that are + /// lit again whole; without it the border stays at 0 for good once + /// (1, 0)'s light lands beside it. + #[test] + fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() { + let tables = tables(); + let mut w: HashMap<(i32, i32), Arc> = HashMap::new(); + for z in -1..=1 { + for x in -1..=1 { + let mut c = Chunk::empty(x, z); + for y in MIN_Y..0 { + for i in 0..256 { + c.set(i & 15, y, i >> 4, STONE); + } + } + if (x, z) == (0, 0) { + c.set(14, 0, 8, TORCH); + } else if (x, z) == (1, 0) { + c.set(6, 0, 8, TORCH); + } + w.insert((x, z), Arc::new(c)); + } + } + let light = |w: &HashMap<(i32, i32), Arc>, (x, z): (i32, i32)| { + let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| { + std::array::from_fn(|dx| { + w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c) + }) + }); + light_chunk(®ion, &tables, true) + }; + let keys: Vec<_> = w.keys().copied().collect(); + for key in &keys { + let lights = light(&w, *key); + let c = Arc::make_mut(w.get_mut(key).unwrap()); + apply_lighting(c, lights); + c.lit = *key != (1, 0); + } + // The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13. + assert_eq!(block(&w, 15, 0, 8), 13); + + Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR); + let mut changed = LightChanges::default(); + relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed); + let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit)); + for &key in &again { + let lights = light(&w, key); + let c = Arc::make_mut(w.get_mut(&key).unwrap()); + apply_relighting(c, lights, &mut LightChanges::default()); + } + // (1, 0)'s own job lands. + let lights = light(&w, (1, 0)); + let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap()); + apply_lighting(c, lights); + c.lit = true; + assert_eq!(block(&w, 16, 0, 8), 8); + assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border"); + assert_eq!(block(&w, 14, 0, 8), 6); + assert!(again.contains(&(0, 0)), "{again:?}"); + } #[test] fn relight_keeps_unchanged_and_unlit_sections() { let mut w = world(|_| {}); diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs index 2b7ef1ab03..171aad1455 100644 --- a/crates/horizon-client-render/src/streaming.rs +++ b/crates/horizon-client-render/src/streaming.rs @@ -1425,6 +1425,7 @@ impl Streaming { &mut changed, ); Self::mark_light_changes(world, &changed); + Self::relight_beside_unlit(world, &changed); if !self.light_snapshots.is_empty() { for p in positions { self.light_edit_sequence += 1; @@ -1454,6 +1455,7 @@ impl Streaming { &mut changed, ); Self::mark_light_changes(world, &changed); + Self::relight_beside_unlit(world, &changed); } let oldest = self.light_snapshots.values().copied().min(); match oldest { @@ -1461,6 +1463,14 @@ impl Streaming { None => self.light_log.clear(), } } + /// A column whose light an edit changed beside a column not lit yet gets + /// a full light job ([`crate::light::beside_unlit`]). + fn relight_beside_unlit(world: &mut World, changed: &LightChanges) { + let columns = crate::light::beside_unlit(changed, |x, z| { + world.chunks.get(&(x, z)).is_some_and(|c| !c.lit) + }); + world.dirty_light.extend(columns); + } /// Sections whose meshes sample a changed light value (faces and corners /// read light from neighbouring blocks). fn mark_light_changes(world: &mut World, changed: &LightChanges) { From 967ec25579028318059ed085799af03c4d862cce Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 01:01:49 +0300 Subject: [PATCH 167/968] Native mods: block entities for mod blocks (hook points API 3) A block entity whose id is a mod's loads as BlockEntity::Mod (its id and saved compound) instead of staying raw chunk data, and saves back as it was. Three hook points let a native mod give its blocks block entities (native_block_entity), say which tick (native_block_entity_ticks) and tick them on the server in vanilla's order (native_block_entity_tick). Player placement, /setblock, dispensers and horizon-ctl make them through one Game::new_block_entity. Fixes #3966. Co-Authored-By: Claude Opus 5.5 --- .../src/chunk/block_entity.rs | 10 +++ .../horizon-client-render/src/containers.rs | 14 ++-- crates/horizon-client/src/game.rs | 9 ++- .../src/game/block_entity_ticks.rs | 2 + crates/horizon-client/src/game/blockctl.rs | 4 +- crates/horizon-client/src/game/commands.rs | 2 +- crates/horizon-client/src/game/dispenser.rs | 3 +- .../horizon-client/src/game/native_hooks.rs | 65 ++++++++++++++++++- crates/horizon-client/src/save.rs | 59 ++++++++++++++++- 9 files changed, 153 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs index abd10b5a31..088c121d7e 100644 --- a/crates/horizon-client-render/src/chunk/block_entity.rs +++ b/crates/horizon-client-render/src/chunk/block_entity.rs @@ -80,6 +80,16 @@ pub enum BlockEntity { Shelf(Box), /// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots. ChiseledBookshelf(Box), + /// A block entity of a mod (`native_block_entity`), kept as its saved + /// compound for the mod to read and change. + Mod(Box), +} +/// A mod's block entity: its type id (`mymod:machine`) and its saved +/// compound without `id`, `x`, `y` and `z`. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ModBlockEntity { + pub id: String, + pub nbt: Compound, } /// `BrushableBlockEntity`: the loot table is resolved on the first brush. #[derive(Clone, Debug, Default)] diff --git a/crates/horizon-client-render/src/containers.rs b/crates/horizon-client-render/src/containers.rs index ec256c6ff2..cd650c7de9 100644 --- a/crates/horizon-client-render/src/containers.rs +++ b/crates/horizon-client-render/src/containers.rs @@ -3,7 +3,7 @@ //! from the data-driven `context_int_provider`/`context_float_provider` files. pub use crate::chunk::block_entity::{ Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, - EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin, + EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, ModBlockEntity, Shelf, Skull, SkullSkin, }; use crate::items::ItemStack; use std::collections::HashMap; @@ -316,7 +316,7 @@ impl BlockEntity { }; Some(Self::Chest(vec![None; size])) } - pub fn id(&self) -> &'static str { + pub fn id(&self) -> &str { match self { Self::Chest(items) => match items.len() { HOPPER_SLOTS => "minecraft:hopper", @@ -349,6 +349,7 @@ impl BlockEntity { Self::Campfire(_) => "minecraft:campfire", Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf", Self::Shelf(_) => "minecraft:shelf", + Self::Mod(m) => &m.id, } } pub fn contents(&self) -> Vec { @@ -368,7 +369,8 @@ impl BlockEntity { | Self::EndGateway(_) | Self::Skull(_) | Self::DaylightDetector - | Self::Brushable(_) => Vec::new(), + | Self::Brushable(_) + | Self::Mod(_) => Vec::new(), Self::Lectern(l) => l.book.iter().cloned().collect(), Self::Sculk(_) => Vec::new(), Self::Banner(_) => Vec::new(), @@ -403,7 +405,8 @@ impl BlockEntity { | Self::Beehive(_) | Self::CommandBlock(_) // Not a Container (only Clearable): hoppers don't reach in. - | Self::Campfire(_) => &[], + | Self::Campfire(_) + | Self::Mod(_) => &[], } } pub fn slots_mut(&mut self) -> &mut [Option] { @@ -430,7 +433,8 @@ impl BlockEntity { | Self::Vault(_) | Self::Beehive(_) | Self::CommandBlock(_) - | Self::Campfire(_) => &mut [], + | Self::Campfire(_) + | Self::Mod(_) => &mut [], } } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 9d1899a39b..f77e6eb2f6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4693,7 +4693,8 @@ impl Game<'_> { }; let target = [pos.x, pos.y, pos.z]; let state = self.apply_item_block_state(target, state, &stack)?; - if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) { + let name = self.res.facts().get(state).name.clone(); + if let Some(entity) = self.new_block_entity(&name) { let key = (target[0], target[1], target[2]); self.world.block_entities.insert(key, entity); let game_master = self.can_use_game_master_blocks(); @@ -6566,7 +6567,8 @@ impl Game<'_> { | BlockEntity::Campfire(_) | BlockEntity::ChiseledBookshelf(_) | BlockEntity::Brushable(_) - | BlockEntity::Shelf(_), + | BlockEntity::Shelf(_) + | BlockEntity::Mod(_), ) => {} None => { v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i)); @@ -6852,7 +6854,8 @@ impl Game<'_> { | BlockEntity::Campfire(_) | BlockEntity::ChiseledBookshelf(_) | BlockEntity::Brushable(_) - | BlockEntity::Shelf(_), + | BlockEntity::Shelf(_) + | BlockEntity::Mod(_), ) => {} None if self.mount_active() => self.draw_mount_background(batch, ox, oy), None => { diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index c5c36f7510..f85a28a095 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -64,6 +64,7 @@ impl Game<'_> { Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?, Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos), Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?, + Some(BlockEntity::Mod(_)) => self.tick_mod_block_entity(pos), _ => {} } } @@ -113,6 +114,7 @@ impl Game<'_> { if record { Ticking::Yes } else { Ticking::Idle } } n if FurnaceKind::from_block(n).is_some() => Ticking::Yes, + n if !n.starts_with("minecraft:") && self.native_ticks(n) => Ticking::Yes, _ => Ticking::No, } }) diff --git a/crates/horizon-client/src/game/blockctl.rs b/crates/horizon-client/src/game/blockctl.rs index 6765c3df83..52385eea9f 100644 --- a/crates/horizon-client/src/game/blockctl.rs +++ b/crates/horizon-client/src/game/blockctl.rs @@ -1,7 +1,7 @@ //! Control-socket block commands for scripted tests: `setblock`, `fill`, //! `getblock` and `container` (list or put items in a block entity). use super::Game; -use horizon_client::{Result, containers::BlockEntity, items::ItemStack}; +use horizon_client::{Result, items::ItemStack}; use horizon_world::{chunk::BlockState, registry::BlockStateId}; use serde_json::{Value, json}; use std::collections::BTreeMap; @@ -50,7 +50,7 @@ impl Game<'_> { let name = self.res.facts().get(state).name.clone(); self.world.block_entities.remove(&(pos[0], pos[1], pos[2])); self.level_set(pos, state)?; - if let Some(entity) = BlockEntity::for_block(&name) { + if let Some(entity) = self.new_block_entity(&name) { self.world .block_entities .insert((pos[0], pos[1], pos[2]), entity); diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 861ade06d2..04886d0d9b 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1998,7 +1998,7 @@ impl Game<'_> { let landed = self.world.block(pos[0], pos[1], pos[2]); if old != new && self.res.facts().get(landed).name == new - && let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new) + && let Some(entity) = self.new_block_entity(&new) { self.world.block_entities.insert(key, entity); } diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index f973bfd56d..0ecc7c4595 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -1549,7 +1549,8 @@ impl Game<'_> { }; let target = (placed.x, placed.y, placed.z); let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?; - if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) { + let name = self.res.facts().get(state).name.clone(); + if let Some(entity) = self.new_block_entity(&name) { self.world.block_entities.insert(target, entity); self.apply_placed_item([target.0, target.1, target.2], &stack, false); } diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index e46f2b3f23..588e526880 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -12,6 +12,10 @@ //! `native_known_recipes`, `native_place_recipe`, `native_give`): what a //! recipe viewer needs. //! +//! API 3 adds block entities for a mod's blocks (`native_block_entity`, +//! `native_block_entity_ticks`, `native_block_entity_tick`): made when the +//! block is placed, ticked on the server and saved with the chunk. +//! //! Names, parameters and call sites are kept from release to release, and //! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`]. //! A bundle that hooks only these names the API it needs (`hooks`), and the @@ -21,7 +25,9 @@ //! either (`tests::hook_points_stay_hookable`). use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage}; use horizon_client::{ + block_entities::Pos, chat::ChatSource, + containers::{BlockEntity, ModBlockEntity}, extras::Corner, extras_hud::Corners, hud::{HudBatch, rgba}, @@ -29,13 +35,14 @@ use horizon_client::{ mod_list::{self, Installed, NativePage}, text::Text, }; +use horizon_world::nbt::Compound; use std::{fs, path::PathBuf, sync::Arc, time::Duration}; use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 2; +pub(crate) const NATIVE_HOOKS_API: u32 = 3; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -319,6 +326,31 @@ impl Game<'_> { pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) { let _ = (mod_id, event); } + /// Block `block` (`mymod:machine`) gets a block entity + /// (`EntityBlock.newBlockEntity`): set `entity` to its id and saved + /// compound when the block is yours and `entity` is none yet. The block's + /// template must have a block entity (a chest, a furnace...), so that + /// breaking the block removes it. API 3. + pub(super) fn native_block_entity( + &mut self, + block: &str, + entity: &mut Option<(String, Compound)>, + ) { + let _ = (block, entity); + } + /// Whether block `block`'s block entity ticks (`EntityBlock.getTicker`): + /// set `ticks` when it is yours. Asked for each of the block's states + /// when a world opens. API 3. + pub(super) fn native_block_entity_ticks(&self, block: &str, ticks: &mut bool) { + let _ = (block, ticks); + } + /// A server tick of block entity `id` at `pos`, in vanilla's order among + /// the others (`Level.tickBlockEntities`): read and change its saved + /// compound `nbt`. It is out of the world during the call; if the call + /// removes or replaces it, `nbt` is dropped. API 3. + pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], id: &str, nbt: &mut Compound) { + let _ = (pos, id, nbt); + } } /// Helpers for native mods, kept as stable as the hook points. @@ -502,6 +534,37 @@ impl Game<'_> { /// Where the game calls the hook points. impl Game<'_> { + /// `EntityBlock.newBlockEntity` of block `name`: Horizon's own, else a + /// native mod's. + pub(super) fn new_block_entity(&mut self, name: &str) -> Option { + if let Some(entity) = BlockEntity::for_block(name) { + return Some(entity); + } + let mut entity = None; + self.native_block_entity(name, &mut entity); + let (id, nbt) = entity?; + Some(BlockEntity::Mod(Box::new(ModBlockEntity { id, nbt }))) + } + /// Whether a native mod's block `name` has a ticking block entity. + pub(super) fn native_ticks(&self, name: &str) -> bool { + let mut ticks = false; + self.native_block_entity_ticks(name, &mut ticks); + ticks + } + /// The tick of the mod block entity at `pos`. + pub(super) fn tick_mod_block_entity(&mut self, pos: Pos) { + let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) else { + return; + }; + // An empty id marks its place while the mod has it. + let mut taken = std::mem::take(&mut **m); + self.native_block_entity_tick([pos.0, pos.1, pos.2], &taken.id, &mut taken.nbt); + if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) + && m.id.is_empty() + { + **m = taken; + } + } /// Options > Mods' rows for the native mods, after the others. pub(super) fn list_native_mods(&mut self) { let mut mods = Vec::new(); diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 09024ad697..fb78f7f419 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -424,6 +424,14 @@ pub fn encode_block_entity( block: &str, ) -> Tag { use crate::containers::BlockEntity; + if let BlockEntity::Mod(m) = e { + let mut map = m.nbt.clone(); + map.insert("id".into(), string(&m.id)); + for (key, v) in [("x", p.0), ("y", p.1), ("z", p.2)] { + map.insert(key.into(), Tag::Int(v)); + } + return Tag::Compound(map); + } let components = match e { BlockEntity::Banner(b) => b.components.clone(), _ => Compound::new(), @@ -564,7 +572,8 @@ pub fn encode_block_entity( entries.push(("item", item_to_nbt(item))); } } - BlockEntity::DaylightDetector => {} + // Encoded above. + BlockEntity::DaylightDetector | BlockEntity::Mod(_) => {} } Tag::compound(entries) } @@ -628,11 +637,28 @@ pub fn decode_end_gateway(tag: &Tag) -> crate::containers::EndGateway { exact_teleport: number(tag.get("ExactTeleport")).unwrap_or(0) != 0, } } +/// A mod's block entity: its compound without `id` and the position. +fn decode_mod_block_entity(id: String, tag: &Tag) -> Option { + let Tag::Compound(map) = tag else { + return None; + }; + let mut nbt = map.clone(); + for key in ["id", "x", "y", "z"] { + nbt.remove(&key.into()); + } + Some(crate::containers::ModBlockEntity { id, nbt }) +} pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::containers::BlockEntity)> { use crate::containers::BlockEntity; let int = |k: &str| number(tag.get(k)).map(|v| v as i32); let id = text(tag.get("id"))?; let pos = (int("x")?, int("y")?, int("z")?); + if id.contains(':') && !id.starts_with("minecraft:") { + return Some(( + pos, + BlockEntity::Mod(Box::new(decode_mod_block_entity(id, tag)?)), + )); + } if id == "minecraft:end_gateway" { return Some((pos, BlockEntity::EndGateway(decode_end_gateway(tag)))); } @@ -800,7 +826,8 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain | BlockEntity::CreakingHeart(_) | BlockEntity::EndGateway(_) | BlockEntity::Skull(_) - | BlockEntity::DaylightDetector => {} + | BlockEntity::DaylightDetector + | BlockEntity::Mod(_) => {} BlockEntity::Spawner(s) => **s = crate::mobs::spawner::Spawner::load(tag), BlockEntity::TrialSpawner(s) => **s = crate::mobs::trial_spawner::TrialSpawner::load(tag), BlockEntity::Vault(v) => **v = crate::vault::Vault::load(tag), @@ -4920,6 +4947,34 @@ mod tests { assert_eq!(again.update_tag(), spawner.update_tag()); } #[test] + fn mod_block_entity_round_trips() { + let saved = horizon_world::nbt::parse_snbt( + r#"{id:"gtmod:macerator",x:5,y:-60,z:12,energy:1200L,progress:7,Items:[{Slot:0b,id:"minecraft:iron_ore",count:3}]}"#, + ) + .unwrap() + .0; + let (pos, back) = decode_block_entity(&saved).expect("mod block entity"); + assert_eq!(pos, (5, -60, 12)); + let crate::containers::BlockEntity::Mod(m) = &back else { + panic!("not a mod's"); + }; + assert_eq!(m.id, "gtmod:macerator"); + // The mod sees its own fields only. + for key in ["id", "x", "y", "z"] { + assert!(!m.nbt.contains_key(&key.into()), "{key}"); + } + assert_eq!(m.nbt.get(&"energy".into()), Some(&Tag::Long(1200))); + assert_eq!(encode_block_entity(pos, &back, "gtmod:macerator"), saved); + // Vanilla's types are never a mod's, modelled or not. + let ender = horizon_world::nbt::parse_snbt(r#"{id:"minecraft:ender_chest",x:0,y:0,z:0}"#) + .unwrap() + .0; + assert!(!matches!( + decode_block_entity(&ender), + Some((_, crate::containers::BlockEntity::Mod(_))) + )); + } + #[test] fn banner_block_entity_round_trips() { let banner = crate::containers::Banner { layers: vec![crate::banner::BannerLayer::new("minecraft:creeper", "lime")], From acce84e884cb9d85b8c409f1e48c4b4368cd13b0 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Thu, 29 Oct 2026 23:14:15 +0100 Subject: [PATCH 168/968] Resource packs: create overlay junctions natively and load models and GUI atlas side by side Each untouched directory of the Windows overlay was a `cmd /C mklink /J` process, about 100 ms apiece: 34 of them were 3.4 s of a 4.3 s overlay for a 44-file server pack. The junction is now an FSCTL_SET_REPARSE_POINT on an empty directory, in horizon-threads (render forbids unsafe code). The world's models and sprites, the GUI atlas and the panorama read the same root and nothing of each other, so the reload now builds them on three threads. --- crates/horizon-client-render/src/packs.rs | 26 ++-------- crates/horizon-client/src/game/packs.rs | 14 ++++-- crates/horizon-threads/Cargo.toml | 4 +- crates/horizon-threads/src/lib.rs | 61 +++++++++++++++++++++++ 4 files changed, 77 insertions(+), 28 deletions(-) diff --git a/crates/horizon-client-render/src/packs.rs b/crates/horizon-client-render/src/packs.rs index 9389e85589..6c22cdc224 100644 --- a/crates/horizon-client-render/src/packs.rs +++ b/crates/horizon-client-render/src/packs.rs @@ -402,31 +402,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> { fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ())) } } -/// A directory junction, which Windows creates without privileges (a directory symlink needs -/// developer mode). The mirror falls back to copying the tree when it fails. +/// A directory junction (`horizon_threads::junction`); the mirror falls back to copying the +/// tree when it fails. #[cfg(windows)] fn junction(target: &Path, at: &Path) -> io::Result<()> { - use std::os::windows::process::CommandExt; - // cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an - // unquoted path would split or change the command. A Windows path cannot hold `"`. - let quoted = |path: &Path| -> io::Result { - let mut quoted = std::ffi::OsString::from("\""); - quoted.push(std::path::absolute(path)?); - quoted.push("\""); - Ok(quoted) - }; - let status = std::process::Command::new("cmd") - .args(["/C", "mklink", "/J"]) - .raw_arg(quoted(at)?) - .raw_arg(quoted(target)?) - .creation_flags(0x0800_0000) // CREATE_NO_WINDOW - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status()?; - status - .success() - .then_some(()) - .ok_or_else(|| io::Error::other("mklink /J failed")) + horizon_threads::junction(target, at) } #[cfg(not(windows))] fn junction(_: &Path, _: &Path) -> io::Result<()> { diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 2096e8c0e7..3aac3b4cd4 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -285,13 +285,21 @@ pub(super) fn stage_pack( let root = startup_root(vanilla_root, selected, server)?; let root = super::mods::content_root(&root, mods_enabled, dev_mods); progress.set(1); - let render = Arc::new(assets.reload(&root)?); - let mut atlas = HudAtlas::build_over(&root, vanilla_root)?; + // The world's models and sprites and the GUI atlas read the same root and nothing of + // each other: about a second each, side by side. + let (render, atlas, panorama) = std::thread::scope(|s| { + let atlas = + s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string())); + let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root)); + (assets.reload(&root), atlas.join(), panorama.join()) + }); + let render = Arc::new(render?); + let mut atlas = atlas.unwrap_or_else(|p| std::panic::resume_unwind(p))?; atlas.font.uniform = force_unicode; atlas.font.japanese = japanese; let gui = UiResources { atlas, - panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)), + panorama: Arc::new(panorama.unwrap_or_else(|p| std::panic::resume_unwind(p))), }; progress.set(2); let pack = PackResources::load( diff --git a/crates/horizon-threads/Cargo.toml b/crates/horizon-threads/Cargo.toml index 67b3c20dae..cd3814c3b6 100644 --- a/crates/horizon-threads/Cargo.toml +++ b/crates/horizon-threads/Cargo.toml @@ -4,13 +4,13 @@ version = "0.1.0" edition.workspace = true license.workspace = true rust-version = "1.85" -description = "Scheduling hints for the game's threads: the OS calls the crates that forbid unsafe code can't make" +description = "Scheduling hints for the game's threads, and the other OS calls the crates that forbid unsafe code can't make" [target.'cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))'.dependencies] libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_WindowsProgramming"] } +windows-sys = { version = "0.61", features = ["Win32_System_IO", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } [lints] workspace = true diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index 01e815e8b3..961ad330de 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -227,6 +227,67 @@ fn release() { #[cfg(not(any(all(target_os = "linux", target_env = "gnu"), target_vendor = "apple")))] fn release() {} +/// A directory junction, which Windows creates without privileges (a directory symlink needs +/// developer mode): the resource pack overlay links its untouched directories with them. +#[cfg(windows)] +pub fn junction(target: &std::path::Path, at: &std::path::Path) -> std::io::Result<()> { + // What `mklink /J` does, without its ~100 ms `cmd` process per directory: an empty + // directory given a mount point reparse point (REPARSE_DATA_BUFFER) naming the target. + use std::os::windows::{ffi::OsStrExt, fs::OpenOptionsExt, io::AsRawHandle}; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FSCTL_SET_REPARSE_POINT: u32 = 0x0009_00A4; + const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA000_0003; + let target = std::path::absolute(target)?; + let target = target.as_os_str().to_string_lossy(); + let target = target.strip_prefix(r"\\?\").unwrap_or(&target); + let name: Vec = std::ffi::OsStr::new(&format!(r"\??\{target}")) + .encode_wide() + .collect(); + let name_bytes = u16::try_from(name.len() * 2).map_err(|_| std::io::ErrorKind::InvalidInput)?; + // Substitute name, its NUL, then an empty print name and its NUL. + let mut buffer = Vec::with_capacity(16 + name.len() * 2 + 4); + buffer.extend(IO_REPARSE_TAG_MOUNT_POINT.to_le_bytes()); + buffer.extend((8 + name_bytes + 4).to_le_bytes()); + buffer.extend(0u16.to_le_bytes()); + buffer.extend(0u16.to_le_bytes()); + buffer.extend(name_bytes.to_le_bytes()); + buffer.extend((name_bytes + 2).to_le_bytes()); + buffer.extend(0u16.to_le_bytes()); + buffer.extend(name.iter().flat_map(|c| c.to_le_bytes())); + buffer.extend([0; 4]); + std::fs::create_dir(at)?; + let set = std::fs::OpenOptions::new() + .write(true) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS) + .open(at) + .and_then(|dir| { + let mut returned = 0; + // SAFETY: a live directory handle, and `buffer` outlives the call. + let ok = unsafe { + windows_sys::Win32::System::IO::DeviceIoControl( + dir.as_raw_handle(), + FSCTL_SET_REPARSE_POINT, + buffer.as_ptr().cast(), + buffer.len() as u32, + std::ptr::null_mut(), + 0, + &mut returned, + std::ptr::null_mut(), + ) + }; + if ok == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + if set.is_err() { + let _ = std::fs::remove_dir(at); + } + set +} + #[cfg(test)] mod tests { /// The hint holds on the platforms that take it. From da98be3276e06d6395e64e18ec8a09c89e5494c1 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 01:14:23 +0300 Subject: [PATCH 169/968] Native mods: a save leaves out a mod block entity mid-tick Review of #3996: while native_block_entity_tick runs, the block entity at its position is an empty-id placeholder; a save then skips it rather than writing `id: ""`. The hook's doc says so, and that clients (the window's own in single player too) don't get mod block entities yet. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 6 ++++-- crates/horizon-client/src/save.rs | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 588e526880..f3d70a1351 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -346,8 +346,10 @@ impl Game<'_> { } /// A server tick of block entity `id` at `pos`, in vanilla's order among /// the others (`Level.tickBlockEntities`): read and change its saved - /// compound `nbt`. It is out of the world during the call; if the call - /// removes or replaces it, `nbt` is dropped. API 3. + /// compound `nbt`. It is out of the world during the call (the block + /// entity at `pos` reads as a `Mod` with an empty id, and a save leaves + /// it out); if the call removes or replaces it, `nbt` is dropped. Clients, + /// the window's own in single player included, don't get it yet. API 3. pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], id: &str, nbt: &mut Compound) { let _ = (pos, id, nbt); } diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index fb78f7f419..6e83b14845 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -1195,6 +1195,9 @@ pub fn encode_with( let mut block_entities: Vec = chunk .block_entities .iter() + // A mod block entity a native mod is ticking right now + // (`tick_mod_block_entity`): its data is out of the world. + .filter(|(_, e)| !matches!(e, crate::containers::BlockEntity::Mod(m) if m.id.is_empty())) .map(|(p, e)| { let block = block_name(p); let mut current = encode_block_entity(*p, e, &block); From 1761eb084210c84f9fe3d8fcfc3ca9d69e96c045 Mon Sep 17 00:00:00 2001 From: unknown <162871325+WhyFovius@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:17:39 +0300 Subject: [PATCH 170/968] Renderer: a Validation acquire recovers, animated sprites layout stays inside the error scopes Opening a world fullscreen could crash ~8s into the loading screen (#3866). Two faults stacked up: - draw_frame answered CurrentSurfaceTexture::Validation with Err on every frame; the loading screen runs ControlFlow::Poll, so the warn ran ~16k times a second (130k log lines in the report) while nothing recovered. - SpriteAnimator::new asked the animated sprites pipeline for its bind group layout after the error scopes in pipeline() had been popped. A device lost during that window makes create_compute_pipeline return a silent invalid placeholder (device-lost errors bypass the scopes), so the scopes saw nothing, and the out-of-scope get_bind_group_layout then hit wgpu's uncaptured-error path and panicked. pipeline() now returns the layout queried while the scopes are open, where a validation error is caught and the CPU path takes over, as it already does for drivers without working compute shaders. Validation on acquire now gets the swapchain a fresh configuration like Outdated does, then reattaches the window like Lost does once that has not helped, and a frame is skipped (Ok(false)) instead of failed; the counter resets on the next acquired frame. Fixes #3866 Co-Authored-By: Claude --- crates/horizon-client-render/src/gfx.rs | 30 ++++++++++++++++++- .../horizon-client-render/src/gfx/animate.rs | 20 ++++++++----- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 179b05f67e..bc631cac47 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -587,6 +587,10 @@ pub struct Renderer { readback: readback::Readback, pub info: wgpu::AdapterInfo, pub suspended: bool, + /// Consecutive frames whose surface acquisition failed validation. The + /// first gets the swapchain a fresh configuration; later ones reattach + /// the window, as a lost surface does. + acquire_validation_failures: u32, pub drawn_sections: usize, /// Last frame's section draws (face ranges and translucent sections), /// and the draw calls the backend issued for them. @@ -2747,6 +2751,7 @@ impl Renderer { readback: readback::Readback::default(), info, suspended: !drawable_size(size.width, size.height, max_side), + acquire_validation_failures: 0, drawn_sections: 0, section_draws: 0, section_draw_calls: 0, @@ -3730,9 +3735,32 @@ impl Renderer { return Ok(false); } Some(wgpu::CurrentSurfaceTexture::Validation) => { - return Err("surface acquisition failed validation".into()); + // Acquire caught a validation error: the swapchain cannot be + // used as configured (wgpu reports a configuration raced with + // a submission this way). Give it a fresh configuration like + // an Outdated one, and once that has not helped, reattach the + // window like a Lost one — a fullscreen loading screen then + // skips frames instead of failing every one of them. + if self.acquire_validation_failures == 0 { + if let Some(surface) = &self.surface { + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); + } + } else { + self.recreate_surface()?; + } + self.acquire_validation_failures = + self.acquire_validation_failures.saturating_add(1); + return Ok(false); } }; + if surface_frame.is_some() { + self.acquire_validation_failures = 0; + } let size = [self.config.width, self.config.height]; let target_texture = match (&surface_frame, &mut self.hdr) { (Some(_), Some(hdr)) => hdr.frame(&self.device, size), diff --git a/crates/horizon-client-render/src/gfx/animate.rs b/crates/horizon-client-render/src/gfx/animate.rs index 96427cca34..e6eb9dd5b7 100644 --- a/crates/horizon-client-render/src/gfx/animate.rs +++ b/crates/horizon-client-render/src/gfx/animate.rs @@ -147,7 +147,7 @@ impl SpriteAnimator { group, }); } - let Some(pipeline) = pipeline else { + let Some((pipeline, layout)) = pipeline else { let groups = keys .iter() .zip(&counts) @@ -193,7 +193,6 @@ impl SpriteAnimator { usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let layout = pipeline.get_bind_group_layout(0); let groups = keys .iter() .zip(&counts) @@ -364,11 +363,15 @@ impl SpriteAnimator { } } -/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL -/// driver can list compute shaders among its extensions while wgpu writes -/// the GLSL version of its core version, which has none (Mesa's -/// `MESA_GL_VERSION_OVERRIDE`, older drivers). -fn pipeline(device: &wgpu::Device) -> Option { +/// `animate.wgsl`'s pipeline and the bind group layout of its group 0, +/// unless the device fails to make the pipeline: an OpenGL driver can list +/// compute shaders among its extensions while wgpu writes the GLSL version +/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`, +/// older drivers). A device lost between the scopes' pushes answers pipeline +/// creation with a silent placeholder and reports its invalidity only when +/// the layout is asked for — inside the scopes that error is caught, outside +/// them it is uncaptured and panics (#3866). +fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> { let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option { compilation_options: Default::default(), cache: None, }); + let layout = pipeline.get_bind_group_layout(0); let internal = pollster::block_on(internal.pop()); match internal.or(pollster::block_on(validation.pop())) { - None => Some(pipeline), + None => Some((pipeline, layout)), Some(e) => { log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})"); None From f3a3c1291b055564db22e977917c83bf85d775e0 Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 01:50:07 +0330 Subject: [PATCH 171/968] Render: custom head items and multi-layer equipment In vanilla Minecraft (26.3), items equipped in the head slot without an armour asset (such as carved pumpkins, player heads, and mob skulls) are rendered via CustomHeadLayer with ItemDisplayContext.HEAD transform (down 0.25, rotated 180 degrees, scaled 0.625; skulls scaled 1.1875) for both the local player, remote players/guests, and humanoid mobs. Additionally, equipment assets can specify multiple layers under their layer kinds (e.g. equipment/leather.json has layer 0 leather and layer 1 leather_overlay). Horizon previously only parsed the first layer index 0, causing overlays like leather_overlay to be dropped. - ArmorStandAssets: load all humanoid and wings layers in equipment JSON definitions into a list of layers. - In armor_layers and armor_stand_geometry: render all humanoid layers for equipped items in order, including non-dyeable overlays. - custom_head_geometry: implement CustomHeadLayer for worn non-armour head items and skulls, drawing worn skulls with their special model (including player profile skin texture) and items (e.g. carved pumpkin) via the item rendering pipeline. - Hook custom_head_geometry into player model rendering, inventory preview, remote players/mobs, and LAN guests. - Update port ledger mappings for CustomHeadLayer and EquipmentLayerRenderer. Co-Authored-By: Antigravity --- .../horizon-client/src/game/armor_stands.rs | 82 +++++++------- .../src/game/entity_model_loading_tests.rs | 12 +- crates/horizon-client/src/game/lan.rs | 6 + crates/horizon-client/src/game/mob_blocks.rs | 104 ++++++++++++++++++ crates/horizon-client/src/game/perspective.rs | 17 ++- tools/port_map/client-005.tsv | 6 +- 6 files changed, 181 insertions(+), 46 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 4d8a9fd31a..8b9ce777a3 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -39,8 +39,8 @@ pub(super) struct ArmorStandAssets { pub layers: HashMap<(&'static str, bool), Part>, /// The player's armour models by slot (helmet, chestplate, leggings, boots). pub player: HashMap<&'static str, Part>, - /// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour). - pub equipment: HashMap)>>, + /// asset id -> layer kind (humanoid / humanoid_leggings) -> [(texture, undyed colour)]. + pub equipment: HashMap)>>>, /// asset id -> `trim_overrides`. pub trim_overrides: HashMap>, pub trims: TrimRegistry, @@ -84,18 +84,21 @@ impl ArmorStandAssets { let Ok(v) = v else { continue }; let mut kinds = HashMap::new(); for kind in ["humanoid", "humanoid_leggings", "wings"] { - if let Some(layer) = v["layers"][kind].get(0) { - let tex = layer["texture"] - .as_str() - .unwrap_or(name) - .trim_start_matches("minecraft:"); - let undyed = layer["dyeable"]["color_when_undyed"] - .as_i64() - .map(|c| c as u32 & 0xFF_FFFF); - kinds.insert( - kind.to_owned(), - (format!("entity/equipment/{kind}/{tex}"), undyed), - ); + if let Some(layers) = v["layers"][kind].as_array() { + let mut list = Vec::new(); + for layer in layers { + let tex = layer["texture"] + .as_str() + .unwrap_or(name) + .trim_start_matches("minecraft:"); + let undyed = layer["dyeable"]["color_when_undyed"] + .as_i64() + .map(|c| c as u32 & 0xFF_FFFF); + list.push((format!("entity/equipment/{kind}/{tex}"), undyed)); + } + if !list.is_empty() { + kinds.insert(kind.to_owned(), list); + } } } trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v)); @@ -190,18 +193,19 @@ impl Game<'_> { } else { "humanoid" }; - let Some((texture, undyed)) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) - else { + let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else { continue; }; - let (Some(r), Some(part)) = ( - self.gui.atlas.sprites.get(texture.as_str()), - assets.player.get(layer), - ) else { + let Some(part) = assets.player.get(layer) else { continue; }; - let color = layer_color(*undyed, stack); - out.push((part, [r.u0, r.v0, r.u1, r.v1], color)); + for (texture, undyed) in layers { + let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else { + continue; + }; + let color = layer_color(*undyed, stack); + out.push((part, [r.u0, r.v0, r.u1, r.v1], color)); + } // EquipmentLayerRenderer: the trim layer over the armour. if let Some(r) = stack .and_then(|s| assets.trim_texture(&asset, kind, s)) @@ -725,16 +729,12 @@ impl Game<'_> { } else { "humanoid" }; - let Some((texture, undyed)) = - assets.equipment.get(&asset).and_then(|k| k.get(kind)) - else { + let Some(layers) = assets.equipment.get(&asset).and_then(|k| k.get(kind)) else { continue; }; - let (Some(uv), Some(part)) = (uv_of(texture), assets.layers.get(&(layer, a.small))) - else { + let Some(part) = assets.layers.get(&(layer, a.small)) else { continue; }; - let color = layer_color(*undyed, Some(stack)); // ArmorStandRenderer's HumanoidArmorLayer: the armour glint // when `stack.hasFoil()`, as on a living entity. let item_glint = self @@ -752,16 +752,22 @@ impl Game<'_> { // ArmorStandArmorModel.setupAnim: the same pose as the stand. let mut layer_poses = Poses::new(part); apply_pose(&mut layer_poses, &a.pose); - horizon_client::mobs::render::draw( - part, - base, - &layer_poses, - uv, - color, - packed, - overlay, - out, - ); + for (texture, undyed) in layers { + let Some(uv) = uv_of(texture) else { + continue; + }; + let color = layer_color(*undyed, Some(stack)); + horizon_client::mobs::render::draw( + part, + base, + &layer_poses, + uv, + color, + packed, + overlay, + out, + ); + } // EquipmentLayerRenderer: the trim layer over the armour. if let Some(trim) = assets .trim_texture(&asset, kind, stack) diff --git a/crates/horizon-client/src/game/entity_model_loading_tests.rs b/crates/horizon-client/src/game/entity_model_loading_tests.rs index 61ed814912..da53e30e98 100644 --- a/crates/horizon-client/src/game/entity_model_loading_tests.rs +++ b/crates/horizon-client/src/game/entity_model_loading_tests.rs @@ -207,9 +207,10 @@ fn armor_equipment_loads_without_an_entity_model_document() { fs::create_dir_all(&equipment).unwrap(); fs::write( equipment.join("leather.json"), - serde_json::to_vec(&json!({"layers": {"humanoid": [{ - "texture": "minecraft:leather", "dyeable": {"color_when_undyed": 0x123456} - }]}})) + serde_json::to_vec(&json!({"layers": {"humanoid": [ + {"texture": "minecraft:leather", "dyeable": {"color_when_undyed": 0x123456}}, + {"texture": "minecraft:leather_overlay"} + ]}})) .unwrap(), ) .unwrap(); @@ -219,7 +220,10 @@ fn armor_equipment_loads_without_an_entity_model_document() { assert!(assets.layers.is_empty()); assert_eq!( assets.equipment["minecraft:leather"]["humanoid"], - ("entity/equipment/humanoid/leather".into(), Some(0x123456)) + vec![ + ("entity/equipment/humanoid/leather".into(), Some(0x123456)), + ("entity/equipment/humanoid/leather_overlay".into(), None), + ] ); assert!(!assets.equipment.contains_key("minecraft:invalid")); } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5cd82b0e22..ffe4ad35c1 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -2293,6 +2293,12 @@ impl Game<'_> { ); self.spin_attack_geometry(m, camera, alpha as f32, packed, out); self.held_item_geometry(m, camera, alpha, packed, true, out); + let head = host + .hub + .guest(*conn) + .and_then(|g| g.armor[0].as_ref()) + .or(m.extra.armor[0].as_ref()); + self.custom_head_geometry(m, head, camera, alpha, packed, true, out); // LivingEntity.isCurrentlyGlowing: the guest's Glowing effect. let guest = host.hub.guest(*conn); if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) { diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 64e72769b0..c557537daa 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -150,6 +150,7 @@ impl Game<'_> { self.captain_banner_geometry(mob, camera, alpha, light, out); self.held_item_geometry(mob, camera, alpha, light, true, out); self.worn_block_geometry(mob, camera, alpha, light, out); + self.custom_head_geometry(mob, None, camera, alpha, light, true, out); self.golem_flower_geometry(mob, camera, alpha, light, out); if mob.species() == Species::Enderman && let Some(state) = mob.extra.carried @@ -271,6 +272,109 @@ impl Game<'_> { } } } + /// `CustomHeadLayer`: a non-armour head item (e.g. carved pumpkin, mob + /// skull, or other items worn on the head) on a player or mob + /// (`translateToHead`: down a quarter, turned, 0.625 scale; skulls 1.1875 scale). + pub(super) fn custom_head_geometry( + &self, + mob: &Mob, + stack: Option<&ItemStack>, + camera: glam::DVec3, + alpha: f64, + light: u32, + lit: bool, + out: &mut Vec, + ) { + if horizon_client::mobs::raider::is_captain(mob) || mob.species() == Species::SnowGolem { + return; + } + let fallback = mob.extra.head.as_ref().map(|item| ItemStack::new(item, 1)); + let Some(stack) = stack.or(mob.extra.armor[0].as_ref()).or(fallback.as_ref()) else { + return; + }; + // A head item that is armour is drawn by HumanoidArmorLayer (armor_layers). + if self + .res + .items() + .get(&stack.item) + .is_some_and(|i| i.equip_asset.is_some()) + { + return; + } + let Some(head) = + render::part_transform(mob, alpha as f32, camera, self.res.mob_data(), "head") + else { + return; + }; + // `CustomHeadLayer`: a worn skull draws the skull model. + if let Some(kind) = super::armor_stands::skull_kind(&stack.item) { + let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875)); + let special = horizon_assets::item_model::SpecialModel { + kind: if kind == "player" { + horizon_assets::item_model::SpecialKind::PlayerHead + } else { + horizon_assets::item_model::SpecialKind::Head { + kind: kind.to_owned(), + texture: None, + animation: 0.0, + } + }, + }; + let mut sink = horizon_client::item_render::WorldSink { + out, + light, + lights: lit.then(|| { + horizon_client::block_entity_render::diffuse_lights( + self.dim == super::Dim::Nether, + ) + }), + glint: self.glint(), + }; + let profile = stack + .extra + .as_deref() + .and_then(|d| d.other.iter().find(|(k, _)| k == "minecraft:profile")) + .map(|(_, t)| horizon_client::scoreboard::tag_to_json(t)) + .unwrap_or(serde_json::Value::Null); + let item_glint = self + .res + .items() + .enchantments + .props + .get(&stack.item) + .is_some_and(|p| p.glint); + self.pack.item_renderer.submit_special( + &special, + &profile, + stack.has_foil(item_glint), + pose, + &self.gui.atlas.sprites, + &self.item_host(), + &mut sink, + ); + return; + } + let pose = head + * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0)) + * glam::Mat4::from_rotation_y(std::f32::consts::PI) + * glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625)); + let owner = super::item_draw::owner_at( + [mob.position.x, mob.position.y, mob.position.z], + mob.body_yaw, + &mob.kind.id, + ); + let state = self.resolve_item( + stack, + horizon_assets::item_model::DisplayContext::Head, + Some(&owner), + mob.id as i32 + 5, + ); + if lit { + self.submit_item_world(&state, pose, light, out); + } else { + self.submit_item_unlit(&state, pose, light, out); + } + } /// `ItemInHandLayer`: both hands through the item model pipeline; `lit` /// applies the level's diffuse lighting (off for GUI previews, which /// light the projected quads themselves). diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 7ab50e524e..749feac61f 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -539,6 +539,7 @@ impl Game<'_> { self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out); self.spin_attack_geometry(m, camera, alpha as f32, packed, out); self.held_item_geometry(m, camera, alpha, packed, true, out); + self.custom_head_geometry(m, self.armor[0].as_ref(), camera, alpha, packed, true, out); // LivingEntity.isCurrentlyGlowing: the Glowing effect. if self.potions.effects.get("glowing").is_some() { let color = super::glowing::team_color(self.own_team()); @@ -627,6 +628,15 @@ impl Game<'_> { } self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts); self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts); + self.custom_head_geometry( + &m, + self.armor[0].as_ref(), + camera, + 1.0, + 240 | 240 << 8, + false, + &mut verts, + ); let height = if m.extra.crouching { 1.5 } else { 1.8 }; // PictureInPictureRenderer: the box centre, (size, size, -size); // then the translation and Rz(pi) * Rx(y_angle * 20 degrees). @@ -681,7 +691,12 @@ impl Game<'_> { return; }; let assets = &self.armor_stand_assets; - let Some((texture, _)) = assets.equipment.get(asset).and_then(|k| k.get("wings")) else { + let Some((texture, _)) = assets + .equipment + .get(asset) + .and_then(|k| k.get("wings")) + .and_then(|l| l.first()) + else { return; }; // WingsLayer.getPlayerElytraTexture: a player's cape texture has its elytra. diff --git a/tools/port_map/client-005.tsv b/tools/port_map/client-005.tsv index 89089d2345..90ae9bbfeb 100644 --- a/tools/port_map/client-005.tsv +++ b/tools/port_map/client-005.tsv @@ -27,13 +27,13 @@ CarriedBlockLayer.submit port crates/horizon-client/src/game/mob_blocks.rs::Game CatCollarLayer.submit port crates/horizon-client/src/mobs/render.rs::layers dyed collar CrossedArmsItemLayer.submit missing player: a villager or wandering trader never shows an item in its folded arms (Horizon villagers carry nothing and the villager model has no right_arm for the hand lookup) CrossedArmsItemLayer.applyTranslation missing player: no crossed-arms item placement (see CrossedArmsItemLayer.submit) -CustomHeadLayer.submit missing player: a skull or head in the head slot (player, zombie, creeper, dragon heads) is not drawn on players or mobs; only the snow golem pumpkin and mooshroom mushrooms are placed -CustomHeadLayer.resolveSkullRenderType missing player: player-head skins on worn heads are never resolved (no worn heads are drawn, see CustomHeadLayer.submit) +CustomHeadLayer.submit port crates/horizon-client/src/game/mob_blocks.rs::Game::custom_head_geometry worn head items and skulls on players and mobs +CustomHeadLayer.resolveSkullRenderType port crates/horizon-client/src/game/mob_blocks.rs::Game::custom_head_geometry worn skulls with profile textures on players and mobs Deadmau5EarsLayer.submit missing minor: only a player named deadmau5 gets ears; the name tag lift for them is missing too DolphinCarryingItemLayer.submit missing player: a dolphin carrying an item shows nothing (the dolphin model has no arm bones for the hand lookup) DrownedOuterLayer.submit port crates/horizon-client/src/mobs/render.rs::layers drowned outer layer, adult and baby EnergySwirlLayer.submit port crates/horizon-client/src/mobs/render.rs::energy_swirl charged creeper and powered wither swirl, drawn in render_visible -EquipmentLayerRenderer.renderLayers port crates/horizon-client/src/game/armor_stands.rs::Game::armor_layers gap: player: the leather overlay (layer 1 of the equipment asset) is never drawn, only layer 0 +EquipmentLayerRenderer.renderLayers port crates/horizon-client/src/game/armor_stands.rs::Game::armor_layers draws all humanoid layers of the equipment asset, including leather overlay EquipmentLayerRenderer.getColorForLayer port crates/horizon-client/src/game/armor_stands.rs::Game::armor_layers gap: player: a dyed leather piece is drawn in the undyed brown; the stack's dyed colour is not read EquipmentLayerRenderer.TrimTextureKey.createTextureWithNoPalette port crates/horizon-client-render/src/trims.rs::TrimTexture::generate a trim with no palette is used as its own texture EyesLayer.submit port crates/horizon-client/src/mobs/render.rs::layers spider, enderman and phantom eyes From dff6db1e86634368a87806e16343158f7b64c124 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:09:02 +0700 Subject: [PATCH 172/968] Hide the own body when the server sets the player's invisible flag Entity.isInvisible reads shared flag 5, which a server sets with the Invisibility effect or alone through Player.setInvisible, as pose plugins (GSit's lay, crawl, spin) do to hide the real body under the pose. The client read the flag for other entities only and hid its own body, cape, first-person arm and name tag (and thinned its effect swirls) only for the effect, so the real body showed under the pose. Keep the own flag from SetEntityData (reset on login and on a respawn without KEEP_ENTITY_DATA) and use it with the effect wherever the own player's isInvisible is asked. Fixes #3788 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 4 +- .../horizon-client/src/game/client_ambient.rs | 2 +- crates/horizon-client/src/game/names.rs | 2 +- crates/horizon-client/src/game/perspective.rs | 55 ++++++++++++++----- crates/horizon-client/src/game/remote.rs | 16 ++++++ 5 files changed, 61 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 38fb0bc082..26fdd84b31 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10916,7 +10916,7 @@ impl Game<'_> { // renderArmWithItem: only the main hand's empty arm shows, // on the main arm (the left one with the main hand set to // left), and not while the player is invisible. - if hand == Hand::Off || self.potions.effects.get("invisibility").is_some() { + if hand == Hand::Off || self.own_invisible() { continue; } // renderPlayerArm @@ -10930,7 +10930,7 @@ impl Game<'_> { let hold = MapHold::of(hand, off_hand_empty); // renderOneHandedMap and renderTwoHandedMap: the arms under // the map, unless the player is invisible. - if self.potions.effects.get("invisibility").is_none() { + if !self.own_invisible() { for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack) { self.first_person_arm(arm, to_world * pose, packed, &mut out); diff --git a/crates/horizon-client/src/game/client_ambient.rs b/crates/horizon-client/src/game/client_ambient.rs index 343390a1af..bad8b24d13 100644 --- a/crates/horizon-client/src/game/client_ambient.rs +++ b/crates/horizon-client/src/game/client_ambient.rs @@ -144,7 +144,7 @@ impl Game<'_> { .filter_map(|e| effect_particle(e.id, e.ambient)) .collect(); let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient); - let invisible = effects.iter().any(|e| e.id == "invisibility"); + let invisible = self.own_invisible(); let size = ( f64::from(p.dimensions.width), f64::from(p.dimensions.height), diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index 4d1d1b0f72..7ed599a906 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -77,7 +77,7 @@ impl Game<'_> { || self.camera_type.first_person() || self.sleeping.is_some() || self.spectator() - || self.potions.effects.get("invisibility").is_some() + || self.own_invisible() { return None; } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index a3dcd2dc08..7e56fe81ac 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -122,15 +122,18 @@ impl CameraType { } } } +/// `Entity.isInvisible` for the window's player: shared flag 5 as its server +/// sets it (`shared_flag`), with the Invisibility effect or by a plugin's +/// `setInvisible` alone, as pose plugins hide the real body under the pose +/// (#3788); a world in the frame has only the effect. +fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag: bool) -> bool { + shared_flag || effects.get("invisibility").is_some() +} /// `LivingEntityRenderer.isBodyVisible` and `isInvisibleTo` for the player's -/// own body, seen by itself: the Invisibility effect (`Entity.isInvisible`) -/// hides it unless its team sees friendly invisibles, where vanilla draws it +/// own body, seen by itself: an invisible player (`own_invisible`) hides it +/// unless its team sees friendly invisibles, where vanilla draws it /// translucent. -fn body_visible( - effects: &horizon_client::effects::PlayerEffects, - team: Option<&horizon_client::scoreboard::Team>, -) -> bool { - let invisible = effects.get("invisibility").is_some(); +fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool { !horizon_client::scoreboard::invisible_to(team, team, false, invisible) } /// The player's model type (`PlayerModel` with the default skin). @@ -487,9 +490,16 @@ impl Game<'_> { None => self.player_team(), } } + /// `Entity.isInvisible` for the player itself (`own_invisible`). + pub(super) fn own_invisible(&self) -> bool { + own_invisible( + &self.potions.effects, + self.remote.as_ref().is_some_and(|r| r.own_invisible), + ) + } /// Whether the player's own body is drawn (`body_visible`). pub(super) fn own_body_visible(&self) -> bool { - body_visible(&self.potions.effects, self.own_team()) + body_visible(self.own_invisible(), self.own_team()) } /// The player's model for the entity pass in third person. pub(super) fn player_model_geometry( @@ -531,7 +541,7 @@ impl Game<'_> { ); let chest = self.armor[1].as_ref().map(|s| &*s.item); // CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks). - if self.potions.effects.get("invisibility").is_none() { + if !self.own_invisible() { self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out); } self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out); @@ -621,7 +631,7 @@ impl Game<'_> { &mut verts, ); let chest = self.armor[1].as_ref().map(|s| &*s.item); - if self.potions.effects.get("invisibility").is_none() { + if !self.own_invisible() { self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts); } self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts); @@ -938,15 +948,32 @@ mod preview_tests { .unwrap() .id; let mut effects = horizon_client::effects::PlayerEffects::default(); - assert!(body_visible(&effects, None)); + assert!(body_visible(own_invisible(&effects, false), None)); effects.add(EffectInstance::new(id, 3600, 0)); - assert!(!body_visible(&effects, None), "no team: the body is hidden"); + let invisible = own_invisible(&effects, false); + assert!( + !body_visible(invisible, None), + "no team: the body is hidden" + ); // Entity.isInvisibleTo: a team that sees friendly invisibles sees it. let mut board = Scoreboard::default(); let team = board.add_team("blue"); - assert!(body_visible(&effects, Some(team))); + assert!(body_visible(invisible, Some(team))); team.see_friendly_invisibles = false; - assert!(!body_visible(&effects, Some(team))); + assert!(!body_visible(invisible, Some(team))); + } + + /// #3788: a pose plugin's `Player.setInvisible(true)` sets only the + /// shared flag (no effect); `Entity.isInvisible` reads that flag, so the + /// real body under the pose is not drawn. + #[test] + fn the_servers_invisible_flag_hides_the_own_body_without_the_effect() { + let effects = horizon_client::effects::PlayerEffects::default(); + assert!(own_invisible(&effects, true)); + assert!(!body_visible(own_invisible(&effects, true), None)); + let mut board = horizon_client::scoreboard::Scoreboard::default(); + let team = board.add_team("blue"); + assert!(body_visible(own_invisible(&effects, true), Some(team))); } /// LivingEntityRenderer.submit: an invisible body is not drawn, its diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index a88570f380..f185b03676 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -198,6 +198,9 @@ pub(super) struct Remote { /// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep /// theirs in `Mob::glowing`. glowing: std::collections::HashSet, + /// The player's own shared flag 5 (`Entity.isInvisible`) as the server + /// last set it. + pub(super) own_invisible: bool, /// The local player's profile UUID (always in the level). profile: u128, /// The local player's profile name: its score holder and team member. @@ -509,6 +512,7 @@ impl Game<'_> { level_players: HashMap::new(), level_player_names: HashMap::new(), glowing: Default::default(), + own_invisible: false, profile: 0, profile_name: String::new(), destroying: None, @@ -1851,6 +1855,7 @@ impl Game<'_> { self.potions.effects.clear(); if let Some(r) = self.remote.as_mut() { r.carried = Some(0); + r.own_invisible = false; r.online_mode = p.online_mode; r.entity_id = p.player_id; r.logged_in = true; @@ -1878,6 +1883,11 @@ impl Game<'_> { // passengers. A dimension change keeps the vehicle it travels in. if p.keep & 2 == 0 { self.riding = None; + // KEEP_ENTITY_DATA copies the old player's synched data + // (its shared flags) to the new one; without it they reset. + if let Some(r) = self.remote.as_mut() { + r.own_invisible = false; + } } let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension); self.enter_remote_dimension(&p.spawn, true); @@ -2267,10 +2277,16 @@ impl Game<'_> { // server stops the sprint after a hit's extra // knockback (the flag only, as assignValues sets // it: the speed modifier and wasSprinting stay). + // Its invisible bit (Entity.isInvisible): the + // Invisibility effect's, or a plugin's that hides + // the real body under a pose (#3788). (0, Value::Byte(flags)) => { if let Some(pl) = self.player.as_mut() { pl.sprinting = flags & 0x08 != 0; } + if let Some(r) = self.remote.as_mut() { + r.own_invisible = flags & 0x20 != 0; + } } // Entity.DATA_AIR_SUPPLY_ID and // Player.DATA_PLAYER_ABSORPTION_ID: the bubbles From 27c3c0be3111f77cc2bef6e3d0f9f5d66f26b165 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 01:28:25 +0300 Subject: [PATCH 173/968] Native mods: the block entity hooks in the hook catalog Co-Authored-By: Claude Opus 5.5 --- native-hooks.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/native-hooks.json b/native-hooks.json index eb127b64b3..b4940bebe8 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -59,6 +59,21 @@ "name": "panel_event", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_panel_event" + }, + { + "name": "block_entity", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity" + }, + { + "name": "block_entity_ticks", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_ticks" + }, + { + "name": "block_entity_tick", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_tick" } ] } From a8b5151ce5032477dbd285098244c478bd9370c4 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:32:19 +0700 Subject: [PATCH 174/968] Controller toggle-sneak comes back after a screen closes The pad's toggle-sneak flipped sneaking with a hold-mode Shift press or release, so KeyMapping.releaseAll dropped it when a screen opened and restoreToggleStatesOnScreenClosed had nothing to restore. ToggleKeys now has a pad toggle: it flips the sneak mapping as ToggleKeyMapping.setDown does with its option on, and while it holds sneaking the mapping toggles (released and remembered by a screen, restored when it closes, a shift release behind the screen ignored), as vanilla's toggle crouch does. Part of #3061. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-input/src/keymapping.rs | 93 ++++++++++++++++++- crates/horizon-client/src/game/pad.rs | 15 ++- docs/controllers.md | 5 +- 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client-input/src/keymapping.rs b/crates/horizon-client-input/src/keymapping.rs index e438c27ce9..a2d82fb763 100644 --- a/crates/horizon-client-input/src/keymapping.rs +++ b/crates/horizon-client-input/src/keymapping.rs @@ -10,6 +10,10 @@ pub struct KeyState { released_by_screen_when_down: bool, toggle: Option, keyboard: bool, + /// Held down by a controller's toggle (Horizon's pad toggle-sneak, which + /// vanilla has no option for): until it lets go, the mapping toggles as + /// if its vanilla option were on, so a screen releases and restores it. + pad_toggled: bool, } impl KeyState { @@ -20,6 +24,7 @@ impl KeyState { released_by_screen_when_down: false, toggle: None, keyboard: true, + pad_toggled: false, } } /// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key. @@ -43,16 +48,28 @@ impl KeyState { true } } + /// The mapping toggles: its option is on, or a pad toggled it down. + fn needs_toggle(&self, option: bool) -> bool { + option || self.pad_toggled + } /// `setDown`; `needs_toggle` is the mapping's option value. pub fn set_down(&mut self, down: bool, needs_toggle: bool) { - if self.toggle.is_some() && needs_toggle { + if self.toggle.is_some() && self.needs_toggle(needs_toggle) { if down { self.is_down = !self.is_down; + // Toggled back up: the pad's toggle is over. + self.pad_toggled &= self.is_down; } } else { self.is_down = down; } } + /// A controller's toggle button: flips the mapping like `setDown(true)` + /// with its option on, whatever the option says. + pub fn pad_toggle(&mut self) { + self.is_down = !self.is_down; + self.pad_toggled = self.is_down; + } /// `shouldSetOnIngameFocus` for a keyboard-bound, bound key. pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool { self.keyboard && (self.toggle.is_none() || !needs_toggle) @@ -61,7 +78,8 @@ impl KeyState { pub fn release(&mut self, needs_toggle: bool) { if self.toggle.is_some() { // ToggleKeyMapping.release does not clear pending clicks. - if needs_toggle && self.is_down || self.released_by_screen_when_down { + if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down + { self.released_by_screen_when_down = true; } self.reset(); @@ -70,7 +88,8 @@ impl KeyState { self.set_down(false, needs_toggle); } } - /// `ToggleKeyMapping.reset`. + /// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen + /// that released it restores it. pub fn reset(&mut self) { self.is_down = false; } @@ -83,7 +102,10 @@ impl KeyState { let Some(restore) = self.toggle else { return false; }; - let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down; + let result = restore + && self.needs_toggle(needs_toggle) + && self.keyboard + && self.released_by_screen_when_down; self.released_by_screen_when_down = false; result } @@ -138,6 +160,15 @@ impl ToggleKeys { _ => {} } } + /// A controller's toggle button bound to `slot` (the pad's toggle-sneak): + /// a press with no screen open flips the mapping and clicks it, as a + /// key press does with the mapping's toggle option on. + pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) { + if !screen_open { + self.0[slot].pad_toggle(); + self.0[slot].click(); + } + } /// `MouseHandler.onButton` for a button bound to `slot`: only without a /// screen or overlay. pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) { @@ -167,6 +198,9 @@ impl ToggleKeys { pub fn respawn(&mut self) { for key in &mut self.0 { key.reset(); + // As with the vanilla option, the death screen closing after + // this still restores a toggle it released. + key.pad_toggled &= key.released_by_screen_when_down; } } } @@ -197,6 +231,57 @@ mod tests { keys.respawn(); assert!(!keys.is_down(SPRINT)); } + /// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes + /// back after a screen, as the vanilla option's toggle does; a + /// keyboard shift-click in the inventory does not drop it. + #[test] + fn pad_toggle_is_restored_after_a_screen() { + let off = [false; 4]; + let mut keys = ToggleKeys::default(); + keys.pad_toggle(SNEAK, false); + assert!(keys.is_down(SNEAK)); + keys.screen_opened(off); + assert!(!keys.is_down(SNEAK)); + keys.key(SNEAK, KeyAction::Press, true, false); + keys.key(SNEAK, KeyAction::Release, true, false); + keys.screen_closed(off); + assert!(keys.is_down(SNEAK), "the pad's toggle is restored"); + // Toggled back up, it stays up through the next screen. + keys.pad_toggle(SNEAK, false); + assert!(!keys.is_down(SNEAK)); + keys.screen_opened(off); + keys.screen_closed(off); + assert!(!keys.is_down(SNEAK)); + // A held key is not restored, as before. + keys.key(SNEAK, KeyAction::Press, false, false); + keys.screen_opened(off); + keys.key(SNEAK, KeyAction::Release, true, false); + keys.screen_closed(off); + assert!(!keys.is_down(SNEAK)); + } + /// After the pad's toggle is over (toggled up, or reset by a respawn + /// with no screen to restore it), the shift key holds again. + #[test] + fn pad_toggle_ends_with_its_release() { + let off = [false; 4]; + let mut keys = ToggleKeys::default(); + keys.pad_toggle(SNEAK, false); + keys.respawn(); + assert!(!keys.is_down(SNEAK)); + keys.key(SNEAK, KeyAction::Press, false, false); + keys.key(SNEAK, KeyAction::Release, false, false); + assert!(!keys.is_down(SNEAK), "held, not toggled"); + // The death screen releases it and closes after the respawn: the + // toggle comes back, as with the vanilla option. + keys.pad_toggle(SNEAK, false); + keys.screen_opened(off); + keys.respawn(); + keys.screen_closed(off); + assert!(keys.is_down(SNEAK)); + // A press with a screen open does nothing. + keys.pad_toggle(SNEAK, true); + assert!(keys.is_down(SNEAK)); + } #[test] fn screen_release_restores_toggled_state() { let mut k = KeyState::toggle(true, true); diff --git a/crates/horizon-client/src/game/pad.rs b/crates/horizon-client/src/game/pad.rs index 9a71bdc759..c77d84e163 100644 --- a/crates/horizon-client/src/game/pad.rs +++ b/crates/horizon-client/src/game/pad.rs @@ -8,6 +8,7 @@ use horizon_client::{ Result, gamepad::{self, Button, Edges, GlyphStyle, HealthWatch, PadState, Rumble}, keybinds::{KeyBinds, key_code, mouse_button}, + keymapping::SNEAK, mouse::Rotation, }; use serde_json::{Value, json}; @@ -214,12 +215,16 @@ impl Game<'_> { "key.sneak" if self.settings.controller.toggle_sneak && !self.settings.toggle_crouch => { - // A stick click flips sneaking instead of holding it. - if down { - let sneaking = self.keys.contains(&KeyCode::ShiftLeft); - self.mapped_key(KeyCode::ShiftLeft, !sneaking, false)?; + // A stick click flips sneaking instead of holding it, as + // vanilla's toggle crouch does: a screen opening releases it + // and closing restores it (ToggleKeyMapping). + if !down { + return Ok(()); } - Ok(()) + let screen_open = self.screen != Screen::Playing || self.title.is_some(); + self.toggles.pad_toggle(SNEAK, screen_open); + self.sync_toggle(SNEAK); + self.key_pressed(KeyCode::ShiftLeft) } _ => { let default = KeyBinds::default_of(action); diff --git a/docs/controllers.md b/docs/controllers.md index b94e07b9c5..9b506d0067 100644 --- a/docs/controllers.md +++ b/docs/controllers.md @@ -65,8 +65,9 @@ loop iteration next to winit's events. The Guide / PS / Steam button is left to the system. Sneak on a stick click toggles by default (holding a stick in while steering the camera is -awkward, which is why Bedrock does the same); the controls options can make it -hold-to-sneak. +awkward, which is why Bedrock does the same) and acts like vanilla's toggle +crouch: a screen stands you up while it is open and closing it crouches you +again. The controls options can make it hold-to-sneak. ## Menus and inventories From decdf499371ad22be3d23356e3d2bf61c762030b Mon Sep 17 00:00:00 2001 From: Senjin Date: Tue, 27 Oct 2026 11:06:58 +0100 Subject: [PATCH 175/968] HDR: the moon shines at the low tier, not the sun's The sun and the moon marked the frame alike, so the moon sat in the sun's tier, whose lift window only its brightest pixels reach: it hardly lifted at all. Each body's quads now carry their tier in the overlay's high bits (as particles do), and fs_sky_celestial writes that tier's mark: the sun's as before, the moon's low. Part of #1441. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/entity.wgsl | 5 ++++- crates/horizon-client/src/game.rs | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client-render/src/entity.wgsl b/crates/horizon-client-render/src/entity.wgsl index e2a09aa637..a22168b2a4 100644 --- a/crates/horizon-client-render/src/entity.wgsl +++ b/crates/horizon-client-render/src/entity.wgsl @@ -306,7 +306,10 @@ fn celestial_color(in: Out) -> vec4 { @fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4 { let color = celestial_color(in); let added = color.rgb * color.a; - return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b)))); + // The sky under the disc keeps alpha 1 less this: 0 for the brightest tier + // (the sun), terrain.wgsl's mark for the lower ones (the moon). + let mark = 1.0 - 0.7 * f32(4u - min(in.tier, 4u)) / 4.0; + return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b)))); } // RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8cba7cb80c..9065b28f6b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -9209,6 +9209,13 @@ impl Game<'_> { let Some(r) = atlas.sprites.get(name).copied() else { return; }; + // How brightly HDR output lets the body shine (mesher::hdr_tier): + // the moon at the low tier, not the sun's. + let tier = if moon { + horizon_client::mesher::LOW + } else { + horizon_client::mesher::VERY_BRIGHT + }; let m = frame(angle); let corners = [(-size, -size), (size, -size), (size, size), (-size, size)]; // buildCelestialQuad (sun) vs buildMoonPhases: the moon's UVs run @@ -9224,7 +9231,7 @@ impl Game<'_> { normal: [0; 4], pos: p.to_array(), uv, - layer: 2, + layer: 2 | u32::from(tier) << 16, color: body_color, light: 0, From 0a72d58c2538cdff52f21ec0d48cbdb3f5d6ad7b Mon Sep 17 00:00:00 2001 From: Senjin Date: Tue, 27 Oct 2026 15:24:31 +0100 Subject: [PATCH 176/968] HDR: mobs' glowing eyes shine like glowstone The eyes render type's layers (spiders', endermen's and phantoms' eyes, the breeze's, the creaking's, the copper golem's) carry the bright tier in the overlay's high bits, as light-giving particles do, so the HDR mark pass lifts them: they glow to frighten, so they shine above a torch. The warden's fading layers and the dragon's additive eyes are left as they are. Part of #1441. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/render.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 29d299bf88..d562997868 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -37,6 +37,10 @@ pub fn scroll_offset(age: f32, speed: f32) -> f32 { fn scroll_u(u: f32, scroll: f32) -> f32 { if scroll == 0.0 { u } else { (u + scroll) % 1.0 } } +/// How brightly the eyes render type's layers (spiders', endermen's and +/// phantoms' eyes, the breeze's, the creaking's, the copper golem's) shine in +/// HDR output, as `mesher::hdr_tier`. +const EYES_TIER: u8 = crate::mesher::BRIGHT; /// One draw of a model with a texture. pub struct Layer { @@ -1431,9 +1435,12 @@ fn render_visible( overlay, out, ); + // The eyes render type's glowing eyes also shine in HDR output, at + // `EYES_TIER` in the overlay's high bits (entity.wgsl fs_hdr_mark). if layer.full_bright && m.species() != Species::Warden { for vertex in &mut out[start..] { vertex.normal = [0; 4]; + vertex.layer |= u32::from(EYES_TIER) << 16; } } } From ef61ac7c6c399536479c71408dd4727921992746 Mon Sep 17 00:00:00 2001 From: Senjin Date: Wed, 28 Oct 2026 04:41:17 +0100 Subject: [PATCH 177/968] HDR: light sources tiered from a look at each in game Beacon, end rod, furnace and redstone lamp shine bright; the froglights, shroomlight and every copper bulb low; the respawn anchor incandescent at every charge. Sea pickles, brown mushrooms, the dragon egg and the end portal frame only glow faintly, as glow lichen, and stay as in SDR. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/mesher.rs | 23 ++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index 8328468f0b..3e765fc1a0 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -139,8 +139,12 @@ mod vertex_tests { assert_eq!(hdr_tier("red_candle", 3), LOW); assert_eq!(hdr_tier("glow_lichen", 7), 0); assert_eq!(hdr_tier("stone", 0), 0); + assert_eq!(hdr_tier("ochre_froglight", 15), LOW); + assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT); + assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW); + assert_eq!(hdr_tier("sea_pickle", 15), 0); // Unlisted ones by their light level. - assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT); + assert_eq!(hdr_tier("vault", 12), INCANDESCENT); assert_eq!(hdr_tier("sculk_sensor", 1), LOW); } } @@ -693,14 +697,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 { match name { _ if level == 0 => 0, "glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern" - | "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT, + | "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace" + | "redstone_lamp" => BRIGHT, "torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire" | "sea_lantern" => INCANDESCENT, - "redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW, + // Glowing at every charge, not only the brighter ones. + "respawn_anchor" => INCANDESCENT, + "redstone_torch" + | "redstone_wall_torch" + | "magma_block" + | "ochre_froglight" + | "verdant_froglight" + | "pearlescent_froglight" + | "shroomlight" => LOW, // Its flame is a particle. _ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW, + // One tier at every oxidation stage, waxed or not. + _ if name.ends_with("copper_bulb") => LOW, // A faint glow in the dark, as in SDR. - "glow_lichen" => 0, + "glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0, _ if level >= 10 => INCANDESCENT, _ => LOW, } From 51cd5409da224784059f82b21281c0eb33cf9533 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:10:20 +0700 Subject: [PATCH 178/968] Creative players are pushed by explosions and crystals ServerExplosion.hurtEntities puts each pushed player in hitPlayers and ServerLevel.explode sends them that push in ClientboundExplodePacket. Horizon pushed the server's copy only and sent it to the client with a hurt's motion packet, which a creative player never gets: a crystal they hit left them standing, and TNT replaced their motion with the server copy's. The pushes now go in the explosion packet (deferred with it in a guest's turn), except for a hurt player, whose motion packet already carries the push. Fixes #2755 (its survival part was fixed by #2730). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 5 +- crates/horizon-client/src/game/explosion.rs | 56 +++++++++++++------ .../horizon-client/src/game/lan_particles.rs | 17 +++--- 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2c097d1ea7..b7dc8995c3 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -57,8 +57,9 @@ pub(super) struct Creatures { /// (game/lan_pvp.rs). pub sweeps: Vec, /// Explosion packets from a guest's turn, waiting for it to end - /// (centre, small, radius, blocks hit; game/lan_particles.rs). - pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>, + /// (centre, small, radius, blocks hit, the players it pushed; + /// game/lan_particles.rs). + pub lan_explosions: Vec<([f64; 3], bool, f32, usize, Vec<(u32, [f64; 3])>)>, /// Ticks left of the elder guardian's apparition (MobAppearanceParticle). pub elder_apparition: i32, /// The wandering trader spawner's clock. diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index f6702d2c33..22233f6775 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -224,20 +224,24 @@ impl Game<'_> { } set.entries().map(|(k, _)| k.0).collect() } - /// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts); - /// the player takes `damage_type`. /// `ServerExplosion.hurtEntities` for the player: damage scaled by /// distance and exposure, through a raised shield (`hurtServer` blocks /// explosions like any attack from where the blast is), then the push /// (less with explosion knockback resistance; none for spectators or a /// creative player flying). + /// + /// Returns the push their client adds from the explosion packet + /// (`hitPlayers`), unless a hurt marked their velocity: that goes to them + /// whole, this push in it, once their server copy has travelled + /// (Entity.markHurt, then ServerEntity.sendChanges), and replaces what the + /// packet would add. pub(super) fn explosion_hurt_player( &mut self, center: [f64; 3], radius: f32, damage_type: &str, exploding: Option, - ) { + ) -> Option<[f64; 3]> { let diameter = f64::from(radius * 2.0); // Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and // piece, so two Blast Protection IV pieces cancel it). @@ -246,9 +250,7 @@ impl Game<'_> { .as_mut() .and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok()) .unwrap_or(0.0); - let Some(p) = self.player.as_ref() else { - return; - }; + let p = self.player.as_ref()?; let feet = [p.position.x, p.position.y, p.position.z]; let dist = ((feet[0] - center[0]).powi(2) + (feet[1] - center[1]).powi(2) @@ -256,7 +258,7 @@ impl Game<'_> { .sqrt() / diameter; if dist > 1.0 || p.health.dead_or_dying() { - return; + return None; } // ServerPlayer.onExplosionHit: the blast that hit the player is theirs. self.adv.marks.blast = Some((feet, false)); @@ -291,6 +293,7 @@ impl Game<'_> { let (creative, spectator) = (self.creative, self.spectator()); let flying = self.player.as_ref().is_some_and(|p| p.flying); let mut outcome = horizon_client::health::BlockOutcome::default(); + let mut pushed = None; if let Some(p) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(damage_type) { @@ -317,21 +320,32 @@ impl Game<'_> { } } if !spectator && !(creative && flying) { + let push = [dir[0] * power, dir[1] * power, dir[2] * power]; p.velocity = Vec3::new( - p.velocity.x + dir[0] * power, - p.velocity.y + dir[1] * power, - p.velocity.z + dir[2] * power, + p.velocity.x + push[0], + p.velocity.y + push[1], + p.velocity.z + push[2], ); + pushed = (!p.health.sync_velocity).then_some(push); } } if raised { self.after_block(outcome, amount, None, false); } + pushed } - fn explosion_hurt_entities(&mut self, center: [f64; 3], radius: f32, damage_type: &str) { + /// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts); + /// the player takes `damage_type`. Returns the players whose clients add a + /// push from the explosion packet, and how much (`explosion_hurt_player`). + fn explosion_hurt_entities( + &mut self, + center: [f64; 3], + radius: f32, + damage_type: &str, + ) -> Vec<(u32, [f64; 3])> { if radius < 1.0e-5 { - return; + return Vec::new(); } self.explosion_hits_special(center, radius); let diameter = f64::from(radius * 2.0); @@ -365,8 +379,10 @@ impl Game<'_> { }) }; // The player, then every other player in the level (LAN guests), - // each swapped in as the player. - self.explosion_hurt_player(center, radius, damage_type, exploding); + // each swapped in as the player. In a guest's turn (their hit on an + // end crystal) the player is that guest. + let own = self.explosion_hurt_player(center, radius, damage_type, exploding); + let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect(); if let Some(mut host) = self.lan.take() { let guests: Vec = host .hub @@ -376,9 +392,12 @@ impl Game<'_> { .map(|g| g.conn) .collect(); for conn in guests { - self.guest_hit_by(&mut host, conn, |g| { - g.explosion_hurt_player(center, radius, damage_type, exploding); + let push = self.as_guest(&mut host, conn, |g| { + g.explosion_hurt_player(center, radius, damage_type, exploding) }); + if let Some(Some(push)) = push { + pushed.push((conn, push)); + } } self.lan = Some(host); } @@ -518,6 +537,7 @@ impl Game<'_> { c.velocity.z + dir[2] * power, ); } + pushed } /// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying /// and plain destruction. @@ -579,7 +599,7 @@ impl Game<'_> { let ctx = horizon_client::sculk::Context::default(); self.post_game_event("minecraft:explode", center, ctx); let mut targets = self.exploded_positions(center, radius, resists); - self.explosion_hurt_entities(center, radius, damage_type); + let pushed = self.explosion_hurt_entities(center, radius, damage_type); if interaction != BlockInteraction::Keep { // Util.shuffle(list, level.random) for i in (2..=targets.len()).rev() { @@ -628,7 +648,7 @@ impl Game<'_> { .explosion(center, small, block_count, radius, &|b| { facts.is_air(world.block(b[0], b[1], b[2])) }); - self.lan_explode(center, small, radius, block_count); + self.lan_explode(center, small, radius, block_count, pushed); Ok(()) } /// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse). diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index fe10118044..07324342fc 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -409,15 +409,18 @@ impl Game<'_> { } } - /// `ServerLevel.explode`'s packet to each player within 64 blocks. The - /// sound and the push reach them on their own (the level's sounds, the - /// motion a hurt player is sent), so the packet carries neither. + /// `ServerLevel.explode`'s packet to each player within 64 blocks, with + /// the push `pushed` holds for them (`ServerExplosion.hitPlayers`: a + /// creative player, whom nothing hurt). The sound reaches them on its own + /// (the level's sounds), and so does a hurt player's push (the motion + /// they are sent). pub(super) fn lan_explode( &mut self, center: [f64; 3], small: bool, radius: f32, block_count: usize, + pushed: Vec<(u32, [f64; 3])>, ) { // In a guest's turn (their hit on an end crystal or a minecart) the // host holds the players (`as_guest`): the packet waits for the turn @@ -425,7 +428,7 @@ impl Game<'_> { if self.lan_acting.is_some() && self.lan.is_none() { self.creatures .lan_explosions - .push((center, small, radius, block_count)); + .push((center, small, radius, block_count, pushed)); return; } let emitter = if small { @@ -437,16 +440,16 @@ impl Game<'_> { if let Some(packet) = self.explosion_packet(center, radius, block_count, emitter, sound, true) { - self.send_explosion(packet, &[]); + self.send_explosion(packet, &pushed); } } /// The explosions guests' turns put off. pub(super) fn flush_lan_explosions(&mut self) { - for (center, small, radius, block_count) in + for (center, small, radius, block_count, pushed) in std::mem::take(&mut self.creatures.lan_explosions) { - self.lan_explode(center, small, radius, block_count); + self.lan_explode(center, small, radius, block_count, pushed); } } From 152258bb839995d080a7628d37ac74c3fa402a47 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:10:20 +0700 Subject: [PATCH 179/968] server_knockback harness: creative players near a crystal and TNT KnockbackClient records the push each ClientboundExplodePacket carries; two cases put Bob in creative two blocks from an end crystal he hits and from primed TNT. Co-Authored-By: Claude Opus 5.5 --- .../horizonlab/parity/KnockbackClient.java | 10 ++++++--- tools/test_server_knockback_parity.py | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/KnockbackClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/KnockbackClient.java index 0c02fa9aa4..5f3336a6e2 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/KnockbackClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/KnockbackClient.java @@ -44,6 +44,7 @@ import net.minecraft.network.protocol.configuration.ConfigurationProtocols; import net.minecraft.network.protocol.configuration.ServerboundFinishConfigurationPacket; import net.minecraft.network.protocol.configuration.ServerboundSelectKnownPacks; import net.minecraft.network.protocol.game.ClientboundChunkBatchFinishedPacket; +import net.minecraft.network.protocol.game.ClientboundExplodePacket; import net.minecraft.network.protocol.game.ClientboundPlayerPositionPacket; import net.minecraft.network.protocol.game.ClientboundSystemChatPacket; import net.minecraft.network.protocol.game.GameProtocols; @@ -83,9 +84,9 @@ import net.minecraft.world.phys.Vec3; * it is back), {@code attack NAME TARGET} (ServerboundAttackPacket then the swing; TARGET is a * player's name or an entity type's path, the last one added), {@code use * NAME} (ServerboundUseItemPacket, main hand) and {@code end}. Each player - * records the ClientboundSetEntityMotionPacket it receives, its own sprint - * flag as the server sets it, teleports, and the system chat (command - * feedback). + * records the ClientboundSetEntityMotionPacket it receives, the push each + * ClientboundExplodePacket carries for it, its own sprint flag as the server + * sets it, teleports, and the system chat (command feedback). */ public final class KnockbackClient { private static volatile String step = "start"; @@ -355,6 +356,9 @@ public final class KnockbackClient { replyLock.notifyAll(); } } + } else if (p instanceof ClientboundExplodePacket e) { + record(e.playerKnockback().map(v -> String.format(java.util.Locale.ROOT, "explode push %.5f %.5f %.5f", + v.x, v.y, v.z)).orElse("explode push none")); } else if (p instanceof net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket d && d.id() == id) { for (var value : d.packedItems()) { if (value.id() == 0) record("sprinting " + ((((Byte) value.value()) & 8) != 0)); diff --git a/tools/test_server_knockback_parity.py b/tools/test_server_knockback_parity.py index b3c4355eaa..64f66881e4 100644 --- a/tools/test_server_knockback_parity.py +++ b/tools/test_server_knockback_parity.py @@ -155,6 +155,20 @@ def mob_hits_case(kind): 'cmd Alice kill @e[type=!player]', 'cmd Alice gamemode survival'] +def blast_case(blast, row): + """A blast two blocks east of Bob in creative, which hurts him not: an end crystal he hits himself, or primed TNT + about to go off. The push his client is sent (the explosion packet's playerKnockback, ServerExplosion.hitPlayers) + and his motion packet. Each case has a floor of its own (`row` blocks south), as a blast digs a crater.""" + at, z = (V[0], V[1], V[2] + row), V[2] + row + summon = {'crystal': f'cmd Alice summon minecraft:end_crystal {at[0] + 2} {at[1]} {z} {{ShowBottom:0b}}', + 'tnt': f'cmd Alice summon minecraft:tnt {at[0] + 2} {at[1]} {z} {{fuse:20s}}'}[blast] + lines = ['cmd Alice kill @e[type=!player]', 'cmd Bob gamemode creative', + f'cmd Alice tp Bob {at[0]} {at[1]} {at[2]} -90 0', *stand('Bob', at, -90), 'ticks 20', summon, 'ticks 5'] + lines += ['attack Bob end_crystal', 'ticks 10'] if blast == 'crystal' else ['ticks 30'] + return lines + [*stand('Bob', at, -90), 'cmd Bob gamemode survival', 'cmd Alice kill @e[type=!player]', + f'cmd Alice tp Bob {V[0]} {V[1]} {V[2]} 90 0', *stand('Bob', V, 90)] + + CASES = { 'cow-plain': mob_case('cow'), 'cow-sprint': mob_case('cow', sprint=True), @@ -177,6 +191,8 @@ CASES = { 'pvp-mace': pvp_case(weapon='minecraft:mace', mace=True), 'pvp-cooldown': pvp_case(cooldown=True), 'zombie-hits-bob': mob_hits_case('zombie'), + 'crystal-creative': blast_case('crystal', 16), + 'tnt-creative': blast_case('tnt', 32), } SETUP = ['cmd Alice gamerule spawn_mobs false', 'cmd Alice gamerule spawn_monsters false', 'cmd Alice kill @e[type=!player]', 'cmd Alice gamerule advance_time false', 'cmd Alice time set midnight', @@ -301,8 +317,8 @@ DATA = re.compile(r'^chat \S+ has the following entity data: \[(.*)\]$') def compared(lines): - """Per case, what is compared: the mob's Pos and Motion (to 1e-9), and each player's motion packets and - sprint flag.""" + """Per case, what is compared: the mob's Pos and Motion (to 1e-9), and each player's motion packets, the push + each explosion packet carries for them and their sprint flag.""" cases = {} for line in lines: who, step, text = line.split(' ', 2) @@ -313,7 +329,7 @@ def compared(lines): data = DATA.match(text) if who == 'Alice' and data: kept.append('data ' + ' '.join(f'{float(v.rstrip("d")):.9f}' for v in data.group(1).split(', '))) - elif text.startswith('motion self') or text.startswith('sprinting'): + elif text.startswith(('motion self', 'sprinting', 'explode push')): kept.append(f'{who} {text}') elif text.startswith('error') or text.startswith('disconnected'): kept.append(f'{who} {text}') From d4a973f65f1afd93595638cd8ddb79e60d9d645d Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:55:32 +0700 Subject: [PATCH 180/968] Creative inventory: the Operator Utilities tab for operators The Operator Items Tab option was greyed out and nothing read it: the creative export was built without permissions, so the operator tab had no stacks and was dropped. VanillaCreativeTabsExporter now builds the tabs with permissions and marks the stacks only that build lists (permissions_only). Creative keeps every exported stack and rebuilds its tabs and search tab for hasPermissions (canUseGameMasterBlocks and the option), as CreativeModeInventoryScreen does on opening and in containerTick (tryRefreshInvalidatedTabs): a selected operator tab left empty gives way to the default tab. The option is switchable again. test_creative_parity.py runs with and without permissions. Part of #3393. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client-render/src/options_ui.rs | 14 +- .../examples/probe/creative_search_fixture.rs | 11 +- crates/horizon-client/src/creative.rs | 287 +++++++++++++++--- crates/horizon-client/src/game.rs | 12 + crates/horizon-client/src/game/remote.rs | 1 + docs/rust-parity.md | 11 +- .../parity/VanillaCreativeSearchOracle.java | 10 +- .../parity/VanillaCreativeTabsExporter.java | 43 ++- tools/test_creative_parity.py | 29 +- 9 files changed, 337 insertions(+), 81 deletions(-) diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 92ac28a953..38c4cb95b9 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -141,9 +141,7 @@ impl Opt { | Opt::ChunkFade | Opt::ImprovedTransparency | Opt::ChunkBuilder - // Saved, but the creative tabs, the quit keys and the mouse - // don't read them yet. - | Opt::OperatorItemsTab + // Saved, but the quit keys and the mouse don't read them yet. | Opt::QuitShortcuts | Opt::CtrlClickEmulatesRightClick ) @@ -3336,6 +3334,16 @@ mod tests { assert!(loaded.toggle_attack && loaded.toggle_use); } + #[test] + fn operator_items_tab_option_can_be_switched() { + // The creative screen reads it (CreativeModeInventoryScreen.hasPermissions). + assert!(Opt::OperatorItemsTab.available()); + let mut s = Settings::default(); + assert!(!s.operator_items_tab); + Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]); + assert!(s.operator_items_tab); + } + #[test] fn render_distance_slider_round_trips_through_extended_options_range() { let mut s = Settings::default(); diff --git a/crates/horizon-client/examples/probe/creative_search_fixture.rs b/crates/horizon-client/examples/probe/creative_search_fixture.rs index 1f4f04b37a..f3bae36491 100644 --- a/crates/horizon-client/examples/probe/creative_search_fixture.rs +++ b/crates/horizon-client/examples/probe/creative_search_fixture.rs @@ -1,9 +1,10 @@ //! The creative inventory, for tools/test_creative_parity.py: loads an //! exported `creative-inventory.json` into `horizon_client::creative::Creative` //! and prints the tabs it shows (canonical stacks) and the search tab's -//! results for every query of the vanilla oracle's output. +//! results for every query of the vanilla oracle's output; with `permissions`, +//! as an operator with the Operator Items Tab option on. //! -//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT +//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions] use horizon_client::{ creative::{Creative, Names}, item_data::canon_stack, @@ -13,11 +14,13 @@ use std::{fs, path::Path}; pub fn main() -> horizon_client::Result<()> { let args: Vec = crate::args().collect(); - let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT"; + let usage = + "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]"; let names = Names::load(Path::new(args.get(1).ok_or(usage)?)); - let creative = Creative::from_json(&serde_json::from_slice(&fs::read( + let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read( args.get(2).ok_or(usage)?, )?)?)?; + creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names); let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?; let tabs: Vec = creative .tabs diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index 7405ae8b4d..06d5e72e64 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -21,6 +21,30 @@ pub struct Tab { pub search: bool, pub inventory: bool, } +impl Tab { + /// The tab without its stacks. + fn header(&self) -> Self { + Self { + id: self.id.clone(), + title: self.title.clone(), + icon: self.icon.clone(), + items: Vec::new(), + top: self.top, + column: self.column, + search: self.search, + inventory: self.inventory, + } + } +} +/// A tab as exported, built with permissions: `operator` marks the stacks a +/// build without them leaves out (the export's `permissions_only`). +#[derive(Clone, Debug)] +struct Exported { + tab: Tab, + /// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`). + category: bool, + operator: Vec, +} /// What the client's creative search trees (SessionSearchTrees) index for one /// stack of the search tab: its creative tooltip lines and its item tags. #[derive(Clone, Debug, Default)] @@ -34,6 +58,8 @@ struct SearchEntry { tags: Vec<(String, String)>, } pub struct Creative { + /// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current + /// permissions put in them. pub tabs: Vec, pub selected: usize, /// Scroll position 0..1 (scrollOffs). @@ -45,6 +71,13 @@ pub struct Creative { /// The search tab's stacks, in its display order. search_items: Vec, search_index: Vec, + /// Every exported tab, and the search tab's stacks with their entries and + /// whether only permissions list them: `rebuild` keeps what is shown. + exported: Vec, + exported_search: Vec<(ItemStack, SearchEntry, bool)>, + /// The `hasPermissions` the tabs were last built with + /// (`CreativeModeTabs.tryRebuildTabContents`). + permissions: bool, /// ItemPickerMenu.items: what the grid shows, filled when the tab or the /// search changes (selectTab, refreshSearchResults), not on every frame. shown: Mutex>, @@ -199,14 +232,22 @@ impl Creative { } pub fn from_json(raw: &Value) -> Result { let mut tabs = Vec::new(); - let mut search_items = Vec::new(); - let mut search_index = Vec::new(); + let mut search = Vec::new(); for t in raw["tabs"].as_array().into_iter().flatten() { + // The stacks only a build with permissions lists, by index. + let only: Vec = t["permissions_only"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .collect(); let mut items = Vec::new(); - for v in t["items"].as_array().into_iter().flatten() { + let mut operator = Vec::new(); + for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() { let snbt = v.as_str().unwrap_or("-"); if let Some(stack) = crate::item_data::parse_stack(snbt)? { items.push(stack); + operator.push(only.contains(&(i as u64))); } } let kind = t["type"].as_str().unwrap_or(""); @@ -220,33 +261,37 @@ impl Creative { }; for (i, stack) in items.iter().enumerate() { let (namespace, path) = split_id(&java_lower(&stack.item)); - search_index.push(SearchEntry { + let entry = SearchEntry { namespace, path, lines: strings(&t["lines"][i]), tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(), - }); + }; + search.push((stack.clone(), entry, operator[i])); } - search_items = items.clone(); } - if kind == "HOTBAR" || t["visible"] == false { + if kind == "HOTBAR" { continue; } let icon = t["icon"] .as_str() .and_then(|s| crate::item_data::parse_stack(s).ok().flatten()); - tabs.push(Tab { - id: t["id"].as_str().unwrap_or("").to_owned(), - title: t["title"].as_str().unwrap_or("").to_owned(), - icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()), - items, - top: t["row"] == "TOP", - column: t["column"].as_u64().unwrap_or(0) as u32, - search: kind == "SEARCH", - inventory: kind == "INVENTORY", + tabs.push(Exported { + tab: Tab { + id: t["id"].as_str().unwrap_or("").to_owned(), + title: t["title"].as_str().unwrap_or("").to_owned(), + icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()), + items, + top: t["row"] == "TOP", + column: t["column"].as_u64().unwrap_or(0) as u32, + search: kind == "SEARCH", + inventory: kind == "INVENTORY", + }, + category: kind == "CATEGORY", + operator, }); } - Ok(Self::new(tabs, search_items, search_index)) + Ok(Self::new(tabs, search)) } /// The old export: item ids per tab, no components, tooltips or tags. fn from_legacy(raw: &Value) -> Result { @@ -268,33 +313,38 @@ impl Creative { } } let kind = t["type"].as_str().unwrap_or(""); - tabs.push(Tab { - id: t["id"].as_str().unwrap_or("").to_owned(), - title: t["title"].as_str().unwrap_or("").to_owned(), - icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(), - items, - top: t["row"] == "TOP", - column: t["column"].as_u64().unwrap_or(0) as u32, - search: kind == "SEARCH", - inventory: kind == "INVENTORY", + tabs.push(Exported { + operator: vec![false; items.len()], + tab: Tab { + id: t["id"].as_str().unwrap_or("").to_owned(), + title: t["title"].as_str().unwrap_or("").to_owned(), + icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(), + items, + top: t["row"] == "TOP", + column: t["column"].as_u64().unwrap_or(0) as u32, + search: kind == "SEARCH", + inventory: kind == "INVENTORY", + }, + category: false, }); } - let search_index = search_items - .iter() + let search = search_items + .into_iter() .map(|s| { let (namespace, path) = split_id(&java_lower(&s.item)); - SearchEntry { + let entry = SearchEntry { namespace, path, ..Default::default() - } + }; + (s, entry, false) }) .collect(); - Ok(Self::new(tabs, search_items, search_index)) + Ok(Self::new(tabs, search)) } - fn new(tabs: Vec, search_items: Vec, search_index: Vec) -> Self { - Self { - tabs, + fn new(exported: Vec, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self { + let mut creative = Self { + tabs: Vec::new(), selected: 0, scroll: 0.0, scrolling: false, @@ -305,10 +355,81 @@ impl Creative { search.text_color = u32::MAX; search }, - search_items, - search_index, + search_items: Vec::new(), + search_index: Vec::new(), + exported, + exported_search, + permissions: false, shown: Mutex::new(None), + }; + creative.rebuild(); + creative + } + /// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator + /// tab's display items generator lists its stacks only with them + /// (`hasPermissions`), and the search tab with it; a category tab is + /// shown with a stack (`CreativeModeTab.shouldDisplay`). + fn rebuild(&mut self) { + let permissions = self.permissions; + let keep = |operator: bool| permissions || !operator; + self.tabs = self + .exported + .iter() + .filter_map(|e| { + let items: Vec = (e.tab.items.iter().zip(&e.operator)) + .filter(|(_, o)| keep(**o)) + .map(|(s, _)| s.clone()) + .collect(); + (!e.category || !items.is_empty()).then(|| Tab { + items, + ..e.tab.header() + }) + }) + .collect(); + (self.search_items, self.search_index) = self + .exported_search + .iter() + .filter(|(_, _, o)| keep(*o)) + .map(|(s, e, _)| (s.clone(), e.clone())) + .unzip(); + self.forget_items(); + } + /// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its + /// constructor and `containerTick` run with `hasPermissions` + /// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option): + /// when that changed, the tabs are built again. A selected tab left + /// without stacks (the operator tab) gives way to the default tab; any + /// other keeps its first row (`refreshCurrentTabContents`). True when the + /// tabs were rebuilt. + pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool { + if permissions == self.permissions { + return false; } + let selected = self.tab().id.clone(); + let row = self.first_row(names); + self.permissions = permissions; + self.rebuild(); + match self.tabs.iter().position(|t| t.id == selected) { + Some(tab) => { + self.selected = tab; + // ItemPickerMenu.getScrollForRowIndex. + let hidden = self.hidden_rows(names); + self.scroll = if hidden > 0 { + (row as f32 / hidden as f32).clamp(0.0, 1.0) + } else { + 0.0 + }; + } + None => { + // CreativeModeTabs.getDefaultTab. + let default = self + .tabs + .iter() + .position(|t| t.id == "minecraft:building_blocks"); + self.select(default.unwrap_or(0)); + } + } + true } /// Where a stack comes in the creative tabs (the search tab lists them /// all, in tab order): the same stack's place, else its item's first. @@ -322,19 +443,7 @@ impl Creative { } /// The tabs without their stacks: what the screen draws of them each frame. pub fn tab_headers(&self) -> Vec { - self.tabs - .iter() - .map(|t| Tab { - id: t.id.clone(), - title: t.title.clone(), - icon: t.icon.clone(), - items: Vec::new(), - top: t.top, - column: t.column, - search: t.search, - inventory: t.inventory, - }) - .collect() + self.tabs.iter().map(Tab::header).collect() } pub fn tab(&self) -> &Tab { &self.tabs[self.selected.min(self.tabs.len() - 1)] @@ -578,7 +687,7 @@ impl Creative { pub fn tab_position(tab: &Tab) -> (f32, f32) { let mut x = tab.column as f32 * 27.0; if tab.column >= 5 { - // Right-aligned tabs (search, inventory). + // Right-aligned tabs (search, operator, inventory). x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0; } let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 }; @@ -1047,6 +1156,84 @@ mod tests { assert_eq!(c.search.value, ""); } + /// An export built with permissions: the operator tab's stacks, and their + /// place at the end of the search tab, only with them. + fn with_operator_tab() -> Creative { + let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}"); + let raw = serde_json::json!({"schema": 3, "tabs": [ + {"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks", + "icon": stack("bricks"), "visible": true, "items": [stack("stone")]}, + {"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities", + "icon": stack("command_block"), "visible": false, + "items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]}, + {"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items", + "icon": stack("compass"), "visible": true, + "items": [stack("stone"), stack("command_block"), stack("barrier")], + "lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []], + "permissions_only": [1, 2]}, + {"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory", + "icon": stack("chest"), "visible": true, "items": []} + ]}); + Creative::from_json(&raw).unwrap() + } + + fn tab_ids(c: &Creative) -> Vec<&str> { + c.tabs.iter().map(|t| t.id.as_str()).collect() + } + + #[test] + fn operator_stacks_stay_out_without_permissions() { + let c = with_operator_tab(); + let names = Names::default(); + assert_eq!( + tab_ids(&c), + [ + "minecraft:building_blocks", + "minecraft:search", + "minecraft:inventory" + ] + ); + assert_eq!(c.search_items().len(), 1); + assert!(c.search_results("command", &names).is_empty()); + assert_eq!(c.search_results("", &names), [0]); + } + + #[test] + fn operator_tab_comes_and_goes_with_permissions() { + let mut c = with_operator_tab(); + let names = Names::default(); + assert!(!c.set_permissions(false, &names)); + assert!(c.set_permissions(true, &names)); + assert!(!c.set_permissions(true, &names)); + assert_eq!( + tab_ids(&c), + [ + "minecraft:building_blocks", + "minecraft:op_blocks", + "minecraft:search", + "minecraft:inventory" + ] + ); + let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect(); + assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]); + assert_eq!(c.search_results("command", &names), [1]); + assert_eq!(c.search_results("", &names), [0, 1, 2]); + // The search tab keeps its place across the rebuild. + c.select(2); + c.search.set_value("block"); + assert!(c.set_permissions(false, &names)); + assert!(c.tab().search); + assert_eq!(c.search.value, "block"); + assert!(c.items(&names).is_empty()); + // The selected operator tab is left empty: the default tab instead. + c.set_permissions(true, &names); + c.select(1); + assert_eq!(c.items(&names).len(), 2); + c.set_permissions(false, &names); + assert_eq!(c.tab().id, "minecraft:building_blocks"); + assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone"); + } + #[test] fn search_matches_vanilla_trees() { let c = creative(0); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8cba7cb80c..2400aecd60 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5291,6 +5291,9 @@ impl Game<'_> { self.tick_debug_keys()?; self.tick_debug_charts(); self.tick_tutorial(); + if self.screen == Screen::Creative { + self.refresh_creative_tabs(); + } if self.remote.is_some() { return self.tick_remote(); } @@ -6519,6 +6522,14 @@ impl Game<'_> { } batch } + /// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks` + /// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`, + /// as the screen's constructor and `containerTick` do. + fn refresh_creative_tabs(&mut self) { + let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab; + self.creative_screen + .set_permissions(permissions, &self.pack.names); + } fn creative_inventory_open(&self) -> bool { self.screen == Screen::Creative && self.creative_screen.tab().inventory } @@ -12661,6 +12672,7 @@ impl Game<'_> { } if self.creative { self.screen = Screen::Creative; + self.refresh_creative_tabs(); self.creative_screen.reopen(); } else { self.screen = Screen::Inventory; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 848667dae0..5398a05504 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5844,6 +5844,7 @@ impl Game<'_> { self.recipe_ui.reset_screen(); if self.creative { self.screen = Screen::Creative; + self.refresh_creative_tabs(); self.creative_screen.reopen(); } else { self.screen = Screen::Inventory; diff --git a/docs/rust-parity.md b/docs/rust-parity.md index 12dd076f5a..781162ad15 100644 --- a/docs/rust-parity.md +++ b/docs/rust-parity.md @@ -1527,15 +1527,20 @@ suggestion list and clipboard keys. vanilla's own `CreativeModeTabs.tryRebuildTabContents` over the vanilla data pack's registries: every stack with its components, and for each stack of the search tab the creative tooltip lines and item tags that the client's search -trees index. The search ports `refreshSearchResults` and `SessionSearchTrees` +trees index. The tabs are built with permissions, and the export marks the +stacks only that build lists (`permissions_only`): the operator tab and its +stacks at the end of the search tab, shown while the player +`canUseGameMasterBlocks` with the Operator Items Tab option on +(`CreativeModeInventoryScreen.hasPermissions`, checked on opening and every +tick as `tryRefreshInvalidatedTabs` does). The search ports `refreshSearchResults` and `SessionSearchTrees` (substring matches over tooltip lines; after a colon the item id's namespace, then its path or a line; tags after `#`), and the scrollbar follows `mouseClicked`/`mouseDragged`/`mouseScrolled`. `tools/test_creative_parity.py` compares every shown tab's stacks, the search tab and 6,793 searches (fixed queries, then every stack's item path and tooltip lines and every tag, whole and halved) with the vanilla client's own search trees -(`VanillaCreativeSearchOracle`). Not compared: the operator tab (off by -default, `operatorItemsTab`) and the saved hotbars tab. +(`VanillaCreativeSearchOracle`), then again with permissions (13 tabs, 2,034 +search stacks, 6,845 searches). Not compared: the saved hotbars tab. ## Pillager patrols and raids diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeSearchOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeSearchOracle.java index 7ec8edf3d0..8118f744d4 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeSearchOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeSearchOracle.java @@ -20,7 +20,10 @@ import net.minecraft.world.item.ItemStack; * (FullTextSearchTree over creative tooltip lines and item ids, IdSearchTree * over item tags after "#"). * - * VanillaCreativeSearchOracle QUERIES.json EXPORT.json OUT.json + * VanillaCreativeSearchOracle QUERIES.json EXPORT.json OUT.json [permissions] + * + * With "permissions", the screen's tabs are built as for an operator in creative + * with the Operator Items Tab option on (CreativeModeInventoryScreen.hasPermissions). * * The queries are the file's list, then every search stack's item path and * tooltip lines and every item tag, each also as a prefix and with "#". @@ -29,11 +32,12 @@ public final class VanillaCreativeSearchOracle { public static void main(String[] args) throws Exception { ParityData.init(); var lookup = ParityData.lookup; + boolean hasPermissions = args.length > 3 && args[3].equals("permissions"); Files.writeString(Path.of(args[1]), - VanillaCreativeTabsExporter.export(FeatureFlags.DEFAULT_FLAGS, VanillaCreativeTabsExporter.HAS_PERMISSIONS, lookup).toString(), + VanillaCreativeTabsExporter.export(FeatureFlags.DEFAULT_FLAGS, lookup).toString(), StandardCharsets.UTF_8); // CreativeModeInventoryScreen.tryRebuildTabContents, as the screen calls it. - CreativeModeTabs.tryRebuildTabContents(FeatureFlags.DEFAULT_FLAGS, VanillaCreativeTabsExporter.HAS_PERMISSIONS, lookup); + CreativeModeTabs.tryRebuildTabContents(FeatureFlags.DEFAULT_FLAGS, hasPermissions, lookup); List searchItems = List.copyOf(CreativeModeTabs.searchTab().getDisplayItems()); var trees = new SessionSearchTrees(); trees.updateCreativeTooltips(lookup, searchItems); diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeTabsExporter.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeTabsExporter.java index ea0b00686a..89c9c4c9d1 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeTabsExporter.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCreativeTabsExporter.java @@ -3,6 +3,9 @@ package dev.horizonlab.parity; import com.google.gson.*; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import net.minecraft.ChatFormatting; import net.minecraft.core.HolderLookup; @@ -24,20 +27,30 @@ import net.minecraft.world.item.TooltipFlag; * of the search tab the inputs of the client's creative search trees * (SessionSearchTrees): its creative tooltip lines and its item tags. * + * The tabs are built with permissions (CreativeModeInventoryScreen.hasPermissions: + * canUseGameMasterBlocks and the operatorItemsTab option), so the operator tab and + * its stacks in the search tab are listed; "permissions_only" gives the indices of + * the stacks a build without permissions leaves out, and "visible" is + * shouldDisplay without them. + * * VanillaCreativeTabsExporter OUT.json */ public final class VanillaCreativeTabsExporter { - /** The operator tab and its items need permissions and the operatorItemsTab option (off by default). */ - public static final boolean HAS_PERMISSIONS = false; - public static void main(String[] args) throws Exception { ParityData.init(); - Files.writeString(Path.of(args[0]), export(FeatureFlags.DEFAULT_FLAGS, HAS_PERMISSIONS, ParityData.lookup).toString(), + Files.writeString(Path.of(args[0]), export(FeatureFlags.DEFAULT_FLAGS, ParityData.lookup).toString(), StandardCharsets.UTF_8); } - public static JsonObject export(FeatureFlagSet features, boolean hasPermissions, HolderLookup.Provider lookup) { - CreativeModeTabs.tryRebuildTabContents(features, hasPermissions, lookup); + public static JsonObject export(FeatureFlagSet features, HolderLookup.Provider lookup) { + CreativeModeTabs.tryRebuildTabContents(features, false, lookup); + var without = new HashMap>(); + var visible = new HashMap(); + for (CreativeModeTab tab : BuiltInRegistries.CREATIVE_MODE_TAB) { + without.put(tab, tab.getDisplayItems().stream().map(VanillaCreativeTabsExporter::snbt).toList()); + visible.put(tab, tab.shouldDisplay()); + } + CreativeModeTabs.tryRebuildTabContents(features, true, lookup); var tabs = new JsonArray(); for (CreativeModeTab tab : BuiltInRegistries.CREATIVE_MODE_TAB) { var o = new JsonObject(); @@ -48,10 +61,21 @@ public final class VanillaCreativeTabsExporter { o.addProperty("column", tab.column()); o.addProperty("title", tab.getDisplayName().getString()); o.addProperty("icon", snbt(tab.getIconItem())); - o.addProperty("visible", tab.shouldDisplay()); + o.addProperty("visible", visible.get(tab)); var items = new JsonArray(); - for (ItemStack stack : tab.getDisplayItems()) items.add(snbt(stack)); + var kept = new ArrayList(); + var permissionsOnly = new JsonArray(); + var base = new HashSet<>(without.get(tab)); + for (ItemStack stack : tab.getDisplayItems()) { + String s = snbt(stack); + if (base.contains(s)) kept.add(s); + else permissionsOnly.add(items.size()); + items.add(s); + } + // The client leaves these indices out without permissions: what remains must be that build, in order. + if (!kept.equals(without.get(tab))) throw new IllegalStateException("permissions reorder " + key); o.add("items", items); + if (!permissionsOnly.isEmpty()) o.add("permissions_only", permissionsOnly); if (tab.getType() == CreativeModeTab.Type.SEARCH) { var context = Item.TooltipContext.of(lookup); var flag = TooltipFlag.Default.NORMAL.asCreative(); @@ -67,8 +91,7 @@ public final class VanillaCreativeTabsExporter { tabs.add(o); } var out = new JsonObject(); - out.addProperty("schema", 2); - out.addProperty("permissions", hasPermissions); + out.addProperty("schema", 3); out.add("tabs", tabs); return out; } diff --git a/tools/test_creative_parity.py b/tools/test_creative_parity.py index 6a96d3c521..b90e9104e5 100644 --- a/tools/test_creative_parity.py +++ b/tools/test_creative_parity.py @@ -12,7 +12,10 @@ horizon_client::creative::Creative. The tabs it shows (id, title, icon, position, every stack with its components), the search tab's stacks and the results of every query must agree. The queries are QUERIES below, then every search stack's item path and tooltip lines, every item tag with and without -"#", and half of each as a prefix. +"#", and half of each as a prefix. Both run twice: as a player without +permissions, then as an operator with the Operator Items Tab option on +(CreativeModeInventoryScreen.hasPermissions), where the operator tab and its +stacks in the search tab appear. test_creative_parity.py [--detail N] """ @@ -63,12 +66,24 @@ def main(): run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes, *(PARITY / f'{n}.java' for n in ('ParityData', 'VanillaCreativeTabsExporter', 'VanillaCreativeSearchOracle'))], 'compile-java') + bad = 0 + for mode in ('', 'permissions'): + print(f'== {mode or "no permissions"}') + bad += compare(args, build, classes, dependencies, queries, run, mode) + if bad: + sys.exit(1) + print('creative parity ok') + + +def compare(args, build, classes, dependencies, queries, run, mode): + suffix = '-' + mode if mode else '' run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaCreativeSearchOracle', - queries, build / 'creative-inventory.json', build / 'vanilla.json'], 'vanilla') + queries, build / 'creative-inventory.json', build / f'vanilla{suffix}.json', *([mode] if mode else [])], + 'vanilla' + suffix) lib.cargo_example('horizon-client', 'creative_search_fixture', lib.RESOURCES, build / 'creative-inventory.json', - build / 'vanilla.json', build / 'rust.json') - vanilla = json.loads((build / 'vanilla.json').read_text()) - rust = json.loads((build / 'rust.json').read_text()) + build / f'vanilla{suffix}.json', build / f'rust{suffix}.json', *([mode] if mode else [])) + vanilla = json.loads((build / f'vanilla{suffix}.json').read_text()) + rust = json.loads((build / f'rust{suffix}.json').read_text()) bad = 0 shown = [{k: t[k] for k in ('id', 'title', 'icon', 'row', 'column', 'items')} for t in vanilla['tabs'] if t['shown']] if [t['id'] for t in shown] != [t['id'] for t in rust['tabs']]: @@ -101,9 +116,7 @@ def main(): for i in hits[:6])) print(f'{len(vanilla["searches"]) - len(wrong)}/{len(vanilla["searches"])} searches match; {len(shown)} tabs, ' f'{len(search)} search stacks') - if bad: - sys.exit(1) - print('creative parity ok') + return bad if __name__ == '__main__': From f57fe836c40b80c81e0235ca10ec3964b9a1de8d Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 01:59:56 +0300 Subject: [PATCH 181/968] Native mods: item slots for mod block entities (hoppers, comparators, drops) A mod block's block entity gets item slots when its mod says the block has a container (native_block_entity_container: the slot count, the slots each face reaches, and which slots take input or give output, as a WorldlyContainer). Hoppers, droppers and comparators use them through the container code vanilla's blocks use, breaking the block drops them (Containers.dropContents), they save as Items, and the tick hook gets them. Fixes #4034. Co-Authored-By: Claude Opus 5.5 --- .../src/block_entities.rs | 12 ++- .../src/chunk/block_entity.rs | 18 +++- .../horizon-client-render/src/containers.rs | 15 +-- .../src/game/block_entity_ticks.rs | 2 + crates/horizon-client/src/game/machines.rs | 91 ++++++++++++++++--- .../horizon-client/src/game/native_hooks.rs | 82 +++++++++++++++-- crates/horizon-client/src/game/spill.rs | 9 +- crates/horizon-client/src/save.rs | 18 +++- native-hooks.json | 5 + 9 files changed, 220 insertions(+), 32 deletions(-) diff --git a/crates/horizon-client-render/src/block_entities.rs b/crates/horizon-client-render/src/block_entities.rs index c0303c726e..e73b9537ce 100644 --- a/crates/horizon-client-render/src/block_entities.rs +++ b/crates/horizon-client-render/src/block_entities.rs @@ -23,7 +23,7 @@ //! Which states tick is a table by state id, built by the game from the block //! registry; whether a slot is made is decided when the slot is promoted at //! the start of the next tick, from the block state then. -use crate::chunk::block_entity::BlockEntity; +use crate::chunk::block_entity::{BlockEntity, ModContainer}; use crate::fast_hash::{FxHashMap, FxHashSet}; use std::collections::HashMap; use std::sync::Arc; @@ -66,6 +66,9 @@ pub struct BlockEntities { out: FxHashSet, /// `Ticking` by block state id; empty until the game builds it. states: Vec, + /// The containers of mod blocks' block entities, by block state id; + /// built with `states`. + mod_containers: FxHashMap>, } impl std::ops::Deref for BlockEntities { @@ -148,6 +151,13 @@ impl BlockEntities { pub fn ticking(&self, state: u32) -> Ticking { self.states.get(state as usize).copied().unwrap_or_default() } + pub fn set_mod_containers(&mut self, containers: FxHashMap>) { + self.mod_containers = containers; + } + /// The container of a mod block's block entity in block state `state`. + pub fn mod_container(&self, state: u32) -> Option<&Arc> { + self.mod_containers.get(&state) + } fn next(&mut self) -> u32 { let n = self.next_slot; diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs index 088c121d7e..74e58e9d29 100644 --- a/crates/horizon-client-render/src/chunk/block_entity.rs +++ b/crates/horizon-client-render/src/chunk/block_entity.rs @@ -84,12 +84,26 @@ pub enum BlockEntity { /// compound for the mod to read and change. Mod(Box), } -/// A mod's block entity: its type id (`mymod:machine`) and its saved -/// compound without `id`, `x`, `y` and `z`. +/// A mod's block entity: its type id (`mymod:machine`), its saved +/// compound without `id`, `x`, `y`, `z` and `Items`, and its item slots +/// (`Items`) when its block has a container (`ModContainer`). #[derive(Clone, Debug, Default, PartialEq)] pub struct ModBlockEntity { pub id: String, pub nbt: Compound, + pub items: Vec>, +} +/// The container of a mod block's block entity, as a `WorldlyContainer` +/// says it: its slot count, the slots each face reaches +/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east), +/// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take +/// from (`canTakeItemThroughFace`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ModContainer { + pub slots: usize, + pub faces: [Vec; 6], + pub inputs: Vec, + pub outputs: Vec, } /// `BrushableBlockEntity`: the loot table is resolved on the first brush. #[derive(Clone, Debug, Default)] diff --git a/crates/horizon-client-render/src/containers.rs b/crates/horizon-client-render/src/containers.rs index cd650c7de9..e3f9a256f8 100644 --- a/crates/horizon-client-render/src/containers.rs +++ b/crates/horizon-client-render/src/containers.rs @@ -3,7 +3,8 @@ //! from the data-driven `context_int_provider`/`context_float_provider` files. pub use crate::chunk::block_entity::{ Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, - EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, ModBlockEntity, Shelf, Skull, SkullSkin, + EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, ModBlockEntity, ModContainer, Shelf, Skull, + SkullSkin, }; use crate::items::ItemStack; use std::collections::HashMap; @@ -369,8 +370,8 @@ impl BlockEntity { | Self::EndGateway(_) | Self::Skull(_) | Self::DaylightDetector - | Self::Brushable(_) - | Self::Mod(_) => Vec::new(), + | Self::Brushable(_) => Vec::new(), + Self::Mod(m) => m.items.iter().flatten().cloned().collect(), Self::Lectern(l) => l.book.iter().cloned().collect(), Self::Sculk(_) => Vec::new(), Self::Banner(_) => Vec::new(), @@ -398,6 +399,7 @@ impl BlockEntity { Self::Crafter(c) => &c.items, Self::ChiseledBookshelf(b) => &b.items, Self::Shelf(b) => &b.items, + Self::Mod(m) => &m.items, Self::CreakingHeart(_) | Self::Spawner(_) | Self::TrialSpawner(_) @@ -405,8 +407,7 @@ impl BlockEntity { | Self::Beehive(_) | Self::CommandBlock(_) // Not a Container (only Clearable): hoppers don't reach in. - | Self::Campfire(_) - | Self::Mod(_) => &[], + | Self::Campfire(_) => &[], } } pub fn slots_mut(&mut self) -> &mut [Option] { @@ -427,14 +428,14 @@ impl BlockEntity { Self::Crafter(c) => &mut c.items, Self::ChiseledBookshelf(b) => &mut b.items, Self::Shelf(b) => &mut b.items, + Self::Mod(m) => &mut m.items, Self::CreakingHeart(_) | Self::Spawner(_) | Self::TrialSpawner(_) | Self::Vault(_) | Self::Beehive(_) | Self::CommandBlock(_) - | Self::Campfire(_) - | Self::Mod(_) => &mut [], + | Self::Campfire(_) => &mut [], } } } diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index f85a28a095..c0152a46b6 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -23,6 +23,8 @@ impl Game<'_> { if !self.world.block_entities.has_states() { let states = self.ticking_states(); self.world.block_entities.set_states(states); + let containers = self.native_containers(); + self.world.block_entities.set_mod_containers(containers); } // Only lingering cooldowns are dropped; a hopper that ticks checks // its own block. Once a second is soon enough for the rest. diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index d8bf925248..1c24008051 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -7,12 +7,13 @@ use super::Game; mod crafter; use horizon_client::{ Result, - containers::{BlockEntity, HOPPER_SLOTS}, + containers::{BlockEntity, HOPPER_SLOTS, ModContainer}, items::{ItemStack, Items}, world::World, }; use horizon_core::{geometry::Direction, position::BlockPos, random::RandomSource}; use horizon_world::{level::Behaviours, registry::BlockStateId}; +use std::sync::Arc; type Pos = (i32, i32, i32); @@ -43,6 +44,8 @@ enum Kind { Brewing, /// `ChiseledBookShelfBlockEntity`: books only, one to a slot. Bookshelf, + /// A mod block's block entity (`ModContainer`). + Mod, /// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder` /// without a block entity): input below 7, empty at 7, bone meal at 8. Composter(i32), @@ -53,6 +56,8 @@ enum Kind { struct View { parts: Vec<(Pos, usize)>, kind: Kind, + /// A mod block's container rules (`Kind::Mod`). + container: Option>, } impl View { fn len(&self) -> usize { @@ -79,6 +84,10 @@ impl View { (Kind::Composter(8), Some(Direction::Down)) => vec![0], (Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0], (Kind::Composter(_), _) => vec![], + (Kind::Mod, Some(face)) => self + .container + .as_ref() + .map_or_else(Vec::new, |c| c.faces[face as usize].clone()), _ => (0..self.len()).collect(), } } @@ -148,6 +157,7 @@ impl Containers<'_> { return Some(View { parts: vec![(pos, usize::from(level != 7))], kind: Kind::Composter(level), + container: None, }); } let entity = self.world.block_entities.get(&pos)?; @@ -161,6 +171,15 @@ impl Containers<'_> { ) { return None; } + // A mod's block entity is a container when its block says so. + if let BlockEntity::Mod(_) = entity { + let container = Arc::clone(self.world.block_entities.mod_container(state.0)?); + return Some(View { + parts: vec![(pos, container.slots)], + kind: Kind::Mod, + container: Some(container), + }); + } let kind = match (entity, name) { (BlockEntity::Furnace(_), _) => Kind::Furnace, (BlockEntity::Crafter(_), _) => Kind::Crafter, @@ -207,13 +226,18 @@ impl Containers<'_> { } else { vec![(other, 27), (pos, size)] }; - return Some(View { parts, kind }); + return Some(View { + parts, + kind, + container: None, + }); } } } Some(View { parts: vec![(pos, size)], kind, + container: None, }) } fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> { @@ -234,6 +258,9 @@ impl Containers<'_> { } /// `Container.canPlaceItem` (+ `canPlaceItemThroughFace` for furnaces). fn can_place(&self, view: &View, slot: usize, stack: &ItemStack) -> bool { + if let Some(c) = &view.container { + return c.inputs.contains(&slot); + } if view.kind == Kind::Crafter { return match self.world.block_entities.get(&view.parts[0].0) { Some(BlockEntity::Crafter(c)) => { @@ -300,6 +327,9 @@ impl Containers<'_> { if view.kind == Kind::Brewing { return slot != 3 || &*stack.item == "minecraft:glass_bottle"; } + if let Some(c) = &view.container { + return c.outputs.contains(&slot); + } view.kind != Kind::Furnace || face != Direction::Down || slot != 1 @@ -457,11 +487,14 @@ impl Game<'_> { } fn slot_mut_at(&mut self, view: &View, slot: usize) -> Option<&mut Option> { let (pos, i) = view.locate(slot)?; - self.world - .block_entities - .get_mut(&pos)? - .slots_mut() - .get_mut(i) + let entity = self.world.block_entities.get_mut(&pos)?; + // A mod's slots grow to its container's count when first used. + if let BlockEntity::Mod(m) = entity + && m.items.len() <= i + { + m.items.resize(view.len(), None); + } + entity.slots_mut().get_mut(i) } /// `BlockEntity.setChanged` for every block entity of a container. fn view_changed(&mut self, view: &View) { @@ -556,11 +589,12 @@ impl Game<'_> { let (x, y, z) = view.parts[0].0; return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up)); } - let slots = if matches!(view.kind, Kind::Furnace | Kind::Brewing) && face.is_some() { - view.slots_for_face(face) - } else { - (0..view.len()).collect() - }; + let slots = + if matches!(view.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() { + view.slots_for_face(face) + } else { + (0..view.len()).collect() + }; let mut rest = Some(stack); for slot in slots { let Some(stack) = rest.take() else { break }; @@ -879,6 +913,7 @@ impl Game<'_> { let view = View { parts: vec![(key, size)], kind: Kind::Generic, + container: None, }; (view, open) } @@ -1301,3 +1336,35 @@ mod item_column_tests { assert!(skipped > 1000, "skipped only {skipped}"); } } + +#[cfg(test)] +mod mod_container_tests { + use super::{Kind, ModContainer, View}; + use horizon_core::geometry::Direction; + use std::sync::Arc; + + /// A mod's container reaches the slots its block names for each face; + /// without a face, every slot. + #[test] + fn mod_container_slots_by_face() { + let mut faces: [Vec; 6] = Default::default(); + faces[Direction::Up as usize] = vec![0]; + faces[Direction::Down as usize] = vec![2]; + faces[Direction::North as usize] = vec![1]; + let view = View { + parts: vec![((0, 0, 0), 3)], + kind: Kind::Mod, + container: Some(Arc::new(ModContainer { + slots: 3, + faces, + inputs: vec![0, 1], + outputs: vec![2], + })), + }; + assert_eq!(view.slots_for_face(Some(Direction::Up)), [0]); + assert_eq!(view.slots_for_face(Some(Direction::Down)), [2]); + assert_eq!(view.slots_for_face(Some(Direction::North)), [1]); + assert!(view.slots_for_face(Some(Direction::East)).is_empty()); + assert_eq!(view.slots_for_face(None), [0, 1, 2]); + } +} diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 10b9631c73..df90a6b0d7 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -14,7 +14,9 @@ //! //! API 3 adds block entities for a mod's blocks (`native_block_entity`, //! `native_block_entity_ticks`, `native_block_entity_tick`): made when the -//! block is placed, ticked on the server and saved with the chunk. +//! block is placed, ticked on the server and saved with the chunk; and their +//! item slots (`native_block_entity_container`), which hoppers, comparators +//! and breaking the block use as they use vanilla's containers. //! //! Names, parameters and call sites are kept from release to release, and //! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`]. @@ -27,10 +29,12 @@ use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::R use horizon_client::{ block_entities::Pos, chat::ChatSource, - containers::{BlockEntity, ModBlockEntity}, + containers::{BlockEntity, ModBlockEntity, ModContainer}, extras::Corner, extras_hud::Corners, + fast_hash::FxHashMap, hud::{HudBatch, rgba}, + items::ItemStack, keybinds, mod_list::{self, Installed, NativePage}, text::Text, @@ -344,14 +348,31 @@ impl Game<'_> { pub(super) fn native_block_entity_ticks(&self, block: &str, ticks: &mut bool) { let _ = (block, ticks); } + /// Block `block`'s block entity has item slots (a `WorldlyContainer`): + /// set `container` when the block is yours. Asked once for each mod + /// block when the ticking table is built; at most 256 slots, and slot + /// numbers past the count are left out. API 3. + pub(super) fn native_block_entity_container( + &self, + block: &str, + container: &mut Option, + ) { + let _ = (block, container); + } /// A server tick of block entity `id` at `pos`, in vanilla's order among /// the others (`Level.tickBlockEntities`): read and change its saved - /// compound `nbt`. It is out of the world during the call (the block + /// compound `nbt` and its item `slots` (none without a container). It is out of the world during the call (the block /// entity at `pos` reads as a `Mod` with an empty id, and a save leaves /// it out); if the call removes or replaces it, `nbt` is dropped. Clients, /// the window's own in single player included, don't get it yet. API 3. - pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], id: &str, nbt: &mut Compound) { - let _ = (pos, id, nbt); + pub(super) fn native_block_entity_tick( + &mut self, + pos: [i32; 3], + id: &str, + nbt: &mut Compound, + slots: &mut [Option], + ) { + let _ = (pos, id, nbt, slots); } } @@ -545,7 +566,43 @@ impl Game<'_> { let mut entity = None; self.native_block_entity(name, &mut entity); let (id, nbt) = entity?; - Some(BlockEntity::Mod(Box::new(ModBlockEntity { id, nbt }))) + let items = Vec::new(); + Some(BlockEntity::Mod(Box::new(ModBlockEntity { + id, + nbt, + items, + }))) + } + /// The containers of the mod blocks' block entities, by block state + /// (`native_block_entity_container`), checked. + pub(super) fn native_containers(&self) -> FxHashMap> { + let registry = self.pack.assets.registry; + let mut by_block: FxHashMap>> = FxHashMap::default(); + let mut out = FxHashMap::default(); + for i in 0..registry.len() as u32 { + let Some(state) = registry.state(horizon_world::registry::BlockStateId(i)) else { + continue; + }; + if state.name.starts_with("minecraft:") { + continue; + } + let container = by_block.entry(state.name.clone()).or_insert_with(|| { + let mut container = None; + self.native_block_entity_container(&state.name, &mut container); + container.map(|mut c| { + c.slots = c.slots.min(256); + let n = c.slots; + for list in c.faces.iter_mut().chain([&mut c.inputs, &mut c.outputs]) { + list.retain(|&s| s < n); + } + Arc::new(c) + }) + }); + if let Some(c) = container { + out.insert(i, Arc::clone(c)); + } + } + out } /// Whether a native mod's block `name` has a ticking block entity. pub(super) fn native_ticks(&self, name: &str) -> bool { @@ -560,7 +617,18 @@ impl Game<'_> { }; // An empty id marks its place while the mod has it. let mut taken = std::mem::take(&mut **m); - self.native_block_entity_tick([pos.0, pos.1, pos.2], &taken.id, &mut taken.nbt); + let state = self.world.block(pos.0, pos.1, pos.2).0; + let slots = self + .world + .block_entities + .mod_container(state) + .map_or(0, |c| c.slots); + // Grown only: items a container no longer has room for stay saved. + if taken.items.len() < slots { + taken.items.resize(slots, None); + } + let ModBlockEntity { id, nbt, items } = &mut taken; + self.native_block_entity_tick([pos.0, pos.1, pos.2], id, nbt, items); if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) && m.id.is_empty() { diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index d45c0affd1..8957d4a16f 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -421,7 +421,14 @@ impl GameLevel<'_> { let entity = self.world.block_entities.remove(&key); self.world.block_names.remove(&key); let name = self.facts.get(old).name.clone(); - let kind = removal::pre_remove(&name); + let kind = match &entity { + // BaseContainerBlockEntity.preRemoveSideEffects: Containers.dropContents. + Some(BlockEntity::Mod(m)) if !m.items.is_empty() => PreRemove::Contents { + slots: m.items.len(), + furnace: false, + }, + _ => removal::pre_remove(&name), + }; let mut neighbour_updates = 0; if side_effects { match kind { diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 6e83b14845..0d81157e1a 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -427,6 +427,9 @@ pub fn encode_block_entity( if let BlockEntity::Mod(m) = e { let mut map = m.nbt.clone(); map.insert("id".into(), string(&m.id)); + if m.items.iter().any(Option::is_some) { + map.insert("Items".into(), encode_items(&m.items)); + } for (key, v) in [("x", p.0), ("y", p.1), ("z", p.2)] { map.insert(key.into(), Tag::Int(v)); } @@ -637,7 +640,9 @@ pub fn decode_end_gateway(tag: &Tag) -> crate::containers::EndGateway { exact_teleport: number(tag.get("ExactTeleport")).unwrap_or(0) != 0, } } -/// A mod's block entity: its compound without `id` and the position. +/// A mod's block entity: its compound without `id` and the position, and +/// its `Items` as slots (`ContainerHelper.loadAllItems`; the game sizes them +/// to the block's container when it uses them). fn decode_mod_block_entity(id: String, tag: &Tag) -> Option { let Tag::Compound(map) = tag else { return None; @@ -646,7 +651,12 @@ fn decode_mod_block_entity(id: String, tag: &Tag) -> Option Option<((i32, i32, i32), crate::containers::BlockEntity)> { use crate::containers::BlockEntity; @@ -4967,6 +4977,10 @@ mod tests { assert!(!m.nbt.contains_key(&key.into()), "{key}"); } assert_eq!(m.nbt.get(&"energy".into()), Some(&Tag::Long(1200))); + // `Items` are its slots, not the mod's compound. + assert!(!m.nbt.contains_key(&"Items".into())); + assert_eq!(m.items.len(), 1); + assert_eq!(m.items[0].as_ref().map(|s| s.count), Some(3)); assert_eq!(encode_block_entity(pos, &back, "gtmod:macerator"), saved); // Vanilla's types are never a mod's, modelled or not. let ender = horizon_world::nbt::parse_snbt(r#"{id:"minecraft:ender_chest",x:0,y:0,z:0}"#) diff --git a/native-hooks.json b/native-hooks.json index b4940bebe8..cf7dd76a24 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -74,6 +74,11 @@ "name": "block_entity_tick", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_tick" + }, + { + "name": "block_entity_container", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_container" } ] } From 05f68f3fdc4847874322719e7f514bbbc50fa099 Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 02:30:12 +0330 Subject: [PATCH 182/968] Linux: OpenGL presents uncapped with VSync off On Linux with OpenGL (the GLES wgpu backend), wgpu-hal hard-coded its surface capabilities present_modes to [Fifo] only (//TODO), never offering Immediate. When VSync was turned off and Max Framerate was set to Unlimited: 1. chosen_present_mode filtered supported modes and could only select Fifo since Immediate was missing from the adapter's capabilities. 2. In gfx.rs, pre_present_notify is called whenever present_mode is Fifo, which on Wayland requests a wl_surface.frame callback from the compositor, pacing RedrawRequested to the display refresh rate (60 FPS). 3. In egl.rs, eglSwapInterval(0) was never called, leaving EGL to default to swap interval 1 (60 FPS on X11 and Wayland). - In wgpu-hal: report [Fifo, Immediate] present modes for GLES on all platforms, and call eglSwapInterval with 0 for Immediate and 1 for Fifo in configure and present. - In gfx.rs: consider Immediate on startup surface configuration if supported, matching vsync_present_mode. - In window.rs: add unit test for present mode choice with [Fifo, Immediate]. Co-Authored-By: Antigravity --- crates/horizon-client-input/src/window.rs | 4 + crates/horizon-client-render/src/gfx.rs | 4 +- third_party/wgpu-hal/src/gles/adapter.rs | 6 +- third_party/wgpu-hal/src/gles/egl.rs | 92 ++++++++++++++++------- 4 files changed, 73 insertions(+), 33 deletions(-) diff --git a/crates/horizon-client-input/src/window.rs b/crates/horizon-client-input/src/window.rs index 33ff521111..014f1f21a8 100644 --- a/crates/horizon-client-input/src/window.rs +++ b/crates/horizon-client-input/src/window.rs @@ -428,6 +428,10 @@ mod tests { vsync_present_mode(&[PresentMode::Fifo], false), Some(PresentMode::Fifo) ); + assert_eq!( + vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false), + Some(PresentMode::Immediate) + ); } #[test] fn vsync_on_waits_for_the_display_whatever_the_present_mode() { diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 179b05f67e..8f95a8cd36 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -1173,7 +1173,9 @@ impl Renderer { .map(|renderer| renderer.config.present_mode) .filter(|mode| caps.present_modes.contains(mode)) .unwrap_or_else(|| { - if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) { + if caps.present_modes.contains(&wgpu::PresentMode::Immediate) { + wgpu::PresentMode::Immediate + } else if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) { wgpu::PresentMode::Mailbox } else { wgpu::PresentMode::Fifo diff --git a/third_party/wgpu-hal/src/gles/adapter.rs b/third_party/wgpu-hal/src/gles/adapter.rs index 47b8115a88..493940afd5 100644 --- a/third_party/wgpu-hal/src/gles/adapter.rs +++ b/third_party/wgpu-hal/src/gles/adapter.rs @@ -1368,11 +1368,7 @@ impl crate::Adapter for super::Adapter { Some(crate::SurfaceCapabilities { formats, - present_modes: if cfg!(windows) { - vec![wgt::PresentMode::Fifo, wgt::PresentMode::Immediate] - } else { - vec![wgt::PresentMode::Fifo] //TODO - }, + present_modes: vec![wgt::PresentMode::Fifo, wgt::PresentMode::Immediate], composite_alpha_modes: vec![wgt::CompositeAlphaMode::Opaque], //TODO maximum_frame_latency: 2..=2, //TODO, unused currently current_extent: None, diff --git a/third_party/wgpu-hal/src/gles/egl.rs b/third_party/wgpu-hal/src/gles/egl.rs index eeb73694fe..b196f31ad5 100644 --- a/third_party/wgpu-hal/src/gles/egl.rs +++ b/third_party/wgpu-hal/src/gles/egl.rs @@ -1092,6 +1092,7 @@ pub struct Swapchain { format_desc: super::TextureFormatDesc, #[allow(unused)] sample_type: wgt::TextureSampleType, + vsync: bool, } #[derive(Debug)] @@ -1133,6 +1134,11 @@ impl Surface { crate::SurfaceError::Lost })?; + let _ = self + .egl + .instance + .swap_interval(self.egl.display, if sc.vsync { 1 } else { 0 }); + unsafe { gl.disable(glow::SCISSOR_TEST) }; unsafe { gl.color_mask(true, true, true, true) }; @@ -1376,35 +1382,66 @@ impl crate::Surface for Surface { }; let format_desc = device.shared.describe_texture_format(config.format); - let gl = &device.shared.context.lock(); - let renderbuffer = unsafe { gl.create_renderbuffer() }.map_err(|error| { - log::error!("Internal swapchain renderbuffer creation failed: {error}"); - crate::DeviceError::OutOfMemory - })?; - unsafe { gl.bind_renderbuffer(glow::RENDERBUFFER, Some(renderbuffer)) }; - unsafe { - gl.renderbuffer_storage( - glow::RENDERBUFFER, - format_desc.internal, - config.extent.width as _, - config.extent.height as _, - ) + let (renderbuffer, framebuffer) = { + let gl = &device.shared.context.lock(); + let renderbuffer = unsafe { gl.create_renderbuffer() }.map_err(|error| { + log::error!("Internal swapchain renderbuffer creation failed: {error}"); + crate::DeviceError::OutOfMemory + })?; + unsafe { gl.bind_renderbuffer(glow::RENDERBUFFER, Some(renderbuffer)) }; + unsafe { + gl.renderbuffer_storage( + glow::RENDERBUFFER, + format_desc.internal, + config.extent.width as _, + config.extent.height as _, + ) + }; + let framebuffer = unsafe { gl.create_framebuffer() }.map_err(|error| { + log::error!("Internal swapchain framebuffer creation failed: {error}"); + crate::DeviceError::OutOfMemory + })?; + unsafe { gl.bind_framebuffer(glow::READ_FRAMEBUFFER, Some(framebuffer)) }; + unsafe { + gl.framebuffer_renderbuffer( + glow::READ_FRAMEBUFFER, + glow::COLOR_ATTACHMENT0, + glow::RENDERBUFFER, + Some(renderbuffer), + ) + }; + unsafe { gl.bind_renderbuffer(glow::RENDERBUFFER, None) }; + unsafe { gl.bind_framebuffer(glow::READ_FRAMEBUFFER, None) }; + (renderbuffer, framebuffer) }; - let framebuffer = unsafe { gl.create_framebuffer() }.map_err(|error| { - log::error!("Internal swapchain framebuffer creation failed: {error}"); - crate::DeviceError::OutOfMemory - })?; - unsafe { gl.bind_framebuffer(glow::READ_FRAMEBUFFER, Some(framebuffer)) }; - unsafe { - gl.framebuffer_renderbuffer( - glow::READ_FRAMEBUFFER, - glow::COLOR_ATTACHMENT0, - glow::RENDERBUFFER, - Some(renderbuffer), - ) + + let vsync = match config.present_mode { + wgt::PresentMode::Immediate => false, + wgt::PresentMode::Fifo => true, + _ => { + log::warn!( + "unsupported present mode: {:?}, defaulting to Fifo", + config.present_mode + ); + true + } }; - unsafe { gl.bind_renderbuffer(glow::RENDERBUFFER, None) }; - unsafe { gl.bind_framebuffer(glow::READ_FRAMEBUFFER, None) }; + + if let Ok(()) = self.egl.instance.make_current( + self.egl.display, + Some(surface), + Some(surface), + Some(self.egl.raw), + ) { + let interval = if vsync { 1 } else { 0 }; + if let Err(e) = self.egl.instance.swap_interval(self.egl.display, interval) { + log::warn!("eglSwapInterval({interval}) failed: {e:?}"); + } + let _ = self + .egl + .instance + .make_current(self.egl.display, None, None, None); + } let mut swapchain = self.swapchain.write(); *swapchain = Some(Swapchain { @@ -1416,6 +1453,7 @@ impl crate::Surface for Surface { format: config.format, format_desc, sample_type: wgt::TextureSampleType::Float { filterable: false }, + vsync, }); Ok(()) From db38f50b12039289255f9673501c42a6da0c7892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=90=D0=BB=D0=B5=D0=BA=D1=87=D0=B5?= =?UTF-8?q?=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 30 Oct 2026 02:01:27 +0300 Subject: [PATCH 183/968] Fix detail requests for retiring fine LOD leaves Seed the existing bounded request path with planned retiring leaves still missing saved detail, so full-detail geometry can retire after its replacement is ready. Fixes #4039 Co-Authored-By: Codex (GPT-6) --- crates/horizon-client-render/src/lod.rs | 83 +++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/crates/horizon-client-render/src/lod.rs b/crates/horizon-client-render/src/lod.rs index ccba798f8f..0592cbbb3e 100644 --- a/crates/horizon-client-render/src/lod.rs +++ b/crates/horizon-client-render/src/lod.rs @@ -1059,6 +1059,16 @@ impl Lod { }) .copied() .collect(); + // Retiring fine columns are empty stand-ins, excluded from `active` + // while their normal meshes still cover the gap. Request their detail + // explicitly: neither the active-tile requests nor sampling an already + // ready stand-in can fill it, so its GPU sections would never retire. + keys.extend(retiring_detail( + &self.plan, + &self.retiring, + &self.saved, + &self.detail_masks, + )); keys.sort_unstable_by_key(|key| (key.distance(camera), *key)); let mut chunks = FxHashSet::default(); for key in keys { @@ -1563,11 +1573,84 @@ impl Drop for Lod { } } } +/// Fine retiring stand-ins need actual column detail before `Plan::replaces` +/// can release their normal meshes. The caller keeps the usual 32-column +/// request budget and waits for the replacement mesh before changing cutout. +fn retiring_detail<'a>( + plan: &'a Plan, + retiring: &'a FxHashSet<(i32, i32)>, + saved: &'a Saved, + masks: &'a FxHashMap, +) -> impl Iterator + 'a { + retiring.iter().filter_map(move |&(x, z)| { + let key = TileKey { x, z, level: 0 }; + (plan.nodes.contains(&key) + && !saved.contains_key(&(x, z)) + && masks.get(&key) != Some(&[u64::MAX; 4])) + .then_some(key) + }) +} #[cfg(test)] mod tests { use super::*; use crate::mesher::TerrainVertex; #[test] + fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() { + let column = (12, 0); + let fine = TileKey { + x: 12, + z: 0, + level: 0, + }; + let mut plan = Plan::default(); + plan.nodes.insert(fine); + let ready: FxHashMap = [(fine, ())].into_iter().collect(); + let retiring = [column].into_iter().collect(); + let saved = Saved::default(); + let mut masks = FxHashMap::default(); + // A covered stand-in is ready, but empty: ordinary sampling skips it, + // and selection leaves it out of `active`. It cannot yet replace the + // old full-detail column, even though the near world has unloaded it. + assert!(!plan.replaces(column, &ready, |key| masks.get(&key) + == Some(&[u64::MAX; 4]))); + assert_eq!( + retiring_detail(&plan, &retiring, &saved, &masks).collect::>(), + [fine] + ); + // The existing RequestDetail -> apply_fine path marks complete detail; + // only then may selection retire it and publish the new mesh cutout. + masks.insert(fine, [u64::MAX; 4]); + assert!( + retiring_detail(&plan, &retiring, &saved, &masks) + .next() + .is_none() + ); + assert!(plan.replaces(column, &ready, |key| masks.get(&key) + == Some(&[u64::MAX; 4]))); + } + #[test] + fn retiring_detail_requests_only_planned_columns_still_missing_detail() { + let fine = |x| TileKey { x, z: 0, level: 0 }; + let mut plan = Plan::default(); + plan.nodes.extend((0..4).map(fine)); + plan.nodes.insert(fine(5)); + let retiring = (0..5).map(|x| (x, 0)).collect(); + let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))] + .into_iter() + .collect(); + let masks = [ + (fine(2), [u64::MAX; 4]), + (fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]), + ] + .into_iter() + .collect(); + let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect(); + requested.sort_unstable(); + // Saved or complete detail needs no request; an unplanned retiree and + // a planned column that is not retiring must not consume the budget. + assert_eq!(requested, [fine(0), fine(3)]); + } + #[test] fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() { let leaves = |plan: &Plan| { plan.nodes From 93f42ead5b789b34e28170153740f6d0a96072ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=90=D0=BB=D0=B5=D0=BA=D1=87=D0=B5?= =?UTF-8?q?=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 30 Oct 2026 02:02:22 +0300 Subject: [PATCH 184/968] Add independent opt-in Windows allocator build features Expose Segment Heap manifest integration and mimalloc separately, with unchanged defaults and reproduction/PE validation instructions. Fixes #4042 Co-Authored-By: Codex (GPT-6) --- Cargo.lock | 19 ++++++ crates/horizon-client/Cargo.toml | 5 ++ crates/horizon-client/build.rs | 8 ++- crates/horizon-client/src/main.rs | 4 ++ .../windows-segment-heap.manifest | 17 +++++ docs/native-client.md | 3 + docs/windows-allocators.md | 64 +++++++++++++++++++ 7 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 crates/horizon-client/windows-segment-heap.manifest create mode 100644 docs/windows-allocators.md diff --git a/Cargo.lock b/Cargo.lock index c37d0d3b60..b8cceb2d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1402,6 +1402,7 @@ dependencies = [ "horizon-threads", "horizon-world", "log", + "mimalloc", "miniz_oxide 0.8.9", "naga", "open", @@ -2115,6 +2116,15 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", +] + [[package]] name = "libredox" version = "0.1.25" @@ -2221,6 +2231,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minimap" version = "1.0.0" diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index 473e017c5c..80be89db9d 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -68,6 +68,8 @@ unicode-bidi = "0.3.18" rustix = { version = "1", default-features = false, features = ["std", "time"] } [target.'cfg(windows)'.dependencies] +# Opt-in Rust allocator experiment; the system allocator remains the default. +mimalloc = { version = "0.1.52", optional = true } # 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). @@ -93,6 +95,9 @@ audio = ["horizon-client-render/audio"] controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"] # Records the call sites of mob random draws (`mob_ai_probe` traces). rng-trace = ["horizon-core/rng-trace"] +# Independent Windows allocator experiments; see docs/windows-allocators.md. +windows-mimalloc = ["dep:mimalloc"] +windows-segment-heap = [] # Its own name, not `probe`: Cargo copies every crate's examples into one target//examples. [[example]] diff --git a/crates/horizon-client/build.rs b/crates/horizon-client/build.rs index d99b304f31..07001380e7 100644 --- a/crates/horizon-client/build.rs +++ b/crates/horizon-client/build.rs @@ -1,5 +1,6 @@ //! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon //! resource 1: Explorer and the task bar show it, and the window takes it as its icon. +//! The optional Segment Heap manifest is embedded in the same resource compilation. fn main() { let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); let ico = format!("{dir}/../../packaging/icons/horizon.ico"); @@ -8,8 +9,13 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { let mut resource = winresource::WindowsResource::new(); resource.set_icon(&ico); + if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() { + let manifest = format!("{dir}/windows-segment-heap.manifest"); + println!("cargo:rerun-if-changed={manifest}"); + resource.set_manifest_file(&manifest); + } if let Err(e) = resource.compile() { - panic!("embedding {ico}: {e}"); + panic!("embedding Windows resources: {e}"); } } } diff --git a/crates/horizon-client/src/main.rs b/crates/horizon-client/src/main.rs index 8d03455af1..181143054e 100644 --- a/crates/horizon-client/src/main.rs +++ b/crates/horizon-client/src/main.rs @@ -4,6 +4,10 @@ mod game; use horizon_client::{crash, logging}; +#[cfg(all(windows, feature = "windows-mimalloc"))] +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + /// The mod API level this game serves, kept in the program for the launcher /// to read without running it (`horizon_mods::api_level::MARK`). `main` /// reads it, so the linker keeps it. diff --git a/crates/horizon-client/windows-segment-heap.manifest b/crates/horizon-client/windows-segment-heap.manifest new file mode 100644 index 0000000000..67c8d113c6 --- /dev/null +++ b/crates/horizon-client/windows-segment-heap.manifest @@ -0,0 +1,17 @@ + + + + + + SegmentHeap + + + + + + + + + + diff --git a/docs/native-client.md b/docs/native-client.md index 7f577ff59b..00a838843d 100644 --- a/docs/native-client.md +++ b/docs/native-client.md @@ -43,6 +43,9 @@ cargo run --release -p horizon-client -- --render-distance 12 Development builds that link quickly: `cargo build --profile fast -p horizon-client`. +Optional Windows allocator build features are documented in +[Windows allocator experiments](windows-allocators.md). + | Option | Meaning | | --- | --- | | `--world NAME` | World folder under `saves/` (default "New World"); reopened if it exists | diff --git a/docs/windows-allocators.md b/docs/windows-allocators.md new file mode 100644 index 0000000000..6ee28747e8 --- /dev/null +++ b/docs/windows-allocators.md @@ -0,0 +1,64 @@ +# Windows allocator experiments + +The normal client uses Rust's system allocator and does not request Windows +Segment Heap. Two independent, off-by-default Cargo features allow experiments: + +| Feature | Effect on Windows | +| --- | --- | +| `windows-mimalloc` | Uses `mimalloc::MiMalloc` as the `horizon-client` executable's Rust global allocator. | +| `windows-segment-heap` | Embeds a manifest requesting Segment Heap for Win32 heap APIs in this package's executables. | + +Neither feature changes non-Windows builds. These are build options, not mods or +runtime settings; changing either requires rebuilding and restarting the client. +Enabling both does not make every allocation use mimalloc: native libraries can +still use Windows heap APIs. No frame-rate improvement is guaranteed. + +```sh +# Baseline, then each option separately, then both. Keep the same profile. +cargo build --profile fast -p horizon-client --bin horizon-client +cargo build --profile fast -p horizon-client --bin horizon-client --features windows-segment-heap +cargo build --profile fast -p horizon-client --bin horizon-client --features windows-mimalloc +cargo build --profile fast -p horizon-client --bin horizon-client --features windows-segment-heap,windows-mimalloc +``` + +Copy each executable to a separate experiment directory before building the next +variant, or use a separate `CARGO_TARGET_DIR`. Keep prepared resources and worlds +outside version control. Use disposable copies of a world and isolated data +directories for tests, following [AGENTS.md](../AGENTS.md). + +Segment Heap is supported starting with Windows 10 version 2004 (build 19041), +according to Microsoft's [application manifest documentation](https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests#heaptype). +The manifest leaves the optional architecture unspecified and keeps the usual `asInvoker` +execution level. The existing icon and version resources are compiled together +with it through the existing Windows-host resource build step. Cross-compiling +that manifest from a non-Windows host is not supported by this build step. +Builds on earlier Windows versions have not been tested. + +## Validate the executable + +On Windows, extract the manifest from a built executable using the Windows SDK's +`mt.exe` (PowerShell syntax): + +```powershell +mt.exe '-inputresource:target/fast/horizon-client.exe;#1' '-out:client.manifest' +mt.exe -manifest client.manifest -validate_manifest +``` + +Check that the feature build contains `SegmentHeap` in the +`http://schemas.microsoft.com/SMI/2020/WindowsSettings` namespace. The baseline +must not contain this element. Confirm Explorer still shows Horizon's icon for +both builds. Inspecting the final executable matters: a valid source XML file +alone does not prove that the linker embedded it. + +## Measure separately + +Use the same commit, compiler, profile, settings, driver configuration, world +copy, warm-up period and flight route for all four variants. Alternate their +order across repeated sessions. Capture individual frame times, their p99 and +maximum, memory use and average FPS. Record game FPS limits, the display refresh +rate and driver-level VSync: a driver override can cap FPS even when game VSync +is disabled. + +These features let measurements distinguish the Rust allocator from the Win32 +heap choice. Results from builds that also change meshing or LOD retirement +cannot establish either allocator's individual contribution. From 3813fba81c2d5d3ebc69bf3b32a2e28c20b28342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B8=D0=B4=D0=BE=D1=80=D0=B5=D0=BD=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=95=D0=B3=D0=BE=D1=80=20=D0=90=D0=BB=D0=B5=D0=BA=D1=87=D0=B5?= =?UTF-8?q?=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 30 Oct 2026 02:06:08 +0300 Subject: [PATCH 185/968] Reuse worker meshing scratch and bounded mesh capacity Keep primitive scratch per worker and recycle cleared CPU mesh buffers through a count/byte-bounded worker-only pool. Cover reset, missing neighbors, visibility, pool accounting and complete geometry across resource reloads. Fixes #4041 Co-Authored-By: Codex (GPT-6) --- crates/horizon-client-render/src/mesher.rs | 74 +++++-- .../src/mesher/reuse_tests.rs | 187 ++++++++++++++++++ .../src/mesher/visibility_tests.rs | 15 ++ crates/horizon-client-render/src/streaming.rs | 108 +++++++++- 4 files changed, 370 insertions(+), 14 deletions(-) create mode 100644 crates/horizon-client-render/src/mesher/reuse_tests.rs diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index 8328468f0b..7dcd921f61 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -171,6 +171,19 @@ impl Default for SectionMesh { } } impl SectionMesh { + /// Retain geometry capacity after an upload, but none of its old contents. + pub(crate) fn clear(&mut self) { + self.solid.clear(); + self.translucent.clear(); + self.block_entities.clear(); + self.sides = Sides::default(); + self.visibility = ALL_VISIBLE; + } + pub(crate) fn capacity_bytes(&self) -> usize { + self.solid.capacity() * std::mem::size_of::() + + self.translucent.capacity() * std::mem::size_of::() + + self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>() + } /// Sorts the solid quads by the side they face, for the renderer to /// leave out those facing away from the eye (`sides`). pub fn sort_solid(&mut self) { @@ -179,7 +192,13 @@ impl SectionMesh { } pub const ALL_VISIBLE: u64 = (1 << 36) - 1; /// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches. +#[cfg(test)] fn visibility(pad: &Padded, info: &[Info]) -> u64 { + visibility_reusing(pad, info, &mut Vec::new()) +} +fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec) -> u64 { + // The all-visible shortcut can leave a partially filled stack behind. + stack.clear(); // Opaque cells are already visited: resolve each state's opacity once, // then reuse this map for the flood fill and the uniform-section shortcuts. let mut seen = [false; 4096]; @@ -201,7 +220,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 { return 0; } let mut result = 0u64; - let mut stack = Vec::new(); for start in 0..4096usize { if seen[start] { continue; @@ -631,21 +649,30 @@ fn pi(x: i32, y: i32, z: i32) -> usize { ((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize } /// Blocks and light of one section plus a `B`-block border. +#[derive(Default)] struct Padded { blocks: Vec, light: Vec, } impl Padded { + #[cfg(test)] fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self { - let mut blocks = vec![0u32; P * P * P]; - let mut light = vec![0u8; P * P * P]; + let mut pad = Self::default(); + pad.fill(region, sy, &mut Box::new([0; 4096])); + pad + } + fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) { + self.blocks.resize(P * P * P, 0); + self.blocks.fill(0); + self.light.resize(P * P * P, 0); + let (blocks, light) = (&mut self.blocks, &mut self.light); let base_y = MIN_Y + sy * 16; // The section itself is unpacked in one pass; the border cells of the // neighbouring sections are read one by one. - let mut own = Box::new([0u32; 4096]); + own.fill(0); let center = region[1][1].map(|c| &c.sections[sy as usize]); if let Some(section) = center { - section.unpack(&mut own); + section.unpack(own); } for y in -B..16 + B { let wy = base_y + y; @@ -668,9 +695,18 @@ impl Padded { } } } - Self { blocks, light } } } +/// Per-worker temporary arrays. They never hold resource-pack references or +/// geometry still owned by the renderer, and total about 88 KiB per worker. +#[derive(Default)] +pub(crate) struct MeshScratch { + padded: Padded, + own: Option>, + visibility: Vec, +} +#[cfg(test)] +mod reuse_tests; /// Set on a terrain vertex's sprite layer when its texture alpha must be ignored. pub const OPAQUE_LAYER: u16 = 1 << 15; /// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines @@ -840,14 +876,30 @@ pub struct Mesher<'a> { impl Mesher<'_> { /// `region[dz][dx]` are the chunks around the meshed column (centre 1,1). pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh { - let mut out = SectionMesh::default(); + self.mesh_reusing( + region, + sy, + SectionMesh::default(), + &mut MeshScratch::default(), + ) + } + pub(crate) fn mesh_reusing( + &mut self, + region: &[[Option<&Chunk>; 3]; 3], + sy: i32, + mut out: SectionMesh, + scratch: &mut MeshScratch, + ) -> SectionMesh { + out.clear(); let center = region[1][1].expect("centre chunk"); let section = ¢er.sections[sy as usize]; if section.is_uniform(0) { return out; } - let pad = Padded::new(region, sy); - out.visibility = visibility(&pad, &self.tables.info); + let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096])); + scratch.padded.fill(region, sy, own); + let pad = &scratch.padded; + out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility); let base_y = MIN_Y + sy * 16; let mut tints = Tints::new( region, @@ -882,7 +934,7 @@ impl Mesher<'_> { if state.fluid.kind() != FluidKind::Empty { let water = tints.get(Resolver::Water, x, y, z); let nether = self.nether_shading; - fluid::tesselate(self.tables, nether, &pad, water, &mut out, [x, y, z]); + fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]); } if !state.model { continue; @@ -932,7 +984,7 @@ impl Mesher<'_> { continue; } self.face( - &pad, + pad, &mut tints, &mut out, [x, y, z], diff --git a/crates/horizon-client-render/src/mesher/reuse_tests.rs b/crates/horizon-client-render/src/mesher/reuse_tests.rs new file mode 100644 index 0000000000..9ab6eac322 --- /dev/null +++ b/crates/horizon-client-render/src/mesher/reuse_tests.rs @@ -0,0 +1,187 @@ +use super::*; + +#[test] +fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads() { + let mut center = Chunk::empty(0, 0); + center.set(0, MIN_Y, 0, 7); + center.set(15, MIN_Y + 15, 15, 9); + let mut neighbor = Chunk::empty(1, 0); + neighbor.set(0, MIN_Y + 2, 3, 11); + let mut region = [[None; 3]; 3]; + region[1][1] = Some(¢er); + region[1][2] = Some(&neighbor); + let mut pad = Padded::new(®ion, 0); + let buffers = (pad.blocks.as_ptr(), pad.light.as_ptr()); + let mut own = Box::new([u32::MAX; 4096]); + for current in [region, [[None; 3]; 3], region] { + pad.fill(¤t, 0, &mut own); + for y in -B..16 + B { + for z in -B..16 + B { + for x in -B..16 + B { + let chunk = current[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize]; + assert_eq!( + pad.blocks[pi(x, y, z)], + chunk.map_or(0, |c| c.get(x & 15, MIN_Y + y, z & 15)) + ); + assert_eq!( + pad.light[pi(x, y, z)], + chunk.map_or(0xF0, |c| c.light(x & 15, MIN_Y + y, z & 15)) + ); + } + } + } + assert_eq!(buffers, (pad.blocks.as_ptr(), pad.light.as_ptr())); + } +} + +/// Run explicitly with prepared assets; never silently pass without exercising geometry. +#[test] +#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"] +fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() { + let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3") + }); + let assets = Assets::load(&root).unwrap(); + let facts = BlockFacts::load(&root, assets.registry).unwrap(); + let biomes = Biomes::load(&root).unwrap(); + let state = |name| assets.registry.default_state(name).unwrap().0; + let names = [ + "minecraft:stone", + "minecraft:glass", + "minecraft:grass_block", + "minecraft:oak_leaves", + "minecraft:water", + "minecraft:chest", + ]; + let mut dense = Chunk::empty(0, 0); + for z in 0..16 { + for x in 0..16 { + dense.set( + x, + MIN_Y + 4, + z, + state(names[((x + z) as usize) % names.len()]), + ); + } + } + let mut sparse = Chunk::empty(-3, 5); + sparse.set(0, MIN_Y + 15, 0, state("minecraft:short_grass")); + sparse.set(15, MIN_Y + 1, 15, state("minecraft:lava")); + let empty = Chunk::empty(0, 0); + let mut neighbor = Chunk::empty(1, 0); + neighbor.set(0, MIN_Y + 4, 0, state("minecraft:stone")); + let mut scratch = MeshScratch::default(); + let mut recycled = SectionMesh::default(); + // The same scratch and output buffers survive a resource reload. They must + // contain no references into the previous RenderAssets or MeshTables. + for reload in 0..2 { + let render = assets.reload(&root).unwrap(); + let visibility = crate::models::load_visibility(&root, assets.registry).unwrap(); + let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap(); + let mut mesher = Mesher { + nether_shading: false, + assets: &assets, + render: &render, + tables: &tables, + biomes: &biomes, + visibility, + zoom: BiomeZoom::new(42), + blend_radius: 2, + smooth_lighting: reload == 0, + cutout_leaves: reload == 0, + }; + for (case, (center, east)) in [ + (&empty, None), + (&dense, Some(&neighbor)), + (&sparse, None), + (&empty, None), + (&dense, None), + ] + .into_iter() + .enumerate() + { + let mut region = [[None; 3]; 3]; + region[1][1] = Some(center); + region[1][2] = east; + let mut fresh = mesher.mesh(®ion, 0); + recycled = mesher.mesh_reusing(®ion, 0, recycled, &mut scratch); + if reload == 0 && case == 0 { + assert!(scratch.own.is_none()); + assert_eq!(scratch.padded.blocks.capacity(), 0); + assert_eq!(scratch.padded.light.capacity(), 0); + assert_eq!(scratch.visibility.capacity(), 0); + } + fresh.sort_solid(); + recycled.sort_solid(); + assert_eq!( + bytemuck::cast_slice::(&fresh.solid), + bytemuck::cast_slice::(&recycled.solid) + ); + assert_eq!( + bytemuck::cast_slice::(&fresh.translucent), + bytemuck::cast_slice::(&recycled.translucent) + ); + assert_eq!(fresh.block_entities, recycled.block_entities); + assert_eq!(fresh.visibility, recycled.visibility); + assert_eq!(fresh.sides, recycled.sides); + if std::ptr::eq(center, &dense) { + assert!(!fresh.solid.is_empty()); + assert!(!fresh.translucent.is_empty()); + assert!(!fresh.block_entities.is_empty()); + } + } + } +} + +/// An isolated scratch-stage comparison, not a whole-mesher or FPS benchmark. +/// Output each sample so the caller can retain the observations and spread. +#[test] +#[ignore = "opt-in scratch microbenchmark; run with --nocapture --test-threads=1"] +fn benchmark_padded_reuse() { + const ITERATIONS: usize = 4096; + let mut chunk = Chunk::empty(0, 0); + for y in 0..16 { + for z in 0..16 { + for x in 0..16 { + chunk.set(x, MIN_Y + y, z, 1 + ((x + y + z) % 7) as u32); + } + } + } + let mut region = [[None; 3]; 3]; + region[1][1] = Some(&chunk); + let mut pad = Padded::new(®ion, 0); + let mut own = Box::new([0; 4096]); + let pointers = (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr()); + let bytes = pad.blocks.capacity() * 4 + pad.light.capacity() + 4096 * 4; + // Warm both code paths and the process allocator before recording samples. + for _ in 0..1024 { + std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0)); + pad.fill(std::hint::black_box(®ion), 0, &mut own); + std::hint::black_box(&pad); + } + for sample in 0..9 { + // Alternate execution order to expose, rather than hide, order effects. + for reused in [sample % 2 == 0, sample % 2 != 0] { + let start = std::time::Instant::now(); + for _ in 0..ITERATIONS { + if reused { + pad.fill(std::hint::black_box(®ion), 0, &mut own); + std::hint::black_box(&pad); + } else { + std::hint::black_box(Padded::new(std::hint::black_box(®ion), 0)); + } + } + println!( + "{{\"sample\":{sample},\"reused\":{reused},\"iterations\":{ITERATIONS},\"elapsed_ms\":{:.6},\"scratch_bytes\":{bytes}}}", + start.elapsed().as_secs_f64() * 1000.0 + ); + assert_eq!( + pointers, + (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr()) + ); + } + } +} diff --git a/crates/horizon-client-render/src/mesher/visibility_tests.rs b/crates/horizon-client-render/src/mesher/visibility_tests.rs index 8f727bc221..af058d21e7 100644 --- a/crates/horizon-client-render/src/mesher/visibility_tests.rs +++ b/crates/horizon-client-render/src/mesher/visibility_tests.rs @@ -187,6 +187,21 @@ fn visibility_matches_reference() { } } +#[test] +fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() { + let info = [info(false), info(true), info(false), info(true)]; + let mut stack = vec![usize::MAX]; + for _ in 0..3 { + for (name, pad) in cases() { + assert_eq!( + visibility_reusing(&pad, &info, &mut stack), + reference(&pad, &info), + "{name}" + ); + } + } +} + #[test] fn visibility_preserves_every_face_pair() { let info = [info(false), info(true), info(false), info(true)]; diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs index 9463a3c830..0824ac6818 100644 --- a/crates/horizon-client-render/src/streaming.rs +++ b/crates/horizon-client-render/src/streaming.rs @@ -6,7 +6,7 @@ use crate::fast_hash::{FxHashMap, FxHashSet}; use crate::{ chunk::{Chunk, SECTIONS}, light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk}, - mesher::{MeshTables, Mesher, Region, SectionMesh, borrow}, + mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow}, world::World, }; use horizon_core::biome::BiomeZoom; @@ -220,7 +220,36 @@ struct Shared { /// packs reload; each mesh job takes the current one. context: RwLock>, done: Mailbox, + /// Only workers use this pool; frames never wait for its lock. Retaining + /// cleared buffers avoids racing the renderer for the process heap. + meshes: Mutex, } +#[derive(Default)] +struct MeshPool { + meshes: Vec, + bytes: usize, +} +impl MeshPool { + const BYTES: usize = 16 << 20; + const COUNT: usize = 128; + fn take(&mut self) -> SectionMesh { + let mesh = self.meshes.pop().unwrap_or_default(); + self.bytes -= mesh.capacity_bytes(); + mesh + } + /// A rejected mesh is dropped by the caller, after releasing the lock. + fn put(&mut self, mut mesh: SectionMesh) -> Option { + let bytes = mesh.capacity_bytes(); + if bytes == 0 || self.meshes.len() == Self::COUNT || bytes > Self::BYTES - self.bytes { + return Some(mesh); + } + mesh.clear(); + self.bytes += bytes; + self.meshes.push(mesh); + None + } +} + /// Finished jobs, from the workers to the game thread, in the order they /// finished. Not an `mpsc` channel: its receiver waits for a sender it finds /// half way through a send, and a worker descheduled there on a busy machine @@ -513,6 +542,7 @@ impl Streaming { stop: AtomicBool::new(false), context: RwLock::new(context.clone()), done: Mailbox::default(), + meshes: Mutex::new(MeshPool::default()), }); let light_tables = context.light.clone(); let saves: Arc = generator.clone(); @@ -531,6 +561,7 @@ impl Streaming { // the worker across mesh jobs, like vanilla's per-thread cache: // cloning it per job copied every state and face shape. let mut faces: Option<(Arc, FaceVisibility)> = None; + let mut mesh_scratch = MeshScratch::default(); loop { let job = { let mut queue = shared.queue.lock().unwrap(); @@ -567,7 +598,10 @@ impl Streaming { Done::Saved(protos) } Job::Free(meshes) => { - drop(meshes); + for (_, mesh) in meshes { + let rejected = shared.meshes.lock().unwrap().put(mesh); + drop(rejected); + } continue; } Job::Light(x, z, region, seq, sky) => { @@ -595,7 +629,13 @@ impl Streaming { biomes: &context.biomes, visibility, }; - let mut mesh = mesher.mesh(&borrow(®ion), sy); + let recycled = shared.meshes.lock().unwrap().take(); + let mut mesh = mesher.mesh_reusing( + &borrow(®ion), + sy, + recycled, + &mut mesh_scratch, + ); mesh.sort_solid(); faces = Some((context.tables.clone(), mesher.visibility)); Done::Meshed(x, sy, z, mesh, seq) @@ -1959,6 +1999,68 @@ fn rank_again(queue: &mut BinaryHeap, distance: impl Fn(i32, i32) -> i64 mod tests { use super::*; + #[test] + fn mesh_pool_reuses_capacity_with_clean_geometry_state() { + let mut mesh = SectionMesh::default(); + mesh.solid.resize(64, bytemuck::Zeroable::zeroed()); + mesh.translucent.resize(16, bytemuck::Zeroable::zeroed()); + mesh.block_entities.push(([1, 2, 3], 7)); + mesh.visibility = 0; + mesh.sides = crate::mesher::Sides::unsorted(16); + let capacities = mesh.capacity_bytes(); + let pointers = (mesh.solid.as_ptr(), mesh.translucent.as_ptr()); + let mut pool = MeshPool::default(); + assert!(pool.put(mesh).is_none()); + assert_eq!(pool.bytes, capacities); + let reused = pool.take(); + assert_eq!(reused.capacity_bytes(), capacities); + assert_eq!( + pointers, + (reused.solid.as_ptr(), reused.translucent.as_ptr()) + ); + assert!(reused.solid.is_empty()); + assert!(reused.translucent.is_empty()); + assert!(reused.block_entities.is_empty()); + assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE); + assert_eq!(reused.sides, crate::mesher::Sides::default()); + assert_eq!(pool.bytes, 0); + } + + #[test] + fn mesh_pool_caps_retained_count_and_bytes() { + let mut pool = MeshPool::default(); + for _ in 0..MeshPool::COUNT { + let mut mesh = SectionMesh::default(); + mesh.solid.reserve_exact(1); + assert!(pool.put(mesh).is_none()); + } + let mut extra = SectionMesh::default(); + extra.solid.reserve_exact(1); + assert!(pool.put(extra).is_some()); + for _ in 0..MeshPool::COUNT { + pool.take(); + } + assert_eq!(pool.bytes, 0); + for i in 0..2 { + let mut mesh = SectionMesh::default(); + mesh.solid.reserve_exact( + MeshPool::BYTES / std::mem::size_of::() / 2 + 1, + ); + assert_eq!(pool.put(mesh).is_some(), i == 1); + assert!(pool.bytes <= MeshPool::BYTES); + assert_eq!(pool.meshes.len(), 1); + } + pool.take(); + assert_eq!(pool.bytes, 0); + let mut huge = SectionMesh::default(); + huge.solid.reserve_exact( + MeshPool::BYTES / std::mem::size_of::() + 1, + ); + assert!(pool.put(huge).is_some()); + assert!(pool.meshes.is_empty()); + assert_eq!(pool.bytes, 0); + } + /// The frame never waits for a worker: while one holds the mailbox, the /// frame takes nothing and finds the results, in order, the next time. #[test] From 5550a6c3272b47e2d4d9ad97d7c8f56bfd53fbe8 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:03:29 +0700 Subject: [PATCH 186/968] Read the glint clock and options once per frame, not per item Game::glint() and glint_ticks() read the clock and looked glintSpeed and glintStrength up by name (a linear scan of the options table) for every item submitted, so a frame with thousands of dropped items read the clock thousands of times. Vanilla snapshots the options once per frame (GameRenderer.extract -> extractOptions into OptionsRenderState) and TextureTransform.setupGlintTexturing reads Util.getMillis() once per glint render-type batch. Capture a GlintFrame at the start of each render() and let world items, armour, held items and GUI items read it. Fixes #3183 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 9 ++- crates/horizon-client/src/game/item_draw.rs | 42 +++++++------- crates/horizon-client/src/item_render.rs | 63 +++++++++++++++++++++ 3 files changed, 89 insertions(+), 25 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8cba7cb80c..f946d59723 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1586,6 +1586,9 @@ struct Game<'a> { /// Controllers (Horizon's own). pad: pad::Pads, frames: u64, + /// The glint clock and options, read once at the start of each frame + /// (game/item_draw.rs). + glint_frame: horizon_client::item_render::GlintFrame, /// `item_level` for one frame, game time, day time and dimension. item_level_cache: std::cell::RefCell< Option<( @@ -11973,6 +11976,7 @@ impl Game<'_> { }); } fn render(&mut self) -> Result { + self.glint_frame = self.capture_glint_frame(); if self.renderer.as_mut().is_some_and(|r| r.display_changed()) { self.apply_ui_settings(); } @@ -12383,8 +12387,8 @@ impl Game<'_> { debug_lines_on_top: &debug_lines_on_top, debug_crosshair: &debug_crosshair, hud: &hud, - glint_alpha: self.settings.file.double("glintStrength") as f32, - glint_ticks: self.glint_ticks(), + glint_alpha: self.glint_frame.alpha, + glint_ticks: self.glint_frame.scroll_ticks(), clouds: (!title && self.dim == Dim::Overworld && horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3])) @@ -14328,6 +14332,7 @@ impl<'a> Game<'a> { last_input: now, pad: Default::default(), frames: 0, + glint_frame: Default::default(), item_level_cache: Default::default(), world_frames: 0, fps: 0.0, diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index f35204edb6..b52f2dbad7 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -309,32 +309,28 @@ impl Game<'_> { batch, x, y, - self.started.elapsed().as_secs_f32(), - self.settings.file.double("glintSpeed") as f32, - self.settings.file.double("glintStrength") as f32, + self.glint_frame.millis / 1000.0, + self.glint_frame.speed, + self.glint_frame.alpha, ); } - /// The `GLINT_TEXTURING` inputs the atlas and options provide. - pub(super) fn glint(&self) -> Option { - self.gui - .atlas - .sprites - .get("misc/enchanted_glint_item") - .copied() - .map(|_| item_render::Glint { - speed: self.settings.file.double("glintSpeed") as f32, - alpha: self.settings.file.double("glintStrength") as f32, - millis: self.started.elapsed().as_secs_f32() * 1000.0, - scale: 0.5, - }) + /// The glint clock, options and sprite for this frame: read once per + /// frame, not per item (`GameRenderer.extractOptions`). + pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame { + item_render::GlintFrame { + millis: self.started.elapsed().as_secs_f32() * 1000.0, + speed: self.settings.file.double("glintSpeed") as f32, + alpha: self.settings.file.double("glintStrength") as f32, + sprite: self + .gui + .atlas + .sprites + .contains_key("misc/enchanted_glint_item"), + } } - /// The glint scroll phase shared by world items and armour - /// (`item_render::Glint::scroll_ticks`). - pub(super) fn glint_ticks(&self) -> f32 { - item_render::Glint::scroll_ticks( - self.started.elapsed().as_secs_f32() * 1000.0, - self.settings.file.double("glintSpeed") as f32, - ) + /// The `ENTITY_GLINT_TEXTURING` inputs of this frame. + pub(super) fn glint(&self) -> Option { + self.glint_frame.glint(0.5) } /// Submit a resolved item under `pose` (camera-relative) at `light` /// (packed lightmap coordinates). diff --git a/crates/horizon-client/src/item_render.rs b/crates/horizon-client/src/item_render.rs index f8fb3af8cb..0d24b520c0 100644 --- a/crates/horizon-client/src/item_render.rs +++ b/crates/horizon-client/src/item_render.rs @@ -96,6 +96,36 @@ impl Glint { ] } } +/// The glint inputs of one frame, read once: `GameRenderer.extract` snapshots +/// `glintSpeed` and `glintStrength` into `OptionsRenderState` +/// (`extractOptions`), and `TextureTransform.setupGlintTexturing` reads +/// `Util.getMillis()` once per glint render-type batch, not per item. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GlintFrame { + /// `Util.getMillis()` this frame. + pub millis: f32, + /// `OptionsRenderState.glintSpeed`. + pub speed: f32, + /// `OptionsRenderState.glintStrength`. + pub alpha: f32, + /// The atlas has `misc/enchanted_glint_item`. + pub sprite: bool, +} +impl GlintFrame { + /// The glint at a texturing scale, if the atlas has its sprite. + pub fn glint(&self, scale: f32) -> Option { + self.sprite.then_some(Glint { + speed: self.speed, + alpha: self.alpha, + millis: self.millis, + scale, + }) + } + /// [`Glint::scroll_ticks`] for this frame. + pub fn scroll_ticks(&self) -> f32 { + Glint::scroll_ticks(self.millis, self.speed) + } +} /// Which glint render type a quad uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Foil { @@ -853,6 +883,39 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64 mod tests { use super::*; + /// One frame's glint is read once and shared: every item drawn from the + /// same `GlintFrame` scrolls by the same phase, and without the atlas + /// sprite no item gets a glint (`TextureTransform.setupGlintTexturing` + /// once per batch, `GameRenderer.extractOptions` once per frame). + #[test] + fn a_frame_glint_is_shared_by_every_item() { + let frame = GlintFrame { + millis: 123_456.0, + speed: 0.5, + alpha: 0.75, + sprite: true, + }; + let world = frame.glint(0.5).expect("the atlas has the glint sprite"); + assert_eq!( + (world.speed, world.alpha, world.millis), + (0.5, 0.75, 123_456.0) + ); + assert_eq!(world.scale, 0.5); + let uvs: Vec<_> = (0..1000) + .map(|_| frame.glint(0.5).unwrap().uv([0.25, 0.75])) + .collect(); + assert!(uvs.iter().all(|uv| *uv == uvs[0])); + assert_eq!(frame.scroll_ticks(), Glint::scroll_ticks(123_456.0, 0.5)); + // 123456 ms * 0.5 * 8 = 493824 ticks, modulo 330000. + assert_eq!(frame.scroll_ticks(), 163_824.0); + let missing = GlintFrame { + sprite: false, + ..frame + }; + assert!(missing.glint(0.5).is_none()); + assert_eq!(missing.scroll_ticks(), frame.scroll_ticks()); + } + /// A bad item model surfaces only once items.json has loaded, so a broken /// items.json still reports itself first. #[test] From 8bbfc5cbf06e18f56b3a44c2de0890ccbb3978b8 Mon Sep 17 00:00:00 2001 From: oscargzss Date: Thu, 29 Oct 2026 17:15:13 -0600 Subject: [PATCH 187/968] renderer: try a backend again with non-conformant adapters wgpu hides every adapter whose driver reports Vulkan conformance version 0 unless `ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER` is set, and Mesa reports 0.0.0.0 for hasvk, which drives Ivy Bridge and Haswell. Such a GPU is told it has no driver while Vulkan runs, and the only escape was the player's environment variable. `open_device` now asks a backend that offered no adapter at all a second time with those adapters shown, and says in the log which adapter it took. Every machine where a backend already gives an adapter is untouched. Fixes #4059 --- crates/horizon-client-render/src/gfx.rs | 255 ++++++++++++++++-------- 1 file changed, 167 insertions(+), 88 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 820d85139d..e8a2c5c211 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -790,9 +790,14 @@ fn mip_chain( /// pass is also what passes each draw's first instance to shaders reading /// `instance_index`, and DirectX 12 is the fallback, where working matters /// more than CPU time. +/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan +/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell, +/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second +/// try at a backend that had no adapter at all. fn instance_descriptor( backends: wgpu::Backends, window: Option<&Arc>, + allow_nonconformant: bool, ) -> wgpu::InstanceDescriptor { let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); if let Some(window) = window { @@ -805,7 +810,13 @@ fn instance_descriptor( .flags .remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL); } - descriptor.with_env() + let mut descriptor = descriptor.with_env(); + if allow_nonconformant { + descriptor + .flags + .insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER); + } + descriptor } /// The window's display connection, so the OpenGL backend creates its EGL /// display from the X11 or Wayland connection the window lives on. Without @@ -944,96 +955,147 @@ async fn open_device( if attempt > 0 { log::warn!("Horizon renderer: trying {name} ({})", failures.join("; ")); } - let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window))); - let surface = if offscreen { - None - } else { - match instance.create_surface(window.clone()) { - Ok(surface) => Some(surface), - Err(e) => { - failures.push(format!("{name} surface: {e}")); - continue; - } - } - }; - let adapter = match instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - compatible_surface: surface.as_ref(), - force_fallback_adapter: false, - ..Default::default() - }) - .await - { - Ok(adapter) => adapter, - Err(e) => { - failures.push(format!("{name}: {e}")); - continue; - } - }; - describe_adapter(&adapter); - let info = adapter.get_info(); - let limits = adapter.limits(); - if let Err(e) = check_layers(&limits, layers) { - failures.push(format!("{name}: {e}")); - continue; - } - // Mappable primary buffers: section pages that no longer fit in - // video memory go to system memory. Shader packs' 16-bit normalized buffers (render - // targets with the adapter's own format features) and R11F_G11F_B10F: a float - // stand-in neither clamps nor drops NaN. - let features = timing::GpuTimer::features(&adapter) - | cull::Cull::features(&adapter) - | (adapter.features() - & (wgpu::Features::INDIRECT_FIRST_INSTANCE - | wgpu::Features::MAPPABLE_PRIMARY_BUFFERS - | wgpu::Features::TEXTURE_FORMAT_16BIT_NORM - | wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES - | wgpu::Features::RG11B10UFLOAT_RENDERABLE)); - let driver = format!("{} {}", info.driver, info.driver_info); - startup_step(format_args!( - "requesting the device (driver {}) with features [{features}] and the adapter's \ - limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)", - match driver.trim() { - "" => "unnamed", - driver => driver, - }, - limits.max_buffer_size, - limits.max_storage_buffer_binding_size, - limits.max_texture_array_layers, - )); - let desc = wgpu::DeviceDescriptor { - label: Some("Horizon device"), - required_features: features, - required_limits: limits, - ..Default::default() - }; - #[cfg(not(target_os = "macos"))] - let opened = match info.backend { - wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc), - _ => None, - }; - #[cfg(target_os = "macos")] - let opened = None; - let opened = match opened { - Some(opened) => opened, - None => adapter.request_device(&desc).await, - }; - match opened { - Ok((device, queue)) => { - return Ok(Opened { - instance, - surface, - adapter, - device, - queue, - }); - } - Err(e) => failures.push(format!("{name} device: {e}")), + match open_with_retry(backends, window, offscreen, layers).await { + Ok(opened) => return Ok(opened), + Err(e) => failures.push(e), } } Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into()) } +/// Why a backend gave no device: no adapter at all, which a second try with the +/// adapters wgpu hides can still find, or a failure after an adapter. +enum NoDevice { + NoAdapter(String), + Failed(String), +} +/// Opens a device on one backend, and on a backend whose adapters wgpu all hid +/// for reporting no Vulkan conformance version tries it again with them shown: +/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so +/// without this the player is told they have no graphics driver while Vulkan +/// runs (a `gl not compatible with provided surface` fallback, or nothing, is +/// all they get). Returns why the backend gave no device. +async fn open_with_retry( + backends: wgpu::Backends, + window: &Arc, + offscreen: bool, + layers: u32, +) -> std::result::Result { + match open_backend(backends, window, offscreen, false, layers).await { + Err(NoDevice::NoAdapter(hidden)) => { + log::warn!( + "Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \ + ones wgpu hides as non-conformant", + backends_name(backends) + ); + match open_backend(backends, window, offscreen, true, layers).await { + Ok(opened) => { + log::warn!( + "Horizon renderer: using {} although its driver reports no Vulkan \ + conformance version", + opened.adapter.get_info().name + ); + Ok(opened) + } + Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e), + } + } + Err(NoDevice::Failed(e)) => Err(e), + Ok(opened) => Ok(opened), + } +} +/// Opens a device on `backends` alone: its surface, adapter and device, or what +/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try. +async fn open_backend( + backends: wgpu::Backends, + window: &Arc, + offscreen: bool, + allow_nonconformant: bool, + layers: u32, +) -> std::result::Result { + let name = backends_name(backends); + let instance = wgpu::Instance::new(instance_descriptor( + backends, + Some(window), + allow_nonconformant, + )); + let surface = if offscreen { + None + } else { + match instance.create_surface(window.clone()) { + Ok(surface) => Some(surface), + Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))), + } + }; + let adapter = match instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: surface.as_ref(), + force_fallback_adapter: false, + ..Default::default() + }) + .await + { + Ok(adapter) => adapter, + Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))), + }; + describe_adapter(&adapter); + let info = adapter.get_info(); + let limits = adapter.limits(); + if let Err(e) = check_layers(&limits, layers) { + return Err(NoDevice::Failed(format!("{name}: {e}"))); + } + // Mappable primary buffers: section pages that no longer fit in + // video memory go to system memory. Shader packs' 16-bit normalized buffers (render + // targets with the adapter's own format features) and R11F_G11F_B10F: a float + // stand-in neither clamps nor drops NaN. + let features = timing::GpuTimer::features(&adapter) + | cull::Cull::features(&adapter) + | (adapter.features() + & (wgpu::Features::INDIRECT_FIRST_INSTANCE + | wgpu::Features::MAPPABLE_PRIMARY_BUFFERS + | wgpu::Features::TEXTURE_FORMAT_16BIT_NORM + | wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES + | wgpu::Features::RG11B10UFLOAT_RENDERABLE)); + let driver = format!("{} {}", info.driver, info.driver_info); + startup_step(format_args!( + "requesting the device (driver {}) with features [{features}] and the adapter's \ + limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)", + match driver.trim() { + "" => "unnamed", + driver => driver, + }, + limits.max_buffer_size, + limits.max_storage_buffer_binding_size, + limits.max_texture_array_layers, + )); + let desc = wgpu::DeviceDescriptor { + label: Some("Horizon device"), + required_features: features, + required_limits: limits, + ..Default::default() + }; + #[cfg(not(target_os = "macos"))] + let opened = match info.backend { + wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc), + _ => None, + }; + #[cfg(target_os = "macos")] + let opened = None; + let opened = match opened { + Some(opened) => opened, + None => adapter.request_device(&desc).await, + }; + match opened { + Ok((device, queue)) => Ok(Opened { + instance, + surface, + adapter, + device, + queue, + }), + Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))), + } +} /// What to tell a player whose system offers no usable graphics adapter. /// Vulkan is loaded at run time (the game links no graphics library), so a /// missing driver shows here rather than as a missing-DLL dialog. @@ -5859,6 +5921,23 @@ mod tests { assert_eq!(order(api, "macos"), [Backends::default()]); } } + /// The second try at a backend shows the adapters wgpu hides for reporting + /// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and + /// Haswell, reports 0.0.0.0 and works). + #[test] + fn the_second_try_at_a_backend_shows_nonconformant_adapters() { + let shown = |allow| { + super::instance_descriptor(wgpu::Backends::VULKAN, None, allow) + .flags + .contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER) + }; + assert!(shown(true)); + // The first try keeps wgpu's own filtering, unless `with_env` turns the + // flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER). + if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() { + assert!(!shown(false)); + } + } /// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend /// writes it for FXC (shader model 5.1), entry point by entry point. #[test] @@ -6003,7 +6082,7 @@ mod tests { #[test] fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() { let validated = |backends| { - super::instance_descriptor(backends, None) + super::instance_descriptor(backends, None, false) .flags .contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL) }; From c449d797bd5ce4cf3affd01de42c8010859d0106 Mon Sep 17 00:00:00 2001 From: septydev Date: Fri, 30 Oct 2026 00:29:17 +0200 Subject: [PATCH 188/968] Fix account chip text overflow --- launcher/src/main.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 890f530d11..12c1ff403e 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1958,14 +1958,21 @@ impl Launcher { div() .flex() .flex_col() + .flex_1() .min_w_0() + .overflow_hidden() .child( blocks::pixel(14., FontWeight::MEDIUM) .truncate() .text_color(t.ink) .child(name), ) - .child(text(theme::CAPTION).text_color(t.muted).child(how)), + .child( + text(theme::CAPTION) + .truncate() + .text_color(t.muted) + .child(how), + ), ) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.account_menu = !this.account_menu; From def1dbef33291cc0eae0b2952080d8197f3b4467 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 02:21:47 +0300 Subject: [PATCH 189/968] Native mods: a mod container opens a vanilla slot menu ModContainer.menu names a plain slot grid (hopper, generic_3x3, generic_9x1..9x6) with as many slots; using the block opens it over the block entity's slots, through the menu code vanilla's containers use, titled with the block's name (Block.getDescriptionId). Co-Authored-By: Claude Opus 5.5 --- .../src/chunk/block_entity.rs | 16 +++++- .../horizon-client-render/src/containers.rs | 4 +- crates/horizon-client/src/game.rs | 3 ++ crates/horizon-client/src/game/lan_actions.rs | 14 ++++-- crates/horizon-client/src/game/machines.rs | 1 + .../horizon-client/src/game/native_hooks.rs | 50 ++++++++++++++++++- 6 files changed, 80 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs index 74e58e9d29..bd38edc043 100644 --- a/crates/horizon-client-render/src/chunk/block_entity.rs +++ b/crates/horizon-client-render/src/chunk/block_entity.rs @@ -97,14 +97,28 @@ pub struct ModBlockEntity { /// says it: its slot count, the slots each face reaches /// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east), /// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take -/// from (`canTakeItemThroughFace`). +/// from (`canTakeItemThroughFace`); and the vanilla menu that shows its +/// slots when the block is used, if any (`MOD_MENUS`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ModContainer { pub slots: usize, pub faces: [Vec; 6], pub inputs: Vec, pub outputs: Vec, + pub menu: Option, } +/// The vanilla menus a mod container can open, with the slot count each +/// shows: plain slot grids, whose clicks need nothing of the block entity. +pub const MOD_MENUS: [(&str, usize); 8] = [ + ("minecraft:hopper", 5), + ("minecraft:generic_3x3", 9), + ("minecraft:generic_9x1", 9), + ("minecraft:generic_9x2", 18), + ("minecraft:generic_9x3", 27), + ("minecraft:generic_9x4", 36), + ("minecraft:generic_9x5", 45), + ("minecraft:generic_9x6", 54), +]; /// `BrushableBlockEntity`: the loot table is resolved on the first brush. #[derive(Clone, Debug, Default)] pub struct Brushable { diff --git a/crates/horizon-client-render/src/containers.rs b/crates/horizon-client-render/src/containers.rs index e3f9a256f8..733c6682d6 100644 --- a/crates/horizon-client-render/src/containers.rs +++ b/crates/horizon-client-render/src/containers.rs @@ -3,8 +3,8 @@ //! from the data-driven `context_int_provider`/`context_float_provider` files. pub use crate::chunk::block_entity::{ Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, - EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, ModBlockEntity, ModContainer, Shelf, Skull, - SkullSkin, + EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer, + Shelf, Skull, SkullSkin, }; use crate::items::ItemStack; use std::collections::HashMap; diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5637023653..5245c34133 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3909,6 +3909,9 @@ impl Game<'_> { if first { self.container_edge_sound(key, true); } + } else if self.open_mod_menu(key) { + // A mod block's container (`native_block_entity_container`). + self.container = Some(key); } else if self.open_station(pos, &name) { // Workstation menus: game/stations.rs. } else { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index add126bfb1..9f66d4b458 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -868,6 +868,8 @@ impl Game<'_> { Slots::Double(other, pos) }; ("minecraft:generic_9x6", halves) + } else if let Some(menu) = self.mod_menu_at(pos) { + (menu, Slots::Block(pos)) } else { let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else { return false; @@ -889,10 +891,16 @@ impl Game<'_> { horizon_client::containers::double_chest_name(custom(first), custom(second)) } Slots::Block(k) => { - let Some(key) = horizon_client::containers::default_name_key(name) else { - return false; + // A mod block's container is named after its block + // (`Block.getDescriptionId`). + let key = match horizon_client::containers::default_name_key(name) { + Some(key) => key.to_owned(), + None if self.mod_menu_at(k).is_some() => { + format!("block.{}", name.replace(':', ".")) + } + None => return false, }; - horizon_client::containers::display_name(custom(k), key) + horizon_client::containers::display_name(custom(k), &key) } }; let Some(items) = host diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 1c24008051..c80f93edd2 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -1359,6 +1359,7 @@ mod mod_container_tests { faces, inputs: vec![0, 1], outputs: vec![2], + menu: None, })), }; assert_eq!(view.slots_for_face(Some(Direction::Up)), [0]); diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index df90a6b0d7..1bb7858073 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -29,7 +29,7 @@ use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::R use horizon_client::{ block_entities::Pos, chat::ChatSource, - containers::{BlockEntity, ModBlockEntity, ModContainer}, + containers::{BlockEntity, MOD_MENUS, ModBlockEntity, ModContainer}, extras::Corner, extras_hud::Corners, fast_hash::FxHashMap, @@ -351,7 +351,10 @@ impl Game<'_> { /// Block `block`'s block entity has item slots (a `WorldlyContainer`): /// set `container` when the block is yours. Asked once for each mod /// block when the ticking table is built; at most 256 slots, and slot - /// numbers past the count are left out. API 3. + /// numbers past the count are left out. Its `menu`, one of + /// `containers::MOD_MENUS` with as many slots, opens when the block is + /// used (its template must open a menu, as a chest or a furnace does); + /// another is left out. API 3. pub(super) fn native_block_entity_container( &self, block: &str, @@ -595,6 +598,9 @@ impl Game<'_> { for list in c.faces.iter_mut().chain([&mut c.inputs, &mut c.outputs]) { list.retain(|&s| s < n); } + c.menu = c + .menu + .filter(|m| mod_menu(m).is_some_and(|(_, size)| size == n)); Arc::new(c) }) }); @@ -610,6 +616,31 @@ impl Game<'_> { self.native_block_entity_ticks(name, &mut ticks); ticks } + /// A mod block's container at `key` opens its menu: its slots grown to + /// the menu's. False for any other block. + pub(super) fn open_mod_menu(&mut self, key: Pos) -> bool { + let state = self.world.block(key.0, key.1, key.2).0; + let Some(c) = self.world.block_entities.mod_container(state) else { + return false; + }; + let slots = c.slots; + if c.menu.is_none() { + return false; + } + let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&key) else { + return false; + }; + if m.items.len() < slots { + m.items.resize(slots, None); + } + true + } + /// The menu type of the mod container at `key`, if it has one. + pub(super) fn mod_menu_at(&self, key: Pos) -> Option<&'static str> { + let state = self.world.block(key.0, key.1, key.2).0; + let c = self.world.block_entities.mod_container(state)?; + mod_menu(c.menu.as_deref()?).map(|(menu, _)| menu) + } /// The tick of the mod block entity at `pos`. pub(super) fn tick_mod_block_entity(&mut self, pos: Pos) { let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) else { @@ -759,6 +790,11 @@ fn debug_takes(corner: Corner, debug: bool, charts: [bool; 2]) -> bool { } } +/// A menu of `MOD_MENUS`, by its name. +fn mod_menu(name: &str) -> Option<(&'static str, usize)> { + MOD_MENUS.into_iter().find(|(menu, _)| *menu == name) +} + /// `v` within `lo..=hi`; `lo` when it is not a number. fn finite(v: f32, lo: f32, hi: f32) -> f32 { if v.is_finite() { v.clamp(lo, hi) } else { lo } @@ -825,6 +861,16 @@ mod tests { assert_eq!(names, declared); } + /// A mod container opens only a plain slot grid, by its slot count. + #[test] + fn mod_menus_are_plain_slot_grids() { + use super::mod_menu; + assert_eq!(mod_menu("minecraft:hopper"), Some(("minecraft:hopper", 5))); + assert_eq!(mod_menu("minecraft:generic_9x3").map(|m| m.1), Some(27)); + assert_eq!(mod_menu("minecraft:furnace"), None); + assert_eq!(mod_menu("hopper"), None); + } + /// Vanilla's F3 draws a column in each top corner (`extractLines`, left /// and right aligned), and its charts along the bottom. #[test] From d9d0bd328932b905984bc6e8c374c59e1a4320bc Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 00:22:16 +0100 Subject: [PATCH 190/968] Right Click Emulation: Control + left click is a right click on macOS The option was in Options > Controls and saved, but nothing read it. MacosUtil.setCtrlClickEmulatesRightClick makes the click a right click, and its release too. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client-input/src/mouse.rs | 39 ++++++++++++++++++++++++ crates/horizon-client/src/game.rs | 14 +++++++++ 2 files changed, 53 insertions(+) diff --git a/crates/horizon-client-input/src/mouse.rs b/crates/horizon-client-input/src/mouse.rs index 1941c310ee..7e60b81c16 100644 --- a/crates/horizon-client-input/src/mouse.rs +++ b/crates/horizon-client-input/src/mouse.rs @@ -342,6 +342,28 @@ pub fn mouse_position_after_grab_change( } } +/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on +/// macOS), a left click made with Control held is a right click, and so is +/// its release, whatever Control does in between. +#[derive(Clone, Copy, Debug, Default)] +pub struct RightClickEmulation { + emulating: bool, +} +impl RightClickEmulation { + /// Whether the left button's `pressed` or released event is a right click. + /// `enabled` is the option on macOS. + pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool { + if pressed { + self.emulating = enabled && control; + } + let right = self.emulating; + if !pressed { + self.emulating = false; + } + right + } +} + #[cfg(test)] mod tests { use super::*; @@ -526,4 +548,21 @@ mod tests { None ); } + + #[test] + fn control_click_is_a_right_click_until_released() { + let mut e = RightClickEmulation::default(); + // The option off, or no Control: a left click stays one. + assert!(!e.is_right_click(true, true, false)); + assert!(!e.is_right_click(false, true, false)); + assert!(!e.is_right_click(true, false, true)); + assert!(!e.is_right_click(false, true, true)); + // Control held at the press: the press and the release are right clicks, + // even when Control was let go before the release. + assert!(e.is_right_click(true, true, true)); + assert!(e.is_right_click(false, false, true)); + // The next left click is its own. + assert!(!e.is_right_click(true, false, true)); + assert!(!e.is_right_click(false, false, true)); + } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8cba7cb80c..f5ab77147d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1527,6 +1527,8 @@ struct Game<'a> { // Physical input is separate from remapped and toggled movement keys. physical_keys: HashSet, physical_buttons: HashSet, + /// Control + left click as a right click (macOS). + right_click_emulation: horizon_client_input::mouse::RightClickEmulation, left: bool, right: bool, cursor: (f32, f32), @@ -13106,6 +13108,17 @@ impl ApplicationHandler for Game<'_> { return; } follow_keymap(&mut event); + // MacosUtil.setCtrlClickEmulatesRightClick: Control + left click is a right click. + if let WindowEvent::MouseInput { state, button, .. } = &mut event + && *button == MouseButton::Left + && self.right_click_emulation.is_right_click( + *state == ElementState::Pressed, + self.modifiers.control_key(), + cfg!(target_os = "macos") && self.settings.ctrl_click_emulates_right_click, + ) + { + *button = MouseButton::Right; + } if let WindowEvent::KeyboardInput { event, .. } = &event && let PhysicalKey::Code(key) = event.physical_key { @@ -14293,6 +14306,7 @@ impl<'a> Game<'a> { keys: HashSet::new(), physical_keys: HashSet::new(), physical_buttons: HashSet::new(), + right_click_emulation: Default::default(), left: false, right: false, cursor: (0.0, 0.0), From 413e6e856e7c9ca16d1b5d2bf6e74888b709f18f Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:22:18 +0700 Subject: [PATCH 191/968] Swapping the same item into the off hand plays the re-equip animation FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem keeps the visible item when it is the same ItemStack object, or one that matchesIgnoringComponents (count, item and components, with damage ignored). Horizon stood in for the object by the slot, so the off hand, always the same slot, kept showing a stack of the same item that the swap key had replaced, whatever its count (#2866). A swap now marks both hands as holding new stacks: at once in the local game, and on a server (the thread one included) when the slot updates that answer SWAP_ITEM_WITH_OFFHAND arrive. Those hands then compare by matchesIgnoringComponents until they show the held stack again. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 6 ++ crates/horizon-client/src/game/hand.rs | 108 ++++++++++++++++++++--- crates/horizon-client/src/game/remote.rs | 51 ++++++++++- 3 files changed, 152 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8cba7cb80c..478b557659 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1712,6 +1712,9 @@ struct Game<'a> { /// LocalPlayer (joining, respawning, changing dimension) starts at 0. sway: horizon_client::first_person::HandSway, visible_hands: [(usize, Option); 2], + /// Whether the main and off hand's slots were set to a new stack that + /// the hand doesn't show yet (`Game::hand_stack_replaced`). + hands_replaced: [bool; 2], /// `Minecraft`'s attack handling, attack clicks since the last tick and /// the attack strength tickers the hand reads. clicks: horizon_client::first_person::Clicks, @@ -12695,6 +12698,8 @@ impl Game<'_> { // handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for // the off hand, then the main hand. std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]); + self.hand_stack_replaced(self.selected); + self.hand_stack_replaced(shield::OFFHAND_SLOT); let (offhand, main) = (self.offhand.clone(), self.inventory[self.selected].clone()); self.player_on_equip("offhand", main.as_ref(), offhand.as_ref()); self.player_on_equip("mainhand", offhand.as_ref(), main.as_ref()); @@ -14408,6 +14413,7 @@ impl<'a> Game<'a> { heights: Default::default(), sway: Default::default(), visible_hands: [(0, None), (0, None)], + hands_replaced: [false; 2], clicks: Default::default(), attack_clicks: 0, hand_strength: Default::default(), diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c241208948..9877151fb6 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -58,7 +58,53 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo distance >= f64::from(min - margin) && distance <= f64::from(max + margin) } +/// `FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem`'s +/// `ItemStack.matchesIgnoringComponents(visible, held, +/// DataComponentType::ignoreSwapAnimation)` for one hand (every item plays +/// the swap animation, `ItemModelResolver.shouldPlaySwapAnimation`): the same +/// stack object, or an equal one, whose damage (the one component built +/// with `ignoreSwapAnimation`) may differ. +/// +/// A stack that stays in its slot with the same item is the same object, +/// whatever its count or damage (vanilla shrinks, grows and damages it in +/// place), unless the slot was `replaced` by one the hand doesn't show yet: +/// swapping the hands (`setItemInHand`, and the server's slot updates that +/// answer it) puts another object in both (#2866). +fn shows_held_stack( + visible: &(usize, Option), + held: &(usize, Option), + replaced: bool, +) -> bool { + let same_object = visible.0 == held.0 && !replaced; + match (&visible.1, &held.1) { + (Some(a), Some(b)) if same_object => a.item == b.item, + (a, b) => matches_for_swap(a.as_ref(), b.as_ref()), + } +} + +/// `ItemStack.matchesIgnoringComponents(a, b, +/// DataComponentType::ignoreSwapAnimation)`: the same count, item and +/// components but damage. +pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a.count == b.count && a.item == b.item && a.extra == b.extra, + _ => false, + } +} + impl Game<'_> { + /// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to + /// a new stack: the hand holding it compares it as another `ItemStack` + /// (`shows_held_stack`) until the hand shows the held stack again. + pub(super) fn hand_stack_replaced(&mut self, slot: usize) { + if slot == self.selected { + self.hands_replaced[0] = true; + } + if slot == super::shield::OFFHAND_SLOT { + self.hands_replaced[1] = true; + } + } /// Opening a screen stops breaking before the next game tick (which may pause). pub(super) fn stop_mining(&mut self) { if self.clicks.destroying.active { @@ -266,22 +312,13 @@ impl Game<'_> { } self.attack_item = main.as_ref().map(|s| s.item.clone()); let next = [(self.selected, main), (OFFHAND, self.offhand.clone())]; - // shouldInstantlyReplaceVisibleItem: the same stack, or an equal one - // (matchesIgnoringComponents; every item plays the swap animation). - // A stack that stays in its slot with the same item is the same - // stack, whatever its count (vanilla shrinks and grows it in place). let mut changed = [false; 2]; for h in 0..2 { - let same = match (&self.visible_hands[h], &next[h]) { - ((_, None), (_, None)) => true, - ((slot, Some(a)), (next_slot, Some(b))) => { - a.item == b.item - && (slot == next_slot || (a.count == b.count && a.extra == b.extra)) - } - _ => false, - }; + let replaced = self.hands_replaced[h]; + let same = shows_held_stack(&self.visible_hands[h], &next[h], replaced); if same { self.visible_hands[h] = next[h].clone(); + self.hands_replaced[h] = false; } changed[h] = !same; } @@ -294,6 +331,7 @@ impl Game<'_> { for h in 0..2 { if replace[h] { self.visible_hands[h] = next[h].clone(); + self.hands_replaced[h] = false; } } } @@ -661,6 +699,52 @@ mod tests { assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None); } + fn stack(item: &str, count: u32, damage: u32) -> Option { + Some(ItemStack { + item: item.into(), + count, + damage, + extra: None, + }) + } + + #[test] + fn a_swapped_in_stack_of_the_same_item_re_equips() { + // #2866: F swaps 64 torches into the off hand that held 32. The off + // hand's slot stays, but the server's slot update puts another stack + // object there; a different count fails matchesIgnoringComponents, + // so the hand plays the swap animation. + let visible = (OFFHAND, stack("minecraft:torch", 32, 0)); + let held = (OFFHAND, stack("minecraft:torch", 64, 0)); + assert!(!shows_held_stack(&visible, &held, true)); + // The same stack shrunk in place (a torch placed): no animation. + assert!(shows_held_stack(&visible, &held, false)); + // An equal stack replaced it: shown at once. + assert!(shows_held_stack(&visible, &visible.clone(), true)); + // DataComponents.DAMAGE ignores the swap animation: a sword with + // other damage swapped in shows at once. + let sword = (OFFHAND, stack("minecraft:iron_sword", 1, 3)); + let worn = (OFFHAND, stack("minecraft:iron_sword", 1, 40)); + assert!(shows_held_stack(&sword, &worn, true)); + // Another component (an anvil's repair cost) differs. + let mut named = worn.clone(); + named.1.as_mut().unwrap().extra = Some(Box::new(horizon_client::item_data::StackData { + repair_cost: 1, + ..Default::default() + })); + assert!(!shows_held_stack(&sword, &named, true)); + // The main hand changing hotbar slot to an equal stack: instant; to + // a different count: animation. + let (a, b) = ( + (0, stack("minecraft:torch", 32, 0)), + (1, stack("minecraft:torch", 32, 0)), + ); + assert!(shows_held_stack(&a, &b, false)); + let c = (1, stack("minecraft:torch", 5, 0)); + assert!(!shows_held_stack(&a, &c, false)); + assert!(shows_held_stack(&(0, None), &(OFFHAND, None), true)); + } + #[test] fn attack_range_uses_inclusive_float_bounds() { let range = serde_json::json!({ diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 848667dae0..3fc5a11f9d 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -211,6 +211,9 @@ pub(super) struct Remote { /// inventory): like the click queue, they wait for the tick's /// `handleKeybinds` phase rather than going out between window events. pub(super) pending_keys: Vec, + /// Whether the main and off hand still wait for the server's answer to + /// a SWAP_ITEM_WITH_OFFHAND (`remote_hand_slot_set`). + swap_answer: [bool; 2], stop_mining: bool, placing: Option<([i32; 3], u64)>, /// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one. @@ -524,6 +527,7 @@ impl Game<'_> { destroy_delay: 0, input: Default::default(), pending_keys: Vec::new(), + swap_answer: [false; 2], stop_mining: false, placing: None, flat: false, @@ -1800,7 +1804,14 @@ impl Game<'_> { if internal == 200 { return; // the result slot is recomputed from the grid } - *self.slot_mut(internal) = item; + let slot = self.slot_mut(internal); + let changed = !super::hand::matches_for_swap(slot.as_ref(), item.as_ref()); + *slot = item; + if internal < 36 { + self.remote_hand_slot_set(internal, changed); + } else if internal == 504 { + self.remote_hand_slot_set(super::shield::OFFHAND_SLOT, changed); + } if internal >= super::stations::INPUT && self.station.is_some() { self.remote_station_slot_changed(internal); } @@ -2836,6 +2847,15 @@ impl Game<'_> { let stack = self.to_stack(&p.item); self.tutorial_get_item(stack.as_ref()); let before = self.creative_stacks(); + let held = match p.slot { + 0..=35 => self.inventory[p.slot as usize].as_ref(), + 40 => self.offhand.as_ref(), + _ => None, + }; + let changed = !super::hand::matches_for_swap(held, stack.as_ref()); + if let Ok(slot) = usize::try_from(p.slot) { + self.remote_hand_slot_set(slot, changed); + } match p.slot { 0..=35 => self.inventory[p.slot as usize] = stack, 36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack, @@ -5909,6 +5929,35 @@ impl Game<'_> { direction: 0, sequence: 0, })); + // Equal stacks swap with no slot update to wait for. + let (main, off) = (self.stack_in(self.selected), self.offhand.as_ref()); + let moves = !super::hand::matches_for_swap(main, off); + if let Some(r) = self.remote.as_mut() { + r.swap_answer = [moves; 2]; + } + } + /// The server set inventory `slot` (`shield::OFFHAND_SLOT` for the off + /// hand), `changed` from the stack this client held there. The stack + /// that answers a swap (`setItemInHand`) is another object, which the + /// hand shows after the swap animation (`hand_stack_replaced`); other + /// updates of a hand's slot count as the held stack changing in place, + /// as this client doesn't predict every use the way vanilla's does + /// (a thrown snowball's stack shrinks only when the server says so). + fn remote_hand_slot_set(&mut self, slot: usize, changed: bool) { + let hand = if slot == super::shield::OFFHAND_SLOT { + 1 + } else if slot == self.selected { + 0 + } else { + return; + }; + let answer = self + .remote + .as_mut() + .is_some_and(|r| std::mem::take(&mut r.swap_answer[hand])); + if answer && changed { + self.hand_stack_replaced(slot); + } } /// A container click: the server answers with the authoritative slots. pub(super) fn remote_click(&mut self, right: bool) { From 8c079ed9d7bd6fc0a8adce9a1d2ca0f7039c1953 Mon Sep 17 00:00:00 2001 From: Senjin Date: Fri, 30 Oct 2026 00:25:33 +0100 Subject: [PATCH 192/968] HDR Windows Auto: builds on Windows; the peak is never below Paper White log_display was still Linux-only, so this didn't build on Windows (found and fixed by martipops's test). Windows' SDR brightness slider can put white above the reported peak (456 against a calibrated 380 on a Samsung QD-OLED): Auto's peak is now at least its Paper White, so highlights stay at white instead of dropping below it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/gfx.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index a06b51bf20..a0ff70d342 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -5758,7 +5758,15 @@ mod tests { os_reported(&info(Some(120.0), Some(1000.0))), Some(Reported { paper_white: 480, - peak: 200 + peak: 480 + }) + ); + // White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456). + assert_eq!( + os_reported(&info(Some(380.0), Some(456.0))), + Some(Reported { + paper_white: 456, + peak: 456 }) ); assert_eq!(os_reported(&info(Some(1000.0), None)), None); @@ -6266,8 +6274,6 @@ static DISPLAY_LOG: std::sync::Mutex = std::sync::Mutex::new(Display pending: None, }); const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1); -// Only Wayland's HDR reports call it so far. -#[cfg(target_os = "linux")] fn log_display(line: String) { let now = std::time::Instant::now(); let mut log = DISPLAY_LOG @@ -6316,9 +6322,12 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option let usable = |n: f32| n.is_finite() && n > 0.0; let peak = luminance.max_nits.filter(|&n| usable(n))?; let white = luminance.sdr_white_nits.filter(|&n| usable(n))?; + let paper_white = (white.round() as u32).clamp(80, 480); Some(crate::graphics::Reported { - paper_white: (white.round() as u32).clamp(80, 480), - peak: (peak.round() as u32).clamp(200, 10_000), + paper_white, + // Windows' SDR brightness slider can set white above the reported peak (480 against + // a calibrated 380): the highlights then stay at white instead of dropping below it. + peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white), }) } From 691f958bc48aca2224aae796d742de0e3f237e4d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:27:00 +0100 Subject: [PATCH 193/968] Merge main: log_display serves Windows too; DisplayWatch built field by field Main limited log_display to Linux, where only Wayland called it; the OS poll calls it on every platform now. On macOS and Windows the struct has only `polled`, so `..Self::default()` was clippy's needless_update. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/gfx.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index aeb3b287d4..e1afe5996f 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -6238,8 +6238,9 @@ impl DisplayWatch { let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter))); log_os_reported(now); let watch = Self { + #[cfg(target_os = "linux")] + wayland: None, polled: Some((now, std::time::Instant::now() + DISPLAY_POLL)), - ..Self::default() }; (watch, now) } @@ -6284,8 +6285,6 @@ static DISPLAY_LOG: std::sync::Mutex = std::sync::Mutex::new(Display pending: None, }); const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1); -// Only Wayland's HDR reports call it so far. -#[cfg(target_os = "linux")] fn log_display(line: String) { let now = std::time::Instant::now(); let mut log = DISPLAY_LOG From 8c1f50f616436d32b523f82c69fdfd72562bba18 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:25:59 +0700 Subject: [PATCH 194/968] The Void superflat preset gets its stone start platform FlatLevelGeneratorSettings.adjustGenerationSettings decorates a flat world with its biome's placed features when `features` is on and the world isn't all air, or its biome is the_void. The Void preset (the_void, features, one air layer) so places the_void's only feature, void_start_platform: a 33x33 stone square with cobblestone at 8, -61, 8. FlatTerrain placed only the layers, so players fell into the void. Biomes now keep each biome's `features` by step, FlatTerrain works out the adjusted list and places void_start_platform (the ported VoidStartPlatformFeature) when it is there. The other features, lakes and structures are still not placed (#2912). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/biome.rs | 16 ++ crates/horizon-client/src/flatgen.rs | 192 +++++++++++++++++++++- 2 files changed, 205 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client-render/src/biome.rs b/crates/horizon-client-render/src/biome.rs index 4538166fa0..73e760f127 100644 --- a/crates/horizon-client-render/src/biome.rs +++ b/crates/horizon-client-render/src/biome.rs @@ -102,6 +102,9 @@ pub struct BiomeInfo { /// The biome's own fog colour attribute; otherwise the dimension's applies. pub fog: Option, pub water_fog: u32, + /// `BiomeGenerationSettings.features`: the placed feature ids of each + /// `GenerationStep.Decoration` step, in order. + pub features: Vec>, } impl BiomeInfo { /// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler @@ -235,6 +238,19 @@ impl Biomes { fog: color(&attributes["minecraft:visual/fog_color"]), water_fog: color(&attributes["minecraft:visual/water_fog_color"]) .unwrap_or(0x050533), + features: v["features"] + .as_array() + .into_iter() + .flatten() + .map(|step| { + step.as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .collect(), name, }); } diff --git a/crates/horizon-client/src/flatgen.rs b/crates/horizon-client/src/flatgen.rs index 339f1fca1c..14c4420dd2 100644 --- a/crates/horizon-client/src/flatgen.rs +++ b/crates/horizon-client/src/flatgen.rs @@ -1,17 +1,69 @@ //! FlatLevelSource: superflat worlds (`minecraft:flat` generator). use crate::{chunk::Chunk, streaming::ChunkGenerator}; -use horizon_world::{nbt::Tag, registry::BlockRegistry}; +use horizon_world::{ + nbt::Tag, + registry::{BlockRegistry, BlockStateId}, + worldgen::feature::{BasicFeature, FeatureBlocks, FeatureWorld}, +}; /// The layers of a flat generator, bottom up from the world's minimum Y. pub struct FlatTerrain { layers: Vec, biome: u16, surface: Option, + /// The blocks of `minecraft:void_start_platform`, when the adjusted + /// generation settings place it (the Void preset). + void_platform: Option, +} +/// `GenerationStep.Decoration` ordinals `adjustGenerationSettings` leaves out. +const LAKES: usize = 1; +const UNDERGROUND_STRUCTURES: usize = 3; +const SURFACE_STRUCTURES: usize = 4; +/// FlatLevelGeneratorSettings.adjustGenerationSettings for the world's own +/// biome: its placed features, by step, when `features` is on and the world +/// isn't only air (`voidGen`) or its biome is the_void. The structure steps +/// are left out, and the lakes step when `lakes` puts the lava lakes there. +fn decoration_features( + biome_features: &[Vec], + features: bool, + lakes: bool, + void_gen: bool, + the_void: bool, +) -> impl Iterator { + let decorate = (!void_gen || the_void) && features; + biome_features + .iter() + .enumerate() + .filter(move |&(step, _)| { + decorate + && step != UNDERGROUND_STRUCTURES + && step != SURFACE_STRUCTURES + && !(lakes && step == LAKES) + }) + .flat_map(|(_, step)| step.iter().map(String::as_str)) +} +/// The proto chunk as the world a feature writes in. +struct ChunkWorld<'a>(&'a mut Chunk); +impl FeatureWorld for ChunkWorld<'_> { + type Error = std::convert::Infallible; + fn block(&mut self, [x, y, z]: [i32; 3]) -> Result { + Ok(self.0.block(x, y, z)) + } + fn set_block( + &mut self, + [x, y, z]: [i32; 3], + state: BlockStateId, + _: i32, + ) -> Result { + self.0.set(x, y, z, state.0); + Ok(true) + } } impl FlatTerrain { /// The overworld generator of `world_gen_settings` (`data.dimensions`), if it - /// is flat. Its `features` and `lakes` decorate the world; a world without - /// them (the default) gets only its layers, as FlatLevelSource places them. + /// is flat. Its layers, as FlatLevelSource places them, and of the features + /// its settings decorate the world with, `void_start_platform`; the other + /// features and the lakes are not placed yet. pub fn from_settings( settings: &Tag, registry: &BlockRegistry, @@ -36,6 +88,29 @@ impl FlatTerrain { } } let biome = biomes.id(&biome); + // FlatLevelGeneratorSettings.updateLayers: every layer is Blocks.AIR. + let void_gen = layers.iter().all(|&id| { + registry + .state(BlockStateId(id)) + .is_some_and(|s| s.name == "minecraft:air") + }); + let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0); + let info = biomes.get(biome); + let void_platform = if decoration_features( + &info.features, + flag("features"), + flag("lakes"), + void_gen, + info.name == "minecraft:the_void", + ) + .any(|f| f == "minecraft:void_start_platform") + { + FeatureBlocks::from_registry(registry) + .inspect_err(|e| log::warn!("Horizon: no void_start_platform: {e}")) + .ok() + } else { + None + }; let mut water = None; let mut surface = None; for (i, &id) in layers @@ -69,6 +144,7 @@ impl FlatTerrain { layers, biome, surface, + void_platform, }) } /// FlatLevelSource.getSpawnHeight: the top of the layers. @@ -207,6 +283,21 @@ impl ChunkGenerator for FlatTerrain { } } } + // ChunkGenerator.applyBiomeDecoration at the chunk's section origin. + // VoidStartPlatformFeature writes only inside its own chunk and draws + // no random numbers, so placing it here gives the FEATURES result. + if let Some(blocks) = self.void_platform { + let origin = [cx * 16, crate::chunk::MIN_Y, cz * 16]; + let mut random = horizon_core::random::LegacyRandom::new(0); + if let Err(e) = BasicFeature::VoidStartPlatform.place( + &mut ChunkWorld(&mut chunk), + &mut random, + origin, + blocks, + ) { + log::warn!("Horizon: placing void_start_platform failed: {e}"); + } + } for s in &mut chunk.sections { std::sync::Arc::make_mut(s).compact(); } @@ -233,6 +324,101 @@ mod tests { ) } + /// A world_gen_settings with a flat overworld of `layers` (block, height). + fn flat_world(biome: &str, features: bool, layers: &[(&str, i32)]) -> Tag { + let layers = layers + .iter() + .map(|&(block, height)| { + Tag::compound([ + ("block", Tag::String(block.into())), + ("height", Tag::Int(height)), + ]) + }) + .collect(); + let settings = Tag::compound([ + ("biome", Tag::String(biome.into())), + ("features", Tag::Byte(i8::from(features))), + ("lakes", Tag::Byte(0)), + ("layers", Tag::List(layers)), + ]); + let generator = Tag::compound([ + ("type", Tag::String("minecraft:flat".into())), + ("settings", settings), + ]); + Tag::compound([( + "dimensions", + Tag::compound([( + "minecraft:overworld", + Tag::compound([("generator", generator)]), + )]), + )]) + } + + #[test] + fn the_void_preset_places_the_start_platform() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let registry = assets.registry; + let biomes = crate::biome::Biomes::load(&root).unwrap(); + let terrain = |biome, features, layers: &[(&str, i32)]| { + FlatTerrain::from_settings(&flat_world(biome, features, layers), registry, &biomes) + .unwrap() + }; + let block = |terrain: &FlatTerrain, [x, y, z]: [i32; 3]| { + let chunk = terrain.finish(terrain.proto(&mut (), x >> 4, z >> 4)); + registry.state(chunk.block(x, y, z)).unwrap().name.clone() + }; + // The Void preset (flat_level_generator_preset/the_void): the_void's + // only feature, VoidStartPlatformFeature, at the minimum Y + 3: a + // 33×33 stone square around 8, 8 with cobblestone at its centre, in + // the chunks within one of chunk 0, 0. + let void = terrain("minecraft:the_void", true, &[("minecraft:air", 1)]); + for (pos, expected) in [ + ([8, -61, 8], "minecraft:cobblestone"), + ([0, -61, 0], "minecraft:stone"), + ([-8, -61, -8], "minecraft:stone"), + ([24, -61, 24], "minecraft:stone"), + ([24, -61, -8], "minecraft:stone"), + ([-9, -61, 8], "minecraft:air"), + ([25, -61, 8], "minecraft:air"), + ([8, -60, 8], "minecraft:air"), + ([8, -62, 8], "minecraft:air"), + ([40, -61, 8], "minecraft:air"), + ] { + assert_eq!(block(&void, pos), expected, "{pos:?}"); + } + // The decoration is off without `features`. + let plain = terrain("minecraft:the_void", false, &[("minecraft:air", 1)]); + assert_eq!(block(&plain, [8, -61, 8]), "minecraft:air"); + // the_void over solid layers still gets its platform. + let floor = terrain("minecraft:the_void", true, &[("minecraft:bedrock", 1)]); + assert_eq!(block(&floor, [8, -64, 8]), "minecraft:bedrock"); + assert_eq!(block(&floor, [8, -61, 8]), "minecraft:cobblestone"); + } + + #[test] + fn adjusted_generation_settings_keep_the_biome_features_outside_structures() { + let steps: Vec> = (0..11).map(|step| vec![format!("f{step}")]).collect(); + let kept = |features, lakes, void_gen, the_void| { + decoration_features(&steps, features, lakes, void_gen, the_void) + .map(str::to_owned) + .collect::>() + }; + let all_but_structures = ["f0", "f1", "f2", "f5", "f6", "f7", "f8", "f9", "f10"]; + assert_eq!(kept(true, false, false, false), all_but_structures); + // The lava lakes take the LAKES step's place. + assert_eq!(kept(true, true, false, false)[1..], all_but_structures[2..]); + // An all-air world is decorated only in the_void. + assert!(kept(true, false, true, false).is_empty()); + assert_eq!(kept(true, false, true, true), all_but_structures); + assert!(kept(false, false, false, true).is_empty()); + } + #[test] fn generator_settings_read_as_flat_level_generator_settings() { // server.properties' default: the preset's own layers, and the codec's error. From 5ee04257c5f88d84f123eebf622266458dc003bf Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:35:23 +0700 Subject: [PATCH 195/968] Wet wolves darken and shake off the water on clients A wolf's wet and shake state lived only in the server's ai_step, so a client of a server (the default single player on its server thread, a LAN guest) never saw a wolf darken in water or rain, nor shake dry. Split Wolf.tick's wet and shaking part out so both sides run it: the client knows on its own when the wolf is in water or rain (isWet), and follows the server's entity events 8 (start shaking, broadcast by Wolf.aiStep) and 56 (cancel, from Wolf.tick back in water or rain) in Wolf.handleEntityEvent. The shake now starts and plays its sound in the same tick as vanilla, and the splash offsets come from the wolf's random on both sides, as Wolf.tick draws them. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 20 +- crates/horizon-client/src/mobs/sync.rs | 1 + crates/horizon-client/src/mobs/tame.rs | 228 +++++++++++++++++++++-- 3 files changed, 225 insertions(+), 24 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 416b73af14..aa9eb9aad3 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4309,7 +4309,8 @@ impl Game<'_> { // server never sends the fluid contact, and the renderers read it (cod, salmon and // tropical fish lie on their side without `in_water`, #1591). // A frozen level ticks only the players, as below. - let (fluids, thundering): (Vec<_>, bool) = { + // Entity.isInRain for the wolves, which dry off by their own `Wolf.tick`. + let (fluids, rain, thundering): (Vec<_>, Vec<_>, bool) = { let w = self.mob_world_base(&[]); let players = self.remote.as_ref().map(|r| &r.level_players); let fluids = self @@ -4320,7 +4321,15 @@ impl Game<'_> { .then(|| w.fluids(m.bounds(), m.eye_y())) }) .collect(); - (fluids, w.thundering) + let rain = self + .mobs + .iter() + .map(|m| { + m.species() == horizon_client::mobs::Species::Wolf + && horizon_client::mobs::tame::in_rain(m, &w) + }) + .collect(); + (fluids, rain, w.thundering) }; for (m, fluids) in self.mobs.iter_mut().zip(fluids) { if let Some(fluids) = fluids { @@ -4365,7 +4374,7 @@ impl Game<'_> { let players = &r.level_players; let mut using = Vec::new(); let mut mob_fx = Vec::new(); - for m in &mut self.mobs { + for (m, rain) in self.mobs.iter_mut().zip(rain) { let player = players.contains_key(&(m.id as i32)); if !running && !player { continue; @@ -4406,8 +4415,11 @@ impl Game<'_> { } // The particles these mobs' own ticks draw on the client side. { - use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither}; + use horizon_client::mobs::{ + Species as S, cube, illusioner, nautilus, tame, wither, + }; match m.species() { + S::Wolf => tame::client_tick(m, rain, &mut mob_fx), S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx), S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx), S::Illusioner => illusioner::client_tick(m, &mut mob_fx), diff --git a/crates/horizon-client/src/mobs/sync.rs b/crates/horizon-client/src/mobs/sync.rs index b56546c59e..964c8c2799 100644 --- a/crates/horizon-client/src/mobs/sync.rs +++ b/crates/horizon-client/src/mobs/sync.rs @@ -126,6 +126,7 @@ pub fn entity_event(m: &mut Mob, event: u8) { Species::Hoglin | Species::Zoglin => piglin::client_event(m, event), Species::IronGolem => golem::client_event(m, event), Species::Creaking => creaking::entity_event(m, event), + Species::Wolf => tame::entity_event(m, event), _ => {} } } diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 97fdaeb222..d93b1d351b 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -654,42 +654,106 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } interest_tick(m); // isWet (extra.flag2), isShaking (counter2 > 0). - let rain = super::ai::in_rain(m, w); + let wet = m.fluids.in_water || super::ai::in_rain(m, w); + server_shake(m, wet, events); +} +/// A server's wolf: `Wolf.aiStep` starts shaking a wet wolf that stands on +/// the ground and is not pathing, and tells the clients (entity event 8), +/// then `Wolf.tick` runs the shake (`shake_tick`). `wet` is +/// `isInWaterOrRain`. +pub fn server_shake(m: &mut Mob, wet: bool, events: &mut Vec) { + if m.extra.flag2 && m.extra.counter2 == 0 && m.ai.nav.is_done() && m.on_ground { + start_shaking(m); + broadcast(m, events, 8); + } + shake_tick(m, wet, true, events); +} +/// `Wolf.tick` for a wolf of a server (`rain` is `isInRain` at the wolf): +/// a client knows it is wet on its own, starts and cancels the shake from +/// the server's entity events (`entity_event`) and draws the splashes. +pub fn client_tick(m: &mut Mob, rain: bool, events: &mut Vec) { + if m.alive() { + shake_tick(m, m.fluids.in_water || rain, false, events); + } +} +/// `Entity.isInRain`, for a client's wolves. +pub fn in_rain(m: &Mob, w: &MobWorld) -> bool { + super::ai::in_rain(m, w) +} +/// `Wolf.handleEntityEvent`: 8 starts the shake, 56 cancels it. +pub fn entity_event(m: &mut Mob, event: u8) { + match event { + 8 => start_shaking(m), + 56 => cancel_shake(m), + _ => {} + } +} +/// `isShaking = true`, `shakeAnim` and `shakeAnimO` from 0. +fn start_shaking(m: &mut Mob) { + m.extra.counter2 = 1; + m.anim.v[SHAKE] = 0.0; + m.anim.vo[SHAKE] = 0.0; +} +/// `Wolf.cancelShake`: it stays wet. +fn cancel_shake(m: &mut Mob) { + m.extra.counter2 = 0; + m.anim.v[SHAKE] = 0.0; + m.anim.vo[SHAKE] = 0.0; +} +fn broadcast(m: &Mob, events: &mut Vec, event: i8) { + events.push(MobEvent::EntityEvent { + mob: m.id, + event, + at: [m.position.x, m.position.y, m.position.z], + }); +} +/// The wet and shaking part of `Wolf.tick`. In water or rain the wolf is +/// wet, and a server cancels its shake (entity event 56). Out of it a +/// shaking wolf shakes for 2 (`shakeAnim` by 0.05 a tick), dry at the end, +/// with splashes past 0.4 (drawn by a client). The sound is the server's: +/// `Entity.playSound` plays nothing on a client (`ClientLevel.playSound` +/// with no player). +fn shake_tick(m: &mut Mob, wet: bool, server: bool, events: &mut Vec) { let shaking = m.extra.counter2 > 0; - if m.fluids.in_water || rain { + if wet { m.extra.flag2 = true; - if shaking { - m.extra.counter2 = 0; - m.anim.v[SHAKE] = 0.0; - m.anim.vo[SHAKE] = 0.0; + if shaking && server { + broadcast(m, events, 56); + cancel_shake(m); } } else if shaking { if m.anim.v[SHAKE] == 0.0 { let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; - events.push(MobEvent::Sound("entity.wolf.shake", m.center(), 0.4, pitch)); + if server { + events.push(MobEvent::Sound("entity.wolf.shake", m.center(), 0.4, pitch)); + } } + m.anim.vo[SHAKE] = m.anim.v[SHAKE]; m.anim.v[SHAKE] += 0.05; if m.anim.vo[SHAKE] >= 2.0 { m.extra.flag2 = false; - m.extra.counter2 = 0; - m.anim.v[SHAKE] = 0.0; - m.anim.vo[SHAKE] = 0.0; + cancel_shake(m); } if m.anim.v[SHAKE] > 0.4 { - let count = (((m.anim.v[SHAKE] - 0.4) * PI).msin() * 7.0) as u32; - if count > 0 { - events.push(MobEvent::Particles { + // The server draws the offsets too (its addParticle draws nothing). + let y = m.position.y as f32 + 0.8; + let count = (((m.anim.v[SHAKE] - 0.4) * PI).msin() * 7.0) as i32; + let half = m.width() * 0.5; + let v = m.velocity; + for _ in 0..count { + let dx = (m.random.next_float() * 2.0 - 1.0) * half; + let dz = (m.random.next_float() * 2.0 - 1.0) * half; + events.push(MobEvent::ParticleAt { kind: "splash", - at: [m.position.x, m.position.y + 0.8, m.position.z], - count, + at: [ + m.position.x + f64::from(dx), + f64::from(y), + m.position.z + f64::from(dz), + ], + velocity: [v.x, v.y, v.z], }); } } - } else if m.extra.flag2 && m.ai.nav.is_done() && m.on_ground { - // Wolf.aiStep: start shaking when wet, standing and not pathing. - m.extra.counter2 = 1; - m.anim.v[SHAKE] = 0.0; - m.anim.vo[SHAKE] = 0.0; } } /// `Wolf.hurtServer`: stop sitting. The anger comes with the target @@ -1549,3 +1613,127 @@ pub fn feline(p: &mut Poses, s: &State, st: &Feline) { p.xrot("head", rot_lerp(relax, hx, -0.581_776_44)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::sensor_scan_tests::kind; + use std::sync::Arc; + + const WHITE: u32 = 0xFFFF_FFFF; + + fn wolf() -> Mob { + let mut kind = kind(); + let k = Arc::make_mut(&mut kind); + k.id = "minecraft:wolf".into(); + k.species = Species::Wolf; + k.width = 0.6; + k.height = 0.85; + let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0); + m.on_ground = true; + m + } + fn entity_events(events: &[MobEvent]) -> Vec { + events + .iter() + .filter_map(|e| match e { + MobEvent::EntityEvent { event, .. } => Some(*event), + _ => None, + }) + .collect() + } + + /// A wolf of a server (the default single player's server thread, a LAN + /// guest): `Wolf.tick` makes it wet in water on the client too, which + /// `getWetShade` darkens; entity event 8 starts the shake, which the + /// client runs to the end, splashing, and then the wolf is dry (#2885). + #[test] + fn a_wolf_of_a_server_darkens_in_water_and_shakes_dry_on_its_event() { + let mut m = wolf(); + let mut events = Vec::new(); + m.fluids.in_water = true; + client_tick(&mut m, false, &mut events); + // 0.75 of the colour: 191. + assert_eq!(tint(&m, 1.0), 0xFFBF_BFBF, "wet in water"); + m.fluids.in_water = false; + client_tick(&mut m, false, &mut events); + assert_eq!(tint(&m, 1.0), 0xFFBF_BFBF, "still wet before it shakes"); + assert_eq!(m.anim.v[SHAKE], 0.0); + // Rain wets it as well. + let mut dry = wolf(); + client_tick(&mut dry, true, &mut events); + assert_ne!(tint(&dry, 1.0), WHITE, "wet in the rain"); + + crate::mobs::sync::entity_event(&mut m, 8); + let mut ticks = 0; + while m.extra.flag2 && ticks < 100 { + client_tick(&mut m, false, &mut events); + ticks += 1; + if ticks == 1 { + assert!((m.anim.v[SHAKE] - 0.05).abs() < 1e-6); + assert_eq!(m.anim.vo[SHAKE], 0.0); + } + } + // shakeAnimO reaches 2 on the 41st tick. + assert!((41..=42).contains(&ticks), "shook for {ticks} ticks"); + assert_eq!(tint(&m, 1.0), WHITE, "dry after the shake"); + assert_eq!((m.extra.counter2, m.anim.v[SHAKE]), (0, 0.0)); + assert!( + events + .iter() + .any(|e| matches!(e, MobEvent::ParticleAt { kind: "splash", .. })), + "the client draws the splashes" + ); + assert!( + !events.iter().any(|e| matches!(e, MobEvent::Sound(..))), + "the shake sound is the server's" + ); + } + + /// `Wolf.handleEntityEvent(56)`: back in the water, the server cancels + /// the shake; the wolf stays wet. A client does not cancel on its own. + #[test] + fn a_wolf_of_a_server_stops_shaking_on_its_cancel_event() { + let mut m = wolf(); + let mut events = Vec::new(); + m.fluids.in_water = true; + client_tick(&mut m, false, &mut events); + m.fluids.in_water = false; + crate::mobs::sync::entity_event(&mut m, 8); + for _ in 0..10 { + client_tick(&mut m, false, &mut events); + } + assert!(m.anim.v[SHAKE] > 0.4); + m.fluids.in_water = true; + client_tick(&mut m, false, &mut events); + assert!(m.extra.counter2 > 0, "only the server cancels"); + crate::mobs::sync::entity_event(&mut m, 56); + assert_eq!((m.extra.counter2, m.anim.v[SHAKE]), (0, 0.0)); + assert_ne!(tint(&m, 1.0), WHITE, "still wet"); + } + + /// `Wolf.aiStep` broadcasts entity event 8 when a wet wolf standing on + /// the ground starts shaking, and `Wolf.tick` event 56 when water or rain + /// cancels it: the clients' only way to know. + #[test] + fn a_server_tells_the_clients_when_a_wolf_shakes() { + let mut m = wolf(); + let mut events = Vec::new(); + server_shake(&mut m, true, &mut events); + assert!(m.extra.flag2); + // Out of the water: it starts and makes its sound in the same tick. + server_shake(&mut m, false, &mut events); + assert_eq!(entity_events(&events), [8]); + assert!(m.extra.counter2 > 0); + assert!((m.anim.v[SHAKE] - 0.05).abs() < 1e-6); + assert!( + events + .iter() + .any(|e| matches!(e, MobEvent::Sound("entity.wolf.shake", ..))) + ); + server_shake(&mut m, true, &mut events); + assert_eq!(entity_events(&events), [8, 56]); + assert_eq!((m.extra.counter2, m.anim.v[SHAKE]), (0, 0.0)); + assert!(m.extra.flag2, "still wet"); + } +} From 0b0a91e4d435605710ebd0c5ee21aebe821aa7d4 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:35:24 +0700 Subject: [PATCH 196/968] Mob AI parity: a wolf shaking dry and a wolf in the rain Co-Authored-By: Claude Opus 5.5 --- tools/test_mob_ai_parity.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/test_mob_ai_parity.py b/tools/test_mob_ai_parity.py index 5bd4d0f319..2d4cdcad49 100644 --- a/tools/test_mob_ai_parity.py +++ b/tools/test_mob_ai_parity.py @@ -304,6 +304,10 @@ def scenarios(): # NeutralMob.updatePersistentAnger in Wolf.aiStep restarts the 20-39 s timer, one random draw, # every tick it has one, not once when hurt. add('wolf_hurt', 200, ['player 5.5 -60 0.5', 'mob minecraft:wolf 0.5 -60 0.5 0 181 persistent', 'hurt 20 0']) + # A wet wolf (#2885): Wolf.aiStep starts the shake of a wet wolf standing still on the ground, + # Wolf.tick plays its sound (two draws) the same tick and cancels it again in water or rain. + add('wolf_shake', 200, ['fill 0 -61 0 0 -61 0 minecraft:water', 'mob minecraft:wolf 0.5 -61 0.5 0 183 persistent']) + add('wolf_rain', 100, ['mob minecraft:wolf 0.5 -60 0.5 0 184 persistent'], 'rain') # Mobs dropped into deep water (#542): FloatGoal users float at the surface (their navigation # can float, so Mob.jumpInLiquid adds 0.04 a tick), zombies sink. for species, seed in [('pig', 171), ('cow', 172), ('wolf', 173), ('cat', 174), ('horse', 175), From 6b8e3b8f5f61ef5d70bdcfb9f7d8314169b7e8fd Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:10:58 +0700 Subject: [PATCH 197/968] Runtime spawns run Mob.finalizeSpawn and wither skeletons hit for 4 Mobs spawned while the game runs (/summon, spawn eggs, spawners, natural spawns, raids) go through the reduced spawning::finalize, which skipped Mob.finalizeSpawn and WitherSkeleton.finalizeSpawn: - no mob got the random_spawn_bonus follow range modifier, and none was ever left-handed (vanilla: 5 percent); - wither skeletons kept the default 2.0 attack damage instead of 4.0. Move the Mob.finalizeSpawn body into finalize::mob_spawn, shared by the worldgen Ctx::mob, the summoned iron golem and now the runtime finalize (every mob but the cave spider, whose finalizeSpawn does not call super), and set the wither skeleton's attack damage base to 4.0. A bred baby (Animal.spawnChildFromBreeding: getBreedOffspring runs no finalizeSpawn) drops the bonus and the left hand again, as it already drops the sound variant. Part of #2783. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 2 + crates/horizon-client/src/mobs/finalize.rs | 35 +++++--- crates/horizon-client/src/mobs/spawning.rs | 92 ++++++++++++++++++++- crates/horizon-client/src/mobs/villager.rs | 13 +-- 4 files changed, 118 insertions(+), 24 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3e13432031..c41f13df38 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1995,6 +1995,8 @@ impl Game<'_> { && let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) { child.extra.age = -24000; + // No Mob.finalizeSpawn either: no follow range bonus, right-handed. + spawning::bred_offspring(child); // getBreedOffspring creates the baby without finalizeSpawn: it keeps // the default (`classic`) sound variant. child.extra.sound_variant = None; diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 771cd51764..2e62678f9d 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -624,6 +624,29 @@ pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) } refresh(m, a); } +/// `AttributeInstance.removeModifier`. +pub fn remove_modifier(m: &mut Mob, a: Attr, id: &str) { + if let Some(s) = m.extra.attributes.as_mut() { + s.modifiers.retain(|x| x.attr != a || x.id != id); + } + refresh(m, a); +} +/// `Mob.finalizeSpawn`, which every mob's own `finalizeSpawn` reaches +/// through `super`: the `random_spawn_bonus` follow range modifier (once) +/// and `setLeftHanded(nextFloat() < 0.05F)`. +pub fn mob_spawn(m: &mut Mob, random: &mut impl RandomSource) { + if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") { + let bonus = random.triangle(0.0, 0.114_850_000_000_000_01); + add_modifier( + m, + Attr::FollowRange, + "minecraft:random_spawn_bonus", + bonus, + Op::AddMultipliedBase, + ); + } + m.extra.left_handed = random.next_float() < 0.05; +} /// `Zombie.handleAttributes`, from the zombie's own random. pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) { let r = &mut m.random; @@ -995,17 +1018,7 @@ impl Ctx<'_, L, R> { /// `Mob.finalizeSpawn`: the follow range spawn bonus and left-handedness. fn mob(&mut self, m: &mut Mob, g: Option) -> Option { - if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") { - let bonus = self.random.triangle(0.0, 0.114_850_000_000_000_01); - add_modifier( - m, - Attr::FollowRange, - "minecraft:random_spawn_bonus", - bonus, - Op::AddMultipliedBase, - ); - } - m.extra.left_handed = self.float() < 0.05; + mob_spawn(m, self.random); g } /// `AgeableMob.finalizeSpawn`. diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index ab7b58fc06..d2004eb019 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -174,6 +174,13 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { if sounds > 0 { m.extra.sound_variant = Some(m.random.next_int_bound(sounds as i32).unwrap_or(0) as u8); } + // Mob.finalizeSpawn, reached through `super` by every mob but the cave + // spider (CaveSpider.finalizeSpawn returns without calling it). + if m.species() != S::CaveSpider { + let mut random = m.random.clone(); + super::finalize::mob_spawn(m, &mut random); + m.random = random; + } match m.species() { // Warden.finalizeSpawn: a minute before it digs back down. S::Warden => super::warden::finalize(m, false, &mut Vec::new()), @@ -253,10 +260,12 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { m.extra.set_main_hand(Some("minecraft:bow".into())); m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special(); } - // WitherSkeleton.populateDefaultEquipmentSlots: a stone sword only. + // WitherSkeleton.populateDefaultEquipmentSlots: a stone sword only; + // WitherSkeleton.finalizeSpawn then sets the attack damage base to 4. S::WitherSkeleton => { m.extra.set_main_hand(Some("minecraft:stone_sword".into())); m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special(); + super::finalize::set_base(m, super::finalize::Attr::AttackDamage, 4.0); } S::Pillager => { m.extra.set_main_hand(Some("minecraft:crossbow".into())); @@ -334,6 +343,14 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { ); } } +/// A baby from `Animal.spawnChildFromBreeding`: `getBreedOffspring` creates +/// it without `finalizeSpawn`, so it never ran `Mob.finalizeSpawn`. Undoes +/// that part of `finalize`: no `random_spawn_bonus`, right-handed. +pub fn bred_offspring(m: &mut Mob) { + use super::finalize::{Attr, remove_modifier}; + remove_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus"); + m.extra.left_handed = false; +} /// VillagerType.byBiome pub fn villager_type(b: &BiomeFacts) -> u8 { if b.desert { @@ -352,3 +369,76 @@ pub fn villager_type(b: &BiomeFacts) -> u8 { 0 } } +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::finalize::{Attr, has_modifier, value}; + + fn types() -> Option { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("items.json").exists() { + return None; + } + Some(crate::mobs::MobTypes::load(&root).unwrap()) + } + fn spawn(types: &crate::mobs::MobTypes, kind: &str, id: u32) -> Mob { + let kind = std::sync::Arc::new(types.types.get(kind).unwrap().clone()); + let mut m = Mob::new( + id, + kind, + horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5), + 0.0, + ); + let difficulty = LocalDifficulty::new(3, 0, 0, 1.0); + finalize(&mut m, &BiomeFacts::default(), &difficulty); + m + } + /// #2783: `/summon`, eggs, spawners and natural spawns run + /// `WitherSkeleton.finalizeSpawn`: attack damage base 4.0, not the + /// default 2.0. + #[test] + fn runtime_wither_skeletons_hit_for_four() { + let Some(types) = types() else { return }; + let w = spawn(&types, "minecraft:wither_skeleton", 1); + assert_eq!(value(&w, Attr::AttackDamage), 4.0); + assert_eq!(w.attrs.attack_damage, 4.0); + } + /// #2783: runtime spawns run `Mob.finalizeSpawn`: the follow range + /// bonus and 5 percent left-handed mobs. + #[test] + fn runtime_spawns_run_the_mob_finalize_spawn() { + let Some(types) = types() else { return }; + let mut left = 0; + for id in 1..=400 { + let m = spawn(&types, "minecraft:skeleton", id); + assert!(has_modifier( + &m, + Attr::FollowRange, + "minecraft:random_spawn_bonus" + )); + left += usize::from(m.extra.left_handed); + } + // 5 percent of 400 is 20. + assert!((5..=40).contains(&left), "{left} of 400 left-handed"); + // A bred baby never ran it. + let mut cow = spawn(&types, "minecraft:cow", 1); + cow.extra.left_handed = true; + bred_offspring(&mut cow); + assert!(!has_modifier( + &cow, + Attr::FollowRange, + "minecraft:random_spawn_bonus" + )); + assert!(!cow.extra.left_handed); + assert_eq!(cow.attrs.follow_range, value(&cow, Attr::FollowRange)); + // CaveSpider.finalizeSpawn does not call super. + let c = spawn(&types, "minecraft:cave_spider", 1); + assert!(!has_modifier( + &c, + Attr::FollowRange, + "minecraft:random_spawn_bonus" + )); + } +} diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index 6bdecedd70..a178807a20 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -790,18 +790,7 @@ pub fn summon_golem( /// `Mob.finalizeSpawn` for a summoned iron golem, from the level's random: /// the follow range spawn bonus and left-handedness. pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) { - use super::finalize::{Attr, Op, add_modifier, has_modifier}; - if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") { - let bonus = random.triangle(0.0, 0.114_850_000_000_000_01); - add_modifier( - m, - Attr::FollowRange, - "minecraft:random_spawn_bonus", - bonus, - Op::AddMultipliedBase, - ); - } - m.extra.left_handed = random.next_float() < 0.05; + super::finalize::mob_spawn(m, random); } /// `Villager.canBreed`: enough food, awake and an adult. pub fn can_breed(m: &Mob) -> bool { From f84b88b2e941858c95636b3b9286329b9291dcb9 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:33:38 +0700 Subject: [PATCH 198/968] Thrown pearls feel the water their box touches, as vanilla's isInWater Projectile::tick chose the water inertia (0.8) from the fluid of the block at the projectile's feet, every tick including the first. Vanilla's ThrowableProjectile.applyInertia and AbstractHurtingProjectile.applyInertia read Entity.isInWater (wasTouchingWater), which the previous tick's Entity.baseTick -> updateFluidInteraction set from the deflated bounding box after the move (EntityFluidInteraction.update: some water surface at or above the box's bottom), and which is still false on the first tick. So a pearl thrown under water lost a fifth of its speed on the first tick (it lands about 6.0 blocks away instead of 7.4), one skimming just above a pool slowed as if under water, and one whose box dipped into the next column's water did not slow at all. The projectile now keeps touching_water from the box after each move (ProjWorld reads it through MobWorld::fluids, the mobs' port of the same test); llama spit, whose base tick runs before its move, tests its box at the start of the tick. Client-side arrows test their box too. Part of #3072. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 129 +++++++++++++++++- .../src/game/remote_projectiles.rs | 4 +- crates/horizon-client/src/mobs/shulker.rs | 2 +- crates/horizon-client/src/projectiles.rs | 31 ++++- 4 files changed, 154 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 3e13432031..ef180a1418 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -107,12 +107,10 @@ impl ProjectileWorld for ProjWorld<'_, '_> { hit.direction.normal(), )) } - fn in_water(&self, at: Vec3) -> bool { - self.w.fluid( - at.x.floor() as i32, - at.y.floor() as i32, - at.z.floor() as i32, - ) == Fluid::Water + /// Entity.updateFluidInteraction over the projectile's box, not the + /// block at its feet (#3072). + fn in_water(&self, bounds: Aabb) -> bool { + self.w.fluids(bounds, bounds.max_position().y).in_water } fn targets(&self) -> &[(Who, Aabb)] { &self.targets @@ -5072,3 +5070,122 @@ mod breeding_tests { assert_eq!(breeding_credit(&mut love, 1, 2), None); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the + /// last `updateFluidInteraction`, water whose surface reaches its 0.25 + /// box (#3072), not the block at its feet. + #[test] + fn a_pearl_feels_the_water_its_box_touches() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("entity-types.json").exists() + || !root.join("movement-properties.nbt").exists() + { + return; + } + let assets = horizon_client::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let registry = assets.registry; + let facts = horizon_client::blocks::BlockFacts::load(&root, registry).unwrap(); + let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap(); + let data = horizon_client::mobs::MobData::load(&root, &facts); + let biomes = horizon_client::biome::Biomes::load(&root).unwrap(); + let items = horizon_client::items::Items::load(&root, registry, &facts).unwrap(); + let collision = horizon_client::collisions::load(&root, registry).unwrap(); + let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); + // A pool of still water one block deep at y = 4, from x = 20 on (clear of the + // scene's pyramid): its + // surface is at 4 + 8/9. + let water = registry.default_state("minecraft:water").unwrap(); + for x in 20..=40 { + for z in 0..=2 { + world.set(x, 4, z, water).unwrap(); + } + } + let lid_openers = std::collections::HashMap::new(); + let w = MobWorld { + teams: Default::default(), + world: &world, + registry, + facts: &facts, + fluid_tables: &motion.fluids, + movement: &motion.blocks, + collision: &collision, + data: &data, + player: None, + nearest_player: None, + others: &[], + others_grid: None, + others_index: None, + daylight: true, + monsters_burn: false, + game_time: 1000, + day_time: 1000, + raining: false, + thundering: false, + nether: false, + block_tags: &items.block_tags, + biomes: &biomes, + mob_griefing: true, + mob_drops: true, + max_entity_cramming: 24, + difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0), + end: false, + world_seed: 0, + sea_level: 63, + items: Some(&items), + lid_openers: &lid_openers, + }; + let pw = ProjWorld { + w: &w, + targets: Vec::new(), + }; + let pearl = |x: f64, y: f64, velocity: Vec3| { + let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None); + p.velocity = velocity; + p + }; + // Skimming the pool: its feet are in the water block, its box above + // the surface. ThrowableProjectile.applyInertia keeps the air drag. + let mut p = pearl(25.5, 4.95, Vec3::new(0.3, 0.0, 0.0)); + p.tick(&pw); + assert!(p.position.y > 4.9, "the pearl dipped: {p:?}"); + p.tick(&pw); + assert!( + (p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9, + "a pearl above the surface slowed as in water: {p:?}" + ); + // At the pool's edge: its feet over the dry block at x = 19, a side + // of its box in the water at x = 20. The water drag after the first + // tick, which still reads `wasTouchingWater` as false. + let mut p = pearl(19.95, 4.3, Vec3::ZERO); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, + "the first tick has the air drag: {p:?}" + ); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9, + "a pearl whose box is in the water kept the air drag: {p:?}" + ); + // Thrown under water, a pearl moves with the air drag on its first + // tick and the water drag after it. + let mut p = pearl(30.5, 4.2, Vec3::ZERO); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, + "the first tick under water has the air drag: {p:?}" + ); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9, + "the second tick under water has the water drag: {p:?}" + ); + } +} diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 9633121bf4..7cbd4b5e8e 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -121,7 +121,7 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) { if p.stuck { return; } - let water = w.in_water(p.position); + let water = w.in_water(p.bounds()); if water { p.velocity = p.velocity.scale(0.6); } @@ -714,7 +714,7 @@ mod tests { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: Aabb) -> bool { false } fn targets(&self) -> &[(Who, Aabb)] { diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index d146d50ec2..49679791c3 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -410,7 +410,7 @@ impl ProjectileWorld for Probe<'_, '_> { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: Aabb) -> bool { false } fn targets(&self) -> &[(Who, Aabb)] { diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 5ef1008c66..c01d8a141d 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -129,6 +129,10 @@ pub struct Projectile { /// `Entity.needsSync`: a server tells its clients about the change this /// tick instead of at the next update interval. pub needs_sync: bool, + /// `Entity.wasTouchingWater`: what the last tick's + /// `updateFluidInteraction` found around the box after the move, read by + /// the next tick's inertia (false until the first tick has moved it). + pub touching_water: bool, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -148,7 +152,9 @@ pub enum Hit { /// the boxes of entities it could hit. pub trait ProjectileWorld { fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])>; - fn in_water(&self, at: Vec3) -> bool; + /// `Entity.isInWater` as `updateFluidInteraction` sets it: some water + /// block's surface reaches the bottom of the box (deflated by 0.001). + fn in_water(&self, bounds: Aabb) -> bool; fn targets(&self) -> &[(Who, Aabb)]; /// `Level.isEmptyBlock`. fn empty(&self, _p: [i32; 3]) -> bool { @@ -176,6 +182,15 @@ fn axis_of(d: u8) -> u8 { } } impl Projectile { + /// `Entity.getBoundingBox`: its type's square box standing on its position. + pub fn bounds(&self) -> Aabb { + let (p, half) = (self.position, self.kind.size() / 2.0); + let height = self.kind.size(); + Aabb::new( + Vec3::new(p.x - half, p.y, p.z - half), + Vec3::new(p.x + half, p.y + height, p.z + half), + ) + } pub fn new(kind: ProjectileKind, position: Vec3, owner: Option) -> Self { let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. } | ProjectileKind::Trident); Self { @@ -194,6 +209,7 @@ impl Projectile { bonus: Default::default(), acceleration: 0.1, needs_sync: false, + touching_water: false, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( @@ -655,7 +671,14 @@ impl Projectile { if matches!(self.kind, ProjectileKind::ShulkerBullet { .. }) { return self.tick_bullet(w); } - let water = w.in_water(self.position); + // ThrowableProjectile and AbstractHurtingProjectile.applyInertia read + // `isInWater` before their move and `Projectile.tick`'s base tick; + // LlamaSpit.tick runs that base tick first, at the spit's box here. + let water = if self.kind == ProjectileKind::LlamaSpit { + w.in_water(self.bounds()) + } else { + self.touching_water + }; match self.kind { ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } @@ -752,6 +775,8 @@ impl Projectile { if !self.is_fireball() { self.update_rotation(); } + // Entity.baseTick's updateFluidInteraction, after the move. + self.touching_water = w.in_water(self.bounds()); let alive = self.position.y > -128.0 && self.age < if self.is_fireball() { 1200 } else { 6000 }; (alive, Hit::None) @@ -828,7 +853,7 @@ mod tests { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: Aabb) -> bool { false } fn targets(&self) -> &[(Who, Aabb)] { From e4b5b0f07faf1f702811741f21145b2ebd9ef4d9 Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 00:45:21 +0100 Subject: [PATCH 199/968] Inventory preview: the player is lit as Lighting.Entry.ENTITY_IN_UI The shading was a rough guess (front 1.0, top 0.4); it is now minecraft_mix_light with the two inventory lights, ambient 0.4 and power 0.6. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/perspective.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 8aa461ec66..65770ad3af 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -825,6 +825,18 @@ impl Game<'_> { } } +/// `Lighting.Entry.ENTITY_IN_UI` through the entity shader's +/// `minecraft_mix_light`: the two inventory lights, (0.2, -1, 1) and +/// (-0.2, -1, 0), with the 0.6 light power and 0.4 ambient. `normal` is the +/// face's outward normal in the preview's screen space (x right, y down, z +/// towards the viewer), where a face that looks up has a negative y. +fn entity_in_ui_shade(normal: glam::Vec3) -> f32 { + const LIGHT_0: glam::Vec3 = glam::Vec3::new(0.2, -1.0, 1.0); + const LIGHT_1: glam::Vec3 = glam::Vec3::new(-0.2, -1.0, 0.0); + let light = LIGHT_0.normalize().dot(normal).max(0.0) + LIGHT_1.normalize().dot(normal).max(0.0); + (light * 0.6 + 0.4).min(1.0) +} + fn player_preview_quads( mut verts: Vec, gui: glam::Mat4, @@ -854,11 +866,10 @@ fn player_preview_quads( continue; } let n = n.normalize_or_zero(); - // Lighting.Entry.ENTITY_IN_UI, roughly: lit from the front and above. let shade = if index * 4 >= flame_start { 1.0 } else { - (0.55 + 0.45 * -n.z - 0.15 * n.y).clamp(0.4, 1.0) + entity_in_ui_shade(-n) }; let c = q[0].color; let ch = |s: u32| ((((c >> s) & 255) as f32 * shade) as u32) << s; @@ -979,6 +990,19 @@ mod preview_tests { assert_eq!(batch.runs(), vec![(0..12, Blend::Depth([1, 0, 1, 1]))]); } + #[test] + fn the_inventory_preview_is_lit_as_entity_in_ui() { + // A face looking at the viewer, up, down, to the right and to the left. + let shade = |x, y, z| entity_in_ui_shade(glam::Vec3::new(x, y, z)); + assert!((shade(0.0, 0.0, 1.0) - 0.8195).abs() < 1e-3); + assert_eq!(shade(0.0, -1.0, 0.0), 1.0); + assert!((shade(0.0, 1.0, 0.0) - 0.4).abs() < 1e-6); + assert!((shade(1.0, 0.0, 0.0) - 0.4839).abs() < 1e-3); + assert!((shade(-1.0, 0.0, 0.0) - 0.5176).abs() < 1e-3); + // Away from the viewer: only the ambient light. + assert!((shade(0.0, 0.0, -1.0) - 0.4).abs() < 1e-6); + } + #[test] #[ignore = "requires prepared model data in HORIZON_TEST_RESOURCES"] fn player_render_transform_uses_vanilla_avatar_scale() { From 24c323960ebd56eee5574d98697262470aeb4bbb Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Fri, 30 Oct 2026 02:46:21 +0300 Subject: [PATCH 200/968] Launcher: keep removable beside its own doc comment The new fn sat between selection_after_removal's doc comment and that function, so the comment documented the wrong one. Co-Authored-By: Hermes Agent (Nous Research) --- launcher/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 0d5ee34830..75e24a01a1 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1642,8 +1642,6 @@ fn starts_now(prepared: bool, preparing: bool, running: bool) -> bool { play_press(prepared, None, preparing || running) == PlayPress::Play } -/// The selected version once one has been removed: the saved choice if it is still installed, -/// else the newest left. /// Whether `tag` can start being deleted now: it is installed, no install is writing it, and it /// is not already going. Another version deleting at the same time does not stop it. fn removable( @@ -1657,6 +1655,8 @@ fn removable( && !removing.iter().any(|t| t == tag) } +/// The selected version once one has been removed: the saved choice if it is still installed, +/// else the newest left. fn selection_after_removal(installed: &[String], saved: Option) -> Option { saved .filter(|s| installed.contains(s)) From 3cb4177026f7fdb6ef7d19b46632ed59dca33dad Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 02:46:50 +0300 Subject: [PATCH 201/968] Native mods: furnace menus for mod containers, with their data slots A mod container may open a furnace's menu (furnace, blast furnace, smoker): its block entity's data slots, which the mod sets in its tick and nothing saves, draw the flame and the arrow as a furnace's dataAccess does. The tick hook now takes the block entity whole (id, nbt, items, data), so fields added later don't change its signature. Co-Authored-By: Claude Opus 5.5 --- .../src/chunk/block_entity.rs | 16 ++++++-- .../horizon-client/src/game/native_hooks.rs | 40 +++++++++---------- crates/horizon-client/src/lan/containers.rs | 2 + crates/horizon-client/src/save.rs | 7 +++- 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs index bd38edc043..c492a7381e 100644 --- a/crates/horizon-client-render/src/chunk/block_entity.rs +++ b/crates/horizon-client-render/src/chunk/block_entity.rs @@ -85,13 +85,16 @@ pub enum BlockEntity { Mod(Box), } /// A mod's block entity: its type id (`mymod:machine`), its saved -/// compound without `id`, `x`, `y`, `z` and `Items`, and its item slots -/// (`Items`) when its block has a container (`ModContainer`). +/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots +/// (`Items`) when its block has a container (`ModContainer`), and the data +/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow), +/// which the mod sets and nothing saves. #[derive(Clone, Debug, Default, PartialEq)] pub struct ModBlockEntity { pub id: String, pub nbt: Compound, pub items: Vec>, + pub data: Vec, } /// The container of a mod block's block entity, as a `WorldlyContainer` /// says it: its slot count, the slots each face reaches @@ -108,8 +111,13 @@ pub struct ModContainer { pub menu: Option, } /// The vanilla menus a mod container can open, with the slot count each -/// shows: plain slot grids, whose clicks need nothing of the block entity. -pub const MOD_MENUS: [(&str, usize); 8] = [ +/// shows: slot grids, and the furnaces' (input, fuel, result), whose four +/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow +/// (`data[2]` of `data[3]`), as `AbstractFurnaceBlockEntity.dataAccess`. +pub const MOD_MENUS: [(&str, usize); 11] = [ + ("minecraft:furnace", 3), + ("minecraft:blast_furnace", 3), + ("minecraft:smoker", 3), ("minecraft:hopper", 5), ("minecraft:generic_3x3", 9), ("minecraft:generic_9x1", 9), diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 1bb7858073..b3dbb93095 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -34,7 +34,6 @@ use horizon_client::{ extras_hud::Corners, fast_hash::FxHashMap, hud::{HudBatch, rgba}, - items::ItemStack, keybinds, mod_list::{self, Installed, NativePage}, text::Text, @@ -362,20 +361,17 @@ impl Game<'_> { ) { let _ = (block, container); } - /// A server tick of block entity `id` at `pos`, in vanilla's order among - /// the others (`Level.tickBlockEntities`): read and change its saved - /// compound `nbt` and its item `slots` (none without a container). It is out of the world during the call (the block - /// entity at `pos` reads as a `Mod` with an empty id, and a save leaves - /// it out); if the call removes or replaces it, `nbt` is dropped. Clients, - /// the window's own in single player included, don't get it yet. API 3. - pub(super) fn native_block_entity_tick( - &mut self, - pos: [i32; 3], - id: &str, - nbt: &mut Compound, - slots: &mut [Option], - ) { - let _ = (pos, id, nbt, slots); + /// A server tick of the block entity at `pos`, in vanilla's order among + /// the others (`Level.tickBlockEntities`), when its `id` is yours: read + /// and change its saved compound `nbt`, its item slots `items` (keep + /// their count: as many as its container has, none without one) and the + /// data slots its menu shows (`data`). It is out of the world during the + /// call (the block entity at `pos` reads as a `Mod` with an empty id, and + /// a save leaves it out); if the call removes or replaces it, `entity` is + /// dropped. Clients, the window's own in single player included, don't + /// get it yet. API 3. + pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], entity: &mut ModBlockEntity) { + let _ = (pos, entity); } } @@ -569,11 +565,10 @@ impl Game<'_> { let mut entity = None; self.native_block_entity(name, &mut entity); let (id, nbt) = entity?; - let items = Vec::new(); Some(BlockEntity::Mod(Box::new(ModBlockEntity { id, nbt, - items, + ..ModBlockEntity::default() }))) } /// The containers of the mod blocks' block entities, by block state @@ -658,8 +653,7 @@ impl Game<'_> { if taken.items.len() < slots { taken.items.resize(slots, None); } - let ModBlockEntity { id, nbt, items } = &mut taken; - self.native_block_entity_tick([pos.0, pos.1, pos.2], id, nbt, items); + self.native_block_entity_tick([pos.0, pos.1, pos.2], &mut taken); if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) && m.id.is_empty() { @@ -861,13 +855,15 @@ mod tests { assert_eq!(names, declared); } - /// A mod container opens only a plain slot grid, by its slot count. + /// A mod container opens a slot grid or a furnace's menu, by its slot + /// count; no menu with rules of its own (a brewing stand's, a crafter's). #[test] - fn mod_menus_are_plain_slot_grids() { + fn mod_menus_are_slot_grids_and_furnaces() { use super::mod_menu; assert_eq!(mod_menu("minecraft:hopper"), Some(("minecraft:hopper", 5))); assert_eq!(mod_menu("minecraft:generic_9x3").map(|m| m.1), Some(27)); - assert_eq!(mod_menu("minecraft:furnace"), None); + assert_eq!(mod_menu("minecraft:furnace").map(|m| m.1), Some(3)); + assert_eq!(mod_menu("minecraft:brewing_stand"), None); assert_eq!(mod_menu("hopper"), None); } diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 6cb294cc49..6069bfe59e 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -604,6 +604,8 @@ impl Hub { .chain([&c.triggered]) .map(|&d| i32::from(d)) .collect(), + // A mod's menu: the data slots its tick set. + Some(BlockEntity::Mod(m)) if !m.data.is_empty() => m.data.clone(), _ => return, } .into_iter() diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 0d81157e1a..2d41f2b800 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -656,7 +656,12 @@ fn decode_mod_block_entity(id: String, tag: &Tag) -> Option Option<((i32, i32, i32), crate::containers::BlockEntity)> { use crate::containers::BlockEntity; From 807901034b6f4c3fdc6053d0aab7c9b7943acb8a Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 02:47:12 +0300 Subject: [PATCH 202/968] Native mods: a mod block entity's tick marks its chunk changed Review follow-up: as a ticking block entity's setChanged does, so a save after an autosave still writes what the tick changed. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 10b9631c73..8013312a03 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -565,6 +565,8 @@ impl Game<'_> { && m.id.is_empty() { **m = taken; + // BlockEntity.setChanged: the next save writes its chunk. + self.world.modified.insert((pos.0 >> 4, pos.2 >> 4)); } } /// Options > Mods' rows for the native mods, after the others. From 6297ea09f6b43f1f82570d4e6c4698bef8f794bb Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:48:20 +0200 Subject: [PATCH 203/968] Bows and crossbows: no use animation with nothing to shoot BowItem.use and CrossbowItem.use (uncharged) FAIL without a projectile unless the player has infinite materials, and Minecraft.startUseItem only calls itemUsed for a SUCCESS, trying the other hand otherwise. The client's remote_use_item treated the bow as used anyway, so the hand dipped as if the draw started and stopped. remote_start_held_use now reports a failed bow or uncharged crossbow use (a charged crossbow still succeeds, it shoots), and remote_use_item passes the turn on. Fixes #4079 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 416b73af14..03f5930780 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5399,7 +5399,11 @@ impl Game<'_> { if self.with_hand(hand, |g| g.remote_use_book(&held)) { return true; } - self.with_hand(hand, |g| g.remote_start_held_use(&held)); + // BowItem.use and CrossbowItem.use FAIL with nothing to shoot: no + // itemUsed (the hand doesn't dip) and the other hand's turn comes. + if !self.with_hand(hand, |g| g.remote_start_held_use(&held)) { + return false; + } let gliding = self.player.as_ref().is_some_and(|p| p.fall_flying); use_ends_click(&info, gliding) } @@ -5465,9 +5469,11 @@ impl Game<'_> { /// the server ends the use: a bow or a trident is let go, a crossbow's /// charge ends, a shield is lowered, a spyglass stops scoping. Their /// sounds, loaded arrows and thrown tridents come from the server. - fn remote_start_held_use(&mut self, held: &ItemStack) { + /// False when the use FAILs here: a bow or an uncharged crossbow with + /// nothing to shoot. + fn remote_start_held_use(&mut self, held: &ItemStack) -> bool { if self.using.is_some() { - return; + return true; } let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle); let start = match &*held.item { @@ -5478,13 +5484,15 @@ impl Game<'_> { // CrossbowItem.use: charging when not charged and with ammunition; // a charged one shoots on the server. "minecraft:crossbow" => { - held.data().is_none_or(|d| d.charged.is_empty()) - && (self.creative || self.projectile_slot("minecraft:crossbow").is_some()) + if held.data().is_some_and(|d| !d.charged.is_empty()) { + return true; + } + self.creative || self.projectile_slot("minecraft:crossbow").is_some() } // TridentItem.use: not about to break, Riptide only in water or rain. "minecraft:trident" => { self.use_trident(held); - return; + return true; } // SpyglassItem.use (its sound is the server's). "minecraft:spyglass" => true, @@ -5496,12 +5504,13 @@ impl Game<'_> { // ShieldItem.use: anything that blocks attacks, off cooldown. _ => { self.raise_shield(self.held_slot()); - return; + return true; } }; if start { self.using = Some((self.held_slot(), 0)); } + start || !matches!(&*held.item, "minecraft:bow" | "minecraft:crossbow") } /// Whether `stack` stays in use until the button lets go (bows, /// crossbows, tridents, spyglasses, shields, eyes of ender, whose From dc925bf620cc2033a511999a1737e09805805335 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 02:50:39 +0300 Subject: [PATCH 204/968] Native mods: reach a neighbouring mod block entity from a tick native_mod_block_entity_at (a helper, API 3): the mod block entity at a position, to read or change from another's tick, as a generator charging the machine beside it. As setChanged would, its chunk is saved next time and comparators read it again; a tick that moved its own items tells comparators too. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_hooks.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 9bc88874c6..90077e4a67 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -385,6 +385,27 @@ impl Game<'_> { pub(super) fn native_on(&self, mod_id: &str) -> bool { !self.settings.mods.disabled.contains(mod_id) } + /// The mod block entity at `pos`, to read or change from a tick (a + /// generator charging the machine beside it); none for any other block + /// entity, and for the one ticking now, which is out of the world. As + /// its `setChanged` would, its chunk is saved next time and comparators + /// read it again. API 3. + pub(super) fn native_mod_block_entity_at( + &mut self, + pos: [i32; 3], + ) -> Option<&mut ModBlockEntity> { + let key = (pos[0], pos[1], pos[2]); + match self.world.block_entities.get(&key) { + Some(BlockEntity::Mod(m)) if !m.id.is_empty() => {} + _ => return None, + } + self.world.modified.insert((key.0 >> 4, key.2 >> 4)); + self.container_changed(key); + match self.world.block_entities.get_mut(&key) { + Some(BlockEntity::Mod(m)) => Some(m), + _ => None, + } + } /// Playing, with nothing over the world: keys and zoom apply. pub(super) fn native_playing(&self) -> bool { self.screen == Screen::Playing @@ -643,6 +664,7 @@ impl Game<'_> { }; // An empty id marks its place while the mod has it. let mut taken = std::mem::take(&mut **m); + let items = taken.items.clone(); let state = self.world.block(pos.0, pos.1, pos.2).0; let slots = self .world @@ -657,9 +679,14 @@ impl Game<'_> { if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) && m.id.is_empty() { + let moved = taken.items != items; **m = taken; - // BlockEntity.setChanged: the next save writes its chunk. + // BlockEntity.setChanged: the next save writes its chunk, and + // comparators read a container whose items changed. self.world.modified.insert((pos.0 >> 4, pos.2 >> 4)); + if moved { + self.container_changed(pos); + } } } /// Options > Mods' rows for the native mods, after the others. From 4d1f533af69141bb92b930a58038c5b884bb5104 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:52:26 +0700 Subject: [PATCH 205/968] Creative tab selection test: give the category tab a stack The fixture's CATEGORY tab was empty, and an empty category tab is now hidden (CreativeModeTab.shouldDisplay), so tab 0 was no longer the tab the test selects. It now holds a stone stack. Part of #3393. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2400aecd60..cf360781c6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -16318,7 +16318,10 @@ mod tests { fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() { let mut creative = Creative::from_json(&serde_json::json!({ "schema": 2, - "tabs": [{"type": "CATEGORY"}, {"type": "INVENTORY"}] + "tabs": [ + {"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]}, + {"type": "INVENTORY"} + ] })) .unwrap(); for button in [Button::Left, Button::Right] { From 94e167b79629ee5d50bc6500e4fca5331bccac73 Mon Sep 17 00:00:00 2001 From: arismoko <19542195+arismoko@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:09:58 -0500 Subject: [PATCH 206/968] Keep stopped server list test offline --- crates/horizon-server/src/lists.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-server/src/lists.rs b/crates/horizon-server/src/lists.rs index 1a44251d3d..e8f2ae6efd 100644 --- a/crates/horizon-server/src/lists.rs +++ b/crates/horizon-server/src/lists.rs @@ -168,6 +168,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join(config::FILE), config::TEMPLATE).unwrap(); + // This test edits local lists; it must not look names up through Mojang's services. + config::update(&dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap(); let s = |v: &[&str]| v.iter().map(|a| (*a).to_owned()).collect::>(); run(&dir, "op", &s(&["Alex"])).unwrap(); run(&dir, "whitelist", &s(&["add", "Steve"])).unwrap(); @@ -192,7 +194,6 @@ mod tests { // A running server owns the lists: without its API, nothing changes. let lock = supervisor::lock(&dir).unwrap(); assert!(run(&dir, "op", &s(&["Alex"])).is_err()); - config::update(&dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap(); assert!(run(&dir, "whitelist", &s(&["add", "Kai"])).is_err()); drop(lock); assert!( From baa05a30ec74271a24a4c8e142bb897ef5bb3439 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:10:05 +0100 Subject: [PATCH 207/968] Send the stored Main Hand option in ClientInformation The Skin Customization Main Hand setting was saved to options.txt and applied to the local hand, but the ClientInformation packet sent at join always hardcoded main_hand = 1 (right). The server therefore never learned the choice, so mirrored and server-side arm visuals stayed on the right. Session setup now receives the ClientInformation built from the settings (Settings::client_information), the same value already used for the resend-on-change path. Adds round-trip and mapping tests (LEFT = 0, RIGHT = 1). --- crates/horizon-client-render/src/settings.rs | 11 +++++ crates/horizon-client/src/game/remote.rs | 16 ++++--- crates/horizon-client/src/net.rs | 42 +++++-------------- .../src/packets/configuration.rs | 15 +++++++ 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/crates/horizon-client-render/src/settings.rs b/crates/horizon-client-render/src/settings.rs index ae205851f6..b8a9a07bd9 100644 --- a/crates/horizon-client-render/src/settings.rs +++ b/crates/horizon-client-render/src/settings.rs @@ -825,6 +825,17 @@ mod tests { assert_eq!(again.keys.get("key.jump"), "key.keyboard.j"); } #[test] + fn client_information_maps_the_stored_main_hand() { + // The Skin Customization option is stored by name; ClientInformation + // sends HumanoidArm (LEFT = 0, RIGHT = 1). + for (stored, arm) in [("left", 0), ("right", 1)] { + let s = Settings::parse(&format!("mainHand:{stored}\n")); + assert_eq!(s.client_information(10).main_hand, arm); + assert!(s.serialize().contains(&format!("mainHand:\"{stored}\""))); + } + assert_eq!(Settings::default().client_information(10).main_hand, 1); + } + #[test] fn no_options_txt_paces_frames_like_an_empty_one() { let (fresh, empty) = (Settings::default(), Settings::parse("")); assert_eq!(fresh.max_fps, 120); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 20dd08a37a..34d1943395 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -361,7 +361,11 @@ impl Game<'_> { .and_then(|p| Builtin::load(&p).ok()) .ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?; let lang = Arc::new(horizon_client::net::load_language(&root)); - let view = client_view_distance(self.settings.render_distance); + // Options.createInformation: what the server learns about this player, + // sent again when one of those options changes. + let info = self + .settings + .client_information(client_view_distance(self.settings.render_distance)); if local.is_none() { horizon_client::crash::set_detail( "Server", @@ -382,7 +386,9 @@ impl Game<'_> { ); self.server_packs_connect(local.is_none().then_some(address), user); let session = match local { - Some(pipe) => Session::connect_local(pipe, name, view, lang.clone(), root.clone()), + Some(pipe) => { + Session::connect_local(pipe, name, info.clone(), lang.clone(), root.clone()) + } None => { self.extras_connecting(address, name); match cookies { @@ -390,7 +396,7 @@ impl Game<'_> { address, name, auth.clone(), - view, + info.clone(), lang.clone(), cookies, root.clone(), @@ -399,7 +405,7 @@ impl Game<'_> { address, name, auth.clone(), - view, + info.clone(), lang.clone(), root.clone(), ), @@ -494,7 +500,7 @@ impl Game<'_> { carried: None, abilities: None, loaded_sent: false, - sent_info: self.settings.client_information(view), + sent_info: info, predictions: HashMap::new(), menu: None, menu_title: None, diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index fef47385c4..95b219f3d8 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -106,20 +106,11 @@ impl Session { address: &str, name: &str, auth: Option>, - view_distance: i8, + info: cfg::ClientInformation, lang: Arc, resources: PathBuf, ) -> Self { - Self::start( - address, - name, - auth, - view_distance, - lang, - None, - None, - resources, - ) + Self::start(address, name, auth, info, lang, None, None, resources) } /// `ConnectScreen.startConnecting` for a `transfer`: the cookies the /// server stored carry to the new connection (`TransferState`). @@ -127,7 +118,7 @@ impl Session { address: &str, name: &str, auth: Option>, - view_distance: i8, + info: cfg::ClientInformation, lang: Arc, cookies: Cookies, resources: PathBuf, @@ -136,7 +127,7 @@ impl Session { address, name, auth, - view_distance, + info, lang, None, Some(cookies), @@ -147,26 +138,17 @@ impl Session { pub fn connect_local( pipe: horizon_protocol::connection::Pipe, name: &str, - view_distance: i8, + info: cfg::ClientInformation, lang: Arc, resources: PathBuf, ) -> Self { - Self::start( - "local", - name, - None, - view_distance, - lang, - Some(pipe), - None, - resources, - ) + Self::start("local", name, None, info, lang, Some(pipe), None, resources) } fn start( address: &str, name: &str, auth: Option>, - view_distance: i8, + info: cfg::ClientInformation, lang: Arc, local: Option, cookies: Option, @@ -192,7 +174,7 @@ impl Session { &address_owned, &name_owned, auth.as_deref(), - view_distance, + info, &lang, &send, &thread_writer, @@ -399,7 +381,7 @@ fn run( address: &str, name: &str, auth: Option<&AuthSession>, - view_distance: i8, + info: cfg::ClientInformation, lang: &Language, send: &Sender, shared_writer: &Mutex>, @@ -529,10 +511,8 @@ fn run( } }; status(send, "Joining world..."); - c.send( - &cfg::Serverbound::ClientInformation(cfg::ClientInformation::new(view_distance)).encode(), - ) - .map_err(e)?; + c.send(&cfg::Serverbound::ClientInformation(info).encode()) + .map_err(e)?; c.send( &cfg::Serverbound::CustomPayload(cfg::CustomPayload { channel: "minecraft:brand".into(), diff --git a/crates/horizon-protocol/src/packets/configuration.rs b/crates/horizon-protocol/src/packets/configuration.rs index 22a8a14560..48976429f9 100644 --- a/crates/horizon-protocol/src/packets/configuration.rs +++ b/crates/horizon-protocol/src/packets/configuration.rs @@ -325,6 +325,21 @@ mod tests { } } + #[test] + fn client_information_carries_the_main_hand() { + // Skin Customization's Main Hand: HumanoidArm LEFT is 0, RIGHT is 1. + for main_hand in [0, 1] { + round_trip( + Serverbound::ClientInformation(ClientInformation { + main_hand, + ..ClientInformation::new(12) + }), + Serverbound::encode, + Serverbound::decode, + ); + } + } + /// The bytes vanilla writes: the length, then the tag (a lone `End` byte /// for none). #[test] From 8f7c959679119e88cfee6de85e64d6f72dceb77b Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 01:14:41 +0100 Subject: [PATCH 208/968] Navigation: trimPath runs in moveTo, before the node count check PathNavigation.moveTo trims the path first and refuses it when the trim leaves no node. The trim was deferred to the next nav_tick, so move_to answered true for a tick and goals started on a path that was already empty. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/mobs/animal_brain.rs | 4 ++-- crates/horizon-client/src/mobs/bee.rs | 4 ++-- crates/horizon-client/src/mobs/goals.rs | 8 ++++---- crates/horizon-client/src/mobs/monster_brain.rs | 4 ++-- crates/horizon-client/src/mobs/nav.rs | 17 ++++++----------- crates/horizon-client/src/mobs/piglin.rs | 8 ++++---- crates/horizon-client/src/mobs/raider.rs | 4 ++-- .../horizon-client/src/mobs/villager_brain.rs | 4 ++-- crates/horizon-client/src/mobs/warden_brain.rs | 4 ++-- 9 files changed, 26 insertions(+), 31 deletions(-) diff --git a/crates/horizon-client/src/mobs/animal_brain.rs b/crates/horizon-client/src/mobs/animal_brain.rs index 77c7f331a9..bf86f8e441 100644 --- a/crates/horizon-client/src/mobs/animal_brain.rs +++ b/crates/horizon-client/src/mobs/animal_brain.rs @@ -1083,7 +1083,7 @@ impl B { *last_target = walk.target.block(cx.w); t.begin(cx.lr, cx.now); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); return true; } let mm = mem(m); @@ -1476,7 +1476,7 @@ impl B { if d > 4 && compute_path(m, cx, &walk, path, speed) { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } } diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 82f367198c..e32eb21b4c 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -940,7 +940,7 @@ impl GoToHive { let close_enough = if closer_than(m, p, 3) { 1 } else { 2 }; m.ai.nav.max_visited_multiplier = 10.0; let path = m.create_path_to(w, p, close_enough); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(w, path, 1.0); m.ai.nav.path.as_ref().is_some_and(|path| path.reached) } } @@ -1182,7 +1182,7 @@ impl Goal for Wander { at[2].floor() as i32, ]; let path = m.create_path_to(cx.w, p, 1); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(cx.w, path, 1.0); } } } diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 0ac9b0cff2..f63df73181 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -1170,9 +1170,9 @@ impl Goal for MeleeAttack { } !(t.is_player && t.creative) } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.speed); + m.nav_move_to_path(cx.w, path, self.speed); m.ai.aggressive = true; self.recalc = 0; self.until_attack = 0; @@ -1791,9 +1791,9 @@ impl Goal for AvoidEntity { fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.walk); + m.nav_move_to_path(cx.w, path, self.walk); } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let from = match &self.what { diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index dcab3f5893..e5ab25ed44 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -609,7 +609,7 @@ impl B { t.begin(cx.lr, cx.now); // start mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); if *slide { // SlideToTargetSink.start let at = m.center(); @@ -961,7 +961,7 @@ impl B { if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } } diff --git a/crates/horizon-client/src/mobs/nav.rs b/crates/horizon-client/src/mobs/nav.rs index e09099b024..e2b14ac127 100644 --- a/crates/horizon-client/src/mobs/nav.rs +++ b/crates/horizon-client/src/mobs/nav.rs @@ -50,9 +50,6 @@ pub struct Navigation { pub below_surface: bool, /// WallClimberNavigation.pathToPosition climb_to: Option<[i32; 3]>, - /// `PathNavigation.moveTo` stored a path that `trimPath` has not seen - /// yet: it runs on the navigation's next tick, which has the world. - trim_pending: bool, } impl Default for Navigation { fn default() -> Self { @@ -83,7 +80,6 @@ impl Default for Navigation { avoid_sun: false, below_surface: false, climb_to: None, - trim_pending: false, } } } @@ -311,7 +307,9 @@ impl Mob { ]; self.create_path_to(w, p, reach) } - pub fn nav_move_to_path(&mut self, path: Option, speed: f64) -> bool { + /// `PathNavigation.moveTo(Path, speed)`: `trimPath` runs before the + /// node count is checked, so a path the trim empties is refused. + pub fn nav_move_to_path(&mut self, w: &MobWorld, path: Option, speed: f64) -> bool { let Some(path) = path else { self.ai.nav.path = None; return false; @@ -322,10 +320,10 @@ impl Mob { if self.ai.nav.is_done() { return false; } + self.trim_path(w); if self.ai.nav.path.as_ref().is_none_or(|p| p.nodes.is_empty()) { return false; } - self.ai.nav.trim_pending = true; self.ai.nav.speed_modifier = speed; self.ai.nav.last_stuck_check = self.ai.nav.tick; // getTempMobPos needs the world for ground navigation; position is close enough @@ -341,7 +339,7 @@ impl Mob { at[2].floor() as i32, ]; let path = self.create_path_to(w, p, 1); - self.nav_move_to_path(path, speed) + self.nav_move_to_path(w, path, speed) } pub fn nav_move_to_entity( &mut self, @@ -361,7 +359,7 @@ impl Mob { self.ai.nav.speed_modifier = speed; return true; } - path.is_some() && self.nav_move_to_path(path, speed) + path.is_some() && self.nav_move_to_path(w, path, speed) } pub fn nav_stop(&mut self) { self.ai.nav.path = None; @@ -402,9 +400,6 @@ impl Mob { /// PathNavigation.tick. pub fn nav_tick(&mut self, w: &MobWorld) { self.ai.nav.tick += 1; - if std::mem::take(&mut self.ai.nav.trim_pending) { - self.trim_path(w); - } if self.ai.nav.delayed_recompute { self.recompute_path(w); } diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index aa6d7bb09b..b6bd921a68 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -240,9 +240,9 @@ impl Goal for HoglinAvoidRepellent { fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(cx.w, path, 1.0); } } /// Baby hoglins flee what hurts them; adults stand their ground. @@ -309,9 +309,9 @@ impl Goal for PiglinAvoidRepellent { fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(cx.w, path, 1.0); } } /// Admiring a gold item held in the off hand: stand and look at it. diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 04ebdff1ea..02bae0a55e 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -380,8 +380,8 @@ impl Goal for ObtainBanner { fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { !m.ai.nav.is_done() && state(m).is_some_and(|s| s.banner_at.is_some()) } - fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { - m.nav_move_to_path(self.path_to_banner.take(), 1.15); + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + m.nav_move_to_path(cx.w, self.path_to_banner.take(), 1.15); } } diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 16c493babb..6916eb740d 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -1513,7 +1513,7 @@ impl B { t.begin(cx.lr, cx.now); // start mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); return true; } let mm = mem(m); @@ -2831,7 +2831,7 @@ impl B { if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } } diff --git a/crates/horizon-client/src/mobs/warden_brain.rs b/crates/horizon-client/src/mobs/warden_brain.rs index 7d63364960..8237dfd9c3 100644 --- a/crates/horizon-client/src/mobs/warden_brain.rs +++ b/crates/horizon-client/src/mobs/warden_brain.rs @@ -686,7 +686,7 @@ impl B { t.begin(cx.lr, cx.now); // start mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); return true; } let mm = mem(m); @@ -1027,7 +1027,7 @@ impl B { { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } // canStillUse is true: they run their duration. From 2c803ae8754e1e24133aa4ca77c3d2c744c588fc Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:15:28 +0100 Subject: [PATCH 209/968] fix(client): send lingering potion cloud colour to clients The area_effect_cloud entity states are built while the LAN hub is out of `self.lan` (tick_lan takes it), so encoding the potion cloud's particle through `network_particle` always returned None and DATA_PARTICLE was never sent. Clients then fell back to AreaEffectCloud.DEFAULT_PARTICLE, the white entity effect, for every potion. Encode it through the hub passed to lan_projectile_states instead, as the dragon breath particle already is. Closes #4090 --- crates/horizon-client/src/game/lan_entities.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 285639113d..e705edcc35 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -655,8 +655,12 @@ impl Game<'_> { if b.waiting() { e.meta.push((9, serializer::BOOLEAN, Value::Bool(true))); } + // The hub is out of `self.lan` while these states are built, so + // the particle must be encoded through the hub passed in: going + // through `network_particle` would drop it and leave the client + // on the default white particle. let particle = match &b.potion { - Some(p) => self.network_particle(&p.particle(), None), + Some(p) => Self::hub_particle(hub, &p.particle(), None), None => breath.clone(), }; if let Some(p) = &particle { From ce91a02d840d6102cba57083fd6dc36756cc4b52 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:20:23 +0100 Subject: [PATCH 210/968] Use the player's attack_speed base for the attack cooldown (#4091) --- crates/horizon-client/src/game/hand.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c241208948..2255cc68ef 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -418,7 +418,21 @@ impl Game<'_> { /// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to /// the attribute's range. pub(super) fn main_hand_attack_speed(&self) -> f64 { - let value = horizon_client::attack_strength::BASE_ATTACK_SPEED + // The base comes from the player's attribute map so `/attribute ... base set` applies. + let base = self + .player + .as_ref() + .and_then(|player| { + let key = player.attributes.key("minecraft:attack_speed")?; + let handle = player.attributes.lookup(Some(&key))?; + player + .attributes + .instance(&handle) + .ok() + .map(|value| value.base_value()) + }) + .unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED); + let value = base + self .attack_item_info() .map_or(0.0, |i| f64::from(i.attack_speed)); From 7df9ed0545033325e714facda15a4f93e550b363 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 03:23:15 +0300 Subject: [PATCH 211/968] Native mods: screens for mod blocks, drawn with the mod's GUI textures A content mod's textures/gui files are GUI sprites under its namespace (mymod:gui/...). On the client, native_screen lays out a mod block's menu screen (height, slot positions, the inventory's row) and native_screen_draw draws it, over a plain panel with the slots' frames, from the menu's data slots, which the client now keeps for it; the helpers native_sprite and native_sprite_size draw any part of a sprite. The server's menu is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/hud.rs | 57 ++++- crates/horizon-client/src/game.rs | 16 +- crates/horizon-client/src/game/machines.rs | 3 + .../horizon-client/src/game/native_hooks.rs | 202 +++++++++++++++++- crates/horizon-client/src/game/recipe_book.rs | 3 +- crates/horizon-client/src/game/remote.rs | 2 + native-hooks.json | 10 + 7 files changed, 282 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 08733bf2be..1d53e4961b 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -151,6 +151,20 @@ fn walk(dir: &Path, out: &mut Vec) { } } } +/// A texture's sprite name: its path under vanilla's textures +/// (`gui/container/furnace`), or a content mod's GUI texture under its +/// namespace (`mymod:gui/container/machine`). +fn sprite_name(textures: &Path, assets: &Path, path: &Path) -> Option { + let plain = |p: &Path| p.with_extension("").to_string_lossy().replace('\\', "/"); + if let Ok(rel) = path.strip_prefix(textures) { + return Some(plain(rel)); + } + let rel = path.strip_prefix(assets).ok()?; + let mut parts = rel.components(); + let ns = parts.next()?.as_os_str().to_str()?.to_owned(); + let rest = parts.as_path().strip_prefix("textures").ok()?; + Some(format!("{ns}:{}", plain(rest))) +} /// A packed atlas image: sprite name, size and RGBA pixels. type PackedImage = (String, u32, u32, Vec); /// A frame of an animated strip at `x, y`, nearest-resampled to the longer @@ -413,18 +427,22 @@ impl HudAtlas { for folder in folders { walk(&textures.join(folder), &mut files); } + // Content mods' GUI textures, for their screens: `:gui/...`. + let assets = root.join("assets"); + if full && let Ok(namespaces) = fs::read_dir(&assets) { + for ns in namespaces.flatten() { + if ns.file_name() != "minecraft" { + walk(&ns.path().join("textures/gui"), &mut files); + } + } + } files.retain(|p| { let s = p.to_string_lossy(); !s.contains("title/background") && !s.contains("realms") && !s.contains("presets") }); type Decoded = (PackedImage, Option, Option<(u32, u32)>, bool); let decode_one = |path: &PathBuf| -> Option { - let name = path - .strip_prefix(&textures) - .ok()? - .with_extension("") - .to_string_lossy() - .replace('\\', "/"); + let name = sprite_name(&textures, &assets, path)?; let (mut w, mut h, pixels) = decode(path).ok()?; let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok(); let animation = mcmeta @@ -1946,6 +1964,33 @@ mod tests { assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}"); } } + /// A content mod's GUI textures join the atlas under its namespace; + /// its other textures and vanilla's names stay as they were. + #[test] + fn a_content_mods_gui_textures_are_sprites_under_its_namespace() { + let root = std::env::temp_dir().join(format!("horizon-mod-gui-{}", std::process::id())); + write_png(&root, "gui/container/furnace", 256, 256); + let png = |rel: &str, w: u32, h: u32| { + let path = root.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let pixels = vec![255; (w * h * 4) as usize]; + encoder + .write_header() + .unwrap() + .write_image_data(&pixels) + .unwrap(); + }; + png("assets/mymod/textures/gui/container/machine.png", 176, 166); + png("assets/mymod/textures/block/machine.png", 16, 16); + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + assert_eq!(atlas.sprites["mymod:gui/container/machine"].w, 176); + assert!(atlas.sprites.contains_key("gui/container/furnace")); + assert!(!atlas.sprites.contains_key("mymod:block/machine")); + } #[test] fn the_default_textures_keep_a_4096_wide_atlas() { let images = sized(400, 64, 64); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5245c34133..b15e2104d5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1865,6 +1865,8 @@ struct Game<'a> { arrow_model: Option, tnts: Vec, hoppers: HashMap<(i32, i32, i32), machines::HopperState>, + /// The screen of a mod block's menu open on this client (`native_screen`). + mod_screen: Option, /// Where item entities are, while the block entities tick (hoppers). item_columns: Option, creative_screen: Creative, @@ -6554,6 +6556,10 @@ impl Game<'_> { return v; } let mut v: Vec = (0..36).collect(); + if let Some(shown) = self.native_screen_slots() { + v.extend(shown); + return v; + } match self.container_kind() { Some(BlockEntity::Chest(_)) => v.extend((0..self.container_size()).map(|i| 300 + i)), Some(BlockEntity::Furnace(_)) => v.extend(300..303), @@ -6615,9 +6621,10 @@ impl Game<'_> { // ContainerScreen (3 rows) shifts the player inventory down by one pixel. // ChestMenu: the inventory sits `18 * (rows - 3) + 1` lower than the // 3-row layout of the other menus. - let shift = match self.chest_rows() { - Some(rows) => 1.0 + (rows as f32 - 3.0) * 18.0, - None => 0.0, + let shift = match (self.chest_rows(), self.native_screen_inventory_y()) { + (_, Some(y)) => y - 84.0, + (Some(rows), None) => 1.0 + (rows as f32 - 3.0) * 18.0, + (None, None) => 0.0, }; match i { 0..9 => (8.0 + i as f32 * 18.0, 142.0 + shift), @@ -6628,6 +6635,7 @@ impl Game<'_> { 500..504 => (8.0, 8.0 + (i - 500) as f32 * 18.0), // InventoryMenu: the shield slot. 504 => (77.0, 62.0), + 300..500 if let Some(p) = self.native_screen_slot(i - 300) => p, 300..500 => match self.container_kind() { Some(BlockEntity::Furnace(_)) => { [(56.0, 17.0), (56.0, 53.0), (116.0, 35.0)][(i - 300).min(2)] @@ -6780,6 +6788,7 @@ impl Game<'_> { .container .and_then(|k| self.world.block_entities.get(&k)) { + Some(_) if self.draw_native_screen(batch, ox, oy) => {} Some(BlockEntity::Chest(_)) if self.draw_machine_screen(batch, ox, oy) => {} Some(BlockEntity::Chest(_)) => self.draw_chest_screen(batch, ox, oy), Some(BlockEntity::Furnace(f)) => { @@ -14516,6 +14525,7 @@ impl<'a> Game<'a> { arrow_model: prepared.arrow_model, tnts: Vec::new(), hoppers: HashMap::new(), + mod_screen: None, item_columns: None, creative_screen: prepared.creative_screen, control: None, diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index c80f93edd2..44834df972 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -1115,6 +1115,9 @@ impl Game<'_> { } /// `imageHeight` of the open screen (HopperScreen is 133 tall). pub(super) fn screen_height(&self) -> f32 { + if let Some(height) = self.native_screen_height() { + return height; + } if self .station .as_ref() diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 90077e4a67..a653f7368f 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -16,7 +16,9 @@ //! `native_block_entity_ticks`, `native_block_entity_tick`): made when the //! block is placed, ticked on the server and saved with the chunk; and their //! item slots (`native_block_entity_container`), which hoppers, comparators -//! and breaking the block use as they use vanilla's containers. +//! and breaking the block use as they use vanilla's containers; and their +//! screens on the client (`native_screen`, `native_screen_draw`), drawn with +//! the mod's own GUI textures (`native_sprite`). //! //! Names, parameters and call sites are kept from release to release, and //! hook points are only ever added: each addition raises [`NATIVE_HOOKS_API`]. @@ -69,6 +71,27 @@ pub(crate) struct NativeMinimap { pub(crate) round: bool, } +/// A mod block's screen over its menu (`native_screen`): its height (176 +/// wide, as vanilla's container screens), where each of the container's +/// slots is (`Slot.x`, `Slot.y`; none hides the slot, and so do slots past +/// the list), and the player inventory's first row (vanilla's is 84; the +/// hotbar is 58 below it), from its top left. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct NativeScreen { + pub height: f32, + pub slots: Vec>, + pub inventory_y: f32, +} + +/// The mod screen open on this client: the block it is for, the key its +/// slots are under, and the menu's data slots as the server sent them. +pub(super) struct OpenNativeScreen { + key: Pos, + block: String, + screen: NativeScreen, + data: Vec, +} + /// The player as a native mod reads it. #[allow(dead_code)] #[derive(Clone, Debug, PartialEq)] @@ -373,6 +396,26 @@ impl Game<'_> { pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], entity: &mut ModBlockEntity) { let _ = (pos, entity); } + /// A menu opens on this client for block `block`, a mod's: set `screen` + /// when the block is yours, to lay its screen out (`native_screen_draw` + /// draws it). The server's menu stays as it is. API 3. + pub(super) fn native_screen(&self, block: &str, screen: &mut Option) { + let _ = (block, screen); + } + /// Draw block `block`'s screen at `origin` (its top left, GUI pixels), + /// over a plain panel with its slots' frames and under the items and + /// labels, from the menu's data slots `data` (the block entity's `data` + /// on the server). Draw with `native_sprite`, `native_fill` and + /// `native_text`. API 3. + pub(super) fn native_screen_draw( + &self, + block: &str, + data: &[i32], + batch: &mut HudBatch, + origin: [f32; 2], + ) { + let _ = (block, data, batch, origin); + } } /// Helpers for native mods, kept as stable as the hook points. @@ -434,6 +477,33 @@ impl Game<'_> { let [a, r, g, b] = argb.to_be_bytes(); batch.fill(&self.gui.atlas, x, y, w, h, rgba(r, g, b, a)); } + /// Part `src` (`[x, y, w, h]` in the sprite's own pixels) of sprite + /// `name` (vanilla's `gui/...`, or a content mod's GUI texture + /// `mymod:gui/...`), drawn stretched into `dst` (`[x, y, w, h]` in GUI + /// pixels). False when there is no such sprite. API 3. + pub(super) fn native_sprite( + &self, + batch: &mut HudBatch, + name: &str, + src: [f32; 4], + dst: [f32; 4], + ) -> bool { + let Some(mut r) = self.gui.atlas.sprites.get(name).copied() else { + return false; + }; + let (du, dv) = ((r.u1 - r.u0) / r.w as f32, (r.v1 - r.v0) / r.h as f32); + r.u0 += src[0] * du; + r.v0 += src[1] * dv; + r.u1 = r.u0 + src[2] * du; + r.v1 = r.v0 + src[3] * dv; + batch.rect_uv(dst[0], dst[1], dst[2], dst[3], r, u32::MAX); + true + } + /// The size of sprite `name` in its own pixels, if there is one. API 3. + pub(super) fn native_sprite_size(&self, name: &str) -> Option<[f32; 2]> { + let r = self.gui.atlas.sprites.get(name)?; + Some([r.w as f32, r.h as f32]) + } /// A line of text at `(x, y)` in GUI pixels, opaque `rgb`. pub(super) fn native_text( &self, @@ -657,6 +727,136 @@ impl Game<'_> { let c = self.world.block_entities.mod_container(state)?; mod_menu(c.menu.as_deref()?).map(|(menu, _)| menu) } + /// A menu opened on this client with its slots under `key`, on block + /// `target`: a mod block's is laid out by its mod (`native_screen`). + pub(super) fn open_native_screen(&mut self, key: Pos, target: Option) { + self.mod_screen = None; + let Some(t) = target else { + return; + }; + let block = self + .res + .facts() + .get(self.world.block(t.0, t.1, t.2)) + .name + .to_string(); + if block.starts_with("minecraft:") { + return; + } + let mut screen = None; + self.native_screen(&block, &mut screen); + if let Some(screen) = screen { + self.mod_screen = Some(OpenNativeScreen { + key, + block, + screen, + data: Vec::new(), + }); + } + } + /// The mod screen showing now, if any. + fn shown_native_screen(&self) -> Option<&OpenNativeScreen> { + self.mod_screen + .as_ref() + .filter(|s| self.screen == Screen::Inventory && self.container == Some(s.key)) + } + /// A data slot of the open mod screen's menu (`ContainerSetData`); false + /// when none is showing. + pub(super) fn native_screen_data(&mut self, id: i16, value: i16) -> bool { + if self.shown_native_screen().is_none() { + return false; + } + let Some(s) = self.mod_screen.as_mut() else { + return false; + }; + // A menu's data slot ids are small; a bad one is dropped. + if let Ok(i @ 0..256) = usize::try_from(id) { + if s.data.len() <= i { + s.data.resize(i + 1, 0); + } + s.data[i] = i32::from(value); + } + true + } + /// The open mod screen's height. + pub(super) fn native_screen_height(&self) -> Option { + self.shown_native_screen().map(|s| s.screen.height) + } + /// Where the open mod screen puts container slot `slot`. + pub(super) fn native_screen_slot(&self, slot: usize) -> Option<(f32, f32)> { + let s = self.shown_native_screen()?; + s.screen + .slots + .get(slot) + .copied() + .flatten() + .map(|p| (p[0], p[1])) + } + /// The container slots the open mod screen shows (300.. ids). + pub(super) fn native_screen_slots(&self) -> Option> { + let s = self.shown_native_screen()?; + let shown = s.screen.slots.iter().enumerate(); + Some( + shown + .filter(|(_, p)| p.is_some()) + .map(|(i, _)| 300 + i) + .collect(), + ) + } + /// The first row of the player's inventory on the open mod screen. + pub(super) fn native_screen_inventory_y(&self) -> Option { + self.shown_native_screen().map(|s| s.screen.inventory_y) + } + /// The open mod screen: a plain panel with its slots' frames, the mod's + /// drawing over it, then the title and the inventory's label. False when + /// none is showing. + pub(super) fn draw_native_screen(&self, batch: &mut HudBatch, ox: f32, oy: f32) -> bool { + let Some(s) = self.shown_native_screen() else { + return false; + }; + let atlas = &self.gui.atlas; + let (w, h) = (176.0, s.screen.height); + batch.fill(atlas, ox, oy, w, h, rgba(0, 0, 0, 255)); + batch.fill( + atlas, + ox + 1.0, + oy + 1.0, + w - 2.0, + h - 2.0, + rgba(198, 198, 198, 255), + ); + let container = self.native_screen_slots().unwrap_or_default(); + for i in (0..36).chain(container) { + let (x, y) = self.slot_position(i); + let (x, y) = (ox + x - 1.0, oy + y - 1.0); + batch.fill(atlas, x, y, 18.0, 18.0, rgba(55, 55, 55, 255)); + batch.fill( + atlas, + x + 1.0, + y + 1.0, + 17.0, + 17.0, + rgba(255, 255, 255, 255), + ); + batch.fill( + atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(139, 139, 139, 255), + ); + } + self.native_screen_draw(&s.block, &s.data, batch, [ox, oy]); + let grey = rgba(64, 64, 64, 255); + if let Some(title) = self.remote_menu_title() { + batch.text_styled(atlas, &title, ox + 8.0, oy + 6.0, grey, false); + } + let inventory = self.translate("container.inventory", "Inventory"); + let label_y = oy + s.screen.inventory_y - 11.0; + batch.text(atlas, &inventory, ox + 8.0, label_y, grey, false); + true + } /// The tick of the mod block entity at `pos`. pub(super) fn tick_mod_block_entity(&mut self, pos: Pos) { let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) else { diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 3676447414..208747c77a 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -113,7 +113,8 @@ impl Game<'_> { if self.screen != Screen::Inventory || self.station.is_some() { return None; } - if self.mount_active() { + // A mod's screen has no book. + if self.mount_active() || self.native_screen_height().is_some() { return None; } match self.container_kind() { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index e02ad88e81..1fdece7064 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2843,6 +2843,7 @@ impl Game<'_> { C::ContainerSetData(p) if self.remote_station_open(p.container) => { self.remote_station_data(usize::try_from(p.id).unwrap_or(usize::MAX), p.value); } + C::ContainerSetData(p) if self.native_screen_data(p.id, p.value) => {} C::ContainerSetData(p) => { let v = i32::from(p.value); match self @@ -6301,6 +6302,7 @@ impl Game<'_> { let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0)); self.world.block_entities.insert(key, entity); self.container = Some(key); + self.open_native_screen(key, target); } self.screen = Screen::Inventory; self.capture(false); diff --git a/native-hooks.json b/native-hooks.json index cf7dd76a24..1c594387cf 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -79,6 +79,16 @@ "name": "block_entity_container", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_block_entity_container" + }, + { + "name": "screen", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_screen" + }, + { + "name": "screen_draw", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_screen_draw" } ] } From d2e26c32d4f0cb6a7579aa395033816e6dab3c9f Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:30:27 +0100 Subject: [PATCH 212/968] Players with a missing skin sprite draw the default skin, not nothing A remote player or mannequin whose skin location had no atlas sprite (a stored slot without its upload, an evicted skin) drew no body. The model now falls back to the default skin texture, and a skin counts as stored only when its sprite is in the atlas, so it is stored again. Refs #4096 --- crates/horizon-client/src/game/remote.rs | 4 +++- crates/horizon-client/src/mobs/render.rs | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index ee09dfd9e3..7999bcadf9 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3576,7 +3576,9 @@ impl Game<'_> { /// again and it is drawn from the next frame. fn again_skin(&mut self, location: &str) -> bool { let mut downloads = self.skin_downloads.borrow_mut(); - if self.skin_slots.contains(location) { + // A slot without its sprite (the atlas was rebuilt, or the upload + // never happened) counts as missing, so the skin is stored again. + if self.skin_slots.contains(location) && self.gui.atlas.sprites.contains_key(location) { downloads.drawn(location); true } else { diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 516bb91a3b..c6ccbd06bc 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1349,7 +1349,16 @@ fn render_visible( let mut poses = Poses::new(&model); anim::setup(m, &s, &mut poses, &data.animations); let draw_body = |out: &mut Vec| { - if body_visible && let Some(uv) = atlas(&texture) { + // A player or mannequin whose skin has no atlas sprite (an evicted or + // never stored download) draws with its default skin, not invisibly. + let uv = atlas(&texture).or_else(|| { + if avatar(m) { + atlas(&m.kind.texture) + } else { + None + } + }); + if body_visible && let Some(uv) = uv { draw( &model, base, From 91bd434281fa36c6bbe9738d79900ec489128fdb Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:31:53 +0100 Subject: [PATCH 213/968] Apply block_break_speed attribute to mining speed Read the player's minecraft:block_break_speed attribute and multiply the destroy speed by it in both the server mining tick and the client's destroy-progress prediction, matching Player.getDestroySpeed. Fixes #4094. --- crates/horizon-client/src/game.rs | 2 ++ .../src/game/enchant_effects.rs | 31 +++++++++++++++++++ crates/horizon-client/src/game/hand.rs | 4 ++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a795c6e626..990d1d7bf8 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5060,6 +5060,7 @@ impl Game<'_> { ticks: 0, }); } + let block_break_speed = self.block_break_speed(); let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); let facts = self.res.facts().get(state); if facts.hardness < 0.0 { @@ -5074,6 +5075,7 @@ impl Game<'_> { let held = self.held(); let mut speed = self.res.items().mining_speed(held, &facts.name); speed = self.modify_destroy_speed(speed); + speed *= block_break_speed; let player = self.player.as_ref().ok_or("no player")?; if !player.ground.on_ground { speed /= 5.0; diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index ea34bb6413..46b071b8ad 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -25,6 +25,12 @@ enum MendSlot { Armor(usize), } +/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player +/// without the attribute keeps the vanilla default of 1.0. +fn block_break_speed_factor(value: Option) -> f32 { + value.map_or(1.0, |value| value as f32) +} + impl Game<'_> { /// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste /// and mining fatigue. @@ -39,6 +45,15 @@ impl Game<'_> { } speed * self.potions.effects.mining_speed_factor() } + /// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor: + /// 1.0 by default, so a missing player or attribute changes nothing. + pub(super) fn block_break_speed(&mut self) -> f32 { + block_break_speed_factor( + self.player + .as_mut() + .and_then(|p| p.attribute("minecraft:block_break_speed").ok()), + ) + } /// `Attributes.SUBMERGED_MINING_SPEED` (0.2, raised by aqua affinity). pub(super) fn submerged_mining_speed(&self) -> f32 { let mut total = 0.0; @@ -1010,3 +1025,19 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::block_break_speed_factor; + + /// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed. + #[test] + fn block_break_speed_is_a_destroy_speed_multiplier() { + assert_eq!(block_break_speed_factor(None), 1.0); + assert_eq!(block_break_speed_factor(Some(1.0)), 1.0); + assert_eq!(block_break_speed_factor(Some(0.5)), 0.5); + assert_eq!(block_break_speed_factor(Some(2.0)), 2.0); + assert_eq!(8.0 * block_break_speed_factor(Some(0.5)), 4.0); + assert_eq!(8.0 * block_break_speed_factor(Some(2.0)), 16.0); + } +} diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c241208948..1371475895 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -155,7 +155,8 @@ impl Game<'_> { Ok(()) } /// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`. - pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 { + pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 { + let block_break_speed = self.block_break_speed(); let state = self.world.block(pos[0], pos[1], pos[2]); let facts = self.res.facts().get(state); if facts.hardness == -1.0 { @@ -164,6 +165,7 @@ impl Game<'_> { let held = self.held(); let mut speed = self.res.items().mining_speed(held, &facts.name); speed = self.modify_destroy_speed(speed); + speed *= block_break_speed; if let Some(eye) = self.eye_position() && self .res From 723a2a44cbe6f6e88ced4550d2c16d8b072b07cc Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 03:38:55 +0300 Subject: [PATCH 214/968] Native mods: set a block's property from a tick (a machine's lit) native_set_block_property (a helper, API 3): as a furnace's tick sets its lit, so a machine shows when it works. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_hooks.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 90077e4a67..1b3f11a16b 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -390,6 +390,31 @@ impl Game<'_> { /// entity, and for the one ticking now, which is out of the world. As /// its `setChanged` would, its chunk is saved next time and comparators /// read it again. API 3. + /// Sets property `key` of the block at `pos` to `value`, as a furnace's + /// tick sets its `lit` (`Level.setBlock`); false when the block has no + /// such property or value. The block entity stays. API 3. + pub(super) fn native_set_block_property( + &mut self, + pos: [i32; 3], + key: &str, + value: &str, + ) -> bool { + let registry = self.pack.assets.registry; + let state = self.world.block(pos[0], pos[1], pos[2]); + let Some(mut s) = registry.state(state).cloned() else { + return false; + }; + match s.properties.get(key) { + Some(v) if v == value => return true, + Some(_) => {} + None => return false, + } + s.properties.insert(key.into(), value.into()); + let Ok(id) = registry.resolve(&s) else { + return false; + }; + self.world.set(pos[0], pos[1], pos[2], id).is_ok() + } pub(super) fn native_mod_block_entity_at( &mut self, pos: [i32; 3], From 7fe6f090e10feb70f5d79023e19c705f67724ba2 Mon Sep 17 00:00:00 2001 From: Benflex Date: Fri, 30 Oct 2026 01:43:11 +0100 Subject: [PATCH 215/968] Draw item tooltips over inventory effect panels The survival and creative inventories drew the active-effect panels after the item tooltip, so the panel backgrounds covered the tooltip text. Draw the panels with the rest of the screen and keep only the hovered effect's tooltip for the end, after the item and slot tooltips. Fixes #4080. --- crates/horizon-client/src/game.rs | 15 ++++++++++++--- crates/horizon-client/src/game/potions.rs | 9 ++++----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a795c6e626..8cca940cb6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6911,10 +6911,16 @@ impl Game<'_> { rgba(255, 255, 255, 128), ); } + let effect_tip = if self.container.is_none() && self.craft_size == 2 && !self.mount_active() + { + self.draw_inventory_effects(batch, (ox, oy), 176) + } else { + None + }; self.draw_carried(batch); self.draw_slot_tooltip(batch); - if self.container.is_none() && self.craft_size == 2 && !self.mount_active() { - self.draw_inventory_effects(batch, (ox, oy), 176); + if let Some(lines) = effect_tip { + self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); } } /// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it @@ -11691,6 +11697,7 @@ impl Game<'_> { oy + Creative::SCROLL_Y + self.creative_screen.scroller_offset(), ); } + let effect_tip = self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32); for t in &tabs { let (tx, ty) = Creative::tab_position(t); if self.cursor.0 >= ox + tx @@ -11734,7 +11741,9 @@ impl Game<'_> { // A `CustomCreativeSlot`: TooltipFlag.asCreative. self.draw_stack_tooltip_with(batch, &item, grid, self.cursor.0, self.cursor.1, tabs); } - self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32); + if let Some(lines) = effect_tip { + self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); + } } fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool { let (ox, oy) = Self::creative_origin(batch); diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index eef14fae08..cd06a9adc5 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -1272,12 +1272,13 @@ impl Game<'_> { hud_layout::paint(batch, &self.gui.atlas, &draws); } /// `EffectsInInventory`: panels to the right of survival/creative inventory. + /// The caller draws the returned tooltip after every other tooltip. pub(super) fn draw_inventory_effects( &self, batch: &mut HudBatch, origin: (f32, f32), image_width: i32, - ) { + ) -> Option<[(String, u32); 2]> { let panels = inventory_effects::panels( batch.width as i32, [origin.0 as i32, origin.1 as i32], @@ -1287,14 +1288,12 @@ impl Game<'_> { &|key| self.pack.names.display_get(key), &|text| self.gui.atlas.font.width(text).ceil() as i32, ); - if let Some(lines) = inventory_effects::paint( + inventory_effects::paint( batch, &self.gui.atlas, &panels, [self.cursor.0 as i32, self.cursor.1 as i32], - ) { - self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); - } + ) } /// Effects saved in level.dat (`Player.active_effects`). pub(super) fn restore_effects(&mut self) { From edefd8ef0c06a06da2f784186f4fdcd15225d84e Mon Sep 17 00:00:00 2001 From: Faux <36577807+Fauxvale@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:48:18 -0700 Subject: [PATCH 216/968] Far terrain: kelp is water, and powder snow and paths are kept A chunk's summary skipped blocks that neither draw solid nor collide as a full block. Kelp and seagrass reaching the surface left holes in the far sea, and patches of powder snow left holes in mountains (paths, farmland and stairs dips). - Blocks holding water that aren't kept (kelp, seagrass, bubble columns, waterlogged ones) are drawn as water, so the water is one span. - Powder snow, dirt paths, farmland, honey blocks and stairs are kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012gFJzABp1uePB4gm32PuaC --- crates/horizon-client-render/src/lod.rs | 119 ++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client-render/src/lod.rs b/crates/horizon-client-render/src/lod.rs index 96159625df..1d01f0e73e 100644 --- a/crates/horizon-client-render/src/lod.rs +++ b/crates/horizon-client-render/src/lod.rs @@ -186,8 +186,15 @@ impl Column { self.push(span); } } - /// `kinds`: `state_kinds`, per block state. - fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self { + /// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`, + /// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as. + fn from_chunk( + chunk: &Chunk, + kinds: &[u8], + water: Option, + x: i32, + z: i32, + ) -> Self { let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0); let face_light = |y: i32| { let mut sky = 0; @@ -222,11 +229,17 @@ impl Column { let step = if uniform { 16 } else { 1 }; for offset in (0..16).step_by(step) { let y = min + offset; - let state = BlockStateId(chunk.get(x, y, z)); - let kind = kind(state); + let mut state = BlockStateId(chunk.get(x, y, z)); + let mut kind = kind(state); if kind & KEPT == 0 { continue; } + if kind & WATERY != 0 + && let Some(water) = water + { + state = water; + kind = kinds[water.0 as usize]; + } let light = face_light(y); column.push(Span { min: y as i16, @@ -269,15 +282,28 @@ impl Column { } /// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker. fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> { + let water = water_source(kinds); Arc::new(std::array::from_fn(|i| { - Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32) + Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32) })) } -/// `state_kinds` bits: kept in columns, a fluid, the air state; the light -/// emission is in the high nibble. +/// The water source state: the first plain kept fluid without light +/// (`minecraft:water` comes before lava, and its level 0 first). +fn water_source(kinds: &[u8]) -> Option { + kinds + .iter() + .position(|&kind| kind == KEPT | FLUID) + .map(|i| BlockStateId(i as u32)) +} +/// `state_kinds` bits: kept in columns, a fluid, the air state, a block +/// holding water; the light emission is in the high nibble. const KEPT: u8 = 1; const FLUID: u8 = 2; const AIR: u8 = 4; +/// A block holding water that draws as none (kelp, seagrass, bubble columns, +/// waterlogged blocks that aren't kept): far terrain draws water there, or +/// kelp reaching the surface leaves holes in the sea. +const WATERY: u8 = 8; /// What `Column::from_chunk` needs of each block state, read from the facts /// once: matching their names for every block made observing a chunk slow. fn state_kinds(facts: &BlockFacts) -> Vec { @@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec { || f.full_collision || f.name.ends_with("_leaves") || chorus - || fluid); - (u8::from(kept) * KEPT) - | (u8::from(fluid) * FLUID) + || fluid + || nearly_full(&f.name)); + let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water; + (u8::from(kept || watery) * KEPT) + | (u8::from(fluid || watery) * FLUID) + | (u8::from(watery) * WATERY) | (u8::from(state == facts.air) * AIR) | (f.emission.min(15) << 4) }) .collect() } +/// Blocks that fill their cell but neither draw solid nor collide as a full +/// block: far terrain draws them as blocks, or patches of powder snow leave +/// holes in mountains (and paths and fields dips). +fn nearly_full(name: &str) -> bool { + matches!( + name, + "minecraft:powder_snow" + | "minecraft:dirt_path" + | "minecraft:farmland" + | "minecraft:honey_block" + ) || name.ends_with("_stairs") +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TileKey { pub x: i32, @@ -1840,6 +1881,40 @@ mod tests { } } #[test] + fn nearly_full_blocks_are_kept() { + assert!(nearly_full("minecraft:powder_snow")); + assert!(nearly_full("minecraft:dirt_path")); + assert!(nearly_full("minecraft:oak_stairs")); + assert!(!nearly_full("minecraft:oak_fence")); + assert!(!nearly_full("minecraft:wheat")); + } + #[test] + fn kelp_to_the_surface_is_drawn_as_water() { + // 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none). + let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY]; + let mut chunk = Chunk::empty(0, 0); + for x in [3, 4] { + for y in MIN_Y..40 { + chunk.set(x, y, 3, 1); + } + } + for y in 40..63 { + chunk.set(3, y, 3, 3); + chunk.set(4, y, 3, 2); + } + let water = water_source(&kinds); + assert_eq!(water, Some(BlockStateId(2))); + let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3); + let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3); + assert_eq!(kelp.spans, sea.spans); + assert_eq!( + kelp.spans.last().map(|s| (s.min, s.max, s.state)), + Some((40, 63, BlockStateId(2))) + ); + assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2)))); + assert_eq!(kelp.surface.unwrap().height, 40); + } + #[test] fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() { let span = |min, max, state| Span { min, @@ -2035,6 +2110,24 @@ mod tests { chunk.set(8, 71, 8, chorus.0); chunk.set(8, 72, 8, chorus.0); chunk.set(8, 73, 8, flower.0); + // Kelp to the surface is water; powder snow and a waterlogged stair + // are kept as themselves. + let state = |name: &str| assets.registry.default_state(name).unwrap(); + let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow")); + let stair = facts + .states + .iter() + .position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged) + .map(|i| BlockStateId(i as u32)) + .unwrap(); + chunk.set(2, 54, 2, stone.0); + for y in 55..64 { + chunk.set(2, y, 2, kelp.0); + } + chunk.set(6, 90, 6, stone.0); + chunk.set(6, 91, 6, powder_snow.0); + chunk.set(10, 54, 10, stone.0); + chunk.set(10, 55, 10, stair.0); lod.observe(Arc::new(chunk.clone()), &facts); receive_observed(&mut lod); let observed = &lod.saved[&(-1, -1)]; @@ -2052,6 +2145,12 @@ mod tests { .collect::>(), vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)] ); + let kelp_column = &observed[2 * 16 + 2]; + assert_eq!(kelp_column.surface.unwrap().height, 55); + assert_eq!(kelp_column.surface.unwrap().water, Some((64, water))); + assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow); + assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92); + assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair); let fine = TileKey { x: -1, From 5c4eb67ba220330d08eb9ec210258e503fb6a6a3 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:50:50 +0100 Subject: [PATCH 217/968] Interpolate elytra lean velocity from the previous tick --- crates/horizon-client/src/game/perspective.rs | 1 + crates/horizon-client/src/mobs.rs | 3 +++ crates/horizon-client/src/mobs/render.rs | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index ead71de8aa..41cecc82c0 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -439,6 +439,7 @@ impl Game<'_> { m.extra.armor = armor; m.extra.crouching = crouching; m.extra.passenger = riding; + m.extra.previous_velocity = m.velocity; m.velocity = velocity; m.extra.fall_flying = fall_flying; m.extra.fall_fly_ticks = fly_ticks; diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 55abeceecd..5b9b001352 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -826,6 +826,9 @@ pub struct Extra { pub blocking: bool, pub fall_flying: bool, pub fall_fly_ticks: i32, + /// `Entity.deltaMovementOnPreviousTick`: the velocity before this tick's + /// move, which the elytra lean interpolates from (`getDeltaMovementLerped`). + pub previous_velocity: Vec3, /// `Entity.DATA_POSE` of another player, as the server sent it. pub synced_pose: u8, /// `LivingEntity.isAutoSpinAttack` (Riptide). diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 516bb91a3b..988640919a 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -956,7 +956,7 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 } let yaw = m.previous_head_yaw + wrap(m.head_yaw - m.previous_head_yaw) * alpha; let (look_x, look_z) = (-(yaw.to_radians()).msin(), (yaw.to_radians()).mcos()); - let v = m.velocity; + let v = m.extra.previous_velocity.lerp(m.velocity, f64::from(alpha)); let view_scale = (pitch.to_radians()).mcos(); let (look_x, look_z) = (look_x * view_scale, look_z * view_scale); let view_length = f64::from(look_x * look_x + look_z * look_z); From d14b3e27ba10b4e925ac3f391e65867ca30a90e2 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 01:59:19 +0100 Subject: [PATCH 218/968] Save the previous velocity for LAN guest bodies too --- crates/horizon-client/src/game/lan.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5eb767ad15..c19bc0cc9b 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -2185,6 +2185,7 @@ impl Game<'_> { m.extra.fall_flying = body.gliding; m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 }; m.extra.auto_spin_attack = body.spin_attack; + m.extra.previous_velocity = m.velocity; m.velocity = g .motion .as_ref() From 6aff9307fefcaf43fcdacf2e8f63262130961ce7 Mon Sep 17 00:00:00 2001 From: kerim Date: Fri, 30 Oct 2026 02:08:34 +0100 Subject: [PATCH 219/968] add more german translations --- launcher/lang/de.json | 106 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 861195e1fb..84b26f81a5 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -61,11 +61,12 @@ "Sign out": "Abmelden", "Offline": "Offline", "Microsoft": "Microsoft", - "Offline, the game's name": "Offline, der Name des Spiels", + "Offline, the game's name": "Offline Spiel", + "New name": "Neuer Name", "Manage accounts": "Konten verwalten", "Offline name": "Offline-Name", "Offline name · plays": "Offline-Name · spielt", - "A name, 3 to 16 letters": "Ein Name, 3 bis 16 Buchstaben", + "A name, 3 to 16 letters": "Name, 3 bis 16 Buchstaben", "Play offline under another name, and switch from the top bar. Online servers don't take offline names.": "Spiele offline unter einem anderen Namen und wechsel über die obere Leiste. Online-Server akzeptieren keine Offline-Namen.", "Add": "Hinzufügen", "Skin…": "Skin…", @@ -132,6 +133,7 @@ "Instance": "Instanz", "Edit…": "Bearbeiten…", "Copy…": "Kopieren…", + "Copy": "Kopieren", "Export…": "Exportieren…", "Close export": "Export schließen", "Launch…": "Start…", @@ -173,6 +175,29 @@ "Not prepared. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Nicht vorbereitet. Horizon kann Minecraft {version} von Mojang für dein Konto herunterladen oder deine eigene Installation verwenden.", "Not prepared. Sign in with the Microsoft account that owns Minecraft {version} first.": "Nicht vorbereitet. Melde dich zuerst mit dem Microsoft-Konto an, das Minecraft {version} besitzt.", "Cancel": "Abbrechen", + "FOLLOWS UPDATES": "FOLGT AKTUALISIERUNGEN", + "This instance follows updates to {version}.": "Diese Instanz folgt den Aktualisierungen von {version}.", + "This instance follows updates to {version}, but is not prepared yet.": "Diese Instanz folgt den Aktualisierungen von {version}, ist aber noch nicht vorbereitet.", + "This instance follows updates to {version}, but is not prepared yet. Horizon can download Minecraft {version} from Mojang for your account, or use your own install.": "Diese Instanz folgt den Aktualisierungen von {version}, ist aber noch nicht vorbereitet. Horizon kann Minecraft {version} von Mojang für dein Konto herunterladen oder deine eigene Installation verwenden.", + "Manage versions": "Versionen verwalten", + "{number} PLAYING NOW": "{number} AM SPIELEN", + "Creative": "Kreativ", + "Survival": "Überleben", + "Adventure": "Abenteuer", + "Spectator": "Zuschauer", + "Hardcore": "Hardcore", + "Worlds": "Welten", + "Back up": "Sichern", + "Delete": "Löschen", + "Data packs": "Datenpakete", + "Remove": "Entfernen", + "Add server": "Server hinzufügen", + "Packs": "Pakete", + "Add pack...": "Paket hinzufügen...", + "RESOURCE PACKS": "RESSOURCENPAKETE", + "None yet: drop a .zip here, or find some on the Mods page.": "Noch keine: Ziehe eine .zip-Datei hierher oder finde welche auf der Mods-Seite.", + "SHADER PACKS": "SHADER-PAKETE", + "None yet: drop a .hshader or a shader pack .zip here.": "Noch keine: Ziehe eine .hshader- oder eine Shader-Paket .zip-Datei hierher.", "Native mods (experimental)": "Native Mods (experimentell)", "Native mods from the Mods page compile into the game with the launcher's own Rust. Not sandboxed: they can do anything you can on this computer.": "Native Mods von der Mods-Seite werden mit dem launcher-eigenen Rust in das Spiel kompiliert. Nicht in einer Sandbox: Sie können alles tun, was du auf diesem Computer tun kannst.", "{version} plays in this instance.": "Diese Instanz spielt {version}.", @@ -181,5 +206,80 @@ "These mods will run with full access to your computer": "Diese Mods erhalten vollen Zugriff auf deinen Computer", "They can read, change or delete your files, and use your accounts and network, like any program you run. Build them only if you trust their authors: the Mods page names them.": "Sie können deine Dateien lesen, ändern oder löschen und deine Konten sowie dein Netzwerk nutzen wie jedes andere Programm, das du ausführst. Kompiliere sie nur, wenn du ihren Autoren vertraust: Die Mods-Seite nennt sie.", "Mods: {mods}": "Mods: {mods}", - "The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal etwa zehn Minuten und einige GB Speicherplatz, danach einige Minuten." + "The build compiles the game: about ten minutes and a few GB of disk the first time, a few minutes after that.": "Der Build kompiliert das Spiel: beim ersten Mal etwa zehn Minuten und einige GB Speicherplatz, danach einige Minuten.", + "Mods and shader packs are made for Horizon from its own reviewed registry, and Java mods, modpacks and resource packs from Modrinth: Horizon ports what a Java mod carries (blocks, items, recipes, worldgen) and says before you install what runs. Each file is checked against its source's checksum before it reaches your game folder.": "Mods und Shader-Pakete werden für Horizon aus dem eigenen geprüften Registry erstellt, und Java-Mods, Modpacks und Ressourcenpakete von Modrinth: Horizon portiert, was eine Java-Mod mitbringt (Blöcke, Gegenstände, Rezepte, Weltgenerierung) und informiert dich darüber, was läuft, bevor du es installierst. Jede Datei wird vor dem Erreichen deines Spielordners gegen die Prüfsumme ihrer Quelle überprüft.", + "Resource packs": "Ressourcenpakete", + "Shader packs": "Shader-Pakete", + "Show everything": "Alles anzeigen", + "Search, then Enter": "Suchen, dann Enter", + "(coming soon)": "(demnächst verfügbar)", + "Best match": "Beste Übereinstimmung", + "Relevance": "Relevanz", + "Follows": "Folgt", + "Newest": "Neueste", + "Updated": "Aktualisiert", + "Filter": "Filter", + "Installs into {instance}: pick another instance on the left.": "Installiert in {instance}: Wähle links eine andere Instanz aus.", + "Your modpacks": "Deine Modpacks", + "Import a pack file...": "Eine Pack-Datei importieren...", + "Import an instance folder...": "Einen Instanz-Ordner importieren...", + ".mrpack (Modrinth), .zip (CurseForge), or a Prism, MultiMC or CurseForge instance. Horizon ports what it can on this computer, from your own downloads, and plays the pack as its own profile. Nothing is uploaded or shared.": ".mrpack (Modrinth), .zip (CurseForge) oder eine Prism-, MultiMC- oder CurseForge-Instanz. Horizon portiert von deinen eigenen Downloads, was es auf diesem Computer kann, und spielt das Pack als eigenes Profil. Nichts wird hochgeladen oder geteilt.", + "No modpack imported yet: import one, or find one below.": "Noch kein Modpack importiert: Importiere eins oder finde unten eins.", + "Browse modpacks": "Modpacks durchsuchen", + "Partly": "Teilweise", + "Fully": "Vollständig", + "Not at all": "Inkompatibel", + "Import": "Importieren", + "Nothing in Horizon's registry matches yet.": "Nichts in Horizons Verzeichnis stimmt überein.", + "Installs into a world of {instance}; the game turns the pack on when the world opens.": "Installiert in eine Welt von {instance}; das Spiel aktiviert das Paket, wenn die Welt geöffnet wird.", + "Host a world for your friends on this computer. Create one, press Start, and give them the address.": "Hoste eine Welt für deine Freunde auf diesem Computer. Erstelle eine, drücke Start und gib ihnen die Adresse.", + "Create a server": "Server erstellen", + "Add a server folder...": "Einen Server-Ordner hinzufügen...", + "No server yet. Create one: it takes a few seconds, and with the defaults (tick the EULA, press Create) you get a friends-only survival world.": "Noch kein Server vorhanden. Erstelle einen: Es dauert ein paar Sekunden, und mit den Standardeinstellungen (EULA ankreuzen & auf Erstellen drücken) erhältst du eine Überlebenswelt nur für Freunde.", + "Builds from GitHub. Each one installs into its own folder.": "Builds von GitHub. Jeder Build installiert sich in seinen eigenen Ordner.", + "Use": "Verwenden", + "INSTALLED": "INSTALLIERT", + "Downloads, sign-ins and updates go through this HTTP proxy (http://host:port). Empty means none: HTTPS_PROXY is used instead when the launcher was started with one.": "Downloads, Anmeldungen und Updates laufen über diesen HTTP-Proxy (http://host:port). Leer bedeutet keiner: HTTPS_PROXY wird stattdessen verwendet, wenn der Launcher mit einem gestartet wurde.", + "For example --server-thread": "Zum Beispiel --server-thread", + "Game arguments": "Spielargumente", + "Extra arguments for the game, passed through as-is (quotes group a word with spaces). Applies the next time the game starts.": "Zusätzliche Argumente für das Spiel, die unverändert weitergegeben werden (Anführungszeichen gruppieren ein Wort mit Leerzeichen). Wird beim nächsten Start des Spiels angewendet.", + "Data collection": "Datenerfassung", + "Each switch is off unless you turn it on. What it sends is below it; the privacy page on playhorizon.dev says how long it is kept.": "Jede Option ist ausgeschaltet, es sei denn, du schaltest sie ein. Was sie sendet, steht darunter; die Datenschutzseite auf playhorizon.dev sagt, wie lange die Daten gespeichert werden.", + "Performance statistics": "Leistungsstatistiken", + "After each session: frame-time percentiles and a 14-bucket histogram, tick times, peak memory and startup times, with your machine (OS, CPU and its cores and clocks, memory size, GPU. its memory and driver, drive type, screen size and refresh rate, laptop or desktop, on the battery or not) and the settings that change performance.": "Nach jeder Sitzung: Frame-Zeit-Prozentwerte und ein 14-Bucket-Histogramm, Tick-Zeiten, Spitzenwerte für Speicher und Startzeiten, mit deinem Gerät (Betriebssystem, CPU und deren Kerne und Taktfrequenzen, Speichergröße, GPU, deren Speicher und Treiber, Laufwerkstyp, Bildschirmgröße und Bildwiederholrate, Laptop oder Desktop, mit oder ohne Akku) und den Einstellungen, die die Leistung beeinflussen.", + "Crash reports": "Absturzberichte", + "When the game crashes: the error, where in Horizon's code it happened, your graphics adapter and settings. No log, no world or server, no user name in paths.": "Wenn das Spiel abstürzt: der Fehler, wo er im Code von Horizon aufgetreten ist, dein Grafikadapter und die Einstellungen. Es wird kein Protokoll, keine Welt oder Server, kein Benutzername in den Pfaden angegeben.", + "Bug reports": "Fehlerberichte", + "Only the reports you write and send yourself, with the game's log if you add it after reading it.": "Nur die Berichte, die du selbst schreibst und sendest, mit dem Spielprotokoll, wenn du es nach dem Lesen hinzufügst.", + "Benchmark uploads": "Benchmark-Uploads", + "The results of benchmarks you run, for the community results on playhorizon.dev.": "Die Ergebnisse von Benchmarks, die du ausführst, für die Community-Ergebnisse auf playhorizon.dev.", + "While you play, the game sends an anonymous \"I'm playing\" ping once a minute, so the launcher can show how many people play Horizon right now. It holds: a random number made when the game starts and forgotten when it closes, the Horizon version, your OS (Linux, macOS or Windows), and whether you have a world open to LAN or friends. It doesn't hold: your account, your install ID, world or server names, or anything you do in the game, and the server doesn't keep your IP address. It is sent whatever the switches say.": "Während du spielst, sendet das Spiel einmal pro Minute ein anonymes „Ich spiele“-Signal, damit der Launcher anzeigen kann, wie viele Personen gerade Horizon spielen. Es enthält: eine Zufällige Zahl, die beim Start des Spiels erzeugt und beim Schließen verworfen wird, die Horizon-Version, dein Betriebssystem (Linux, macOS oder Windows) und ob du eine Welt für LAN oder Freunde geöffnet hast. Es enthält nicht: dein Konto, deine Installations-ID, Welt- oder Servernamen oder irgendetwas, das du im Spiel tust; außerdem speichert der Server deine IP-Adresse nicht. Es wird unabhängig von den Einstellungen der Option gesendet.", + "ALWAYS ON": "IMMER AN", + "Your data": "Deine Daten", + "No install ID: nothing has been collected · {count} waiting to be sent": "Keine Installations-ID: Es wurden keine Daten gesammelt · {count} warten darauf, gesendet zu werden", + "Install ID: {id} · {count} waiting to be sent": "Installations-ID: {id} · {count} warten darauf, gesendet zu werden", + "Install ID: {id} · {count} sent": "Installations-ID: {id} · {count} gesendet", + "Install ID: {id} · {count} sent, {count2} waiting to be sent": "Installations-ID: {id} · {count} gesendet, {count2} warten darauf, gesendet zu werden", + "Never sent: your account name or UUID, world names, server addresses, chat, or your IP address. Each report is dated to the day, under a random install ID you can delete.": "Nie gesendet: dein Kontoname oder UUID, Welt-Namen, Server-Adressen, Chat oder deine IP-Adresse. Jeder Bericht ist mit dem Datum versehen und unter einer zufälligen Installations-ID gespeichert, die du löschen kannst.", + "Launcher updated to {version}": "Launcher auf {version} aktualisiert", + "New instance": "Neue Instanz", + "My instance": "Meine Instanz", + "START FROM": "STARTEN VON", + "Empty": "Leer", + "A copy of the selected one": "Eine Kopie der ausgewählten", + "Its own worlds and packs; your options and player name come along.": "Eigene Welten und Pakete; deine Optionen und dein Spielername werden übernommen.", + "GROUP": "GRUPPE", + "No group": "Keine Gruppe", + "ICON": "Symbol", + "NONE": "KEINS", + "Import a modpack...": "Ein Modpack importieren...", + "No AI": "Keine KI", + "On: every AI feature is hidden and off. The game opens no control socket, and horizon-ctl and the MCP servers refuse.": "An: Jede KI-Funktion ist verborgen und ausgeschaltet. Das Spiel öffnet keinen Kontroll-Socket, und horizon-ctl sowie die MCP-Server lehnen ab.", + "Hides and turns off every AI feature: agent control of the launcher and the game, horizon-ctl and the MCP servers.": "Verbirgt und schaltet jede KI-Funktion aus: Agentensteuerung des Launchers und des Spiels, horizon-ctl und die MCP-Server.", + "View what would be sent": "Anzeigen, was gesendet werden würde", + "Delete my data": "Meine Daten löschen", + "Support the work below": "Unterstütze die Arbeit unten", + "Horizon is built in the open. Sponsors fund the next release.": "Horizon wird offen entwickelt. Sponsoren finanzieren die nächste Version.", + "What changed in each release, newest first.": "Was sich in jeder Version geändert hat, die neueste zuerst.", + "IN USE": "IN BENUTZUNG" } From 7cb03beeede53d610f0b95cbf7baee75ef81ff58 Mon Sep 17 00:00:00 2001 From: Benflex Date: Fri, 30 Oct 2026 02:17:10 +0100 Subject: [PATCH 220/968] Play the merchant's yes sound on a shift-clicked trade MerchantMenu.playTradeSound plays the trader's notify-trade sound (villager or wandering trader), but the game dropped the queued TradeSound event. Play it at the trade's origin with volume and pitch 1. Part of #3515. --- crates/horizon-client/src/game/trading.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index c2aa273d84..f2c0b9109c 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -451,7 +451,14 @@ impl Game<'_> { merchant::Event::Traded(index) => { traded.extend(session.offers.get(index).map(|o| o.result.clone())); } - merchant::Event::TradeSound => {} + merchant::Event::TradeSound => { + let sound = if villager { + "minecraft:entity.villager.yes" + } else { + "minecraft:entity.wandering_trader.yes" + }; + self.audio.play(sound, Some(origin), 1.0, 1.0); + } } } (session, traded) From 29363ee3e9fdae134d9a3fcd29ddb5d25d4e1484 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:19:33 +0100 Subject: [PATCH 221/968] Apply the held item's main-hand modifiers to the player's attributes /attribute reported the base attack damage, attack speed and reach with a weapon in hand because apply_equipment skipped the main-hand slot. Apply them and refresh when the held item changes. Fixes #4118. --- crates/horizon-client/src/game.rs | 29 ++++++++++++++++++++------ crates/horizon-client/src/game/hand.rs | 1 + 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index faf1d9a210..d83dff80ad 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1402,6 +1402,14 @@ struct DragView { slots: HashMap>, left: Option, } +/// Whether an item modifier of slot `group` applies in the worn or held `slot` +/// (`EquipmentSlotGroup.test`). +fn equipment_modifier_applies(group: &str, slot: &str) -> bool { + group == slot + || group == "any" + || (group == "armor" && slot != "mainhand") + || (group == "hand" && slot == "mainhand") +} /// Ctrl+Q reads the physical keyboard, independently of sprint key mappings. /// A chunk whose ticking future can complete (`ChunkMap.prepareTickingChunk`): /// lit, with its neighbours loaded, as `postprocess_chunks` waits for. @@ -9920,12 +9928,9 @@ impl Game<'_> { continue; }; for (attribute, id, amount, op, group) in &info.modifiers { - let matches = group == slot - || group == "any" - || (group == "armor" && slot != "mainhand") - || (group == "hand" && slot == "mainhand"); - // Main-hand attack modifiers are read by the attack code directly. - if !matches || slot == "mainhand" { + // The attack code still reads the held item's attack damage and + // speed directly; the map copy is what `/attribute` reports. + if !equipment_modifier_applies(group, slot) { continue; } if player @@ -16356,6 +16361,18 @@ mod entity_model_loading_tests; mod tests { use super::*; use horizon_client::container_screen::{Button, Clicks, Release, Target}; + + /// Held items apply their main-hand modifiers to the player (#4118). + #[test] + fn equipment_modifiers_follow_their_slot_group() { + assert!(equipment_modifier_applies("mainhand", "mainhand")); + assert!(equipment_modifier_applies("hand", "mainhand")); + assert!(equipment_modifier_applies("any", "mainhand")); + assert!(!equipment_modifier_applies("armor", "mainhand")); + assert!(!equipment_modifier_applies("mainhand", "head")); + assert!(equipment_modifier_applies("armor", "chest")); + assert!(!equipment_modifier_applies("hand", "feet")); + } use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys}; /// A key bound to key.attack keeps attacking while it is held: each diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c241208948..a6a51a6e49 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -263,6 +263,7 @@ impl Game<'_> { rest: format!("{} {} {:?}", s.count, s.damage, s.extra), })) { self.attack_ticks = 0; + self.apply_equipment(); } self.attack_item = main.as_ref().map(|s| s.item.clone()); let next = [(self.selected, main), (OFFHAND, self.offhand.clone())]; From fc26a5f40acd9490e3d71dcd959ac0e26dd219ac Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:31:19 +0100 Subject: [PATCH 222/968] Seat armor stands that ride a mob or the player (frozen name tag) A server's name tag hologram rides the player as an armor stand. The client ignored SetPassengers for armor stands, so the stand stayed where it spawned while the real name tag followed the player. --- crates/horizon-client/src/entities.rs | 5 ++++ crates/horizon-client/src/game/passengers.rs | 26 +++++++++++++++++++ .../src/game/remote_vehicles.rs | 7 +++++ 3 files changed, 38 insertions(+) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index c2e3d4a5b1..7b6527a8d2 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -779,6 +779,10 @@ pub struct ArmorStand { /// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right /// arm, left leg, right leg, in degrees (`POSE_KEYS` order). pub pose: [[f32; 3]; 6], + /// `Entity.vehicle` on a server's client (`SetPassengers`): the mob it + /// rides, which seats it every tick (`positionRider`), as NPC plugins hang + /// a second name over a player's head. + pub vehicle: Option, } impl ArmorStand { pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"]; @@ -813,6 +817,7 @@ impl ArmorStand { name_visible: false, last_hit: i64::MIN / 2, pose: Self::DEFAULT_POSE, + vehicle: None, } } pub fn environment_tick( diff --git a/crates/horizon-client/src/game/passengers.rs b/crates/horizon-client/src/game/passengers.rs index c51f843653..7cde83a271 100644 --- a/crates/horizon-client/src/game/passengers.rs +++ b/crates/horizon-client/src/game/passengers.rs @@ -146,6 +146,32 @@ impl Game<'_> { }; display.ride(glam::DVec3::new(at.x, at.y, at.z)); } + // Armor stands riding a mob or the local player (a server's name tag + // hologram): seated like the displays above, so the name follows its + // vehicle instead of staying where the stand spawned. + for i in 0..self.armor_stands.len() { + let Some(vehicle) = self.armor_stands[i].vehicle else { + continue; + }; + let at = if Some(vehicle) == me { + let Some(at) = own_seat else { continue }; + at + } else { + let Some(&j) = index.get(&vehicle) else { + continue; + }; + let vehicle = &self.mobs[j]; + riders::seat( + riders::passenger_attachment(vehicle), + 0.0, + vehicle.position, + vehicle.yaw, + ) + }; + let stand = &mut self.armor_stands[i]; + stand.position = at; + stand.velocity = Vec3::ZERO; + } } /// `finalizeSpawn` jockeys of a naturally spawned mob: baby zombies on diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 14cc354de2..dee704a1f5 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -123,6 +123,13 @@ impl Game<'_> { display.vehicle = None; } } + for a in &mut self.armor_stands { + if passengers.contains(&(a.id as i32)) { + a.vehicle = Some(vehicle_id); + } else if a.vehicle == Some(vehicle_id) { + a.vehicle = None; + } + } if passengers.contains(&me) { if self.riding != Some(vehicle_id) { self.riding = Some(vehicle_id); From 300d048a1c27425803cdb0c97445c188605a392d Mon Sep 17 00:00:00 2001 From: TraiNguyenVan Date: Fri, 30 Oct 2026 08:34:36 +0700 Subject: [PATCH 223/968] README: the launcher's two pictures, retaken signed in on 0.2.28 Both are fresh 1280x800 captures of launcher 0.2.28, taken with a Microsoft account selected so the chip reads " / Microsoft account" as it does for players. The name is blurred with a feathered gaussian, in the chip and in the Settings account row, so no account name is readable in either file. The instance uses Horizon's own violet accent tile (#5b3fd6, the H) rather than Minecraft's grass block, which the launcher only composites from the player's own prepared textures. It has v0.2.97 prepared, so Resources reads "Prepared for Minecraft 26.3"; the playing count and the newest Nightly line are live values and will drift. README alt text is unchanged; it describes the same two pages. Co-Authored-By: Qoder --- docs/media/launcher-play.webp | Bin 41060 -> 36774 bytes docs/media/launcher-settings.webp | Bin 43646 -> 41220 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/media/launcher-play.webp b/docs/media/launcher-play.webp index 3b324014c1bfef48eb16b17e0d4755696bf41223..470472ee43c23af42a578d8cda0590c389024e76 100644 GIT binary patch literal 36774 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 literal 41060 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

vK4F&_(9JsYo2B5H42B1Q= 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 OJ~wYz7+3t~9Qi+Sk77mu literal 0 HcmV?d00001 diff --git a/docs/media/spawner-data/after-zombie.png b/docs/media/spawner-data/after-zombie.png new file mode 100644 index 0000000000000000000000000000000000000000..4da61b0f8b53d131758043979397a7c7897b08b1 GIT binary patch literal 216091 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

Y?BKOt(n><5S)pZkUOk;`x|h)tUPj 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

F1CcT_(kB8Ta{;Q0v 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 H?W6w>mJ_*E literal 0 HcmV?d00001 diff --git a/tools/spawner_display_e2e.py b/tools/spawner_display_e2e.py new file mode 100644 index 0000000000..369fff1a83 --- /dev/null +++ b/tools/spawner_display_e2e.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Render regression for monster spawner mob previews. + +Requires a built client/ctl, prepared resources and Pillow. Uses a fresh world +on the normal integrated-server path. A stone backdrop and a zero activation +range keep the cage static; changing only its spawn type must change the pixels +inside it. This fails when the display cache exists but is never rendered. +""" +import argparse +import json +from pathlib import Path +import subprocess +import tempfile + +from PIL import Image, ImageChops + +import lab + +ROOT = Path(__file__).resolve().parents[1] + + +def changed_pixels(first, second): + with Image.open(first) as left, Image.open(second) as right: + assert left.size == right.size, "The test window changed size" + width, height = left.size + # Camera points at the cage's centre; ignore chat, hands and tutorial UI. + box = (int(width * .3), int(height * .35), int(width * .7), int(height * .65)) + difference = ImageChops.difference(left.convert("RGB").crop(box), + right.convert("RGB").crop(box)) + return sum(max(difference.getpixel((x, y))) > 20 + for y in range(difference.height) for x in range(difference.width)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--game", type=Path, default=ROOT / "target/fast/horizon-client") + parser.add_argument("--ctl", type=Path, default=ROOT / "target/fast/horizon-ctl") + parser.add_argument("--resources", type=Path, default=ROOT / ".local/horizon-client/26.3") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + output = (args.output or Path(tempfile.mkdtemp(prefix="spawner-display-"))).resolve() + output.mkdir(parents=True, exist_ok=True) + work = Path(tempfile.mkdtemp(prefix="game-", dir=output)) + homes = {} + for key in ("HORIZON_HOME", "HORIZON_LAUNCHER_HOME", "XDG_DATA_HOME", "XDG_CONFIG_HOME"): + homes[key] = work / key + homes[key].mkdir() + env = lab.game_env(**homes, HORIZON_TELEMETRY_URL="off") + socket = work / "ctl.sock" + + def ctl(*words, timeout=60): + result = subprocess.run([str(args.ctl.resolve()), "--socket", str(socket), *map(str, words)], + env=env, cwd=work, capture_output=True, text=True, timeout=timeout) + reply = json.loads(result.stdout) + if result.returncode or "error" in reply: + raise RuntimeError(f"{words}: {reply}; {result.stderr}") + return reply + + def command(text): + return ctl("command", text) + + results = [] + try: + ctl("launch", "--game", args.game.resolve(), "--world", "Preview", "--creative", "--wait", + "--offscreen", "--no-pause", "--render-distance", 2, + "--resources", args.resources.resolve(), timeout=120) + state = ctl("state") + x, _, z = state["player"]["block_pos"] + x, z = x // 16 * 16 + 8, z // 16 * 16 + 8 + y = 200 + command(f"fill {x-4} {y} {z-5} {x+4} {y+8} {z+4} minecraft:air") + command(f"fill {x-4} {y-1} {z-5} {x+4} {y-1} {z+4} minecraft:stone") + command(f"fill {x-4} {y} {z+2} {x+4} {y+6} {z+2} minecraft:stone") + ctl("tp", x + .5, y, z - 3.5) + ctl("look", 0, 15) + ctl("options-set", "guiScale", 2) + ctl("key", "f1", "tap") # Hide command chat, the hand and all other HUD changes. + for block in ("spawner",): + command(f"setblock {x} {y} {z} minecraft:{block}") + shots = [] + for kind in ("zombie", "pig", "zombie"): + tag = '{SpawnData:{entity:{id:"minecraft:' + kind + '"}},RequiredPlayerRange:0s}' + command(f"data merge block {x} {y} {z} {tag}") + command("time set noon") + ctl("wait", 5) + shot = output / f"{block}-{len(shots)}-{kind}.png" + ctl("screenshot", shot) + shots.append(shot) + forward = changed_pixels(shots[0], shots[1]) + backward = changed_pixels(shots[1], shots[2]) + result = {"block": block, "zombie_to_pig_pixels": forward, + "pig_to_zombie_pixels": backward, "pass": min(forward, backward) >= 50} + results.append(result) + print(json.dumps(result), flush=True) + finally: + try: + ctl("quit") + except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError): + pass + (output / "results.json").write_text(json.dumps(results, indent=2) + "\n") + assert len(results) == 1 and all(row["pass"] for row in results), "Spawner mob preview is not rendered" + + +if __name__ == "__main__": + main() From dc3d702e01a418286ada72bedfba80292faad382 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:08:00 +0200 Subject: [PATCH 292/968] Creative armour swap gives the worn piece back to the hand Equippable.swapWithEquipmentSlot: in creative a single item swaps with the worn piece too (the hand gets copyAndClear of the worn stack, the slot a copy), and from a larger stack the worn piece goes to the inventory. The game kept the new piece in the hand and deleted the worn one, so the held item never changed and the hand never played its swap animation. Fixes #4150 Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/armor_stands.rs | 125 +++++++++++++++--- 1 file changed, 104 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 7019dc8245..f7bd3d8ca7 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -877,6 +877,52 @@ fn overlaps(a: Aabb, b: Aabb) -> bool { ); a0.x < b1.x && b0.x < a1.x && a0.y < b1.y && b0.y < a1.y && a0.z < b1.z && b0.z < a1.z } +/// What `Equippable.swapWithEquipmentSlot` leaves once it has passed its +/// checks: the piece worn in the slot, the hand, and the old piece bound for +/// the inventory. +#[derive(Debug, PartialEq)] +struct ArmorSwap { + worn: ItemStack, + hand: Option, + to_inventory: Option, +} +/// `Equippable.swapWithEquipmentSlot` after its checks. A single item swaps +/// with the worn piece: the hand gets the worn piece (`copyAndClear`), or +/// keeps the item in creative when nothing was worn; creative wears a copy +/// (`copy` instead of `copyAndClear`). From a larger stack one item is worn +/// (`consumeAndReturn`, which takes none in creative) and the worn piece goes +/// to the inventory. +fn swap_with_equipment_slot( + stack: &ItemStack, + current: Option, + creative: bool, +) -> ArmorSwap { + let one = ItemStack { + count: 1, + ..stack.clone() + }; + if stack.count <= 1 { + let hand = match current { + Some(old) => Some(old), + None if creative => Some(stack.clone()), + None => None, + }; + return ArmorSwap { + worn: one, + hand, + to_inventory: None, + }; + } + let mut held = stack.clone(); + if !creative { + held.count -= 1; + } + ArmorSwap { + worn: one, + hand: Some(held), + to_inventory: current, + } +} impl Game<'_> { /// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into /// its slot (the worn piece comes back to the hand). @@ -902,28 +948,19 @@ impl Game<'_> { }) { return true; } - let equipped = ItemStack { - count: 1, - ..stack.clone() - }; - self.armor[index] = Some(equipped.clone()); + let swap = swap_with_equipment_slot(stack, current, self.creative); + self.armor[index] = Some(swap.worn.clone()); // Player.setItemSlot: onEquipItem plays the equip sound. - self.player_equipped(slot, Some(&equipped)); - if !self.creative { - let mut held = stack.clone(); - held.count -= 1; - if held.count == 0 { - // The worn piece comes back to the hand. - *self.held_mut() = current; - } else { - *self.held_mut() = Some(held); - if let Some(old) = current - && let Some(rest) = self.give(old) - && let Some(eye) = self.eye_position() - { - self.spawn_item(rest, eye, [0.0; 3], 40); - } - } + self.player_equipped(slot, Some(&swap.worn)); + *self.held_mut() = swap.hand; + // Inventory.add, else Player.drop (in creative a full inventory + // takes it anyway: hasInfiniteMaterials empties the stack). + if let Some(old) = swap.to_inventory + && let Some(rest) = self.give(old) + && !self.creative + && let Some(eye) = self.eye_position() + { + self.spawn_item(rest, eye, [0.0; 3], 40); } self.apply_equipment(); self.swing_arm(); @@ -1070,6 +1107,52 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { (pos, drops) } #[cfg(test)] +mod swap_tests { + use super::*; + fn stack(item: &str, count: u32) -> ItemStack { + ItemStack::new(item, count) + } + #[test] + fn creative_swap_gives_the_worn_piece_to_the_hand() { + let swap = swap_with_equipment_slot( + &stack("minecraft:diamond_chestplate", 1), + Some(stack("minecraft:iron_chestplate", 1)), + true, + ); + assert_eq!(swap.worn, stack("minecraft:diamond_chestplate", 1)); + assert_eq!(swap.hand, Some(stack("minecraft:iron_chestplate", 1))); + assert_eq!(swap.to_inventory, None); + } + #[test] + fn creative_equip_with_nothing_worn_keeps_the_hand() { + let swap = swap_with_equipment_slot(&stack("minecraft:diamond_helmet", 1), None, true); + assert_eq!(swap.worn, stack("minecraft:diamond_helmet", 1)); + assert_eq!(swap.hand, Some(stack("minecraft:diamond_helmet", 1))); + } + #[test] + fn survival_swap_gives_the_worn_piece_or_empties_the_hand() { + let worn = Some(stack("minecraft:iron_boots", 1)); + let swap = swap_with_equipment_slot(&stack("minecraft:diamond_boots", 1), worn, false); + assert_eq!(swap.hand, Some(stack("minecraft:iron_boots", 1))); + let swap = swap_with_equipment_slot(&stack("minecraft:diamond_boots", 1), None, false); + assert_eq!(swap.hand, None); + } + #[test] + fn a_larger_stack_wears_one_and_sends_the_old_piece_to_the_inventory() { + // Armour stacks past one only with a max_stack_size component. + let worn = Some(stack("minecraft:leather_helmet", 1)); + let swap = + swap_with_equipment_slot(&stack("minecraft:golden_helmet", 3), worn.clone(), false); + assert_eq!(swap.worn, stack("minecraft:golden_helmet", 1)); + assert_eq!(swap.hand, Some(stack("minecraft:golden_helmet", 2))); + assert_eq!(swap.to_inventory, worn); + let swap = + swap_with_equipment_slot(&stack("minecraft:golden_helmet", 3), worn.clone(), true); + assert_eq!(swap.hand, Some(stack("minecraft:golden_helmet", 3))); + assert_eq!(swap.to_inventory, worn); + } +} +#[cfg(test)] mod burn_drop_tests { use super::*; #[test] From 186540eb84b5e6c409e4d7ecea4654e1ad126488 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:09:41 +0500 Subject: [PATCH 293/968] parity: give the mob_ai oracle an 8g heap VanillaMobAiOracle ran out of its 4g heap (OutOfMemoryError: Java heap space, 34 times in one run) within about 3 minutes of a fresh run on main, with nothing else running. Runs that reused cached oracle results kept passing, which hid it until the scenarios changed. With 8g the fresh run passes in 2.9 minutes. Co-Authored-By: Claude Opus 5.5 --- tools/test_mob_ai_parity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/test_mob_ai_parity.py b/tools/test_mob_ai_parity.py index 90ffa11164..915a596597 100644 --- a/tools/test_mob_ai_parity.py +++ b/tools/test_mob_ai_parity.py @@ -594,7 +594,8 @@ def main(): shutil.rmtree(world, ignore_errors=True) # Each scenario forces 9x9 chunks 512 blocks past the last one, so a run generates ~80 chunks per scenario; # 3g ran out of heap in PoiManager.tick on a busy runner (#2160's CI, 143 scenarios). - oracle_harness.run_logged(['java', '-Xmx4g', '-Djava.awt.headless=true', '-cp', str(classes) + os.pathsep + dependencies, + # 4g then ran out within ~3 minutes on a fresh run once the scenarios grew (2026-10-30); 8g passes. + oracle_harness.run_logged(['java', '-Xmx8g', '-Djava.awt.headless=true', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaMobAiOracle', inputs, build / 'vanilla.txt', world], build, 'vanilla', timeout=3600) if not args.java_only: From 7a1a5e8e88105b4ab3597dfcd5e28a8f8955a5aa Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:11:33 +0700 Subject: [PATCH 294/968] Drop a killed enderman's carried block Enderman.dropCustomDeathLoot rolls the carried block state's own drops with a diamond axe enchanted by the enderman_loot_drop provider (Silk Touch I) and spawns them after Monster's equipment drops. Horizon's death drops never read the carried state, so the block was lost. mobs::drops::die now yields a CarriedBlock drop in that place, and the game resolves it through enderman::carried_block_drops (the block's loot table with the silk touch axe, the enderman as this entity, from the table's random sequence). Part of #3513 (the Enderman.dropCustomDeathLoot row). Co-Authored-By: Claude Opus 5.5 --- .../probe/entity_death_loot_fixture.rs | 38 +++++- crates/horizon-client/src/game/creatures.rs | 14 +++ crates/horizon-client/src/mobs/drops.rs | 17 ++- crates/horizon-client/src/mobs/enderman.rs | 108 +++++++++++++++++- 4 files changed, 173 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs index 7ef9c8de71..198ad6a594 100644 --- a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs @@ -4,7 +4,12 @@ //! //! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT use horizon_client::{ - mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube}, + assets::Assets, + blocks::BlockFacts, + items::Items, + mobs::{ + Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube, + }, random_sequences::RandomSequences, }; use horizon_core::{geometry::Vec3, random::LegacyRandom}; @@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> { let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?; let types = MobTypes::load(root)?; let loot = LootData::load(root, &PackOverlay::NONE)?; + // Block loot for an enderman's carried block (BlockState.getDrops). + let assets = Assets::load(root)?; + let registry = assets.registry; + let facts = BlockFacts::load(root, registry)?; + let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?; + // The stand-in level's random (RandomSource.create(0)), never reset. + let mut level_random = LegacyRandom::new(0); let mut out = String::new(); for raw in input.lines() { if raw.trim().is_empty() || raw.starts_with('#') { @@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> { m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8; } m.extra.sheared = get("sheared", "0") == "1"; + if let Some(block) = c.get("carried") { + m.extra.carried = Some( + registry + .default_state(block) + .ok_or_else(|| format!("unknown block {block}"))?, + ); + } if get("leader", "0") == "1" { raider::state_mut(&mut m).patrol_leader = true; } @@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> { for d in items { let _ = match d { drops::Drop::Item(s) => writeln!(out, "{}", line(&s)), + drops::Drop::CarriedBlock(state) => { + let stacks = enderman::carried_block_drops( + &block_items, + registry, + state, + &mut level_random, + Some((&mut sequences, world_seed)), + ); + for s in stacks { + let _ = write!(out, "drop {} {}", s.item, s.count); + if s.damage != 0 { + let _ = write!(out, " damage={}", s.damage); + } + let _ = writeln!(out); + } + Ok(()) + } drops::Drop::Orb(v) => writeln!(out, "orb {v}"), }; } diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..efbc7347dc 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1056,6 +1056,20 @@ impl Game<'_> { // the orbs' merge ids and block ticks read next). self.spawn_item(item, pos, dispenser::new_item_motion(), 10); } + drops::Drop::CarriedBlock(state) => { + // Enderman.dropCustomDeathLoot: spawnAtLocation for each + // of the carried block's silk-touch drops. + let stacks = horizon_client::mobs::enderman::carried_block_drops( + world_items!(self), + self.pack.assets.registry, + state, + &mut self.world.random, + Some((&mut self.rand_sequences, self.level.seed)), + ); + for item in stacks { + self.spawn_item(item, pos, dispenser::new_item_motion(), 10); + } + } drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32), drops::Drop::Orb(_) => {} } diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index ca0a90fde3..2a23be894b 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -2,11 +2,15 @@ //! killer's `killedEntity` hook (a charged creeper's head drop), then //! `dropAllDeathLoot`: the entity loot table (drawn from the world's random //! sequence), `Mob.dropCustomDeathLoot` (equipment by drop chance, with the -//! entity random), the species' extra drops and the experience orbs. +//! entity random), the species' extra drops (an enderman's carried block) and +//! the experience orbs. use super::{Mob, Species, equine, gear, raider, render::DYE_NAMES, species}; use crate::random_sequences::RandomSequences; use horizon_core::random::RandomSource; -use horizon_world::loot::{EntityLootParams, LootDamage, LootData, LootEntity, LootStack}; +use horizon_world::{ + loot::{EntityLootParams, LootDamage, LootData, LootEntity, LootStack}, + registry::BlockStateId, +}; /// `DropChances.DEFAULT_EQUIPMENT_DROP_CHANCE`. pub const DEFAULT_DROP_CHANCE: f32 = 0.085; @@ -17,6 +21,9 @@ pub use horizon_world::loot::EQUIPMENT_SLOTS as SLOTS; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Drop { Item(LootStack), + /// `Enderman.dropCustomDeathLoot`: the carried block's own drops, rolled + /// with `enderman::carried_block_tool` (`enderman::carried_block_drops`). + CarriedBlock(BlockStateId), /// An experience orb of this value. Orb(i32), } @@ -480,6 +487,12 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec ItemStack { + let mut axe = ItemStack::new("minecraft:diamond_axe", 1); + axe.enchant("minecraft:silk_touch", 1); + axe +} + +/// `Enderman.dropCustomDeathLoot`: `BlockState.getDrops` of the carried +/// state with [`carried_block_tool`] and the enderman as `this` entity, +/// drawing from the block table's random sequence (`sequences`: the world's +/// sequences and seed), else `random` (the level random). +pub fn carried_block_drops( + items: &Items, + registry: &BlockRegistry, + state: BlockStateId, + random: &mut impl RandomSource, + sequences: Option<(&mut RandomSequences, i64)>, +) -> Vec { + let tool = carried_block_tool(); + items + .block_drops_exploded( + registry, + state, + Some(&tool), + &[], + random, + sequences, + None, + true, + ) + .into_iter() + .filter(|s| s.count > 0 && &*s.item != "minecraft:air") + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -476,4 +519,67 @@ mod tests { assert!(!dodges(&seated, &Damage::of(kind)), "{kind:?}"); } } + /// Enderman.dropCustomDeathLoot (#3513): a killed enderman drops the + /// block it carries, mined with a silk touch diamond axe, so a grass + /// block comes back as a grass block (not dirt) and a nylium as itself. + #[test] + fn a_killed_enderman_drops_its_carried_block() { + use crate::mobs::drops; + use horizon_world::loot::{LootData, PackOverlay}; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("items.json").exists() || !root.join("data").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let registry = assets.registry; + let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap(); + let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).unwrap(); + let loot = LootData::load(&root, &PackOverlay::NONE).unwrap(); + let death = |mob_drops| drops::Death { + loot: &loot, + damage: None, + player: None, + luck: 0.0, + vehicle: None, + mob_drops, + world_seed: 0, + }; + let mut sequences = RandomSequences::default(); + let mut random = horizon_core::random::LegacyRandom::new(0); + for block in [ + "minecraft:grass_block", + "minecraft:crimson_nylium", + "minecraft:tnt", + ] { + let mut m = enderman(); + m.extra.carried = registry.default_state(block); + let (out, _) = drops::die(&mut m, &death(true), &mut sequences); + let dropped: Vec = out + .into_iter() + .flat_map(|d| match d { + drops::Drop::CarriedBlock(state) => carried_block_drops( + &items, + registry, + state, + &mut random, + Some((&mut sequences, 0)), + ), + _ => Vec::new(), + }) + .map(|s| format!("{} {}", s.item, s.count)) + .collect(); + assert_eq!(dropped, [format!("{block} 1")], "{block}"); + } + // doMobLoot off: no dropCustomDeathLoot, so no block either. + let mut m = enderman(); + m.extra.carried = registry.default_state("minecraft:grass_block"); + let (out, _) = drops::die(&mut m, &death(false), &mut sequences); + assert!( + !out.iter() + .any(|d| matches!(d, drops::Drop::CarriedBlock(_))), + "{out:?}" + ); + } } From 956bd64c5d183f51f317f202125c2675d343ba93 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:11:35 +0700 Subject: [PATCH 295/968] Death loot parity: endermen carrying each holdable block VanillaEntityDeathLootOracle sets the carried block, and the corpus kills endermen carrying every enderman_holdable block, by a player with Looting and by generic damage. Co-Authored-By: Claude Opus 5.5 --- .../parity/VanillaEntityDeathLootOracle.java | 1 + tools/test_entity_death_loot_parity.py | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityDeathLootOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityDeathLootOracle.java index bffe88e56b..f66361b6b6 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityDeathLootOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityDeathLootOracle.java @@ -257,6 +257,7 @@ public final class VanillaEntityDeathLootOracle { if (c.containsKey("size")) victim.getClass().getMethod("setSize", int.class, boolean.class).invoke(victim, Integer.parseInt(c.get("size")), true); if (c.containsKey("color") && victim instanceof net.minecraft.world.entity.animal.sheep.Sheep sheep) sheep.setColor(DyeColor.byName(c.get("color"), DyeColor.WHITE)); if (c.getOrDefault("sheared", "0").equals("1") && victim instanceof net.minecraft.world.entity.animal.sheep.Sheep sheep) sheep.setSheared(true); + if (c.containsKey("carried")) ((net.minecraft.world.entity.monster.Enderman) victim).setCarriedBlock(BuiltInRegistries.BLOCK.getValue(Identifier.parse(c.get("carried"))).defaultBlockState()); if (c.getOrDefault("leader", "0").equals("1")) ((net.minecraft.world.entity.monster.PatrollingMonster) victim).setPatrolLeader(true); if (c.getOrDefault("captain", "0").equals("1")) victim.setItemSlot(EquipmentSlot.HEAD, net.minecraft.world.entity.raid.Raid.getOminousBannerInstance(access.lookupOrThrow(Registries.BANNER_PATTERN))); diff --git a/tools/test_entity_death_loot_parity.py b/tools/test_entity_death_loot_parity.py index 39aa6edda0..f1e981204b 100644 --- a/tools/test_entity_death_loot_parity.py +++ b/tools/test_entity_death_loot_parity.py @@ -8,7 +8,7 @@ Every living entity type with a loot table is killed by a player with and without looting, by nothing, on fire, with a smelting weapon, as a baby, with luck and with worn equipment, plus each table's special conditions (sheep colours, cube sizes, frog kills, skeleton and charged creeper kills, fireballs, -lightning, raid captains and vehicles). Loot draws come from the world's random +lightning, raid captains, vehicles and an enderman's carried block). Loot draws come from the world's random sequences; several victims are killed in turn per case. """ import argparse @@ -36,6 +36,10 @@ WEAPONS = ['minecraft:iron_sword', 'minecraft:diamond_sword;looting:1', 'minecra 'minecraft:netherite_sword;looting:3', 'minecraft:golden_sword;fire_aspect:2', 'minecraft:diamond_axe;looting:3;fire_aspect:1'] ARMOUR = {'head': 'minecraft:iron_helmet', 'chest': 'minecraft:chainmail_chestplate', 'legs': 'minecraft:golden_leggings', 'feet': 'minecraft:diamond_boots', 'mainhand': 'minecraft:iron_sword', 'offhand': 'minecraft:shield'} +# enderman_holdable blocks (and a few whose plain drop differs from the block). +CARRIED = ['grass_block', 'dirt', 'podzol', 'mycelium', 'mud', 'moss_block', 'pale_moss_block', 'sand', 'red_sand', 'gravel', + 'dandelion', 'poppy', 'brown_mushroom', 'red_mushroom', 'tnt', 'cactus', 'clay', 'pumpkin', 'carved_pumpkin', 'melon', + 'crimson_fungus', 'crimson_nylium', 'crimson_roots', 'warped_fungus', 'warped_nylium', 'warped_roots', 'cactus_flower'] DYES = ['white', 'orange', 'magenta', 'light_blue', 'yellow', 'lime', 'pink', 'gray', 'light_gray', 'cyan', 'purple', 'blue', 'brown', 'green', 'red', 'black'] @@ -104,6 +108,11 @@ def corpus(types): add('zombie-baby-chicken', type='minecraft:zombie', baby=1, vehicle='minecraft:chicken', killer='player', kills=3) add('zombie-baby-chicken-none', type='minecraft:zombie', baby=1, vehicle='minecraft:chicken', source='generic', kills=2) add('husk-camel', type='minecraft:husk', vehicle='minecraft:camel_husk', killer='player', kills=3) + # Enderman.dropCustomDeathLoot: the carried block's drops with a silk touch diamond axe. + for block in CARRIED: + add(f'enderman-carried-{block}', type='minecraft:enderman', carried='minecraft:' + block, killer='player', + weapon='minecraft:iron_sword;looting:3', kills=3) + add(f'enderman-carried-{block}-none', type='minecraft:enderman', carried='minecraft:' + block, source='generic', kills=2) for weapon in WEAPONS: add('wither-' + weapon.split(';')[-1].replace(':', ''), type='minecraft:wither', killer='player', weapon=weapon, kills=2) return cases @@ -123,7 +132,8 @@ def main(): types = sorted('minecraft:' + p.stem for p in tables.glob('*.json') if p.stem not in SKIPPED) oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaEntityDeathLootOracle.java' sources = [oracle, Path(__file__), lab.ROOT / 'crates/horizon-client/examples/probe/entity_death_loot_fixture.rs', - lab.ROOT / 'crates/horizon-client/src/mobs/drops.rs', lab.ROOT / 'crates/horizon-world/src/loot.rs'] + lab.ROOT / 'crates/horizon-client/src/mobs/drops.rs', lab.ROOT / 'crates/horizon-client/src/mobs/enderman.rs', + lab.ROOT / 'crates/horizon-world/src/loot.rs'] tested = base.source_hashes(sources) inputs = build / 'inputs.txt' cases = corpus(types) From d0c5f220bc68d85577fa4176dfc38dd7d4654cf9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 08:11:41 +0100 Subject: [PATCH 296/968] Keep the destination of a portal trip running with a portal ticket Horizon only lit the exit chunks long enough to land an entity that went through a portal, then nothing in that dimension ticked while no player was in it, so an item or mob stayed frozen there and never came back through. Vanilla places TicketType.PORTAL (300 ticks, radius 3, level 30 of both kinds, keeps the dimension active) on the exit chunk when the entity arrives (Entity.placePortalTicket via TeleportTransition.PLACE_PORTAL_TICKET and NetherPortalBlock.getExitPortal, EndPortalBlock.getPortalDestination). Add the ticket to the level's chunk tickets, tick the dimension while one is held and stream its area. Fixes #3292 --- crates/horizon-client/src/game.rs | 10 ++- crates/horizon-client/src/game/lan.rs | 7 +++ crates/horizon-client/src/game/levels.rs | 15 +++++ .../horizon-client/src/game/nether_portal.rs | 25 ++++++++ .../horizon-client/src/game/server_travel.rs | 6 +- crates/horizon-client/src/ticket_levels.rs | 63 ++++++++++++++++++- 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5f1bcd0194..7e4db75f5d 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3234,9 +3234,9 @@ impl Game<'_> { self.tick_chunks.recycle_added(added); } /// `DistanceManager`'s tickets of the level in place, from every player in - /// it (the local player, LAN guests, a server's players) and its forced - /// chunks. A game with neither a server nor a player (the headless - /// fixtures) ticks everywhere. + /// it (the local player, LAN guests, a server's players), its forced + /// chunks and its portal tickets. A game with neither a server nor a + /// player (the headless fixtures) ticks everywhere. fn update_chunk_tickets(&mut self) { let mut players: Vec<(i32, i32)> = self.lan.as_ref().map_or_else(Vec::new, |h| { h.hub @@ -3257,6 +3257,8 @@ impl Game<'_> { horizon_client::ticket_levels::Sources { players, forced: self.streaming.forced.iter().copied().collect(), + // Filled from the `PORTAL` tickets by `update`. + portal: Vec::new(), view_distance: self.settings.render_distance.clamp(2, 32), simulation_distance: self.settings.simulation_distance.max(2), } @@ -3283,6 +3285,8 @@ impl Game<'_> { /// ServerLevel.tick block phases: scheduled ticks, random ticks in the /// chunks the tickets simulate, block events and moving pistons. fn tick_blocks(&mut self) -> Result<()> { + // ServerChunkCache.tick: the tickets that time out. + self.tickets.tick_portal(); self.sync_tick_chunks(); self.start_ticking_chunks(); let levels = self.tickets.levels(); diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 0cfca976ac..5e12d164e1 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -2167,6 +2167,13 @@ impl Game<'_> { // then ticks without a player. .map(|chunk| (chunk, 1)), ) + // A portal ticket keeps the area around the exit loaded and + // ticking (its entity-ticking chunks and their neighbours). + .chain( + self.portal_ticket_chunks(self.dim) + .into_iter() + .map(|chunk| (chunk, 1)), + ) .collect(); } diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 6a7b88e134..b7a128367c 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -456,6 +456,9 @@ impl Game<'_> { // A pending portal arrival keeps its destination level // running so it can land and tick (the portal ticket). || !self.arrival_chunks(*d).is_empty() + // A portal ticket keeps its dimension active + // (`TicketType.shouldKeepDimensionActive`). + || !self.portal_ticket_chunks(*d).is_empty() }) .collect(); if dims.is_empty() { @@ -464,6 +467,18 @@ impl Game<'_> { dims } + /// The chunks holding a portal ticket in `dim`'s level (`placePortalTicket`). + pub(super) fn portal_ticket_chunks(&self, dim: Dim) -> Vec<(i32, i32)> { + if dim == self.dim { + self.tickets.portal_chunks().collect() + } else { + self.levels + .parked + .get(&dim) + .map_or_else(Vec::new, |p| p.tickets.portal_chunks().collect()) + } + } + /// Count the parked levels' empty ticks, and save and close the ones /// (other than the Overworld) nobody has been in for a while. pub(super) fn close_idle_levels(&mut self) -> Result<()> { diff --git a/crates/horizon-client/src/game/nether_portal.rs b/crates/horizon-client/src/game/nether_portal.rs index e0fb6874ed..452008e9e0 100644 --- a/crates/horizon-client/src/game/nether_portal.rs +++ b/crates/horizon-client/src/game/nether_portal.rs @@ -372,6 +372,9 @@ impl Game<'_> { max_section, ); if let Some(found) = found { + // NetherPortalBlock.getExitPortal: the ticket goes on the chunk + // of the portal found. + self.place_portal_ticket((found.x >> 4, found.z >> 4)); let state = self.world.block(found.x, found.y, found.z); let x_axis = self.res.behaviours().portal_axis(state).unwrap_or(true); return Some(largest_rectangle_around( @@ -389,8 +392,28 @@ impl Game<'_> { b.create_portal(level, ticks, approximate, x_axis, &border, logical) }); let _ = self.process_level_events(); + if let Some(exit) = created { + // A portal built for the entity: the ticket goes on the chunk it + // arrives in (`TeleportTransition.PLACE_PORTAL_TICKET`). + let (at, _) = self.arrival(plan, exit); + self.place_portal_ticket(Self::chunk_of(at)); + } created } + /// `ChunkPos.containing`: the chunk a position is in. + pub(super) fn chunk_of(at: Vec3) -> (i32, i32) { + ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4) + } + /// `Entity.placePortalTicket`: a `TicketType.PORTAL` ticket of radius 3 + /// on `chunk` in the level in place, which keeps the area running for 300 + /// ticks with no player in it (the entity that just came through ticks + /// there, and goes back through the portal when its cooldown ends). + /// Only a server holds levels with tickets of their own. + pub(super) fn place_portal_ticket(&mut self, chunk: (i32, i32)) { + if !self.options.execution_role.is_client() { + self.tickets.add_portal(chunk); + } + } /// `NetherPortalBlock.createDimensionTransition`: the arrival position /// and the yaw turned by. pub(super) fn arrival(&self, plan: &Plan, exit: FoundRectangle) -> (Vec3, f32) { @@ -1030,6 +1053,8 @@ impl Game<'_> { let (at, motion, [yaw, pitch]) = if plan.end { // Relative.DELTA: the motion is kept. let at = self.end_portal_exit(&plan); + // EndPortalBlock.getPortalDestination: PLACE_PORTAL_TICKET. + self.place_portal_ticket(Self::chunk_of(at)); let into_end = plan.dim == Dim::End; let turn = end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle); diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index 639ed0f843..11ca3ab6ce 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -590,7 +590,11 @@ impl Game<'_> { None => self.end_arrival(trip.target, &mut trip.finder), }; match arrival { - Ok(Some((at, turned))) => (at, turned.unwrap_or(yaw)), + Ok(Some((at, turned))) => { + // EndPortalBlock.getPortalDestination: PLACE_PORTAL_TICKET. + self.place_portal_ticket(Self::chunk_of(at)); + (at, turned.unwrap_or(yaw)) + } Ok(None) => { self.server_trips.insert(k, trip); k += 1; diff --git a/crates/horizon-client/src/ticket_levels.rs b/crates/horizon-client/src/ticket_levels.rs index de2c427adb..ea5276df17 100644 --- a/crates/horizon-client/src/ticket_levels.rs +++ b/crates/horizon-client/src/ticket_levels.rs @@ -6,6 +6,9 @@ //! - `PLAYER_SIMULATION` at level `31 - simulationDistance` on its own chunk. //! //! A `/forceload`ed chunk holds a `FORCED` ticket at level 31 of both kinds. +//! An entity that came through a portal leaves a `PORTAL` ticket of radius 3 +//! (`Entity.placePortalTicket`: level 30 of both kinds, for 300 ticks) on the +//! chunk it arrived in; it keeps that level running with no player in it. //! Levels rise by one per chunk away from a ticket (`ChunkTracker`) and each //! chunk takes the lowest. The loading level gives the chunk's //! `FullChunkStatus` (`ChunkLevel.fullStatus`: 31 entity ticking, 32 block @@ -31,12 +34,20 @@ pub const BLOCK_TICKING: u8 = 32; /// What a chunk with no ticket near it has (`FULL` and above count as none). const NONE: (u8, u8) = (BLOCK_TICKING + 1, BLOCK_TICKING + 1); -/// The tickets of one level: where its players are, its forced chunks, and -/// the server's view and simulation distances. +/// `TicketType.PORTAL`'s timeout, in ticks. +const PORTAL_TIMEOUT: i32 = 300; +/// `Entity.placePortalTicket`'s radius: the ticket's level is `FULL` (33) +/// minus it. +const PORTAL_RADIUS: i32 = 3; +const PORTAL_LEVEL: i32 = BLOCK_TICKING as i32 + 1 - PORTAL_RADIUS; + +/// The tickets of one level: where its players are, its forced chunks, its +/// portal tickets, and the server's view and simulation distances. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Sources { pub players: Vec<(i32, i32)>, pub forced: Vec<(i32, i32)>, + pub portal: Vec<(i32, i32)>, pub view_distance: i32, pub simulation_distance: i32, } @@ -130,6 +141,8 @@ impl Levels { pub struct ChunkTickets { sources: Option, levels: Levels, + /// The `PORTAL` tickets: the chunk and the ticks it has left. + portal: Vec<((i32, i32), i32)>, } impl ChunkTickets { @@ -139,6 +152,8 @@ impl ChunkTickets { s.players.sort_unstable(); s.players.dedup(); s.forced.sort_unstable(); + s.portal = self.portal.iter().map(|&(c, _)| c).collect(); + s.portal.sort_unstable(); } if sources == self.sources { return; @@ -149,6 +164,27 @@ impl ChunkTickets { pub fn levels(&self) -> Levels { self.levels.clone() } + /// `TicketStorage.addTicket` of a `PORTAL` ticket: one already on the + /// chunk only starts its timeout again. + pub fn add_portal(&mut self, chunk: (i32, i32)) { + match self.portal.iter_mut().find(|(c, _)| *c == chunk) { + Some((_, left)) => *left = PORTAL_TIMEOUT, + None => self.portal.push((chunk, PORTAL_TIMEOUT)), + } + } + /// `TicketStorage.purgeStaleTickets` for the `PORTAL` tickets, once a + /// tick: they time out after `PORTAL_TIMEOUT + 1` ticks. + pub fn tick_portal(&mut self) { + for (_, left) in &mut self.portal { + *left -= 1; + } + self.portal.retain(|&(_, left)| left >= 0); + } + /// A `PORTAL` ticket is held (`shouldKeepDimensionActive`), the chunks + /// it is on. + pub fn portal_chunks(&self) -> impl Iterator + '_ { + self.portal.iter().map(|&(c, _)| c) + } } /// How far a player's tickets reach, and the simulation ticket's level. @@ -168,7 +204,8 @@ fn compute(s: &Sources) -> Map { .players .iter() .map(|&p| (p, reach)) - .chain(s.forced.iter().map(|&f| (f, 1))); + .chain(s.forced.iter().map(|&f| (f, 1))) + .chain(s.portal.iter().map(|&p| (p, PORTAL_RADIUS))); let Some((x0, z0, x1, z1)) = boxes.fold(None, |b, ((x, z), r)| { let (lo_x, lo_z, hi_x, hi_z) = ( i64::from(x) - i64::from(r), @@ -216,6 +253,14 @@ fn compute(s: &Sources) -> Map { } } } + for &(cx, cz) in &s.portal { + for dz in -PORTAL_RADIUS..=PORTAL_RADIUS { + for dx in -PORTAL_RADIUS..=PORTAL_RADIUS { + let level = PORTAL_LEVEL + dx.abs().max(dz.abs()); + lower(cx + dx, cz + dz, level, level); + } + } + } Map::Grid { x0, z0, @@ -254,6 +299,14 @@ fn compute_sparse(s: &Sources) -> FxHashMap<(i32, i32), (u8, u8)> { } } } + for &(cx, cz) in &s.portal { + for dz in -PORTAL_RADIUS..=PORTAL_RADIUS { + for dx in -PORTAL_RADIUS..=PORTAL_RADIUS { + let level = PORTAL_LEVEL + dx.abs().max(dz.abs()); + lower((cx + dx, cz + dz), level, level); + } + } + } levels.retain(|_, l| *l != NONE); levels } @@ -267,6 +320,7 @@ mod tests { t.update(Some(Sources { players: players.to_vec(), forced: forced.to_vec(), + portal: Vec::new(), view_distance: view, simulation_distance: sim, })); @@ -322,6 +376,7 @@ mod tests { Some(Sources { players, forced: Vec::new(), + portal: Vec::new(), view_distance: 2, simulation_distance: 2, }) @@ -360,6 +415,7 @@ mod tests { let s = Sources { players, forced, + portal: Vec::new(), view_distance: 2 + next(12).abs(), simulation_distance: 2 + next(12).abs(), }; @@ -405,6 +461,7 @@ mod tests { let s = Sources { players: at.clone(), forced: Vec::new(), + portal: Vec::new(), view_distance: 10, simulation_distance: 10, }; From 64b7e511b101d608c835725fcae499f7310168a6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 08:15:21 +0100 Subject: [PATCH 297/968] Play the minecart steps a server sends along the track Horizon ignored ClientboundMoveMinecartPacket, which a server sends in place of move packets for minecarts on NewMinecartBehavior (the minecart_improvements experiment), so such a cart and a rider in it stayed where they were added until a teleport. Decode the packet's steps (MinecartStep: position, movement, yRot, xRot, weight) and play them as NewMinecartBehavior.lerpClientPositionAndRotation does: queued steps become the current ones over 3 ticks by their weights (getCurrentLerpStep), and the cart takes the position, movement and yaw they give at the end of each tick. A teleport puts such a cart there at once (its interpolation handler is NO_OP). The cart's pitch still comes from its rails. Fixes #3796 --- crates/horizon-client/src/game/remote.rs | 181 ++++++++++++++++-- .../horizon-client/src/game/remote_objects.rs | 14 +- crates/horizon-protocol/src/packets/play.rs | 19 ++ 3 files changed, 199 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 8e715916eb..6fa272028a 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -91,6 +91,117 @@ struct Lerp { steps: u32, } +/// `NewMinecartBehavior.MinecartStep` (its xRot is not kept: a minecart here +/// pitches along its rails). +#[derive(Clone, Copy)] +struct CartStep { + position: Vec3, + movement: Vec3, + yaw: f32, + weight: f32, +} + +/// The client's state of a minecart on `NewMinecartBehavior`: the steps the +/// server sent (`lerpSteps`), the ones being played over 3 ticks +/// (`currentLerpSteps`) and where the cart was when they began (`oldLerp`). +struct CartLerp { + pending: Vec, + current: Vec, + total_weight: f64, + delay: i32, + old: CartStep, +} + +impl CartLerp { + fn new() -> Self { + Self { + pending: Vec::new(), + current: Vec::new(), + total_weight: 0.0, + delay: 0, + old: CartStep { + position: Vec3::ZERO, + movement: Vec3::ZERO, + yaw: 0.0, + weight: 0.0, + }, + } + } + + /// `NewMinecartBehavior.getCartLerpPosition`, `getCartLerpMovements` and + /// `getCartLerpYRot` at `partial`: the step the weights put this moment + /// in, and how far along it, between it and the one before. + fn at(&self, partial: f32) -> (Vec3, Vec3, f32) { + let alpha = ((3 - self.delay) as f32 + partial) / 3.0; + let mut count_up = 0.0f32; + let mut in_step = 1.0f32; + let mut found = None; + for (i, s) in self.current.iter().enumerate() { + if s.weight <= 0.0 { + continue; + } + count_up += s.weight; + if f64::from(count_up) >= self.total_weight * f64::from(alpha) { + let before = count_up - s.weight; + in_step = ((f64::from(alpha) * self.total_weight - f64::from(before)) + / f64::from(s.weight)) as f32; + found = Some(i); + break; + } + } + let index = found.unwrap_or(self.current.len() - 1); + let current = self.current[index]; + let previous = if index > 0 { + self.current[index - 1] + } else { + self.old + }; + let t = f64::from(in_step); + let lerp = |a: Vec3, b: Vec3| { + Vec3::new( + a.x + t * (b.x - a.x), + a.y + t * (b.y - a.y), + a.z + t * (b.z - a.z), + ) + }; + ( + lerp(previous.position, current.position), + lerp(previous.movement, current.movement), + previous.yaw + in_step * wrap_degrees(current.yaw - previous.yaw), + ) + } + + /// `NewMinecartBehavior.lerpClientPositionAndRotation`: once the steps + /// being played are used up, the queued ones become the current ones; + /// the cart then sits where the steps put it at the end of this tick. + /// False when it has no steps to play. + fn tick(&mut self, c: &mut super::minecarts::Minecart) -> bool { + self.delay -= 1; + if self.delay <= 0 { + self.old = CartStep { + position: c.position, + movement: c.velocity, + yaw: c.yaw, + weight: 0.0, + }; + self.current.clear(); + if !self.pending.is_empty() { + self.current.append(&mut self.pending); + self.total_weight = self.current.iter().map(|s| f64::from(s.weight)).sum(); + self.delay = if self.total_weight == 0.0 { 0 } else { 3 }; + } + } + if self.current.is_empty() { + return false; + } + let (position, movement, yaw) = self.at(1.0); + c.position = position; + c.velocity = movement; + c.yaw = yaw; + true + } +} + /// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a /// mob: its yaw kept continuous (the same turn as vanilla's, without the /// wrap that would spin it in the frame between ticks), its pitch clamped. @@ -159,6 +270,9 @@ pub(super) struct Remote { use_target: Option<[i32; 3]>, entities: HashMap, lerps: HashMap, + /// The minecarts the server moves with `ClientboundMoveMinecartPacket` + /// (`NewMinecartBehavior`), which have no interpolation handler. + cart_lerps: HashMap, /// Living entities' `SteppedInterpolationHandler`s. stepped: HashMap, /// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it. @@ -506,6 +620,7 @@ impl Game<'_> { use_target: None, entities: HashMap::new(), lerps: HashMap::new(), + cart_lerps: HashMap::new(), stepped: HashMap::new(), head_lerps: HashMap::new(), ghast_dismount: None, @@ -1254,6 +1369,7 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.entities.clear(); r.lerps.clear(); + r.cart_lerps.clear(); r.stepped.clear(); r.head_lerps.clear(); r.pending_items.clear(); @@ -1768,17 +1884,27 @@ impl Game<'_> { t.on_ground = on_ground; return; } - // AbstractInterpolationHandler.interpolateTo: without a rotation, - // the one it was heading for. - let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation)); - r.lerps.insert( - id, - Lerp { - target: position, - rotation, - steps: 3, - }, - ); + if r.cart_lerps.contains_key(&id) { + // A minecart on NewMinecartBehavior keeps Entity's NO_OP + // InterpolationHandler: a teleport puts it there at once. + if path.is_some() + && let Some(c) = self.carts.iter_mut().find(|c| c.id == id as u32) + { + c.position = at; + } + } else { + // AbstractInterpolationHandler.interpolateTo: without a + // rotation, the one it was heading for. + let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation)); + r.lerps.insert( + id, + Lerp { + target: position, + rotation, + steps: 3, + }, + ); + } } self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground); self.move_remote_text_display(id, position, rotation); @@ -1801,6 +1927,7 @@ impl Game<'_> { r.glowing.remove(&id); r.entities.remove(&id); r.lerps.remove(&id); + r.cart_lerps.remove(&id); r.stepped.remove(&id); r.head_lerps.remove(&id); r.pending_items.remove(&id); @@ -2291,6 +2418,7 @@ impl Game<'_> { } } C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw), + C::MoveMinecartAlongTrack(p) => self.remote_move_minecart(p), // ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3). C::RotateHead(p) => { if let Some(r) = self.remote.as_mut() { @@ -4554,6 +4682,7 @@ impl Game<'_> { return; }; let mut lerps = std::mem::take(&mut r.lerps); + let mut cart_lerps = std::mem::take(&mut r.cart_lerps); let stepped = &mut r.stepped; let head_lerps = &mut r.head_lerps; let players = &r.level_players; @@ -4791,7 +4920,11 @@ impl Game<'_> { } _ => at, }; - self.tick_remote_objects(&mut step); + self.tick_remote_objects(&mut step, |c| { + cart_lerps + .get_mut(&(c.id as i32)) + .is_some_and(|l| l.tick(c)) + }); self.tick_remote_projectiles(&mut step); // LightningBolt.tick on a client, and the sky flash fading. self.flash = self.flash.saturating_sub(1); @@ -4802,8 +4935,32 @@ impl Game<'_> { for (id, l) in lerps { r.lerps.entry(id).or_insert(l); } + for (id, l) in cart_lerps { + r.cart_lerps.entry(id).or_insert(l); + } } } + /// `ClientPacketListener.handleMinecartAlongTrack`: the steps join the ones + /// the minecart has queued, if it runs `NewMinecartBehavior`. + fn remote_move_minecart(&mut self, p: play::MoveMinecartAlongTrack) { + if !self.carts.iter().any(|c| c.id == p.id as u32) { + return; + } + let Some(r) = self.remote.as_mut() else { + return; + }; + let at = |v: [f64; 3]| Vec3::new(v[0], v[1], v[2]); + r.cart_lerps + .entry(p.id) + .or_insert_with(CartLerp::new) + .pending + .extend(p.steps.iter().map(|s| CartStep { + position: at(s.position), + movement: at(s.movement), + yaw: horizon_protocol::buf::unpack_degrees(s.yaw), + weight: s.weight, + })); + } /// `Consumable.onConsumeTick` for another player using a consumable: the /// crumbs at their mouth every 4 ticks once 21.875% of its use has passed /// (`shouldEmitParticlesAndSounds`; the sounds come from the server). diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index df2dbab004..19559cacb3 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -285,12 +285,20 @@ impl Game<'_> { } /// One tick of interpolation: `step` moves a position a step toward - /// the entity's last sent one, if it has a move in progress. - pub(super) fn tick_remote_objects(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) { + /// the entity's last sent one, if it has a move in progress. A minecart + /// on `NewMinecartBehavior` is moved by `lerp_cart` (true when it did) + /// instead. + pub(super) fn tick_remote_objects( + &mut self, + mut step: impl FnMut(u32, Vec3) -> Vec3, + mut lerp_cart: impl FnMut(&mut super::minecarts::Minecart) -> bool, + ) { for c in &mut self.carts { c.previous = c.position; c.previous_yaw = c.yaw; - c.position = step(c.id, c.position); + if !lerp_cart(c) { + c.position = step(c.id, c.position); + } if c.kind == super::minecarts::CartKind::Furnace && c.fuel > 0 && self.world.random.next_int_bound(4).unwrap_or(1) == 0 diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index a27ca12007..47c2f7ced5 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -773,6 +773,24 @@ packet!( /// controls back where it was (PositionAndRotation: x, y, z, yRot, xRot). MoveVehicle { position: [f64; 3] => Vec3d, yaw: f32 => Float, pitch: f32 => Float } ); +packet!( + /// NewMinecartBehavior.MinecartStep: where a minecart is at the end of one + /// of the server's steps along the track, its motion, its rotation (yRot, + /// xRot) and the step's weight in the 3 ticks they are played over. + MinecartStep { + position: [f64; 3] => Vec3d, + movement: [f64; 3] => Vec3d, + yaw: i8 => Byte, + pitch: i8 => Byte, + weight: f32 => Float, + } +); +packet!( + /// ClientboundMoveMinecartPacket: the steps a minecart took along the + /// track since the last one (the `minecart_improvements` experiment sends + /// these in place of move packets). + MoveMinecartAlongTrack { id: i32 => VarInt, steps: Vec => List } +); /// ClientboundSetEquipmentPacket: (EquipmentSlot ordinal, stack) pairs. pub struct Equipment; impl Codec)>> for Equipment { @@ -1905,6 +1923,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { SetObjective(SetObjective) = SET_OBJECTIVE, SetPassengers(SetPassengers) = SET_PASSENGERS, MoveVehicle(MoveVehicle) = MOVE_VEHICLE, + MoveMinecartAlongTrack(MoveMinecartAlongTrack) = MOVE_MINECART_ALONG_TRACK, ProjectilePower(ProjectilePower) = PROJECTILE_POWER, SetPlayerInventory(SetPlayerInventory) = SET_PLAYER_INVENTORY, SetPlayerTeam(SetPlayerTeam) = SET_PLAYER_TEAM, From 4459fe39891e7a2e84bf44373289197fb609d586 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:19:08 +0300 Subject: [PATCH 298/968] Native mods: a mod's own Items stays its own Items become slots only when they are an item list saved exactly so; any other Items stays in the mod's compound. A block without a container gets its Items back in its compound before its tick, and breaking it drops nothing. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_hooks.rs | 5 +++ crates/horizon-client/src/game/spill.rs | 13 +++++-- crates/horizon-client/src/save.rs | 37 +++++++++++++++++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 901292c7c3..6431e2508b 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -642,6 +642,11 @@ impl Game<'_> { if taken.items.len() < slots { taken.items.resize(slots, None); } + // Without a container, an `Items` item list is the mod's own field. + if slots == 0 && !taken.items.is_empty() { + let items = horizon_client::save::encode_items(&std::mem::take(&mut taken.items)); + taken.nbt.insert("Items".into(), items); + } let ModBlockEntity { id, nbt, items } = &mut taken; self.native_block_entity_tick([pos.0, pos.1, pos.2], id, nbt, items); if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 8957d4a16f..cf4fae43c6 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -423,10 +423,15 @@ impl GameLevel<'_> { let name = self.facts.get(old).name.clone(); let kind = match &entity { // BaseContainerBlockEntity.preRemoveSideEffects: Containers.dropContents. - Some(BlockEntity::Mod(m)) if !m.items.is_empty() => PreRemove::Contents { - slots: m.items.len(), - furnace: false, - }, + Some(BlockEntity::Mod(m)) + if !m.items.is_empty() + && self.world.block_entities.mod_container(old.0).is_some() => + { + PreRemove::Contents { + slots: m.items.len(), + furnace: false, + } + } _ => removal::pre_remove(&name), }; let mut neighbour_updates = 0; diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 171ebce1e2..47bc6a004b 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -681,7 +681,8 @@ pub fn decode_end_gateway(tag: &Tag) -> crate::containers::EndGateway { } /// A mod's block entity: its compound without `id` and the position, and /// its `Items` as slots (`ContainerHelper.loadAllItems`; the game sizes them -/// to the block's container when it uses them). +/// to the block's container when it uses them). `Items` that are not an item +/// list saved exactly so stay the mod's own. fn decode_mod_block_entity(id: String, tag: &Tag) -> Option { let Tag::Compound(map) = tag else { return None; @@ -691,9 +692,14 @@ fn decode_mod_block_entity(id: String, tag: &Tag) -> Option Date: Fri, 30 Oct 2026 13:59:24 +0700 Subject: [PATCH 299/968] Spectators on a server see through the entity they spectate ClientboundSetCameraPacket (/spectate, a spectator's click) was decoded as an unknown packet, so the view stayed on the player while the server kept that player on the camera entity: the view did not turn with the mob, sat at the player's eye height inside the mob's body, and the client's own moves made the server rubber-band it ("moved too quickly"). Follow ClientPacketListener.handleSetCamera / Minecraft.setCameraEntity: the camera entity's id is kept until the player's own id, a login or a respawn puts the view back. While another entity is the camera the view is aligned with it (Camera.alignWithEntity: its head's yaw as LivingEntity.getViewYRot, its eye height eased by Camera.tick), the entity is not drawn in first person, the pick starts from its eyes, and the player neither moves nor sends moves (LocalPlayer.applyInput / sendPosition check isControlledCamera); the input packet still goes out, so sneaking lets go. A spectator's click now sends ServerboundSpectatorActionPacket (Minecraft.startAttack, MultiPlayerGameMode.spectate / spectatorNoAction). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 14 ++- crates/horizon-client/src/game/creatures.rs | 5 + crates/horizon-client/src/game/fov.rs | 8 ++ crates/horizon-client/src/game/perspective.rs | 97 +++++++++------- crates/horizon-client/src/game/remote.rs | 32 +++++- .../horizon-client/src/game/remote_camera.rs | 108 ++++++++++++++++++ crates/horizon-protocol/src/packets/play.rs | 36 ++++++ 7 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 crates/horizon-client/src/game/remote_camera.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5f1bcd0194..d567c2113b 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -189,6 +189,7 @@ mod pumpkin; mod raids; mod recipe_book; mod remote; +mod remote_camera; mod remote_chunks; mod remote_mob_data; mod remote_mob_particles; @@ -2244,6 +2245,14 @@ impl Game<'_> { /// crosshair target (blocks, fluids, entities, mod raycasts) is found /// along this ray, so a tracked controller can stand in for it. fn aim(&self) -> Option<([f64; 3], [f64; 3])> { + // Minecraft.pick: from the camera entity's eyes along its view + // (LivingEntity.getViewYRot: the head's yaw). + if let Some(m) = self.camera_mob() { + return Some(( + [m.position.x, m.eye_y(), m.position.z], + view_vector(m.head_yaw, m.head_pitch), + )); + } Some((self.eye_position()?, view_vector(self.yaw, self.pitch))) } fn reach(&self) -> f64 { @@ -8901,8 +8910,9 @@ impl Game<'_> { let mut best: Option<(usize, f64)> = None; for (i, m) in self.mobs.iter().enumerate() { // ProjectileUtil.getEntityHitResult: the mob the player rides - // shares its root vehicle and is never the hit. - if !m.alive() || Some(m.id) == self.riding { + // shares its root vehicle and is never the hit, nor is the + // camera entity the pick starts from. + if !m.alive() || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() { continue; } if let Some(t) = ray_box(eye, dir, reach, m.bounds()) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..3c2fab2474 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -4801,6 +4801,11 @@ impl Game<'_> { if mob.invisible && outline.is_none() { continue; } + // LevelRenderer: the entity the view looks out of is not drawn + // in first person. + if self.hides_as_camera(mob.id) { + continue; + } let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera; // Entity.shouldRenderAtSqrDistance: 64 times the bounding box's // mean edge, scaled by the view scale. RemotePlayer's box counts diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 40ee2f05f4..1223399b6d 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -79,6 +79,14 @@ impl Game<'_> { .fov_player() .map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect)); let target = self.zoomed_fov_target(target); + // A server's spectator camera on a mob: its eye height. Its modifier + // stays 1 (another player's own modifier is not modelled here); the + // zoom extra still narrows it. + if let Some(m) = self.camera_mob() { + let eye = m.eye_height() as f32; + self.view.tick(Some(eye), self.zoomed_fov_target(1.0)); + return; + } let eye = self.player.as_ref().map(|p| { if self.sleeping.is_some() { 0.2 diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 6655fd899f..807428b4df 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -213,6 +213,60 @@ impl Game<'_> { /// front of) it, pulled in where visual shapes are in the way. Returns the /// position, yaw and pitch to render from. pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> { + // Minecraft.cameraEntity: a server's spectator camera on a mob or + // another player. + let (entity, descending) = match self.camera_mob() { + Some(m) => (super::remote_camera::mob_camera_entity(m), false), + None => ( + self.player_camera_entity()?, + self.player.as_ref()?.shift_key_down, + ), + }; + let mut view = self.view; + let kind = match self.camera_type { + CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson, + CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack, + CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront, + }; + let context = CollisionContext::for_entity( + CollisionEntity { + identity: EntityIdentity(0), + y: entity.position[1], + descending, + main_hand: Some(CollisionItem::EMPTY), + }, + false, + ); + let mut rays = VisualRays { + scene: SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }, + facts: self.res.facts(), + context, + }; + view.align_with_entity(&entity, partial, kind, |from, to| { + let hit = clip_world( + &mut rays, + Vec3::new(from[0], from[1], from[2]), + Vec3::new(to[0], to[1], to[2]), + RayBlockMode::Visual, + RayFluidMode::None, + 4096, + ) + .ok()?; + (!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z]) + }); + self.view = view; + Some(( + glam::DVec3::from_array(view.position), + view.y_rot, + view.x_rot, + )) + } + /// The player as `Camera.alignWithEntity` sees it. + fn player_camera_entity(&mut self) -> Option { let (scale, distance) = { let p = self.player.as_mut()?; ( @@ -282,48 +336,7 @@ impl Game<'_> { }), mount, }; - let mut view = self.view; - let kind = match self.camera_type { - CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson, - CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack, - CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront, - }; - let context = CollisionContext::for_entity( - CollisionEntity { - identity: EntityIdentity(0), - y: p.position.y, - descending: p.shift_key_down, - main_hand: Some(CollisionItem::EMPTY), - }, - false, - ); - let mut rays = VisualRays { - scene: SceneCollisions { - world: &self.world, - binding: self.collision, - subject: None, - }, - facts: self.res.facts(), - context, - }; - view.align_with_entity(&entity, partial, kind, |from, to| { - let hit = clip_world( - &mut rays, - Vec3::new(from[0], from[1], from[2]), - Vec3::new(to[0], to[1], to[2]), - RayBlockMode::Visual, - RayFluidMode::None, - 4096, - ) - .ok()?; - (!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z]) - }); - self.view = view; - Some(( - glam::DVec3::from_array(view.position), - view.y_rot, - view.x_rot, - )) + Some(entity) } /// Keep the player's model in step with the player (`LivingEntity.tick` /// body turning, `WalkAnimationState`, the swing and the pose). diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 8e715916eb..4375b892a4 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -122,6 +122,9 @@ pub(super) struct Remote { pub(super) logged_in: bool, status: String, pub(super) entity_id: i32, + /// `Minecraft.cameraEntity` when the server put the view on another + /// entity (ClientboundSetCameraPacket); None while it is the player. + pub(super) camera: Option, /// The item the player's use began with (`LivingEntity.useItem`). pub(super) use_item: Option>, pub(super) dimension: DimensionType, @@ -482,6 +485,7 @@ impl Game<'_> { logged_in: false, status: "Connecting to the server...".into(), entity_id: 0, + camera: None, use_item: None, dimension: DimensionType::vanilla("minecraft:overworld"), chunk_format: Arc::new(ChunkFormat::new( @@ -1996,6 +2000,8 @@ impl Game<'_> { r.carried = Some(0); r.online_mode = p.online_mode; r.entity_id = p.player_id; + // handleLogin: the new LocalPlayer is the camera. + r.camera = None; r.logged_in = true; r.view_distance = p.chunk_radius; r.show_death_screen = p.show_death_screen; @@ -2032,6 +2038,8 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.death_message = None; r.health = (20.0, 20, 5.0); + // handleRespawn: the new LocalPlayer is the camera. + r.camera = None; } self.capture(true); } @@ -2051,6 +2059,7 @@ impl Game<'_> { self.snap_player_model_pitch(); } C::PlayerLookAt(p) => self.remote_look_at(&p), + C::SetCamera(p) => self.remote_set_camera(p.camera_id), C::LevelChunkWithLight(p) => self.remote_chunk(&p, None), C::LightUpdate(p) => self.remote_light_update(&p), C::ForgetLevelChunk(p) => { @@ -4163,6 +4172,14 @@ impl Game<'_> { | u8::from(keys.jump) << 4 | u8::from(keys.shift) << 5 | u8::from(keys.sprint) << 6; + // LocalPlayer.applyInput and aiStep: watching through another + // entity, the player does not move itself; its keys still reach the + // server, whose sneak check lets go of the camera. + let keys = if self.controls_camera() { + keys + } else { + Keys::default() + }; // A passenger moves with its vehicle (Entity.rideTick), not by itself. let riding = self.screen != Screen::Dead && self.remote_ride(keys); // The server sends no passenger's position: they sit where their @@ -4858,6 +4875,7 @@ impl Game<'_> { ); let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected); let riding = self.riding.is_some(); + let controls_camera = self.controls_camera(); let vehicle = self.remote_vehicle_packets(); let Some(r) = self.remote.as_mut() else { return; @@ -4890,9 +4908,12 @@ impl Game<'_> { flags: u8::from(on_ground) | u8::from(collision) << 1, })); out.extend(vehicle); - } else if let Some(packet) = - r.position_sender - .update(pos, (yaw, pitch), on_ground, collision) + } else if controls_camera + // LocalPlayer.sendPosition: no move while the view is another + // entity's (the server keeps the player on it). + && let Some(packet) = + r.position_sender + .update(pos, (yaw, pitch), on_ground, collision) { out.push(packet); } @@ -6045,6 +6066,11 @@ impl Game<'_> { return false; } if self.spectator() { + // Minecraft.startAttack: a spectator's click on an entity asks + // to watch through it (MultiPlayerGameMode.spectate); a click on + // nothing still tells the server (spectatorNoAction). + let target = self.spectator_click_target(); + self.send(S::SpectatorAction(play::SpectatorAction { target })); return true; } // A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server diff --git a/crates/horizon-client/src/game/remote_camera.rs b/crates/horizon-client/src/game/remote_camera.rs new file mode 100644 index 0000000000..01311652f0 --- /dev/null +++ b/crates/horizon-client/src/game/remote_camera.rs @@ -0,0 +1,108 @@ +//! A server's spectator camera (`ClientPacketListener.handleSetCamera`, +//! `Minecraft.cameraEntity`): `/spectate` or a spectator's click (sent as +//! `MultiPlayerGameMode.spectate`) attaches the view to another entity. The +//! view follows that entity while the player stands still and sends no moves +//! (`LocalPlayer.isControlledCamera`); the server keeps its player on the +//! camera and lets go when the player sneaks. +use super::Game; +use horizon_client::{ + camera::{CameraEntity, LivingCamera}, + mobs::Mob, +}; + +/// `Minecraft.setCameraEntity` from a ClientboundSetCameraPacket: the player's +/// own id puts the view back on the player; an id the level does not hold +/// changes nothing (`ClientboundSetCameraPacket.getEntity` is null). +pub(super) fn camera_after_packet( + current: Option, + player_id: i32, + camera_id: i32, + in_level: bool, +) -> Option { + if camera_id == player_id { + None + } else if in_level { + Some(camera_id) + } else { + current + } +} + +/// `Camera.alignWithEntity`'s view of a mob or another player: its +/// interpolated position, `getViewYRot`/`getViewXRot` and eye height, with +/// its scale and camera distance for a detached view. A living entity's +/// `getViewYRot` is its head's yaw (`yHeadRot`), not its body's `yRot`. +pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity { + let attribute = + |name: &str, default: f64| m.kind.attributes.get(name).copied().unwrap_or(default); + CameraEntity { + position: [m.position.x, m.position.y, m.position.z], + previous: [m.previous.x, m.previous.y, m.previous.z], + x_rot: m.head_pitch, + y_rot: m.head_yaw, + x_rot_o: m.previous_head_pitch, + y_rot_o: m.previous_head_yaw, + eye_height: m.eye_height() as f32, + living: Some(LivingCamera { + scale: attribute("minecraft:scale", 1.0) as f32, + camera_distance: attribute("minecraft:camera_distance", 4.0), + sleeping: None, + dead: None, + }), + mount: None, + } +} + +impl Game<'_> { + /// `ClientPacketListener.handleSetCamera`. + pub(super) fn remote_set_camera(&mut self, camera_id: i32) { + let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32); + if let Some(r) = self.remote.as_mut() { + r.camera = camera_after_packet(r.camera, r.entity_id, camera_id, in_level); + } + } + /// The entity a server attached the view to, when it is not the player. + pub(super) fn remote_camera_id(&self) -> Option { + self.remote.as_ref()?.camera.map(|id| id as u32) + } + /// `LocalPlayer.isControlledCamera`: the view is the player's own. + pub(super) fn controls_camera(&self) -> bool { + self.remote_camera_id().is_none() + } + /// The mob the view follows, while the level still holds it. + pub(super) fn camera_mob(&self) -> Option<&Mob> { + let id = self.remote_camera_id()?; + self.mobs.iter().find(|m| m.id == id) + } + /// The entity under a spectator's crosshair (`Minecraft.hitResult` as an + /// `EntityHitResult`): the nearest mob or object before any block. + pub(super) fn spectator_click_target(&self) -> Option { + let mob = self.mob_hit().map(|(i, d)| (self.mobs[i].id as i32, d)); + let object = self.remote_object_hit(false).map(|h| (h.id, h.distance)); + [mob, object] + .into_iter() + .flatten() + .filter(|(_, d)| self.before_block(*d)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(id, _)| id) + } + /// `LevelRenderer`: the camera entity is not drawn from its own eyes. + pub(super) fn hides_as_camera(&self, id: u32) -> bool { + self.camera_type.first_person() && self.remote_camera_id() == Some(id) + } +} + +#[cfg(test)] +mod tests { + use super::camera_after_packet; + + /// The camera goes to a known entity, back to the player on its own id, + /// and stays where it is for an entity the level does not hold. + #[test] + fn set_camera_follows_vanilla() { + assert_eq!(camera_after_packet(None, 5, 9, true), Some(9)); + assert_eq!(camera_after_packet(Some(9), 5, 5, false), None); + assert_eq!(camera_after_packet(Some(9), 5, 12, false), Some(9)); + assert_eq!(camera_after_packet(None, 5, 12, false), None); + } +} diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index a27ca12007..c46aef0f4f 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -797,6 +797,11 @@ packet!(SetEquipment { id: i32 => VarInt, slots: Vec<(u8, Option)> => Equ packet!(SetExperience { progress: f32 => Float, level: i32 => VarInt, total: i32 => VarInt }); packet!(SetHealth { health: f32 => Float, food: i32 => VarInt, saturation: f32 => Float }); packet!(SetHeldSlot { slot: i32 => VarInt }); +packet!( + /// ClientboundSetCameraPacket: the entity the view is attached to (the + /// player's own id puts it back on the player). + SetCamera { camera_id: i32 => VarInt } +); packet!(SetPlayerInventory { slot: i32 => VarInt, item: Option => Slot }); packet!( /// ClockNetworkState for one world clock (registry id). @@ -1891,6 +1896,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { SetBorderWarningDelay(SetBorderWarningDelay) = SET_BORDER_WARNING_DELAY, SetBorderWarningDistance(SetBorderWarningDistance) = SET_BORDER_WARNING_DISTANCE, SetChunkCacheCenter(SetChunkCacheCenter) = SET_CHUNK_CACHE_CENTER, + SetCamera(SetCamera) = SET_CAMERA, SetChunkCacheRadius(Distance) = SET_CHUNK_CACHE_RADIUS, SetCursorItem(SetCursorItem) = SET_CURSOR_ITEM, SetDefaultSpawnPosition(SetDefaultSpawnPosition) = SET_DEFAULT_SPAWN_POSITION, @@ -2063,6 +2069,11 @@ impl Codec for TrackedWaypoint { packet!(AcceptTeleportation { id: i32 => VarInt, x: f64 => Double, y: f64 => Double, z: f64 => Double, yaw: f32 => Float, pitch: f32 => Float }); packet!(Attack { id: i32 => VarInt }); packet!(TeleportToEntity { uuid: u128 => Uuid }); +packet!( + /// ServerboundSpectatorActionPacket: the entity a spectator clicked, or + /// none for a click on nothing. + SpectatorAction { target: Option => OptVarInt } +); packet!( /// GameType ids: 0 survival, 1 creative, 2 adventure, 3 spectator. ChangeGameMode { mode: i32 => VarInt } @@ -2311,6 +2322,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound { AcceptTeleportation(AcceptTeleportation) = ACCEPT_TELEPORTATION, Attack(Attack) = ATTACK, TeleportToEntity(TeleportToEntity) = TELEPORT_TO_ENTITY, + SpectatorAction(SpectatorAction) = SPECTATOR_ACTION, ChangeGameMode(ChangeGameMode) = CHANGE_GAME_MODE, Chat(Chat) = CHAT, ChatAck(ChatAck) = CHAT_ACK, @@ -3101,6 +3113,30 @@ mod tests { } } + /// ClientboundSetCameraPacket: one VarInt, the camera entity's id. + #[test] + fn set_camera_is_the_camera_entity_id() { + // 300 as a VarInt: 0xAC 0x02. + let p = + Clientbound::decode(crate::ids::play::clientbound::SET_CAMERA, &[0xAC, 0x02]).unwrap(); + assert!(!matches!(p, Clientbound::Unknown(..)), "{p:?}"); + assert!(format!("{p:?}").contains("camera_id: 300"), "{p:?}"); + assert_eq!(p.encode()[1..], [0xAC, 0x02]); + } + + /// ServerboundSpectatorActionPacket: `OPTIONAL_VAR_INT`, the clicked + /// entity's id plus one, or 0 for a click on nothing. + #[test] + fn spectator_action_is_an_optional_entity_id() { + let id = crate::ids::play::serverbound::SPECTATOR_ACTION; + for (bytes, shown) in [([8u8], "Some(7)"), ([0u8], "None")] { + let p = Serverbound::decode(id, &bytes).unwrap(); + assert!(!matches!(p, Serverbound::Unknown(..)), "{p:?}"); + assert!(format!("{p:?}").contains(shown), "{p:?}"); + assert_eq!(p.encode()[1..], bytes); + } + } + #[test] fn unknown_packets_keep_their_payload() { let p = From 803bff885bb21a05d45bafb769f066cd2cb8582c Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 08:20:27 +0100 Subject: [PATCH 300/968] lan_e2e: level_respawn_points leaves no Overworld spawnpoint (#4234) --- tools/lan_e2e.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..1556c013ab 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -940,8 +940,8 @@ def level_respawn_points(host, guest, out): results['anchor'] = die_and_respawn(guest) results['charges'] = guest.raw('block', x + 2, 100, z).get('state') finally: - sx, sy, sz = (int(v // 1) for v in pos_of(host)) - guest('command', command=f'execute in {OVERWORLD} run spawnpoint @s {sx} {sy} {sz}') + # Leave no Overworld spawnpoint behind: with the anchor removed below, the guest's next death + # falls back to the world spawn and clears its point, which check_world_spawn relies on. guest('command', command=f'execute in {NETHER} run setblock {x + 2} 100 {z} minecraft:air') guest('command', command=f'execute in {OVERWORLD} run tp @s {home[0]:.2f} {home[1]:.2f} {home[2]:.2f}') wait_dimension(guest, OVERWORLD, 400) From aba4ee3d568051c410388c5a9d859614e5e56f96 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:21:46 +0300 Subject: [PATCH 301/968] Native mods: a mod menu shows only its container's slots A menu reads, writes and sends only the declared slot count; items saved past it (a container that shrank) stay saved and untouched. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/menu.rs | 82 ++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 688dc6183d..82321def89 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -6,6 +6,7 @@ use super::guest::Guest; use crate::{ bundle::{Contents, can_be_in_bundle, toggle_selected}, + containers::BlockEntity, items::{ItemStack, Items}, world::World, }; @@ -89,7 +90,7 @@ impl Slots { return guest.ender_items.get(n).cloned().flatten(); } for k in self.halves() { - let slots = world.block_entities.get(&k)?.slots(); + let slots = shown(world, k)?; if n < slots.len() { return slots[n].clone(); } @@ -104,7 +105,7 @@ impl Slots { } let mut items = Vec::new(); for k in self.halves() { - items.extend_from_slice(world.block_entities.get(&k)?.slots()); + items.extend_from_slice(shown(world, k)?); } Some(items) } @@ -116,8 +117,9 @@ impl Slots { } let mut rest = items.into_iter(); for k in self.halves() { + let n = shown(world, k).map_or(0, <[_]>::len); if let Some(e) = world.block_entities.get_mut(&k) { - for slot in e.slots_mut() { + for slot in e.slots_mut().iter_mut().take(n) { *slot = rest.next().flatten(); } } @@ -125,6 +127,22 @@ impl Slots { } } +/// The slots of the block entity at `k` its menu shows: a mod container's +/// count, though items past it stay saved. +fn shown(world: &World, k: (i32, i32, i32)) -> Option<&[Option]> { + let e = world.block_entities.get(&k)?; + let slots = e.slots(); + if !matches!(e, BlockEntity::Mod(_)) { + return Some(slots); + } + let state = world.block(k.0, k.1, k.2).0; + let count = world + .block_entities + .mod_container(state) + .map_or(0, |c| c.slots); + Some(&slots[..count.min(slots.len())]) +} + /// Slots of a menu seen as one list. pub struct View<'a> { pub guest: &'a mut Guest, @@ -778,6 +796,64 @@ mod tests { assert_eq!(contents(&view.get(2)), []); } + /// A mod container saved with more items than its menu has slots: the + /// menu reads and writes only its count, the rest stay saved. + #[test] + fn mod_menu_shows_only_its_container_slots() { + use crate::containers::{ModBlockEntity, ModContainer}; + let mut world = World::new(horizon_world::registry::BlockStateId(0), 0); + let container = ModContainer { + slots: 5, + ..Default::default() + }; + world + .block_entities + .set_mod_containers([(0, Arc::new(container))].into_iter().collect()); + let mut items = vec![None; 9]; + items[1] = Some(ItemStack::new("minecraft:stone", 3)); + items[7] = Some(ItemStack::new("minecraft:iron_ore", 2)); + let k = (0, 64, 0); + world.block_entities.insert( + k, + BlockEntity::Mod(Box::new(ModBlockEntity { + id: "gtmod:machine".into(), + items, + ..Default::default() + })), + ); + let mut g = guest(); + let slots = Slots::Block(k); + let mut read = slots.read(&world, &g).unwrap(); + assert_eq!(read.len(), 5); + assert_eq!(slots.get(&world, &g, 7), None); + // A shift-click from the hotbar (menu slot 5 + 27) lands in the + // container's first empty slot, not past its count. + if let Some(rules) = rules() { + g.inventory[0] = Some(ItemStack::new("minecraft:dirt", 1)); + let mut drag = None; + let mut view = View { + guest: &mut g, + container: Some(&mut read), + shulker: false, + }; + click(&mut view, &mut drag, 5 + 27, 0, input::QUICK_MOVE, &rules); + assert_eq!(view.get(0).map(|s| s.item), Some("minecraft:dirt".into())); + } else { + read[0] = Some(ItemStack::new("minecraft:dirt", 1)); + } + slots.write(&mut world, &mut g, read); + let Some(BlockEntity::Mod(m)) = world.block_entities.get(&k) else { + panic!("not a mod's"); + }; + assert_eq!(m.items.len(), 9); + assert_eq!( + m.items[0].as_ref().map(|s| &*s.item), + Some("minecraft:dirt") + ); + assert_eq!(m.items[1].as_ref().map(|s| s.count), Some(3)); + assert_eq!(m.items[7].as_ref().map(|s| s.count), Some(2)); + } + #[test] fn guest_shulker_box_menu_refuses_shulker_boxes() { let Some(rules) = rules() else { From 912b671416e79abbff4f7770f15020c45848ca02 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:17:59 +0700 Subject: [PATCH 302/968] Volume sliders play a preview sound on the title screen's options Vanilla's sound sliders (Options.createSoundSliderOptionInstance) call SoundPreviewHandler.preview when there is no level: each category plays its own UI sound at the slider's volume (zombie ambient for Hostile Mobs, cow ambient for Friendly Mobs, thunder for Weather...), a preview still playing is not restarted, and moving another category's slider stops the previous one. Horizon had no preview at all. Port SoundPreviewHandler (sound_preview.rs), report a changed category volume from the options screen (Event::Volume, only for a new value as OptionInstance.set), and play the preview from the game when the options were opened from the title screen. Part of #3518. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/audio.rs | 10 + crates/horizon-client-render/src/lib.rs | 1 + .../horizon-client-render/src/options_ui.rs | 58 +++++- .../src/sound_preview.rs | 179 ++++++++++++++++++ crates/horizon-client/src/game/options.rs | 6 + .../horizon-client/src/game/startup_menu.rs | 5 +- crates/horizon-client/src/lib.rs | 5 +- tools/port_map/rest-004.tsv | 2 +- tools/port_map/rest-005.tsv | 4 +- 9 files changed, 258 insertions(+), 12 deletions(-) create mode 100644 crates/horizon-client-render/src/sound_preview.rs diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs index c61812e45e..1a93200863 100644 --- a/crates/horizon-client-render/src/audio.rs +++ b/crates/horizon-client-render/src/audio.rs @@ -165,6 +165,8 @@ pub struct Audio { /// How many times each selection was stopped ("record *", "* minecraft:…", `*` for /// any), with or without an audio device, as `heard`. pub stopped: std::collections::BTreeMap, + /// The volume sliders' `SoundPreviewHandler`. + preview: crate::sound_preview::SoundPreview, } fn decode(path: &Path) -> Option<(Vec, u32)> { use symphonia::core::{ @@ -634,6 +636,7 @@ impl Audio { capture: None, heard: Default::default(), stopped: Default::default(), + preview: Default::default(), } } #[cfg(not(feature = "audio"))] @@ -817,6 +820,13 @@ impl Audio { false, ); } + /// `SoundPreviewHandler.preview`: a category volume slider moved to + /// `volume` on the title screen's options. + pub fn preview(&mut self, source: usize, volume: f32) { + let mut preview = std::mem::take(&mut self.preview); + preview.preview(self, source, volume); + self.preview = preview; + } /// `play` with the seed a sound packet carries. pub fn play_seeded( &mut self, diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index 0f610d282f..dc3512aca3 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -71,6 +71,7 @@ pub mod signs; pub mod skins; pub mod sound_engine; pub mod sound_events; +pub mod sound_preview; pub mod spans; pub mod split_layout; mod sprite_cache; diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 3ec3fd8255..96499a1658 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -952,6 +952,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) { apply_preset(s); } } +/// A slider pressed or dragged to `v`: apply it live, and tell the game when +/// a category volume changed (`Options.createSoundSliderOptionInstance`'s +/// callback, which `OptionInstance.set` runs only for a new value). +fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec) { + let volume = |s: &Settings| match opt { + Opt::Volume(0) => Some(s.master_volume), + Opt::Volume(i) => Some(s.volumes[i]), + _ => None, + }; + let before = volume(s); + set_slider(opt, s, v); + events.push(Event::SliderDrag); + if let (Opt::Volume(source), Some(after)) = (opt, volume(s)) + && before != Some(after) + { + events.push(Event::Volume(source, after)); + } +} /// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture /// filtering and anisotropy have no option here yet). pub fn apply_preset(s: &mut Settings) { @@ -1106,6 +1124,9 @@ pub enum Event { Changed, /// A slider is being dragged: apply settings live but defer saving. SliderDrag, + /// A category volume slider (`SoundSource` index) moved to a new value, + /// after its `SliderDrag` (the sound slider's `onValueUpdate`). + Volume(usize, f32), /// The language changed (reload translations). Language(String), /// The pack selection changed (reload resources). @@ -2747,8 +2768,7 @@ impl OptionsUi { { let v = ui::slider_value(wd.x, wd.w, x); self.slider_value = Some((opt, v, Instant::now())); - set_slider(opt, s, v); - events.push(Event::SliderDrag); + drag_slider(opt, s, v, &mut events); } return events; } @@ -2861,8 +2881,7 @@ impl OptionsUi { self.dragging = Some(opt); let v = ui::slider_value(wd.x, wd.w, x); self.slider_value = Some((opt, v, Instant::now())); - set_slider(opt, s, v); - events.push(Event::SliderDrag); + drag_slider(opt, s, v, &mut events); } WKind::LanguageRow { code, .. } => { if self.language_choice.as_deref() == Some(code.as_str()) { @@ -4093,7 +4112,7 @@ mod tests { for cx in [-100.0, 250.0, 1000.0] { assert_eq!( ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false), - vec![Event::SliderDrag] + vec![Event::SliderDrag, Event::Volume(0, s.master_volume)] ); } assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]); @@ -4103,6 +4122,35 @@ mod tests { ); } + #[test] + fn a_moved_volume_slider_reports_its_category_and_value() { + let atlas = atlas(); + let size = (480.0, 320.0); + let mut s = Settings::default(); + let mut ui = OptionsUi::default(); + ui.show(Page::Sound); + let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap(); + let (widgets, _, _, _) = ui.layout(&s, size.0, size.1); + let slider = widgets + .iter() + .find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile)) + .unwrap(); + let y = slider.y + 1.0; + let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5; + let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false); + let volume = s.volumes[hostile]; + assert!((volume - 0.5).abs() < 1e-3, "{volume}"); + assert_eq!( + events, + vec![Event::SliderDrag, Event::Volume(hostile, volume)] + ); + // OptionInstance.set: an unchanged value runs no callback. + assert_eq!( + ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false), + vec![Event::SliderDrag] + ); + } + #[test] fn classic_sliders_follow_the_pointer_and_snap_on_release() { let atlas = atlas(); diff --git a/crates/horizon-client-render/src/sound_preview.rs b/crates/horizon-client-render/src/sound_preview.rs new file mode 100644 index 0000000000..babb802dd3 --- /dev/null +++ b/crates/horizon-client-render/src/sound_preview.rs @@ -0,0 +1,179 @@ +//! `SoundPreviewHandler`: the sound a category volume slider plays while it +//! moves on the title screen's options (`Options.createSoundSliderOptionInstance` +//! calls `preview` when there is no level). +use crate::settings::SOUND_SOURCES; +use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds}; + +/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index, +/// or `None` (`SoundEvents.EMPTY`) for master, music and voice. +pub fn preview_event(source: usize) -> Option<&'static str> { + Some(match *SOUND_SOURCES.get(source)? { + "record" => "minecraft:block.note_block.guitar", + "weather" => "minecraft:entity.lightning_bolt.thunder", + "block" => "minecraft:block.grass.place", + "hostile" => "minecraft:entity.zombie.ambient", + // SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound() + "neutral" => "minecraft:entity.cow.ambient", + "player" => "minecraft:entity.generic.eat", + "ambient" => "minecraft:ambient.cave", + "ui" => "minecraft:ui.button.click", + _ => return None, + }) +} + +/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`. +#[derive(Debug, Default)] +pub struct SoundPreview { + active: Option, + previous: Option, +} + +impl SoundPreview { + /// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the + /// preview of another category, then, unless the last preview still + /// plays, play this category's sound (`SimpleSoundInstance.forUI` at + /// pitch 1 and the slider's volume). + pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) { + self.stop_other_category_preview(sounds, source); + if !self.can_play_sound(sounds) { + return; + } + let Some(event) = preview_event(source) else { + return; + }; + if let Some(old) = self.active.take() { + sounds.release(old); + } + let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded()); + self.active = Some(sounds.play_instance(instance, true).0); + } + + /// The preview playing now, if any. + pub fn active(&self) -> Option { + self.active + } + + /// `stopOtherCategoryPreview`. + fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) { + if self.previous != Some(source) { + self.previous = Some(source); + if let Some(active) = self.active { + sounds.stop_instance(active); + } + } + } + + /// `canPlaySound`: no preview yet, or the last one has finished. + fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool { + self.active.is_none_or(|active| !sounds.is_active(active)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null}; + use crate::sound_events::SoundRegistry; + + fn engine() -> SoundEngine { + let mut registry = SoundRegistry::default(); + registry.add_file( + "minecraft", + br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]}, + "entity.cow.ambient": {"sounds": ["mob/cow/say1"]}, + "ui.button.click": {"sounds": ["random/click"]}}"#, + &|_, _| true, + ); + SoundEngine::new(registry) + } + + fn source(name: &str) -> usize { + SOUND_SOURCES.iter().position(|s| *s == name).unwrap() + } + + #[test] + fn each_category_previews_its_vanilla_sound() { + let expected = [ + ("master", None), + ("music", None), + ("record", Some("minecraft:block.note_block.guitar")), + ("weather", Some("minecraft:entity.lightning_bolt.thunder")), + ("block", Some("minecraft:block.grass.place")), + ("hostile", Some("minecraft:entity.zombie.ambient")), + ("neutral", Some("minecraft:entity.cow.ambient")), + ("player", Some("minecraft:entity.generic.eat")), + ("ambient", Some("minecraft:ambient.cave")), + ("voice", None), + ("ui", Some("minecraft:ui.button.click")), + ]; + for (name, event) in expected { + assert_eq!(preview_event(source(name)), event, "{name}"); + } + } + + fn slide( + preview: &mut SoundPreview, + engine: &mut SoundEngine, + backend: &mut Null, + name: &str, + volume: f32, + ) { + preview.preview(&mut Bound { engine, backend }, source(name), volume); + } + + /// Ticks enough for the engine to drop finished or stopped channels. + fn settle(engine: &mut SoundEngine, backend: &mut Null) { + for _ in 0..40 { + engine.tick(false, &TickContext::default(), backend); + } + } + + #[test] + fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() { + let (mut engine, mut backend) = (engine(), Null::default()); + let mut preview = SoundPreview::default(); + slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4); + let first = preview + .active() + .expect("the hostile slider plays a preview"); + let instance = engine.instance(first).unwrap(); + assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient"); + assert_eq!(instance.source, UI); + assert_eq!((instance.volume, instance.pitch), (0.4, 1.0)); + assert!(instance.relative); + assert!(engine.is_active(first)); + // canPlaySound: still playing, so dragging further does not restart it. + slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6); + assert_eq!(preview.active(), Some(first)); + assert_eq!(engine.active_instances(), vec![first]); + // Once it has finished, the next move plays it again. + for stopped in backend.stopped.values_mut() { + *stopped = true; + } + settle(&mut engine, &mut backend); + assert!(!engine.is_active(first)); + slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6); + let second = preview.active().unwrap(); + assert_ne!(second, first); + assert!(engine.is_active(second)); + // Another category stops the playing preview (SoundManager.stop); the + // engine lets its channel go on the next ticks, then that category's + // own sound plays. + slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0); + assert!(backend.stopped.values().all(|stopped| *stopped)); + settle(&mut engine, &mut backend); + assert!(engine.active_instances().is_empty()); + slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0); + let third = preview.active().unwrap(); + assert_eq!( + engine.instance(third).unwrap().identifier, + "minecraft:entity.cow.ambient" + ); + // Master has no sound but still stops the other category's preview. + slide(&mut preview, &mut engine, &mut backend, "master", 1.0); + settle(&mut engine, &mut backend); + assert!(engine.active_instances().is_empty()); + slide(&mut preview, &mut engine, &mut backend, "master", 1.0); + assert!(engine.active_instances().is_empty()); + } +} diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 9c9c93ffc3..22e0e2e0d0 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -220,6 +220,12 @@ impl Game<'_> { Event::SliderDrag => { self.apply_settings(); } + // Options.createSoundSliderOptionInstance: the preview plays + // only without a level (on the title screen's options). + Event::Volume(source, volume) if self.title.is_some() => { + self.audio.preview(source, volume); + } + Event::Volume(..) => {} Event::CopyAgentDetails => { use horizon_client::clipboard::Clipboard; let text = self.agent_details(); diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index 95f7c6d847..a377377bb0 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -434,8 +434,9 @@ impl Loading { } // Mods' settings pages open only in a running game. Event::NativeSettings(_) | Event::NativeButton(..) => {} - // The startup menu runs before the game opens a control socket. - Event::ButtonClick | Event::CopyAgentDetails => {} + // The startup menu runs before the game opens a control socket, + // and before its sound engine (no clicks, no volume previews). + Event::ButtonClick | Event::CopyAgentDetails | Event::Volume(..) => {} } } } diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 5709610187..428d29e827 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -14,8 +14,9 @@ pub use horizon_client_render::{ mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections, potent_sulfur, providers, screen_draw, screen_effects, sculk, section_mask, settings, shader_pack_list, signs, - skins, sound_engine, sound_events, spans, split_layout, sprites, streaming, tab_list, - telemetry, testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip, + skins, sound_engine, sound_events, sound_preview, spans, split_layout, sprites, streaming, + tab_list, telemetry, testgen, title_edition, trims, ui, vault, weather, widgets, world, + worldborder, zip, }; pub mod attack_strength; pub mod attribute_updates; diff --git a/tools/port_map/rest-004.tsv b/tools/port_map/rest-004.tsv index 4e9702f016..4dda1efd60 100644 --- a/tools/port_map/rest-004.tsv +++ b/tools/port_map/rest-004.tsv @@ -9,7 +9,7 @@ OptionInstance.UnitDouble.validateValue port crates/horizon-client-render/src/op OptionInstance.UnitDouble.codec port crates/horizon-client-render/src/options_file.rs::codec_double Options.updateResourcePacks port crates/horizon-client/src/game/options.rs::Game::handle_option_events gap: minor: saves and reloads on every packs event; vanilla only when the list changed Options.maxAnisotropyValue missing player: no anisotropic filtering setting; textures are sampled without anisotropy -Options.createSoundSliderOptionInstance port crates/horizon-client-render/src/audio.rs::Audio::sync_volumes gap: player: no sound preview outside a level and no now-playing toast when master or music changes +Options.createSoundSliderOptionInstance port crates/horizon-client-render/src/audio.rs::Audio::sync_volumes gap: player: no now-playing toast when master or music changes Options.setGraphicsPresetToCustom inline crates/horizon-client-render/src/options_ui.rs::Opt::set_slider gap: minor: cycle buttons set custom in Opt::cycle as well Options.processOptions inline crates/horizon-client-render/src/options_file.rs::OptionsFile::parse gap: minor: the save direction is OptionsFile::serialize Options.dataFix port crates/horizon-client-render/src/options_fixes.rs::update diff --git a/tools/port_map/rest-005.tsv b/tools/port_map/rest-005.tsv index cc60a6b8c7..b714a2f0df 100644 --- a/tools/port_map/rest-005.tsv +++ b/tools/port_map/rest-005.tsv @@ -78,8 +78,8 @@ SoundManager.apply port crates/horizon-client-render/src/audio.rs::Audio::reload SoundManager.validateSoundResource inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file file existence check through the exists closure SoundManager.Preparations.handleRegistration port crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file replace flag and sound entries SoundManager.Preparations.apply inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::load registry replaced with the prepared one -SoundPreviewHandler.preview missing player: moving a category volume slider in Options plays no preview sound (Options.java calls SoundPreviewHandler.preview) -SoundPreviewHandler.stopOtherCategoryPreview missing minor: part of the volume preview +SoundPreviewHandler.preview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::preview +SoundPreviewHandler.stopOtherCategoryPreview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::stop_other_category_preview WeighedSoundEvents.preloadIfRequired skip:java preload is a cache; Horizon decodes on demand CraftPlanksTutorialStep.tick missing player: a new survival world shows no craft-planks tutorial toast; Horizon has no tutorial steps (tutorialStep is only stored in options) CraftPlanksTutorialStep.hasCraftedPlanksPreviously missing minor: part of the craft-planks tutorial From 3c2a49c4efa44f0d0030f7038e635bded055e22b Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:24:44 +0300 Subject: [PATCH 303/968] Native mods: neighbour changes notify after the change; properties update neighbours native_with_mod_block_entity runs the mod's change first, then marks the chunk changed and, when items moved, lets comparators read the container again (native_mod_block_entity_at notified before). native_set_block_property goes through Level.setBlock with UPDATE_ALL, so neighbours and observers hear of it. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_hooks.rs | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index df4635ebf1..df2ffb59c0 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -41,7 +41,8 @@ use horizon_client::{ mod_list::{self, Installed, NativePage}, text::Text, }; -use horizon_world::nbt::Compound; +use horizon_core::position::BlockPos; +use horizon_world::{level::flags, nbt::Compound}; use std::{fs, path::PathBuf, sync::Arc, time::Duration}; use winit::{event::MouseButton, keyboard::KeyCode}; @@ -400,14 +401,10 @@ impl Game<'_> { pub(super) fn native_on(&self, mod_id: &str) -> bool { !self.settings.mods.disabled.contains(mod_id) } - /// The mod block entity at `pos`, to read or change from a tick (a - /// generator charging the machine beside it); none for any other block - /// entity, and for the one ticking now, which is out of the world. As - /// its `setChanged` would, its chunk is saved next time and comparators - /// read it again. API 3. /// Sets property `key` of the block at `pos` to `value`, as a furnace's - /// tick sets its `lit` (`Level.setBlock`); false when the block has no - /// such property or value. The block entity stays. API 3. + /// tick sets its `lit` (`Level.setBlock` with `UPDATE_ALL`: neighbours + /// and clients are told); false when the block has no such property or + /// value. The block entity stays. API 7. pub(super) fn native_set_block_property( &mut self, pos: [i32; 3], @@ -428,23 +425,34 @@ impl Game<'_> { let Ok(id) = registry.resolve(&s) else { return false; }; - self.world.set(pos[0], pos[1], pos[2], id).is_ok() + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags::UPDATE_ALL)) } - pub(super) fn native_mod_block_entity_at( + /// Runs `change` on the mod block entity at `pos` (a generator charging + /// the machine beside it) and returns what it returns; none for any + /// other block entity, and for the one ticking now, which is out of the + /// world. Afterwards, as its `setChanged` would, its chunk is saved next + /// time and, when its items changed, comparators read it again. API 7. + pub(super) fn native_with_mod_block_entity( &mut self, pos: [i32; 3], - ) -> Option<&mut ModBlockEntity> { + change: impl FnOnce(&mut ModBlockEntity) -> R, + ) -> Option { let key = (pos[0], pos[1], pos[2]); - match self.world.block_entities.get(&key) { - Some(BlockEntity::Mod(m)) if !m.id.is_empty() => {} - _ => return None, + let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&key) else { + return None; + }; + if m.id.is_empty() { + return None; } + let items = m.items.clone(); + let out = change(m); + let moved = m.items != items; self.world.modified.insert((key.0 >> 4, key.2 >> 4)); - self.container_changed(key); - match self.world.block_entities.get_mut(&key) { - Some(BlockEntity::Mod(m)) => Some(m), - _ => None, + if moved { + self.container_changed(key); } + Some(out) } /// Playing, with nothing over the world: keys and zoom apply. pub(super) fn native_playing(&self) -> bool { From 0271c628250ebedb6b381bcfc485201d3ca7ee60 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:25:16 +0200 Subject: [PATCH 304/968] Falling blocks keep their random model offset (pointed dripstone) FallingBlockRenderer draws the block through ModelBlockRenderer.tesselateBlock, which moves it by BlockState.getOffset(BlockPos.containing(x, maxY, z)). Horizon drew falling blocks centred, so a pointed dripstone (OffsetType.XZ) jumped sideways when it broke off and fell. Fixes #4102 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 65 ++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5f1bcd0194..cb8bb6bccf 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1232,6 +1232,30 @@ struct FallingBlock { /// The UUID and compound it was loaded with, kept while it moves. saved: Option>, } +impl FallingBlock { + /// Where `FallingBlockRenderer` draws the block's model corner, `alpha` + /// into the tick: centred on the entity, then moved by `offset`, the + /// block's random model offset (`ModelBlockRenderer.tesselateBlock`: + /// `BlockState.getOffset` of `BlockPos.containing(x, maxY, z)`), so a + /// pointed dripstone keeps the place it hung at while it falls. + fn model_origin( + &self, + alpha: f64, + offset: impl Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>, + ) -> glam::DVec3 { + let [dx, dy, dz] = offset( + self.state, + self.position.x.floor() as i32, + self.position.z.floor() as i32, + ) + .unwrap_or([0.0; 3]); + glam::DVec3::new( + self.previous.x + (self.position.x - self.previous.x) * alpha - 0.5 + dx, + self.previous.y + (self.position.y - self.previous.y) * alpha + dy, + self.previous.z + (self.position.z - self.previous.z) * alpha - 0.5 + dz, + ) + } +} /// The data loaded once at startup and never changed: blocks, items, /// recipes, biomes, mobs, collision and motion. Read-only and `Send + Sync`, /// so the client and an integrated server thread can share one copy. @@ -8706,11 +8730,8 @@ impl Game<'_> { let Some((_, tint)) = self.block_face_sprite(f.state) else { continue; }; - let p = glam::DVec3::new( - f.previous.x + (f.position.x - f.previous.x) * alpha - 0.5, - f.previous.y + (f.position.y - f.previous.y) * alpha, - f.previous.z + (f.position.z - f.previous.z) * alpha - 0.5, - ) - camera; + let tables = &self.pack.context.tables; + let p = f.model_origin(alpha, |state, x, z| tables.offset(state, x, z)) - camera; let o = p.as_vec3(); let light = self.world.light( f.position.x.floor() as i32, @@ -16598,6 +16619,40 @@ mod tests { use horizon_client::container_screen::{Button, Clicks, Release, Target}; use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys}; + /// A falling block is drawn moved by its column's model offset, as + /// `ModelBlockRenderer.tesselateBlock` does with `BlockState.getOffset` + /// of `BlockPos.containing(x, maxY, z)`: a pointed dripstone that breaks + /// off stays where it hung instead of jumping to the block's centre (#4102). + #[test] + fn a_falling_block_keeps_its_model_offset() { + let at = Vec3::new(3.5, 70.25, -1.5); + let falling = FallingBlock { + id: 0, + state: BlockStateId(7), + start_pos: BlockPos::new(3, 72, -2), + position: at, + previous: Vec3::new(3.5, 71.0, -1.5), + velocity: Vec3::ZERO, + age: 5, + no_gravity: false, + drop_item: true, + fall_distance: 0.0, + stuck: Vec3::ZERO, + damage: horizon_client::falling_block::Damage::default(), + saved: None, + }; + // OffsetType.XZ: one offset per column (Mth.getSeed(x, 0, z)). + let offset = |state: BlockStateId, x: i32, z: i32| { + assert_eq!(state, BlockStateId(7)); + ((x, z) == (3, -2)).then_some([0.125, 0.0, -0.0625]) + }; + let origin = falling.model_origin(0.5, offset); + assert_eq!(origin, glam::DVec3::new(3.125, 70.625, -2.0625)); + // Blocks without a random offset are drawn centred on the entity. + let origin = falling.model_origin(1.0, |_, _, _| None); + assert_eq!(origin, glam::DVec3::new(3.0, 70.25, -2.0)); + } + /// A key bound to key.attack keeps attacking while it is held: each /// repeat clicks like the press (#3243). #[test] From c89bf6241d07449d02298f4dd900e80e6d6f7911 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:26:36 +0300 Subject: [PATCH 305/968] Native mods: a mod screen takes data only for its own window ContainerSetData for another window (an old or replaced one) no longer reaches the open mod screen. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_hooks.rs | 60 ++++++++++++++----- crates/horizon-client/src/game/remote.rs | 4 +- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 40993ab408..dcedb42aa7 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -88,14 +88,34 @@ pub struct NativeScreen { } /// The mod screen open on this client: the block it is for, the key its -/// slots are under, and the menu's data slots as the server sent them. +/// slots are under, the window id of its menu, and the menu's data slots as +/// the server sent them. pub(super) struct OpenNativeScreen { key: Pos, + window: i32, block: String, screen: NativeScreen, data: Vec, } +impl OpenNativeScreen { + /// Data slot `id` of window `window`'s menu: false for another window's + /// (an old or replaced one), which this screen leaves alone. + fn set_data(&mut self, window: i32, id: i16, value: i16) -> bool { + if window != self.window { + return false; + } + // A menu's data slot ids are small; a bad one is dropped. + if let Ok(i @ 0..256) = usize::try_from(id) { + if self.data.len() <= i { + self.data.resize(i + 1, 0); + } + self.data[i] = i32::from(value); + } + true + } +} + /// The player as a native mod reads it. #[allow(dead_code)] #[derive(Clone, Debug, PartialEq)] @@ -777,7 +797,7 @@ impl Game<'_> { } /// A menu opened on this client with its slots under `key`, on block /// `target`: a mod block's is laid out by its mod (`native_screen`). - pub(super) fn open_native_screen(&mut self, key: Pos, target: Option) { + pub(super) fn open_native_screen(&mut self, key: Pos, target: Option, window: i32) { self.mod_screen = None; let Some(t) = target else { return; @@ -796,6 +816,7 @@ impl Game<'_> { if let Some(screen) = screen { self.mod_screen = Some(OpenNativeScreen { key, + window, block, screen, data: Vec::new(), @@ -808,23 +829,15 @@ impl Game<'_> { .as_ref() .filter(|s| self.screen == Screen::Inventory && self.container == Some(s.key)) } - /// A data slot of the open mod screen's menu (`ContainerSetData`); false - /// when none is showing. - pub(super) fn native_screen_data(&mut self, id: i16, value: i16) -> bool { + /// A data slot of window `window`'s menu (`ContainerSetData`); false + /// when no mod screen is showing for that window. + pub(super) fn native_screen_data(&mut self, window: i32, id: i16, value: i16) -> bool { if self.shown_native_screen().is_none() { return false; } - let Some(s) = self.mod_screen.as_mut() else { - return false; - }; - // A menu's data slot ids are small; a bad one is dropped. - if let Ok(i @ 0..256) = usize::try_from(id) { - if s.data.len() <= i { - s.data.resize(i + 1, 0); - } - s.data[i] = i32::from(value); - } - true + self.mod_screen + .as_mut() + .is_some_and(|s| s.set_data(window, id, value)) } /// The open mod screen's height. pub(super) fn native_screen_height(&self) -> Option { @@ -1081,6 +1094,21 @@ mod tests { /// horizon-mixins refuses targets whose body has a `cfg` or a macro: /// the hook points and helpers must stay free of both. The launcher /// reads the API line as it is. + /// A data packet for an old window leaves the open mod screen alone. + #[test] + fn mod_screen_data_is_its_windows_only() { + let mut s = super::OpenNativeScreen { + key: (0, 0, 0), + window: 4, + block: "gtmod:machine".into(), + screen: super::NativeScreen::default(), + data: Vec::new(), + }; + assert!(s.set_data(4, 1, 7)); + assert!(!s.set_data(3, 1, 99)); + assert_eq!(s.data, [0, 7]); + } + #[test] fn hook_points_stay_hookable() { let source = include_str!("native_hooks.rs"); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 9e91673e3d..031b17e553 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -3002,7 +3002,7 @@ impl Game<'_> { C::ContainerSetData(p) if self.remote_station_open(p.container) => { self.remote_station_data(usize::try_from(p.id).unwrap_or(usize::MAX), p.value); } - C::ContainerSetData(p) if self.native_screen_data(p.id, p.value) => {} + C::ContainerSetData(p) if self.native_screen_data(p.container, p.id, p.value) => {} C::ContainerSetData(p) => { let v = i32::from(p.value); match self @@ -6662,7 +6662,7 @@ impl Game<'_> { let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0)); self.world.block_entities.insert(key, entity); self.container = Some(key); - self.open_native_screen(key, target); + self.open_native_screen(key, target, p.container); } self.screen = Screen::Inventory; self.capture(false); From 14d7f036c3d5c35dc29ed8f370e4e5ad0c294c53 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:26:49 +0300 Subject: [PATCH 306/968] Native mods: item slots are API 5 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 6431e2508b..8a1b6a2e94 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -49,7 +49,7 @@ use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 4; +pub(crate) const NATIVE_HOOKS_API: u32 = 5; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -366,7 +366,7 @@ impl Game<'_> { /// Block `block`'s block entity has item slots (a `WorldlyContainer`): /// set `container` when the block is yours. Asked once for each mod /// block when the ticking table is built; at most 256 slots, and slot - /// numbers past the count are left out. API 4. + /// numbers past the count are left out. API 5. pub(super) fn native_block_entity_container( &self, block: &str, @@ -379,7 +379,7 @@ impl Game<'_> { /// compound `nbt` and its item `slots` (none without a container). It is out of the world during the call (the block /// entity at `pos` reads as a `Mod` with an empty id, and a save leaves /// it out); if the call removes or replaces it, `nbt` is dropped. Clients, - /// the window's own in single player included, don't get it yet. API 4. + /// the window's own in single player included, don't get it yet. API 5. pub(super) fn native_block_entity_tick( &mut self, pos: [i32; 3], From a3aa60d7bc57e7389ccb090a857c8e49249fd852 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:27:05 +0300 Subject: [PATCH 307/968] Native mods: neighbour helpers are API 7 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 4720ef11c7..9f930ab0b8 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -49,7 +49,7 @@ use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 6; +pub(crate) const NATIVE_HOOKS_API: u32 = 7; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the From 7255ecd851e8046080acd291dd6d7ff577ccd552 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 10:27:06 +0300 Subject: [PATCH 308/968] Native mods: mod screens are API 8 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 1e9f0f2d7b..10f4f0d669 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -51,7 +51,7 @@ use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 7; +pub(crate) const NATIVE_HOOKS_API: u32 = 8; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -434,7 +434,7 @@ impl Game<'_> { } /// A menu opens on this client for block `block`, a mod's: set `screen` /// when the block is yours, to lay its screen out (`native_screen_draw` - /// draws it). The server's menu stays as it is. API 3. + /// draws it). The server's menu stays as it is. API 8. pub(super) fn native_screen(&self, block: &str, screen: &mut Option) { let _ = (block, screen); } @@ -442,7 +442,7 @@ impl Game<'_> { /// over a plain panel with its slots' frames and under the items and /// labels, from the menu's data slots `data` (the block entity's `data` /// on the server). Draw with `native_sprite`, `native_fill` and - /// `native_text`. API 3. + /// `native_text`. API 8. pub(super) fn native_screen_draw( &self, block: &str, @@ -548,7 +548,7 @@ impl Game<'_> { /// Part `src` (`[x, y, w, h]` in the sprite's own pixels) of sprite /// `name` (vanilla's `gui/...`, or a content mod's GUI texture /// `mymod:gui/...`), drawn stretched into `dst` (`[x, y, w, h]` in GUI - /// pixels). False when there is no such sprite. API 3. + /// pixels). False when there is no such sprite. API 8. pub(super) fn native_sprite( &self, batch: &mut HudBatch, @@ -567,7 +567,7 @@ impl Game<'_> { batch.rect_uv(dst[0], dst[1], dst[2], dst[3], r, u32::MAX); true } - /// The size of sprite `name` in its own pixels, if there is one. API 3. + /// The size of sprite `name` in its own pixels, if there is one. API 8. pub(super) fn native_sprite_size(&self, name: &str) -> Option<[f32; 2]> { let r = self.gui.atlas.sprites.get(name)?; Some([r.w as f32, r.h as f32]) From b91fb97b1c5e119fe4cf35a50c62d709426142ed Mon Sep 17 00:00:00 2001 From: Teiwazik Date: Fri, 30 Oct 2026 08:28:10 +0100 Subject: [PATCH 309/968] Pack overlay: stamp vanilla's files by their metadata on Windows Without a change timestamp, Windows checked every file of a cached overlay by its bytes, vanilla's included: tens of thousands of opens, each scanned by the antivirus, took minutes on every resource reload. Vanilla's files now keep to their size and write and creation times, as on Unix; the packs' own files are still read. The marker version moves to 6. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH --- .../horizon-client-render/src/packs/cache.rs | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client-render/src/packs/cache.rs b/crates/horizon-client-render/src/packs/cache.rs index ce8fd94481..5f369dfd25 100644 --- a/crates/horizon-client-render/src/packs/cache.rs +++ b/crates/horizon-client-render/src/packs/cache.rs @@ -9,7 +9,9 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf}; // Bump when overlay/merge semantics change. Markers are disposable cache data. // Version 4 mirrored every untouched directory file by file on Windows. // Version 3 could omit untouched vanilla directories on Windows (#1649, #1688). -const VERSION: u32 = 5; +// Version 5 read every vanilla file to stamp it on Windows. +// Version 6 stamps vanilla's files by their metadata on Windows too. +const VERSION: u32 = 6; #[derive(Default, Serialize, Deserialize)] pub(super) struct Dependencies { @@ -18,13 +20,16 @@ pub(super) struct Dependencies { failed: bool, #[serde(skip)] tracking: bool, + /// The vanilla root (absolute): its files are stamped without reading them. + #[serde(skip)] + vanilla: PathBuf, } impl Dependencies { fn record(&mut self, path: &Path, directory: bool) { if !self.tracking { return; } - match dependency_signature(path, directory) { + match dependency_signature(path, directory, &self.vanilla) { Ok(signature) => self.entries.push((path.into(), directory, signature)), Err(_) => self.failed = true, } @@ -38,7 +43,7 @@ impl Dependencies { fn valid(&self) -> bool { !self.failed && self.entries.iter().all(|(path, directory, signature)| { - dependency_signature(path, *directory).is_ok_and(|s| s == *signature) + dependency_signature(path, *directory, &self.vanilla).is_ok_and(|s| s == *signature) }) } } @@ -72,7 +77,8 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> { } Ok(()) } -fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> { +/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes. +fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> { let meta = fs::metadata(path)?; hash.update(meta.len().to_le_bytes()); field(hash, format!("{:?}", meta.modified()?).as_bytes()); @@ -87,8 +93,11 @@ fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> { #[cfg(not(unix))] { // Portable metadata lacks a change timestamp. Preserve content-based - // validation for materialized files on those platforms. - if meta.is_file() { + // validation for the packs' materialized files on those platforms; + // vanilla's (the launcher's, tens of thousands) keep to the size and + // the write and creation times: reading each costs minutes on Windows, + // where every open is scanned by the antivirus. + if read && meta.is_file() { return content(hash, path); } if let Ok(created) = meta.created() { @@ -104,17 +113,18 @@ fn entries(path: &Path) -> io::Result> { entries.sort(); Ok(entries) } -fn dependency_signature(path: &Path, directory: bool) -> io::Result { +fn dependency_signature(path: &Path, directory: bool, vanilla: &Path) -> io::Result { let mut hash = Sha256::new(); + let read = !(path.starts_with(vanilla) || absolute(path).is_ok_and(|p| p.starts_with(vanilla))); match fs::metadata(path) { Err(e) if e.kind() == io::ErrorKind::NotFound => field(&mut hash, b"missing"), Err(e) => return Err(e), Ok(meta) if directory && meta.is_dir() => { field(&mut hash, b"directory"); - stamp(&mut hash, path)?; + stamp(&mut hash, path, read)?; } Ok(_) if directory => field(&mut hash, b"not-directory"), - Ok(_) => stamp(&mut hash, path)?, + Ok(_) => stamp(&mut hash, path, read)?, } Ok(finish(hash)) } @@ -190,7 +200,7 @@ fn tree_inner( if pack.is_none() { return content(hash, path); } - stamp(hash, path)?; + stamp(hash, path, true)?; } } Ok(()) @@ -283,11 +293,14 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<() let signature = pack_signature(packs); if let Ok(signature) = &signature && let Ok(bytes) = fs::read(&marker_path) - && let Ok(marker) = serde_json::from_slice::(&bytes) + && let Ok(mut marker) = serde_json::from_slice::(&bytes) && marker.version == VERSION && marker.vanilla == vanilla_key && marker.packs == *signature - && marker.dependencies.valid() + && { + marker.dependencies.vanilla = vanilla_key.clone(); + marker.dependencies.valid() + } && out.is_dir() { return Ok(()); @@ -300,6 +313,7 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<() } let mut dependencies = Dependencies { tracking: signature.is_ok(), + vanilla: vanilla_key.clone(), ..Dependencies::default() }; build_overlay_uncached(vanilla, packs, out, &mut dependencies)?; From df71f965b046ef9718fc98a67555ceeaf3954d89 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:16:05 +0700 Subject: [PATCH 310/968] A tame pet's death tells its owner how it died TamableAnimal.die sends the owner, when showDeathMessages is on and the owner is online in any dimension, the pet's CombatTracker.getDeathMessage ("Rex was slain by Zombie"). Horizon's mobs had no combat tracker and mob_died had no such step, so the owner was never told. Mobs now keep a CombatTracker (mobs::combat): Mob::damage records each hit that reaches the health (not one wolf armor takes), LivingEntity.tick's recheckStatus forgets them after 100 ticks (300 in a fight), and /kill records its generic_kill hit (LivingEntity.kill). mob_died tells a tame wolf, cat, parrot or nautilus's owner, the host or a guest by UUID, a message built by the player's own getDeathMessage code from those hits, with the kill credit (getKillCredit) and the name of a creeper that blew up. /damage by a player makes them the mob's lastHurtByPlayer, as LivingEntity.hurtServer does. Fixes #3514 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/combat.rs | 137 +++++++++++++++++- crates/horizon-client/src/game/commands.rs | 10 ++ .../src/game/commands/world_cmds.rs | 11 +- crates/horizon-client/src/game/creatures.rs | 7 +- crates/horizon-client/src/lan/hub.rs | 12 ++ crates/horizon-client/src/mobs.rs | 28 +++- crates/horizon-client/src/mobs/combat.rs | 95 ++++++++++++ 7 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 crates/horizon-client/src/mobs/combat.rs diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 63883dca6c..f4b5c6c167 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -1,8 +1,9 @@ //! The player's `CombatTracker`: the damage taken lately, who dealt it and //! where the player fell from, turned into the vanilla death message. -use super::{Game, chat::PLAYER_NAME}; +use super::{Game, chat::PLAYER_NAME, commands::Target}; use horizon_client::{ health::HealthEvent, + mobs::{Mob, Species}, motion::MotionEvent, particles::{ParticleOptions, ParticleType}, text::Text, @@ -72,6 +73,10 @@ pub(super) struct CombatTracker { /// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list, /// but the mobs they killed still ask for their loot. pub(super) charged_exploders: Vec, + /// The mobs whose blast went off this tick and that left the mob list + /// (`MobEvent::ExplosionSource`): their names, for the death messages of + /// the mobs the blast killed. + pub(super) exploded: Vec<(u32, Text)>, /// The player's fall distance before this tick's movement (as of the /// end of the last one). fall_before: f64, @@ -391,4 +396,134 @@ impl Game<'_> { // template that names a killer then shows as written). Text::translate(&key, &[me]) } + /// `TamableAnimal.die`: with `showDeathMessages`, a tame wolf, cat, + /// parrot or nautilus's owner, when online in any dimension + /// (`EntityReference.getEntity` looks through every level), is sent how it + /// died. `killer` is the player who last hurt it (`None` inside for the + /// host), from `last_hurt_by_player`. + pub(super) fn tell_owner_pet_died(&mut self, m: &Mob, killer: Option>) { + let tamable = matches!( + m.species(), + Species::Wolf + | Species::Cat + | Species::Parrot + | Species::Nautilus + | Species::ZombieNautilus + ); + let Some(owner) = m.extra.owner.filter(|_| tamable && m.extra.tame) else { + return; + }; + if !self.ui.rules.bool("show_death_messages") { + return; + } + let host = self.player.is_some() + && self.options.dedicated.is_none() + && horizon_client::mobs::tame::owned_by( + Some(owner), + self.level.player_uuid.unwrap_or_default(), + ); + let uuid = horizon_client::lan::playerdata::ints_uuid(owner); + let guest = !host + && self + .lan + .as_ref() + .is_some_and(|l| l.hub.guests.iter().any(|g| g.placed && g.uuid == uuid)); + if !host && !guest { + return; + } + let message = self.mob_death_message(m, killer); + if host { + self.system_message(message); + } else if let Some(lan) = &self.lan { + let content = horizon_client::lan::data::component_nbt(&message.component()); + lan.hub.system_message_to_player(uuid, content); + } + } + /// `CombatTracker.getDeathMessage` for a mob, from the hits it recorded + /// (`Mob::combat`), told the way the player's own tracker tells them. + pub(super) fn mob_death_message(&mut self, m: &Mob, killer: Option>) -> Text { + use horizon_client::mobs::{DamageKind, Who}; + let me = mob_name(m); + // The player behind a `Who::Player` hit: the last one to hurt it, or + // the one in place (a server with nobody acting has none). + let player = match killer { + Some(Some(conn)) => Some((u32::MAX - 1 - conn, self.target_name(Target::Guest(conn)))), + Some(None) => Some((u32::MAX, self.target_name(Target::Player))), + None if self.player.is_some() || self.lan_acting.is_some() => { + Some((u32::MAX, self.target_name(Target::Player))) + } + None => None, + }; + // The cause's display name: a mob that is gone (a creeper that blew + // up) is still named by its blast. + let name = |g: &Self, who: Who| match who { + Who::Player => player.clone(), + Who::Mob(id) => g + .mob_display_name(id) + .or_else(|| { + g.combat + .exploded + .iter() + .find(|(e, _)| *e == id) + .map(|(_, n)| n.clone()) + }) + .map(|n| (id, n)), + }; + let entries = m + .combat + .entries + .iter() + .map(|e| { + let attacker = e.source.attacker.and_then(|w| name(self, w)); + // AbstractArrow.onHitEntity: an arrow nobody shot is its own + // cause. + let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow) + .then(|| Text::translate("entity.minecraft.arrow", &[])); + Entry { + // DamageSources.explosion: player_explosion whenever + // something caused the blast (the only kind that asks). + damage_type: super::creatures::damage_type( + e.source.kind, + e.source.attacker.is_some(), + ) + .to_owned(), + damage: e.damage, + fall_location: e.in_water.then_some("water"), + fall_distance: e.fall_distance, + attacker, + direct, + } + }) + .collect(); + // LivingEntity.getKillCredit: the player who hurt it lately, else the + // last mob to hurt it within 100 ticks. + let credit = if m.ai.last_hurt_by_player > 0 && player.is_some() { + player.clone() + } else { + m.ai.last_hurt_by + .filter(|_| m.age as i32 - m.ai.last_hurt_by_time <= 100) + .and_then(|w| name(self, w)) + }; + let now = self.world.game_time; + let mut tracker = CombatTracker { + entries, + last_damage: now, + in_combat: m.combat.in_combat, + last_hurt_by: credit.map(|c| (c, now)), + ..CombatTracker::default() + }; + std::mem::swap(&mut self.combat, &mut tracker); + let text = self.combat_death_message_for(me); + std::mem::swap(&mut self.combat, &mut tracker); + text + } +} + +/// `Entity.getDisplayName` of a mob: its custom name or its type's name. +fn mob_name(m: &Mob) -> Text { + match (&m.custom_name_text, &m.custom_name) { + (Some(text), _) => text.clone(), + (None, Some(name)) => Text::literal(name.clone()), + (None, None) => Text::translate(&format!("entity.{}", m.kind.id.replace(':', ".")), &[]), + } } diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index cea056268c..517e7f6952 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1731,6 +1731,16 @@ impl Game<'_> { } Target::Mob(id) => { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { + // LivingEntity.kill: a generic_kill hit of Float.MAX_VALUE, + // which its death message tells. + if m.alive() { + m.record_damage( + horizon_client::mobs::Damage::of( + horizon_client::mobs::DamageKind::GenericKill, + ), + f32::MAX, + ); + } m.health = 0.0; if m.death_time == 0 { m.death_time = 1; diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 899286b2de..ccab1aa96f 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -202,10 +202,17 @@ impl Game<'_> { attacker, from, }; - self.mobs + let hurt = self + .mobs .iter_mut() .find(|m| m.id == id) - .is_some_and(|m| m.damage(amount, damage)) + .is_some_and(|m| m.damage(amount, damage)); + // LivingEntity.hurtServer: a player's hit makes them + // `lastHurtByPlayer`, whose kill and death message it is. + if hurt && attacker == Some(Who::Player) { + self.last_hurt_by_player.insert(id, self.lan_acting); + } + hurt } _ => false, }; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..8e8e121e63 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -589,8 +589,10 @@ impl Game<'_> { self.died.insert(m.id); self.mob_died(&m); } - // Their victims have been looted: no charged creeper is waiting any more. + // Their victims have been looted and mourned: no charged creeper is + // waiting any more. self.combat.charged_exploders.clear(); + self.combat.exploded.clear(); let uuid = self.level.player_uuid; let mut snapshot: Vec = self.mobs.iter().map(|m| seen(m, uuid)).collect(); let mut events = Vec::new(); @@ -833,6 +835,8 @@ impl Game<'_> { self.set_bed_occupied(bed, false); } let killer = self.last_hurt_by_player.remove(&m.id); + // TamableAnimal.die, before the rest of LivingEntity.die. + self.tell_owner_pet_died(m, killer); self.love_cause.remove(&m.id); let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0; let mut dying = m.clone(); @@ -1319,6 +1323,7 @@ impl Game<'_> { self.combat.explosion_source = Some(mob); // The creeper causes the blast's damage, but it is out of // the mob list already: its name comes with the event. + self.combat.exploded.push((mob, name.clone())); self.note_named_attacker("minecraft:explosion", mob, name); if charged { self.combat.charged_exploders.push(mob); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index c347f989cf..cf0530d3f2 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -512,6 +512,18 @@ impl Hub { g.sender.send(body.clone()); } } + /// `ServerPlayer.sendSystemMessage` to the guest with `uuid`, in any + /// dimension. + pub fn system_message_to_player(&self, uuid: u128, content: Tag) { + let body = C::SystemChat(play::SystemChat { + content, + overlay: false, + }) + .encode(); + for g in self.guests.iter().filter(|g| g.placed && g.uuid == uuid) { + g.sender.send(body.clone()); + } + } /// `ServerPlayer.sendOverlayMessage` to every guest (the action bar). pub fn overlay_message(&self, content: Tag) { self.broadcast(&C::SystemChat(play::SystemChat { diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 286d254d1d..b797b231b1 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -20,6 +20,7 @@ pub mod brain; pub mod breeze; pub mod camel; pub mod caravan; +pub mod combat; pub mod control; pub mod copper_golem; pub mod creaking; @@ -1076,6 +1077,8 @@ pub struct Mob { pub air: i32, pub last_hurt: f32, pub last_damage: Option<(Damage, i64)>, + /// `LivingEntity.combatTracker`: the hits taken lately. + pub combat: combat::Tracker, /// Set by `remove`: the mob disappears without dying (conversion, despawn, pickup). pub removed: bool, /// Client-side animation clocks (attack swing, eating, shaking...). @@ -2169,6 +2172,7 @@ impl Mob { air: 300, last_hurt: 0.0, last_damage: None, + combat: combat::Tracker::default(), removed: false, custom_name: None, custom_name_text: None, @@ -2237,6 +2241,17 @@ impl Mob { ), ) } + /// `CombatTracker.recordDamage`: a hit that took `damage` health, with + /// the fall it ends. + pub fn record_damage(&mut self, source: Damage, damage: f32) { + let entry = combat::Entry { + source, + damage, + in_water: self.fluids.in_water, + fall_distance: self.fall_distance as f32, + }; + self.combat.record(entry, self.age); + } pub fn alive(&self) -> bool { self.health > 0.0 } @@ -2384,7 +2399,7 @@ impl Mob { // Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf. let absorbed = self.wolf_armor_absorbs(&source); let mut knock = true; - if self.invulnerable as f32 > 10.0 { + let dealt = if self.invulnerable as f32 > 10.0 { if amount <= self.last_hurt { return false; } @@ -2396,6 +2411,7 @@ impl Mob { } self.last_hurt = amount; knock = false; + dealt } else { self.last_hurt = amount; self.invulnerable = 20; @@ -2405,6 +2421,13 @@ impl Mob { self.health -= amount; } self.hurt_time = 10; + amount + }; + // LivingEntity.actuallyHurt: a hit that reaches the health is + // recorded (`CombatTracker.recordDamage`), with the fall it ends; + // Wolf.actuallyHurt keeps a hit its armor takes out of it. + if !absorbed && dealt != 0.0 { + self.record_damage(source, dealt); } // Animal.actuallyHurt: damage taken ends love mode (`resetLove`). if drops::is_animal(self.species()) { @@ -2568,6 +2591,9 @@ impl Mob { level_random: &mut LegacyRandom, events: &mut Vec, ) -> bool { + // LivingEntity.tick: CombatTracker.recheckStatus. + let alive = self.alive(); + self.combat.recheck(alive, self.age); ai::tick(self, w, level_random, events) } pub fn distance_to(&self, p: [f64; 3]) -> f64 { diff --git a/crates/horizon-client/src/mobs/combat.rs b/crates/horizon-client/src/mobs/combat.rs new file mode 100644 index 0000000000..c795064416 --- /dev/null +++ b/crates/horizon-client/src/mobs/combat.rs @@ -0,0 +1,95 @@ +//! A mob's `CombatTracker`: the hits it took lately, which its death message +//! is built from (`TamableAnimal.die` sends it to the owner). +use super::Damage; + +/// `CombatEntry`: one hit, with where the mob was falling from when it landed. +#[derive(Clone, Copy, Debug)] +pub struct Entry { + pub source: Damage, + pub damage: f32, + /// `FallLocation.getCurrentFallLocation` is `WATER`. Horizon keeps no + /// `lastClimbablePos` for mobs, so the climbable locations are not known. + pub in_water: bool, + pub fall_distance: f32, +} + +/// `CombatTracker` for a mob, timed by its `tickCount` (`Mob::age`). +#[derive(Clone, Debug, Default)] +pub struct Tracker { + pub entries: Vec, + last_damage_time: u32, + pub in_combat: bool, + taking_damage: bool, +} + +impl Tracker { + /// `CombatTracker.recordDamage`: the hit after forgetting an old fight; a + /// living cause (`shouldEnterCombat`) starts a fight. It runs before the + /// hit takes the health (`actuallyHurt`), so the mob is still alive. + pub fn record(&mut self, entry: Entry, tick: u32) { + self.recheck(true, tick); + let living_cause = entry.source.attacker.is_some(); + self.entries.push(entry); + self.last_damage_time = tick; + self.taking_damage = true; + if !self.in_combat && living_cause { + self.in_combat = true; + } + } + /// `CombatTracker.recheckStatus`: 100 ticks without a hit (300 in a + /// fight) forget them all. Vanilla also forgets them once the mob is + /// dead, after `die` has read them; Horizon's `die` runs after the tick, + /// so a dead mob's hits stay for it. + pub fn recheck(&mut self, alive: bool, tick: u32) { + let reset = if self.in_combat { 300 } else { 100 }; + if alive && self.taking_damage && tick.wrapping_sub(self.last_damage_time) > reset { + self.taking_damage = false; + self.in_combat = false; + self.entries.clear(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::{DamageKind, Who}; + + fn hit(attacker: Option) -> Entry { + Entry { + source: Damage { + kind: DamageKind::MobAttack, + attacker, + from: None, + }, + damage: 2.0, + in_water: false, + fall_distance: 0.0, + } + } + + #[test] + fn old_hits_are_forgotten_after_100_ticks_or_300_in_a_fight() { + let mut t = Tracker::default(); + t.record(hit(None), 10); + t.recheck(true, 110); + assert_eq!(t.entries.len(), 1, "100 ticks later it still remembers"); + t.recheck(true, 111); + assert!(t.entries.is_empty()); + t.record(hit(Some(Who::Mob(7))), 200); + assert!(t.in_combat); + t.recheck(true, 500); + assert_eq!(t.entries.len(), 1, "a fight is remembered for 300 ticks"); + t.record(hit(None), 501); + assert_eq!(t.entries.len(), 1, "the new hit comes after forgetting"); + assert!(!t.in_combat); + } + + #[test] + fn a_dead_mob_keeps_its_hits_for_its_death_message() { + let mut t = Tracker::default(); + t.record(hit(Some(Who::Mob(7))), 0); + t.recheck(false, 1000); + assert_eq!(t.entries.len(), 1); + } +} From e66a24edd4382785af0bdec4c47699a811a6abd0 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:16:09 +0700 Subject: [PATCH 311/968] lan_e2e: pet_death check for the owner's death message Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..cfa16058d4 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -298,6 +298,7 @@ Needs the prepared resources in .local/horizon-client. On Linux the games run on desktop), or headless (offscreen, no pause) where there is none; on macOS both games render offscreen. Games started here have no audio (HORIZON_NO_AUDIO=1). """ import argparse +import hashlib import json import math import os @@ -8930,5 +8931,49 @@ CHECKS += ['keys_released'] CHECK_FNS['keys_released'] = check_keys_released +def offline_uuid_ints(name): + """UUIDUtil.createOfflinePlayerUUID as the contents of an int-array UUID tag.""" + digest = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest()) + digest[6] = digest[6] & 0x0f | 0x30 + digest[8] = digest[8] & 0x3f | 0x80 + return ','.join(str(int.from_bytes(digest[i:i + 4], 'big', signed=True)) for i in range(0, 16, 4)) + + +def check_pet_death(host, guest, out): + """TamableAnimal.die (#3514): with showDeathMessages, a tame pet's owner, and nobody else, hears how it + died (CombatTracker.getDeathMessage): the guest's wolf slain by a zombie, the host's cat killed.""" + hx, hy, hz = pos_of(host) + tag = 'pet_death' + try: + host('command', command=f'summon minecraft:zombie {hx + 1.5:.2f} {hy:.2f} {hz - 2:.2f} ' + f'{{NoAI:1b,Silent:1b,Tags:["{tag}"]}}') + host('command', command=f'summon minecraft:wolf {hx - 1.5:.2f} {hy:.2f} {hz - 2:.2f} ' + f'{{CustomName:"Rex",NoAI:1b,Tags:["{tag}","rex"],' + f'Owner:[I;{offline_uuid_ints(GUEST_NAME)}]}}') + host('command', command=f'summon minecraft:cat {hx:.2f} {hy:.2f} {hz - 3:.2f} ' + f'{{CustomName:"Tom",NoAI:1b,Tags:["{tag}","tom"],' + f'Owner:[I;{offline_uuid_ints(host.player)}]}}') + host.wait(5) + host('command', command=f'damage @e[tag=rex,limit=1] 100 minecraft:mob_attack ' + f'by @e[type=minecraft:zombie,tag={tag},limit=1]') + host('command', command='kill @e[tag=tom]') + host.wait(20) + guest_chat = guest.raw('messages', 10)['chat'] + host_chat = host.raw('messages', 10)['chat'] + guest.shot(out, 'pet-death') + finally: + host('command', command=f'kill @e[tag={tag}]') + seen = {'guest': guest_chat, 'host': host_chat} + assert 'Rex was slain by Zombie' in guest_chat, f"the wolf's owner was not told: {seen}" + assert 'Rex was slain by Zombie' not in host_chat, f"someone else's wolf was told to the host: {seen}" + assert 'Tom was killed' in host_chat, f"the cat's owner was not told: {seen}" + assert 'Tom was killed' not in guest_chat, f"someone else's cat was told to the guest: {seen}" + return seen + + +CHECKS += ['pet_death'] +CHECK_FNS['pet_death'] = check_pet_death + + if __name__ == '__main__': main() From 03c8dd0d9871399da255a334e7cdaebe6586e32e Mon Sep 17 00:00:00 2001 From: CocofireHD Date: Fri, 30 Oct 2026 08:33:54 +0100 Subject: [PATCH 312/968] Item names in command feedback follow the item_name component ItemStack.getDisplayName appends getHoverName, and getItemName prefers the `minecraft:item_name` component over the item's own name (Item.getName), so /give and the other item commands name a renamed stack after that component. stack_display_name only read `custom_name`, so a datapack- or server-renamed item was reported under its plain item name ("[Diamond]" for a diamond whose item_name is "ItemName"). Read the component first, as vanilla does. Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) --- crates/horizon-client/src/game/commands.rs | 5 +++++ tools/command_exec_corpus.py | 1 + 2 files changed, 6 insertions(+) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index cea056268c..6a6e77ffdc 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1034,6 +1034,11 @@ impl Game<'_> { let custom = s.data().and_then(|data| data.custom_name.as_ref()); let hover = if let Some(c) = custom { horizon_client::component::name_text(c) + } else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) { + // `ItemStack.getItemName`: the `item_name` component wins over the + // item's own name (`Item.getName`), so a datapack's or a server's + // renaming shows here as it does on the tooltip's first line. + horizon_client::component::name_text(name) } else if let Some(title) = s .data() .and_then(|d| d.written.as_ref()) diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 30500cfa4b..1a144d11ca 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -168,6 +168,7 @@ def player_state(): cmd('/effect clear @s'), cmd('/give @s minecraft:diamond 3'), cmd('/give @s minecraft:diamond_sword[minecraft:custom_name="Blade"]'), + cmd('/give @s minecraft:diamond[item_name={text:"ItemName",color:"red"}]'), cmd('/give @s minecraft:stone 130'), cmd('/clear @s minecraft:diamond 0'), cmd('/clear @s minecraft:stone 0'), From 393d90d8709ac6239bcf4fb0673fdb43ffa532e4 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:25:42 +0200 Subject: [PATCH 313/968] Return to Sender: a fireball struck back credits the player's kill The large and small fireball hits on a mob didn't record the player who owned the fireball (LivingEntity.hurtServer's setLastHurtByPlayer), as an arrow's hit does. On the default single player the host plays as a guest on its server thread, so a ghast killed by its own fireball, turned back, was credited to nobody: no Return to Sender, Monster Hunter or kill statistic. tools/test_return_to_sender_parity.py strikes a still fireball into a ghast with no AI on both single-player paths and expects the advancement. Fixes #4212 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 27 +++++- tools/parity_hosts.json | 1 + tools/test_return_to_sender_parity.py | 102 ++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 tools/test_return_to_sender_parity.py diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..716f9fc05e 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2798,6 +2798,15 @@ impl Game<'_> { self.flush_lan_explosions(); self.flush_wind_bursts() } + /// `LivingEntity.hurtServer`'s `setLastHurtByPlayer` for a projectile a + /// player owns (a fireball they turned back): the kill is theirs, so a + /// guest's (the host on its server thread too) reaches their statistics + /// and `player_killed_entity`, as the arrow's hit does. + fn player_hurt_mob(&mut self, p: &Projectile, id: u32) { + if p.owner == Some(Who::Player) { + self.last_hurt_by_player.insert(id, self.lan_acting); + } + } /// `projectile_hit` for a projectile the guest `conn` shot, run as them; /// as the host's when they have left. fn guest_projectile_hit(&mut self, conn: u32, p: &Projectile, hit: Hit) -> Result<()> { @@ -3495,14 +3504,19 @@ impl Game<'_> { } } Who::Mob(id) => { - if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { + let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| { let remaining = m.fire_ticks; if !m.kind.flags.fire_immune { m.ignite_for_ticks(100); } - if !m.damage(5.0, source) { + let hurt = m.damage(5.0, source); + if !hurt { m.fire_ticks = remaining; } + hurt + }); + if hurt { + self.player_hurt_mob(p, id); } } }, @@ -3537,8 +3551,13 @@ impl Game<'_> { ); } Who::Mob(id) => { - if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { - m.damage(6.0, source); + if self + .mobs + .iter_mut() + .find(|m| m.id == id) + .is_some_and(|m| m.damage(6.0, source)) + { + self.player_hurt_mob(p, id); } } } diff --git a/tools/parity_hosts.json b/tools/parity_hosts.json index 2df32ba411..e6cdb0b900 100644 --- a/tools/parity_hosts.json +++ b/tools/parity_hosts.json @@ -45,6 +45,7 @@ "solo": { "remote_fireball": "two game windows (lan_e2e); on 4 CPUs beside other harnesses, screenshots and control requests take ticks the check needs", "remote_fireworks": "two game windows (lan_e2e); a screenshot took ~1.5 s beside 3 other harnesses on playhorizon's runner, past the sparks' life (2026-10-27)", + "return_to_sender": "a game window: an attack on a fireball and its flight to the ghast take ticks the check needs", "server_player_sculk": "two game windows (lan_e2e); failed on playhorizon's runner beside other harnesses, passes alone (2026-10-27)", "world_reopen": "a game window reopening a world: failed on playhorizon's runner beside other harnesses (2026-10-27)" }, diff --git a/tools/test_return_to_sender_parity.py b/tools/test_return_to_sender_parity.py new file mode 100644 index 0000000000..76b0c965f3 --- /dev/null +++ b/tools/test_return_to_sender_parity.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""A ghast killed by the fireball the player struck back grants Return to Sender. + +Vanilla 26.3: the player's attack deflects the fireball (`Projectile.deflect`, the +player is its owner now). `LargeFireball.onHitEntity` hurts the ghast with +`damageSources().fireball(this, owner)`; `Ghast.hurtServer` takes a reflected +fireball (`isReflectedFireball`: a large fireball, caused by a player) as 1000 +damage, and `LivingEntity.hurtServer` remembers the player (`setLastHurtByPlayer`). +On death, `LivingEntity.die` credits that player (`awardKillScore`), whose +`player_killed_entity` trigger matches `nether/return_to_sender`'s killing blow: +a `#is_projectile` damage whose direct entity is a `minecraft:fireball`. + +The ghast died, but the advancement never came on the default single player +(the host on its server thread plays as a guest there): the fireball's hit did +not record which player hurt the ghast, as an arrow's does, so the kill went to +nobody (#4212). Both single-player paths: the server thread and in-frame. + + test_return_to_sender_parity.py [--bin-dir DIR] [--keep] +""" +import argparse +import json +import math +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_world_reopen_parity import game_env # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +RESOURCES = ROOT / '.local' / 'horizon-client' / '26.3' +RUN = ROOT / '.local' / 'return-to-sender' +MODES = {'server thread': [], 'in-frame': ['--in-frame']} +TITLE = 'advancements.nether.return_to_sender.title' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build them with the fast profile)') + parser.add_argument('--keep', action='store_true', help='keep the run folder') + args = parser.parse_args() + bin_dir = Path(args.bin_dir).resolve() if args.bin_dir else None + if bin_dir is None: + subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '--bins'], cwd=ROOT, check=True) + bin_dir = ROOT / os.environ.get('CARGO_TARGET_DIR', 'target') / 'fast' + shutil.rmtree(RUN, ignore_errors=True) + RUN.mkdir(parents=True) + env, extra = game_env() + failures = 0 + for index, (mode, flags) in enumerate(MODES.items()): + folder = RUN / f'h{index}' + folder.mkdir() + socket = Path(tempfile.mkdtemp(prefix='hzrs')) / 'control.sock' # sockets need short paths + + def ctl(*words, check=True): + result = subprocess.run([str(bin_dir / 'horizon-ctl'), '--socket', str(socket), '--timeout', '600', + *map(str, words)], cwd=folder, env=env, capture_output=True, text=True) + if check and result.returncode: + sys.exit(f'horizon-ctl {" ".join(map(str, words))} failed: {result.stdout}{result.stderr}') + return json.loads(result.stdout) if result.stdout.strip().startswith('{') else result.stdout + + # The game keeps launch's output handles, so it writes to a file: a pipe would not close. + with open(folder / 'launch.txt', 'w') as out: + subprocess.run([str(bin_dir / 'horizon-ctl'), '--socket', str(socket), '--timeout', '600', 'launch', + '--resources', str(RESOURCES), '--world', 'world', '--seed', '1', '--render-distance', '4', + *flags, *extra], cwd=folder, env=env, stdout=out, stderr=subprocess.STDOUT, check=True) + try: + ctl('wait-ready', '300') + x, _, z = (math.floor(v) for v in ctl('status')['player']['position']) + y = 200 + # A glass floor in the sky: the player at its south end, facing a ghast that + # does not move or shoot, and a still fireball just in front of the player. + ctl('command', 'effect give @s resistance infinite 255 true') + ctl('command', f'fill {x - 3} {y - 1} {z - 2} {x + 3} {y - 1} {z + 12} minecraft:glass') + ctl('tp', x + 0.5, y, z + 0.5) + ctl('wait', '10') + ctl('command', f'summon minecraft:ghast {x + 0.5} {y - 1} {z + 10.5} {{NoAI:1b}}') + ctl('command', f'summon minecraft:fireball {x + 0.5} {y + 1.3} {z + 2.5} ' + '{Motion:[0.0d,0.0d,0.0d],acceleration_power:0.0d}') + ctl('wait', '5') + # Aim at the fireball's middle: struck back along the view, it flies into the ghast. + ctl('look-at', x + 0.5, y + 1.8, z + 2.5) + ctl('attack') + ctl('wait', '60') + ghasts = [e for e in ctl('entities')['entities'] if e.get('type') == 'minecraft:ghast'] + granted = TITLE in json.dumps(ctl('messages')['styled']) + finally: + ctl('quit', check=False) + ok = not ghasts and granted + failures += not ok + print(f'{mode}:', 'ok' if ok else f'FAIL: ghast alive {bool(ghasts)}, Return to Sender granted {granted}') + if not args.keep and not failures: + shutil.rmtree(RUN, ignore_errors=True) + print('return to sender:', 'PASS' if not failures else f'{failures} FAILED') + sys.exit(1 if failures else 0) + + +if __name__ == '__main__': + main() From c24f9a169383d074070f46d6dceaa8f8a13f4bbd Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:35:22 +0100 Subject: [PATCH 314/968] Dedicated server: no render face visibility, which only the mesher and block entity renderer read Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 20 +++++++++++--- crates/horizon-client/src/game/packs.rs | 1 + crates/horizon-world/src/block_render.rs | 33 ++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5f1bcd0194..de3af7bf0a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1422,7 +1422,8 @@ pub struct PackResources { } impl PackResources { /// Everything a pack overlay at `root` provides, over `assets` and `render` - /// loaded from it. + /// loaded from it. A dedicated server (`draws` false) skips the faces the + /// mesher and the block entity renderer cull with (19 MB). fn load( root: &std::path::Path, assets: Arc, @@ -1430,8 +1431,13 @@ impl PackResources { facts: &BlockFacts, biomes: Arc, light: Arc, + draws: bool, ) -> Result { - let visibility = models::load_visibility(root, assets.registry)?; + let visibility = if draws { + models::load_visibility(root, assets.registry)? + } else { + horizon_world::block_render::FaceVisibility::empty() + }; let tables = Arc::new(MeshTables::new(&assets, &render, facts, visibility)?); let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library( root, @@ -15344,7 +15350,15 @@ fn load_resources( log::info!("load biomes+env: {:.2}s", started.elapsed().as_secs_f32()); progress.set(3); let light = Arc::new(LightTables::load(&root, assets.registry, &facts)?); - let pack = PackResources::load(&root, assets.clone(), render, &facts, biomes.clone(), light)?; + let pack = PackResources::load( + &root, + assets.clone(), + render, + &facts, + biomes.clone(), + light, + !headless, + )?; log::info!("load pack tables: {:.2}s", started.elapsed().as_secs_f32()); progress.set(4); if !menu { diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 2096e8c0e7..1f221eac03 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -301,6 +301,7 @@ pub(super) fn stage_pack( res.facts(), context.biomes.clone(), context.light.clone(), + true, )?; let textures = prepare_renderer_textures(&pack); progress.set(3); diff --git a/crates/horizon-world/src/block_render.rs b/crates/horizon-world/src/block_render.rs index aa10d361cb..e0d8dcaa89 100644 --- a/crates/horizon-world/src/block_render.rs +++ b/crates/horizon-world/src/block_render.rs @@ -46,6 +46,15 @@ pub struct FaceVisibility { cache: HashMap<(u32, u32), bool>, } impl FaceVisibility { + /// No state's faces, for a server, which draws nothing: every lookup + /// answers "unknown render state". + pub fn empty() -> Self { + Self { + states: Vec::new(), + faces: vec![VoxelShape::empty(), VoxelShape::block()], + cache: HashMap::new(), + } + } pub fn new( occlusion: &CachedCollisionRegistry<'_>, definitions: &BTreeMap, @@ -214,3 +223,27 @@ impl FaceVisibility { Ok(visible) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A server's empty visibility knows no state: lookups fail instead of + /// panicking. + #[test] + fn empty_visibility_knows_no_state() { + let mut visibility = FaceVisibility::empty(); + let stone = BlockStateId(1); + assert!(visibility.occlusion_face(stone, Direction::Up).is_none()); + assert!( + visibility + .skip_rendering(stone, stone, Direction::Up, true) + .is_err() + ); + assert!( + visibility + .should_render_face(stone, stone, Direction::Up, true) + .is_err() + ); + } +} From 034191391b76fc30408c092b25c8a1c32ca2acff Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:07:53 +0700 Subject: [PATCH 315/968] Credit a tame wolf's kills to its owner LivingEntity.resolvePlayerResponsibleForDamage: a hit by a tame wolf sets the victim's last-hurt-by-player to the wolf's owner for 100 ticks, so the kill drops experience and player-only loot, and the owner, when in the world, gets ServerPlayer.awardKillScore (mob_kills, totalKillCount, team kills, player_killed_entity). The killed statistic stays the causing entity's (Player.killedEntity), so a mob's kill no longer adds it, and Raider.die's hero and Villager.tellWitnessesThatIWasMurdered, which also follow the causing entity, skip a kill whose blow came from a mob. Applied after a mob's melee hit and after /damage with a causing entity. Co-Authored-By: Claude Opus 5.5 --- .../src/game/commands/world_cmds.rs | 9 ++- crates/horizon-client/src/game/creatures.rs | 68 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 899286b2de..0e1fcd552f 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -202,10 +202,15 @@ impl Game<'_> { attacker, from, }; - self.mobs + let hurt = self + .mobs .iter_mut() .find(|m| m.id == id) - .is_some_and(|m| m.damage(amount, damage)) + .is_some_and(|m| m.damage(amount, damage)); + if hurt { + self.resolve_player_responsible(id, damage); + } + hurt } _ => false, }; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..30408488f8 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -883,8 +883,12 @@ impl Game<'_> { } } self.score_entity_removed(m.id); - self.raider_died(m, by_player); - self.villager_murdered(m, by_player, killer.flatten()); + // Raider.die's hero and Villager.tellWitnessesThatIWasMurdered go to + // the causing entity, not the credited player: a tame wolf's kill is + // neither its owner's heroism nor a murder by them. + let slain_by_player = by_player && !killed_by_mob(m); + self.raider_died(m, slain_by_player); + self.villager_murdered(m, slain_by_player, killer.flatten()); let pos = [m.position.x, m.position.y + 0.5, m.position.z]; self.drop_death_loot(&mut dying, by_player, consumed, pos); self.create_wither_rose(m); @@ -919,7 +923,12 @@ impl Game<'_> { /// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s /// statistics and scores, and the `player_killed_entity` trigger. fn credit_kill(&mut self, m: &Mob) { - self.stats.add("killed", &m.kind.id, 1); + // Player.killedEntity: the killed statistic goes to the causing + // entity, so a kill by a mob (a tame wolf credited to its owner) + // leaves it out; ServerPlayer.awardKillScore counts the rest. + if !killed_by_mob(m) { + self.stats.add("killed", &m.kind.id, 1); + } self.stats.custom("mob_kills", 1); self.score_player_killed(super::commands::Target::Mob(m.id)); let view = self.mob_entity(m); @@ -1088,6 +1097,9 @@ impl Game<'_> { } } } + if hurt { + self.resolve_player_responsible(target, source); + } if infested { self.mob_infested(target); } @@ -1110,6 +1122,49 @@ impl Game<'_> { } hurt } + /// `LivingEntity.resolvePlayerResponsibleForDamage` after `source` hurt a + /// mob: a tame wolf's hit is its owner's (`setLastHurtByPlayer(uuid, 100)`), + /// so the kill drops experience and player-only loot and the owner, when + /// in the world, gets the credit (`getKillCredit`); a tame wolf without an + /// owner clears it. A player's own hits are recorded where they land. + pub(super) fn resolve_player_responsible(&mut self, victim: u32, source: Damage) { + let Some(Who::Mob(attacker)) = source.attacker else { + return; + }; + let Some(owner) = self + .mobs + .iter() + .find(|m| m.id == attacker && m.species() == Species::Wolf && m.extra.tame) + .map(|m| m.extra.owner) + else { + return; + }; + let player = owner.and_then(|uuid| self.player_with_uuid(uuid)); + let Some(m) = self.mobs.iter_mut().find(|m| m.id == victim) else { + return; + }; + m.ai.last_hurt_by_player = if owner.is_some() { 100 } else { 0 }; + match player { + Some(who) => self.last_hurt_by_player.insert(victim, who), + None => self.last_hurt_by_player.remove(&victim), + }; + } + /// The player in the world with `uuid` (`EntityReference.getPlayer`), as + /// `last_hurt_by_player` keeps them: None for the player in place, else the + /// guest's connection. + fn player_with_uuid(&self, uuid: [i32; 4]) -> Option> { + if self.player.is_some() + && horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid()) + { + return Some(self.lan_acting); + } + let uuid = horizon_client::lan::playerdata::ints_uuid(uuid); + self.lan + .iter() + .flat_map(|h| h.hub.guests.iter()) + .find(|g| g.placed && g.uuid == uuid) + .map(|g| Some(g.conn)) + } /// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up. pub(super) fn mob_infested(&mut self, mob: u32) { if let Some(m) = self.mobs.iter().find(|m| m.id == mob) { @@ -5349,6 +5404,13 @@ mod tests { } } +/// Whether the killing blow's causing entity (`DamageSource.getEntity`) was a +/// mob, such as a tame wolf whose owner gets the kill credit. +fn killed_by_mob(m: &Mob) -> bool { + m.last_damage + .is_some_and(|(d, _)| matches!(d.attacker, Some(Who::Mob(_)))) +} + /// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the /// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)` /// is the host, `Some(Some(conn))` a guest. Both animals are spent. From 1e8f93a6924f067678a19a9674a3ce1dab132cc3 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:07:54 +0700 Subject: [PATCH 316/968] Check tame wolf kill credit in the command execution harness Co-Authored-By: Claude Opus 5.5 --- tools/command_exec_corpus.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 30500cfa4b..5e1aafb43c 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -1212,6 +1212,39 @@ def ender_dragon(): ] +def tame_wolf_kills(): + # LivingEntity.resolvePlayerResponsibleForDamage: a tame wolf's hit is its + # owner's (the harness player, OfflinePlayer:Player), so the kill drops + # experience and ServerPlayer.awardKillScore counts it (mob_kills, + # totalKillCount); the killed statistic stays the wolf's + # (Player.killedEntity). A wild wolf's kill is nobody's. + owner = '[I;-1608632253,-450807400,-1786055591,-2146546415]' + return [ + *setup('/gamerule mob_drops true', '/scoreboard objectives add wolf_kills minecraft.killed:minecraft.zombie', + '/scoreboard objectives add wolf_mob_kills minecraft.custom:minecraft.mob_kills', + '/scoreboard objectives add wolf_total totalKillCount'), + cmd('/summon minecraft:wolf 4.5 210 4.5 {NoAI:1b,NoGravity:1b,Tags:["wild"]}'), + cmd('/summon minecraft:zombie 6.5 210 4.5 {NoAI:1b,NoGravity:1b,Tags:["prey"]}'), + cmd('/damage @e[tag=prey,limit=1] 100 minecraft:mob_attack by @e[tag=wild,limit=1]'), + {'op': 'tick', 'n': 25}, + cmd('/execute if entity @e[type=minecraft:experience_orb,limit=1]'), + cmd('/scoreboard players get @s wolf_kills'), + cmd('/scoreboard players get @s wolf_mob_kills'), + cmd('/scoreboard players get @s wolf_total'), + cmd(f'/summon minecraft:wolf 4.5 210 8.5 {{NoAI:1b,NoGravity:1b,Owner:{owner},Tags:["pet"]}}'), + cmd('/summon minecraft:zombie 6.5 210 8.5 {NoAI:1b,NoGravity:1b,Tags:["prey"]}'), + cmd('/damage @e[tag=prey,limit=1] 100 minecraft:mob_attack by @e[tag=pet,limit=1]'), + {'op': 'tick', 'n': 25}, + cmd('/execute if entity @e[type=minecraft:experience_orb,limit=1]'), + cmd('/scoreboard players get @s wolf_kills'), + cmd('/scoreboard players get @s wolf_mob_kills'), + cmd('/scoreboard players get @s wolf_total'), + *setup('/kill @e[type=!player]', '/scoreboard objectives remove wolf_kills', + '/scoreboard objectives remove wolf_mob_kills', '/scoreboard objectives remove wolf_total', + '/gamerule mob_drops false'), + ] + + def groups(): return [ ('setup', group_setup()), @@ -1238,4 +1271,5 @@ def groups(): ('test', test_command()), ('wither breaks', wither_breaks()), ('ender dragon', ender_dragon()), + ('tame wolf kills', tame_wolf_kills()), ] From f69bf3deb5d9bdbce61f99898f5157957cb31c33 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 08:42:28 +0100 Subject: [PATCH 317/968] Clip GUI item triangles at the scissor instead of dropping them by centre Advancement icons at the top and bottom of the tree lost one of their two triangles when the scissor cut through them. HudBatch::clip_since now clips non-rectangular geometry as polygons, interpolating UV, glint UV and depth. Refs #4248 --- crates/horizon-client-render/src/hud.rs | 96 +++++++++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 04c93929a3..18ea5b74a0 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -644,6 +644,45 @@ pub struct HudVertex { /// unless `uv.x >= 2`. pub glint_uv: [f32; 2], } +/// Sutherland-Hodgman against one half plane, interpolating every attribute. +fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec { + let inside = |v: &HudVertex| { + if keep_below { + v.pos[axis] < limit + } else { + v.pos[axis] >= limit + } + }; + let lerp = |a: &HudVertex, b: &HudVertex, t: f32| { + let mix = |p: f32, q: f32| p + (q - p) * t; + let mut v = *a; + v.pos = [mix(a.pos[0], b.pos[0]), mix(a.pos[1], b.pos[1])]; + v.uv = [mix(a.uv[0], b.uv[0]), mix(a.uv[1], b.uv[1])]; + v.depth = mix(a.depth, b.depth); + v.glint_uv = [ + mix(a.glint_uv[0], b.glint_uv[0]), + mix(a.glint_uv[1], b.glint_uv[1]), + ]; + v.pos[axis] = limit; + v + }; + let mut out = Vec::with_capacity(poly.len() + 2); + for i in 0..poly.len() { + let (a, b) = (&poly[i], &poly[(i + 1) % poly.len()]); + let (ia, ib) = (inside(a), inside(b)); + if ia { + out.push(*a); + } + if ia != ib { + out.push(lerp( + a, + b, + (limit - a.pos[axis]) / (b.pos[axis] - a.pos[axis]), + )); + } + } + out +} /// The GUI Scale option (0 = auto). pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); /// One frame of overlay geometry in GUI pixels. @@ -883,8 +922,8 @@ impl HudBatch { } } /// Clip the quads drawn since `start` to `[x0, x1) x [y0, y1)` (the - /// GUI scissor): axis-aligned quads are cut with their UVs, others are - /// kept or dropped by their centre. + /// GUI scissor): axis-aligned quads are cut with their UVs, others (item + /// triangles) are clipped as polygons. /// Scale the vertices pushed since `start` about `(cx, cy)` (a GUI pose /// matrix of translate, scale, translate back). pub fn scale_since(&mut self, start: usize, cx: f32, cy: f32, sx: f32, sy: f32) { @@ -915,10 +954,26 @@ impl HudBatch { find(x_lo, y_hi), ); let (Some(tl), Some(tr), Some(br), Some(bl)) = rect else { - let cx = corners.iter().map(|v| v.pos[0]).sum::() / 4.0; - let cy = corners.iter().map(|v| v.pos[1]).sum::() / 4.0; - if cx >= x0 && cx < x1 && cy >= y0 && cy < y1 { - self.vertices.extend_from_slice(&q); + // Item geometry arrives as triangles: clip each against the + // rectangle and re-emit the fan, so a triangle straddling the + // edge keeps its visible part instead of vanishing whole. + for tri in [[q[0], q[1], q[2]], [q[3], q[4], q[5]]] { + let area = (tri[1].pos[0] - tri[0].pos[0]) * (tri[2].pos[1] - tri[0].pos[1]) + - (tri[2].pos[0] - tri[0].pos[0]) * (tri[1].pos[1] - tri[0].pos[1]); + if area == 0.0 { + continue; + } + let mut poly = tri.to_vec(); + for (axis, limit, keep_below) in + [(0, x0, false), (0, x1, true), (1, y0, false), (1, y1, true)] + { + poly = clip_polygon(&poly, axis, limit, keep_below); + } + for i in 1..poly.len().saturating_sub(1) { + // A triangle as the quad `a, b, c, c`. + let (a, b, c) = (poly[0], poly[i], poly[i + 1]); + self.vertices.extend_from_slice(&[a, b, c, a, c, c]); + } } continue; }; @@ -1816,6 +1871,35 @@ impl HudRenderer { mod tests { use super::*; + #[test] + fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() { + let mut batch = HudBatch::new([320, 240]); + let start = batch.vertices.len(); + // A right triangle (a quad `a, b, c, c`) whose centre lies outside the rect. + batch.quad( + [[0.0, 0.0], [16.0, 0.0], [0.0, 16.0], [0.0, 16.0]], + [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]], + u32::MAX, + ); + batch.clip_since(start, 0.0, 0.0, 16.0, 4.0); + assert!(!batch.vertices.is_empty()); + assert!(batch.vertices.iter().all(|v| v.pos[1] <= 4.0)); + let area: f32 = batch + .vertices + .as_chunks::<3>() + .0 + .iter() + .map(|t| { + ((t[1].pos[0] - t[0].pos[0]) * (t[2].pos[1] - t[0].pos[1]) + - (t[2].pos[0] - t[0].pos[0]) * (t[1].pos[1] - t[0].pos[1])) + .abs() + / 2.0 + }) + .sum(); + // Rows 0..4 of the triangle: 16 * 16 / 2 - 12 * 12 / 2. + assert!((area - 56.0).abs() < 1e-3, "{area}"); + } + /// Write a PNG `width x height` whose `frames` stacked rows are solid /// colours, then its mcmeta beside it. fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) { From fb2fb00ec4ca181f5d10bbdf167c4e5f7898710b Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:42:32 +0100 Subject: [PATCH 318/968] MeshTables: no occlusion faces from a server's empty visibility Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/mesher.rs | 7 ++++++- crates/horizon-world/src/block_render.rs | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index 8328468f0b..a4bdb348c6 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -458,7 +458,12 @@ impl MeshTables { }); let mut face = [0u8; 6]; let mut cover = [0f32; 6]; - for d in Direction::ALL { + // A server's visibility knows no state (`FaceVisibility::empty`): + // it meshes no fluid, so these stay unset. + for d in Direction::ALL + .into_iter() + .filter(|_| !visibility.is_empty()) + { let (face_id, shape) = visibility .occlusion_face(BlockStateId(id as u32), d) .ok_or("missing occlusion face")?; diff --git a/crates/horizon-world/src/block_render.rs b/crates/horizon-world/src/block_render.rs index e0d8dcaa89..51ea6be894 100644 --- a/crates/horizon-world/src/block_render.rs +++ b/crates/horizon-world/src/block_render.rs @@ -55,6 +55,10 @@ impl FaceVisibility { cache: HashMap::new(), } } + /// [`Self::empty`]: no state is known. + pub fn is_empty(&self) -> bool { + self.states.is_empty() + } pub fn new( occlusion: &CachedCollisionRegistry<'_>, definitions: &BTreeMap, @@ -233,6 +237,7 @@ mod tests { #[test] fn empty_visibility_knows_no_state() { let mut visibility = FaceVisibility::empty(); + assert!(visibility.is_empty()); let stone = BlockStateId(1); assert!(visibility.occlusion_face(stone, Direction::Up).is_none()); assert!( From 95d9303204d2b24073fe2d80c91294c884716c46 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:39:54 +0700 Subject: [PATCH 319/968] Ender pearls go through End gateways A thrown ender pearl flew straight through an End gateway: the gateway passes in game/end.rs only collected dropped items and mobs. Pearls now join them (EndGatewayBlock.entityInside, Entity.handlePortal): the pearl lands at the exit with no motion and rotation 0 (EndGatewayBlock.getPortalDestination for a ThrownEnderpearl), waits in the arrivals queue while the exit loads, keeps its landing chunk loaded for TicketType.PORTAL's 300 ticks (placePortalTicket), and then takes its owner where it lands. ThrownEnderpearl.onInsideBlock fires the owner's enter_block trigger (Remote Getaway), for a guest as them. Part of #3514. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/end_gateway.rs | 99 +++++++++++ crates/horizon-client/src/game/end.rs | 207 +++++++++++++++++------ 2 files changed, 256 insertions(+), 50 deletions(-) diff --git a/crates/horizon-client/src/end_gateway.rs b/crates/horizon-client/src/end_gateway.rs index 41f2a66342..c4089bda74 100644 --- a/crates/horizon-client/src/end_gateway.rs +++ b/crates/horizon-client/src/end_gateway.rs @@ -4,6 +4,7 @@ //! (the entity goes through on the next tick), and //! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands). use crate::nether_portal::{ENTITY_DELAY, PortalState}; +use crate::projectiles::{Projectile, ProjectileKind}; use horizon_core::{geometry::Vec3, position::BlockPos}; /// The End gateway travel of one entity. @@ -65,6 +66,35 @@ pub fn swept_gateway( found } +/// The End gateway a thrown ender pearl passed through on its last move, from +/// where it started the tick to where it is (`Entity.checkInsideBlocks` from +/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles +/// are not looked at. +pub fn pearl_gateway( + pearl: &Projectile, + is_gateway: impl FnMut(BlockPos) -> bool, +) -> Option { + if !matches!(pearl.kind, ProjectileKind::EnderPearl) { + return None; + } + let size = pearl.kind.size() as f32; + swept_gateway(pearl.previous, pearl.position, size, size, is_gateway) +} + +/// A thrown ender pearl at its gateway's exit `at` +/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a +/// `TeleportTransition` with no motion, rotation 0 and nothing relative). +/// It falls from there and takes its owner where it lands. +pub fn place_pearl(pearl: &mut Projectile, at: Vec3) { + pearl.position = at; + pearl.previous = at; + pearl.velocity = Vec3::ZERO; + pearl.yaw = 0.0; + pearl.pitch = 0.0; + // A server tells its clients of the jump at once. + pearl.needs_sync = true; +} + /// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller /// lands at a gateway's exit. pub fn bottom_center(p: [i32; 3]) -> Vec3 { @@ -150,6 +180,75 @@ mod tests { assert_eq!(traveller.travel(false), None); } + /// A world with no blocks in the way and nothing to hit: the gateway + /// block has no collision shape, so a projectile flies into it. + struct Open; + impl crate::projectiles::ProjectileWorld for Open { + fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { + None + } + fn in_water(&self, _: Vec3) -> bool { + false + } + fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] { + &[] + } + } + + /// The ticks a projectile flies, thrown east at the gateway, until its + /// move goes through one (`None` if it never does in 5 ticks). + fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option) { + let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None); + shot.velocity = at(1.5, 0.0, 0.0); + for _ in 0..5 { + shot.tick(&Open); + if let Some(g) = pearl_gateway(&shot, is_gateway) { + return (shot, Some(g)); + } + } + (shot, None) + } + + #[test] + fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() { + let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl); + let gateway = BlockPos::new(702, 100, 0); + // ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside + // takes it in, and it goes through on the next tick. + assert_eq!(found, Some(gateway)); + let mut traveller = Traveller::default(); + traveller.enter(gateway); + assert_eq!(traveller.travel(true), Some(gateway)); + // getPortalDestination: at the exit, no motion and rotation 0. + let exit = bottom_center([8, 100, 0]); + place_pearl(&mut pearl, exit); + assert_eq!(pearl.position, exit); + assert_eq!(pearl.previous, exit); + assert_eq!(pearl.velocity, Vec3::ZERO); + assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0)); + // It falls straight down from the exit (ThrowableProjectile gravity, + // then inertia), where it lands and takes its owner along. + pearl.tick(&Open); + assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5)); + assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9); + // The pearl is out of the gateway: it does not go back at once. + assert_eq!(traveller.travel(true), None); + assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1); + } + + #[test] + fn a_pearl_flying_past_the_gateway_stays_out_of_it() { + // Thrown one block higher, over the gateway block. + let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None); + pearl.velocity = at(1.5, 0.0, 0.0); + for _ in 0..5 { + pearl.tick(&Open); + assert_eq!(pearl_gateway(&pearl, is_gateway), None); + } + // Only the pearl travels: a snowball on the same path is not taken in. + assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None); + } + #[test] fn a_traveller_lands_at_the_bottom_centre_of_the_exit() { // TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 60e98abfac..7f9d2a5d86 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -10,8 +10,10 @@ use horizon_client::{ environment::Environment, items::ItemStack, mesher::Vertex, + mobs::Who, motion::PlayerMotion, particles::{ParticleOptions, ParticleType}, + projectiles::{Projectile, ProjectileKind}, vanillagen::VanillaTerrain, world_spawn::PlayerSpawnFinder, }; @@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag { Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect()) } +/// `TicketType.PORTAL`'s timeout, in ticks. +const PORTAL_TICKET: u32 = 300; + /// ServerLevel.END_SPAWN_POINT. pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0]; /// EnderDragonFight gateway ring radius and height. @@ -47,18 +52,27 @@ pub(super) enum GatewayJob { Known { exit: [i32; 3], exact: bool }, } -/// An entity travelling through an End gateway: its saved compound and the -/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`). +/// An entity travelling through an End gateway: the entity and the job that +/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`). #[derive(Clone, Debug)] struct GatewayArrival { - /// The entity's compound, as it waits for the exit's chunks. - tag: Tag, + /// The entity, as it waits for the exit's chunks. + traveller: Arriving, /// The gateway's exit, or the search for one. job: GatewayJob, /// The spot to load before the job is resolved. target: Vec3, } +/// What waits at a gateway's exit. +#[derive(Clone, Debug)] +enum Arriving { + /// An item or a mob, as its saved compound. + Saved(Tag), + /// A thrown ender pearl (projectiles are not saved). + Pearl(Box), +} + impl GatewayJob { /// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks. fn for_gateway( @@ -120,6 +134,10 @@ pub(super) struct EndState { arrivals: Vec, /// The chunks kept loaded for those arrivals. areas: Vec<(i32, i32)>, + /// `Entity.placePortalTicket` of the pearls that came out of a gateway: + /// the chunk each landed in, kept loaded for the ticks left of its + /// `TicketType.PORTAL`, so that it can come down and take its owner there. + tickets: Vec<((i32, i32), u32)>, /// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure). locator: std::sync::Mutex>>, /// Thrown eyes of ender in flight. @@ -1462,13 +1480,17 @@ impl Game<'_> { // ---- End gateway travel for the entities ---- - /// `EndGatewayBlock.entityInside` for the items and mobs in the End: the - /// gateway blocks their box swept through. A gateway that is cooling down - /// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in. + /// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender + /// pearls in the End: the gateway blocks their box swept through. A + /// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`) + /// takes nobody in. pub(super) fn find_end_gateway_portals(&mut self) { if self.remote.is_some() || self.dim != Dim::End { return; } + let is_gateway = |game: &Self, p: BlockPos| { + &*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway" + }; let items: Vec<(u32, Vec3, f32, f32)> = self .dropped .iter() @@ -1484,13 +1506,28 @@ impl Game<'_> { for (id, to, width, height) in items.into_iter().chain(mobs) { let from = self.portals.before.get(&id).copied().unwrap_or(to); let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| { - &*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name - == "minecraft:end_gateway" + is_gateway(self, p) }); if let Some(pos) = found { inside.push((id, pos)); } } + // A pearl's move this tick, from where it started it. + let pearls: Vec<(u32, Option, Option, BlockPos)> = self + .creatures + .projectiles + .iter() + .filter_map(|p| { + let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?; + Some((p.id, p.owner, p.guest, pos)) + }) + .collect(); + for (id, owner, guest, pos) in pearls { + if owner == Some(Who::Player) { + self.pearl_owner_in_gateway(guest, pos); + } + inside.push((id, pos)); + } for (id, pos) in inside { // One entity at a time: the gateway cools down for 40 ticks. if !self.gateway_enter([pos.x, pos.y, pos.z]) { @@ -1504,6 +1541,22 @@ impl Game<'_> { } } + /// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's + /// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had + /// walked in. A guest's pearl triggers theirs; one whose guest has left + /// has no owner. + fn pearl_owner_in_gateway(&mut self, guest: Option, pos: BlockPos) { + let state = self.world.block(pos.x, pos.y, pos.z); + let Some(conn) = guest else { + self.trigger_enter_block(state); + return; + }; + if let Some(mut host) = self.lan.take() { + self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state)); + self.lan = Some(host); + } + } + /// `Entity.handlePortal` for the entities in an End gateway: they go /// through to the gateway's exit, where they wait for its chunks. pub(super) fn handle_end_gateway_portals(&mut self) { @@ -1515,8 +1568,20 @@ impl Game<'_> { .iter() .map(|e| (e.id, true)) .chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m)))) + .chain( + self.creatures + .projectiles + .iter() + .filter(|p| matches!(p.kind, ProjectileKind::EnderPearl)) + .map(|p| (p.id, true)), + ) .collect(); - let present: std::collections::HashSet = ids.iter().map(|(id, _)| *id).collect(); + let mut present: std::collections::HashSet = ids.iter().map(|(id, _)| *id).collect(); + // A pearl waiting for its exit keeps its portal cooldown. + present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller { + Arriving::Pearl(p) => Some(p.id), + Arriving::Saved(_) => None, + })); self.end.gateways.retain(|id, _| present.contains(id)); let mut leaving: Vec<(u32, BlockPos)> = Vec::new(); for (id, allowed) in ids { @@ -1573,11 +1638,28 @@ impl Game<'_> { self.forget_entity(g); } tag + } else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) { + let pearl = Box::new(self.creatures.projectiles.remove(i)); + let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]); + let arrival = GatewayArrival { + traveller: Arriving::Pearl(pearl), + job, + target, + }; + // It is at the exit before it moves this tick, once that has loaded. + if let Some(waiting) = self.arrive_through_gateway(arrival) { + self.end.arrivals.push(waiting); + } + return; } else { return; }; let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]); - self.end.arrivals.push(GatewayArrival { tag, job, target }); + self.end.arrivals.push(GatewayArrival { + traveller: Arriving::Saved(tag), + job, + target, + }); } /// The entities that reached a gateway's exit: they land where the @@ -1586,47 +1668,60 @@ impl Game<'_> { if self.remote.is_some() || self.dim != Dim::End { return Ok(()); } - let mut i = 0; - while i < self.end.arrivals.len() { - let (cx, cz) = ( - (self.end.arrivals[i].target.x.floor() as i32) >> 4, - (self.end.arrivals[i].target.z.floor() as i32) >> 4, - ); - if !self.world.chunks.contains_key(&(cx, cz)) { - i += 1; - continue; - } - let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target); - let at = match self.resolve_gateway(job, target) { - Ok(at) => at, - Err(e) => { - // The traveller waits for the exit: the failure is retried. - log::warn!("End gateway: {e}"); - i += 1; - continue; - } - }; - let mut tag = self.end.arrivals.remove(i).tag; - if let Tag::Compound(c) = &mut tag { - c.insert("Pos".into(), doubles([at.x, at.y, at.z])); - c.insert("Motion".into(), doubles([0.0, 0.0, 0.0])); - // setPortalCooldown before the teleport. - c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY)); - } - let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4); - match self - .world - .loaded_entities - .iter_mut() - .find(|(k, _)| *k == key) - { - Some((_, tags)) => tags.push(tag), - None => self.world.loaded_entities.push((key, vec![tag])), + for arrival in std::mem::take(&mut self.end.arrivals) { + if let Some(waiting) = self.arrive_through_gateway(arrival) { + self.end.arrivals.push(waiting); } } Ok(()) } + /// `Entity.teleport` of a gateway traveller once its exit's chunk has + /// loaded: an item or a mob joins the chunk it lands in, a pearl flies + /// on from there. The traveller comes back while it still waits. + fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option { + let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4); + if !self.world.chunks.contains_key(&chunk(arrival.target)) { + return Some(arrival); + } + let at = match self.resolve_gateway(arrival.job, arrival.target) { + Ok(at) => at, + Err(e) => { + // The traveller waits for the exit: the failure is retried. + log::warn!("End gateway: {e}"); + return Some(arrival); + } + }; + let key = chunk(at); + match arrival.traveller { + Arriving::Saved(mut tag) => { + if let Tag::Compound(c) = &mut tag { + c.insert("Pos".into(), doubles([at.x, at.y, at.z])); + c.insert("Motion".into(), doubles([0.0, 0.0, 0.0])); + // setPortalCooldown before the teleport. + c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY)); + } + match self + .world + .loaded_entities + .iter_mut() + .find(|(k, _)| *k == key) + { + Some((_, tags)) => tags.push(tag), + None => self.world.loaded_entities.push((key, vec![tag])), + } + } + Arriving::Pearl(mut pearl) => { + horizon_client::end_gateway::place_pearl(&mut pearl, at); + // ThrownEnderpearl.teleport: placePortalTicket where it came out. + self.end.tickets.retain(|(c, _)| *c != key); + self.end.tickets.push((key, PORTAL_TICKET)); + self.add_projectile(*pearl); + } + } + None + } + /// The chunks the entities waiting to arrive need, kept loaded like the /// areas a server keeps around its players. pub(super) fn keep_gateway_destinations(&mut self) { @@ -1634,12 +1729,24 @@ impl Game<'_> { self.streaming .extra .retain(|&(c, r)| !(r == 1 && areas.contains(&c))); + // The pearls' portal tickets hold End chunks, and run out there + // (TicketType.PORTAL's timeout). + let in_end = self.dim == Dim::End; + if in_end { + self.end.tickets.retain_mut(|(_, ticks)| { + *ticks = ticks.saturating_sub(1); + *ticks > 0 + }); + } let mut areas = Vec::new(); - for a in &self.end.arrivals { - let c = ( + let arrivals = self.end.arrivals.iter().map(|a| { + ( (a.target.x.floor() as i32) >> 4, (a.target.z.floor() as i32) >> 4, - ); + ) + }); + let tickets = self.end.tickets.iter().filter(|_| in_end); + for c in arrivals.chain(tickets.map(|&(c, _)| c)) { if !areas.contains(&c) { areas.push(c); self.streaming.extra.push((c, 1)); From bf103fc383d2a2ffd24aa3ea2442e38423df7e15 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 08:50:52 +0100 Subject: [PATCH 320/968] Keep GUI blend ranges aligned when clip_since rewrites vertices --- crates/horizon-client-render/src/hud.rs | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 18ea5b74a0..4024e8410f 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -935,7 +935,11 @@ impl HudBatch { pub fn clip_since(&mut self, start: usize, x0: f32, y0: f32, x1: f32, y1: f32) { let quads: Vec<[HudVertex; 6]> = self.vertices[start..].as_chunks::<6>().0.to_vec(); self.vertices.truncate(start); + // Where each source quad now begins, to move the blend ranges (foil + // items are an additive run) along with their vertices. + let mut moved = Vec::with_capacity(quads.len() + 1); for q in quads { + moved.push(self.vertices.len() as u32); let corners = [q[0], q[1], q[2], q[5]]; let (mut x_lo, mut x_hi) = (f32::INFINITY, f32::NEG_INFINITY); let (mut y_lo, mut y_hi) = (f32::INFINITY, f32::NEG_INFINITY); @@ -997,6 +1001,14 @@ impl HudBatch { tl.color, ); } + moved.push(self.vertices.len() as u32); + let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)]; + for (range, _) in &mut self.blends { + if range.start as usize >= start { + *range = at(range.start)..at(range.end); + } + } + self.blends.retain(|(range, _)| !range.is_empty()); } /// `blit` of the top-left `w x h` texels of a `tw x th` texture. pub fn sprite_region( @@ -1900,6 +1912,28 @@ mod tests { assert!((area - 56.0).abs() < 1e-3, "{area}"); } + #[test] + fn clip_since_moves_blend_ranges_with_their_vertices() { + let mut batch = HudBatch::new([320, 240]); + let start = batch.vertices.len(); + let tri = |batch: &mut HudBatch, y: f32| { + batch.quad( + [[0.0, y], [8.0, y], [0.0, y + 8.0], [0.0, y + 8.0]], + [[0.0; 2]; 4], + u32::MAX, + ); + }; + tri(&mut batch, 100.0); // outside, dropped + let foil = batch.vertices.len(); + tri(&mut batch, 0.0); // straddles the bottom edge + batch.blend_since(foil, Blend::Additive); + batch.clip_since(start, 0.0, 0.0, 16.0, 4.0); + assert_eq!( + batch.blends, + [(0..batch.vertices.len() as u32, Blend::Additive)] + ); + } + /// Write a PNG `width x height` whose `frames` stacked rows are solid /// colours, then its mcmeta beside it. fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) { From 8e10fc9924acf4fe57804c7d8dfed08363f14727 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 08:50:52 +0100 Subject: [PATCH 321/968] Lectern: close the book screen after Take Book --- crates/horizon-client/src/game/books.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 01cf4fdbed..ad3078ddb6 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -833,6 +833,23 @@ impl Game<'_> { self.lectern_menu_done(key, &menu); self.follow_lectern(); } + /// LecternScreen's Take Book: the menu's button 3. The book leaves the + /// lectern, so the screen closes (the server closes the menu once the + /// book is gone; a server's client closes at once rather than wait). + fn take_lectern_book(&mut self, key: (i32, i32, i32)) { + self.lectern_button(key, 3); + if self.may_build() + && matches!( + self.book, + Some(BookScreen::View { + lectern: Some(_), + .. + }) + ) + { + self.close_book(); + } + } /// The `book` control command: a button of the open book or lectern /// screen (the page arrows, Take Book, Done), then its page. pub(super) fn book_control( @@ -849,7 +866,7 @@ impl Game<'_> { Some("next") => self.turn_page(true), Some("back") => self.turn_page(false), Some("take") => match lectern { - Some(key) => self.lectern_button(key, 3), + Some(key) => self.take_lectern_book(key), None => return Err("not a lectern".into()), }, // The book and quill's buttons: Sign, then Sign and Close; Done @@ -1064,8 +1081,7 @@ impl Game<'_> { self.close_book(); } else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) { click(self); - // LecternScreen's Take Book: the menu's button 3. - self.lectern_button(key, 3); + self.take_lectern_book(key); } } else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) { click(self); From e17aabe3226ce3c4e54e53bcda3c3072be216d05 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:52:54 +0200 Subject: [PATCH 322/968] A hit stands a sitting tame parrot up Parrot.hurtServer calls setOrderedToSit(false) before LivingEntity.hurtServer, as Wolf.hurtServer does. Horizon only did it for wolves, and only after the damage landed, so a sitting parrot kept sitting when hit, and a wolf hit inside its hurt frames kept sitting. Both now stand up in accepts_damage, which runs where vanilla's override does (after isInvulnerableTo, before the hurt frames). Part of #3512 (row Parrot.hurtServer). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/ai.rs | 8 +++- crates/horizon-client/src/mobs/tame.rs | 57 ++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 70c6b782e1..c8f1227c31 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -2009,6 +2009,13 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, _amount: f32) -> bool { super::bee::hurt(m); true } + // Wolf.hurtServer, Parrot.hurtServer: a hit it isn't invulnerable to + // stands it up before LivingEntity.hurtServer, even one the hurt + // frames then turn away. + Species::Wolf | Species::Parrot => { + super::tame::stand_when_hurt(m); + true + } // Panda.hurtServer: up first, whatever the damage. Species::Panda => { super::panda::hurt(m); @@ -2035,7 +2042,6 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) { Species::Armadillo => super::armadillo::after_hurt(m, source), Species::Shulker => super::shulker::after_hurt(m), Species::Warden => super::warden::hurt(m, source), - Species::Wolf => super::tame::after_hurt(m, source), Species::Nautilus | Species::ZombieNautilus => super::nautilus::after_hurt(m, source), Species::Zombie | Species::Husk diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index f6e3fa14ec..a7c280b92f 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -2,7 +2,7 @@ //! sitting, following and defending the owner, begging, anger and shaking //! off water (`Wolf`, `WolfModel`). use super::{ - Damage, Mob, MobEvent, MobWorld, PlayerView, Species, Who, + Mob, MobEvent, MobWorld, PlayerView, Species, Who, anim::{Poses, State}, goals::{ Breed, Cx, Float, Goal, HurtByTarget, JUMP, LOOK, LeapAtTarget, LookAtPlayer, MOVE, @@ -701,9 +701,10 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.anim.vo[SHAKE] = 0.0; } } -/// `Wolf.hurtServer`: stop sitting. The anger comes with the target -/// (`HurtByTargetGoal`, then `updatePersistentAnger`). -pub fn after_hurt(m: &mut Mob, _source: &Damage) { +/// `Wolf.hurtServer`, `Parrot.hurtServer`: `setOrderedToSit(false)`. A +/// wolf's anger comes with the target (`HurtByTargetGoal`, then +/// `updatePersistentAnger`). +pub fn stand_when_hurt(m: &mut Mob) { m.extra.sitting = false; } /// `Wolf.getTailAngle`. @@ -1574,3 +1575,51 @@ pub fn feline(p: &mut Poses, s: &State, st: &Feline) { p.xrot("head", rot_lerp(relax, hx, -0.581_776_44)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::{Damage, sensor_scan_tests::kind}; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + /// A tame mob of a species, told to sit. + fn sitting(species: Species, name: &str) -> Mob { + let mut kind = kind(); + let k = Arc::make_mut(&mut kind); + k.id = name.into(); + k.species = species; + k.max_health = 20.0; + let mut m = Mob::new(1, kind, Vec3::new(0.0, 64.0, 0.0), 0.0); + m.extra.tame = true; + m.extra.sitting = true; + m + } + + #[test] + fn a_hit_stands_a_sitting_wolf_or_parrot_up() { + // Wolf.hurtServer and Parrot.hurtServer call setOrderedToSit(false) + // before LivingEntity.hurtServer. + for (species, name) in [ + (Species::Wolf, "minecraft:wolf"), + (Species::Parrot, "minecraft:parrot"), + ] { + let mut m = sitting(species, name); + assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0]))); + assert!(!m.extra.sitting, "{name} still sits after a hit"); + // A weaker hit inside the hurt frames deals nothing, but it + // reached hurtServer, so it still stands the mob up. + m.extra.sitting = true; + assert!(!m.damage(0.5, Damage::player([0.0, 64.0, 1.0]))); + assert!(!m.extra.sitting, "{name} still sits after a blocked hit"); + } + } + + #[test] + fn a_hit_leaves_a_sitting_cat_sitting() { + // Cat has no hurtServer override: a hit does not stand it up. + let mut m = sitting(Species::Cat, "minecraft:cat"); + assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0]))); + assert!(m.extra.sitting); + } +} From 9ca7f51d050c098349c6f8358cf749f19caf8544 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 08:55:14 +0100 Subject: [PATCH 323/968] Sneaking players descend through scaffolding on the server (#4254) --- crates/horizon-client/src/lan/hub.rs | 12 ++++++++++-- crates/horizon-world/src/collision_context.rs | 7 +++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index c347f989cf..a2e5c23c3a 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -2498,7 +2498,15 @@ impl Hub { ); let fail = !g.creative() && scene - .resolve(old, movement, 0.6, g.on_ground) + .resolve_with_context( + old, + movement, + 0.6, + g.on_ground, + // The mover's own sneaking, as CollisionContext.of(player): + // a sneaking player descends through scaffolding. + CollisionContext::position(g.position[1]).with_descending(g.sneaking), + ) .is_ok_and(|done| { let (dx, dz) = (movement.x - done.x, movement.z - done.z); dx * dx + dz * dz > 0.0625 @@ -2513,7 +2521,7 @@ impl Hub { let Ok(old_shape) = VoxelShape::from_box(old.deflate(shrink)) else { return (fail, false); }; - let context = CollisionContext::position(g.position[1]); + let context = CollisionContext::position(g.position[1]).with_descending(g.sneaking); let Ok(scan) = BlockCollisionScan::new(&mut scene, context, new.deflate(shrink), false, 1 << 20) else { diff --git a/crates/horizon-world/src/collision_context.rs b/crates/horizon-world/src/collision_context.rs index 70362f3bbe..edcb0d5163 100644 --- a/crates/horizon-world/src/collision_context.rs +++ b/crates/horizon-world/src/collision_context.rs @@ -79,6 +79,13 @@ impl<'a> CollisionContext<'a> { } } + /// The same context for an entity that is (or is not) sneaking. Only block + /// shapes that read `isDescending` (scaffolding) see the difference. + pub const fn with_descending(mut self, descending: bool) -> Self { + self.descending = descending; + self + } + /// Ordinary EntityCollisionContext. The native factory overload taking an /// explicit fluid flag also uses this path for experimental minecarts. pub fn for_entity(entity: CollisionEntity<'a>, always_fluid: bool) -> Self { From d12fd7341cffc1db893ff7fa4a2a0c333e740cdc Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:58:54 +0100 Subject: [PATCH 324/968] Dedicated server: parse no item models, which only the item renderer bakes Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/assets.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client-render/src/assets.rs b/crates/horizon-client-render/src/assets.rs index 2bd22d9b52..b920654a4c 100644 --- a/crates/horizon-client-render/src/assets.rs +++ b/crates/horizon-client-render/src/assets.rs @@ -192,9 +192,8 @@ impl Assets { pub fn reload(&self, root: &Path) -> Result { RenderAssets::load(root, self) } - /// [`Assets::reload`] for a server, which draws nothing: the item models - /// and the sprites' layers, without the block models or the textures' - /// pixels. + /// [`Assets::reload`] for a server, which draws nothing: the sprites' + /// layers, without the block or item models or the textures' pixels. pub fn reload_for_server(&self, root: &Path) -> Result { RenderAssets::load_with(root, self, false) } @@ -209,8 +208,13 @@ impl RenderAssets { for name in MATERIALS { sprites.insert(root, &format!("minecraft:block/{name}"))?; } - let parsed = crate::model_library::ParsedModels::load(root); - // A server draws no block: its models (about 24 MB baked) stay empty. + // A server draws no block and no item: its block models (about 24 MB + // baked) and item model library (7 MB) stay empty. + let parsed = if draws { + crate::model_library::ParsedModels::load(root) + } else { + crate::model_library::ParsedModels::default() + }; let models = if draws { crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)? } else { From c7cfd91f14b67f143f7f2cd7cf34943a20a8a6c1 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:58:55 +0200 Subject: [PATCH 325/968] Keep the mouse wheel off the hotbar while a book or lectern is open MouseHandler.onScroll gives the wheel to the open screen and turns the hotbar only when there is none. Books, lecterns, signs, server dialogs and the link and pack prompts are drawn over gameplay while the screen stays Playing, so the wheel still changed the selected slot under them. Fixes #4253 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5f1bcd0194..0b081344bf 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6074,6 +6074,17 @@ impl Game<'_> { ) || self.book.is_some() || self.win_screen_pauses()) } + /// A screen open over gameplay while `screen` stays `Playing`: a book or + /// lectern, a sign, a server dialog, the code of conduct, a link or + /// server pack prompt (vanilla's `Minecraft.screen` is set for each). + fn screen_over_playing(&self) -> bool { + self.book.is_some() + || self.sign_edit.is_some() + || self.dialog_open() + || self.code_of_conduct.is_some() + || self.link_prompt.is_some() + || self.server_packs.prompt.is_some() + } /// `Minecraft.pauseIfInactive`: a window without focus for more than 500 ms /// pauses the game. An open screen (books, signs, the pack prompt, a server /// dialog) stays instead of pausing (`Gui.setPauseScreen`). @@ -13560,7 +13571,11 @@ impl ApplicationHandler for Game<'_> { let batch = HudBatch::new([size.width, size.height]); title.scroll_worlds(d, self.cursor, (batch.width, batch.height)); } + // MouseHandler.onScroll: the hotbar (or the spectator + // menu) takes the wheel only when no screen is open; a + // book, lectern, sign or prompt drawn over gameplay is one. if self.screen == Screen::Playing + && !self.screen_over_playing() && self.sleeping.is_none() && self.player.is_some() && !self.native_wheel_event(d) From fb8321177ba5570d3a33e850f04e5b3cf3ded467 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:01:29 +0100 Subject: [PATCH 326/968] Pass the world to nav_move_to_path in the callers main added Main's piglin WalkAway goal and MoveThroughVillageGoal still called the two-argument form. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/piglin.rs | 6 +++--- crates/horizon-client/src/mobs/undead.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 5a4eb7c8d5..64e611992a 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -946,9 +946,9 @@ impl Goal for WalkAway { fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { self.source(m, cx.w).is_some() && !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.speed); + m.nav_move_to_path(cx.w, path, self.speed); } fn every_tick(&self) -> bool { true @@ -956,7 +956,7 @@ impl Goal for WalkAway { fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { if self.step(m, cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.speed); + m.nav_move_to_path(cx.w, path, self.speed); } } } diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index 0c598dc94c..136ceb9457 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -1074,8 +1074,8 @@ impl Goal for MoveThroughVillage { ) } /// `MoveThroughVillageGoal.start`. - fn start(&mut self, m: &mut Mob, _: &mut Cx) { - m.nav_move_to_path(self.path.clone(), self.speed); + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + m.nav_move_to_path(cx.w, self.path.clone(), self.speed); } /// `MoveThroughVillageGoal.stop`: a point reached (or the walk ended) is /// visited. From 4d42e8b877e73bf7f756e64ecca9f6da0c43e4c6 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:02:47 +0100 Subject: [PATCH 327/968] Dedicated server: no block entity renderer or animate tables, which only frames read Co-Authored-By: Claude Opus 5.5 --- .../src/block_entity_render.rs | 2 ++ crates/horizon-client/src/animate_tick.rs | 4 +++- crates/horizon-client/src/game.rs | 19 +++++++++++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client-render/src/block_entity_render.rs b/crates/horizon-client-render/src/block_entity_render.rs index 383e6c9052..5117609a1d 100644 --- a/crates/horizon-client-render/src/block_entity_render.rs +++ b/crates/horizon-client-render/src/block_entity_render.rs @@ -789,6 +789,8 @@ pub struct Scene<'a> { pub glyphs: u64, } +/// Empty by default (a dedicated server): no state has a block entity model. +#[derive(Default)] pub struct BlockEntityRenderer { models: HashMap<&'static str, ModelPart>, kinds: Vec, diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index ac23d329ee..81d1958a44 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -296,7 +296,9 @@ fn candle_offsets(count: u8) -> &'static [[f64; 3]] { } } -/// Per-state animate behaviour and the block tags it needs. +/// Per-state animate behaviour and the block tags it needs. Empty by default +/// (a dedicated server): every lookup finds nothing to animate. +#[derive(Default)] pub struct AnimateTables { kinds: Vec, impermeable: Vec, diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5f1bcd0194..cb402db70f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -15412,6 +15412,7 @@ fn load_resources( visibility, &progress, started, + true, ) .map_err(|error| error.to_string())?; resources @@ -15440,6 +15441,7 @@ fn load_resources( visibility, progress, started, + !headless, )?; resources .rest @@ -15468,6 +15470,9 @@ fn load_world_rest( visibility: horizon_world::block_render::FaceVisibility, progress: &Progress, started: Instant, + // False on a dedicated server, which draws nothing: no block entity + // renderer or animate tables (client-side display ticks). + draws: bool, ) -> Result { let facts = resources.facts(); let biomes = resources.biomes(); @@ -15501,8 +15506,11 @@ fn load_world_rest( &items, &horizon_world::loot::PackOverlay::NONE, ); - let animate = - horizon_client::animate_tick::AnimateTables::load(root, registry_owner.registry, biomes)?; + let animate = if draws { + horizon_client::animate_tick::AnimateTables::load(root, registry_owner.registry, biomes)? + } else { + Default::default() + }; let fog = horizon_client::fog::FogTables::load(root, biomes); Ok(WorldRest { stations, @@ -15510,12 +15518,15 @@ fn load_world_rest( recipes, transformers, providers: Providers::load(root), - block_entity_render: + block_entity_render: if draws { horizon_client::block_entity_render::BlockEntityRenderer::load_with_visibility( root, registry_owner.registry, visibility, - )?, + )? + } else { + Default::default() + }, animate, fog, music: horizon_client::music::MusicTables::load(root), From dab1861c210aef7f1122917de55cc8a70ac8edd4 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 09:06:38 +0100 Subject: [PATCH 328/968] Let a candle be placed on an uneaten cake (CakeBlock.useItemOn) Fixes #4266 --- crates/horizon-client/src/game.rs | 12 ++++++++++ crates/horizon-world/src/level/interact.rs | 26 ++++++++++++++++++++++ crates/horizon-world/src/level/tests.rs | 24 ++++++++++++++++++++ tools/port_map/classes-03.tsv | 2 +- 4 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 987a628d20..6fbf09bfa5 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -4660,6 +4660,9 @@ impl Game<'_> { let player = self.use_context(); let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]); let item = held.as_ref().map(|s| s.item.to_string()); + let cake = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); + let adds_candle = + self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some()); let result = self.with_level(|b, level, ticks| { b.use_item_on( level, @@ -4672,6 +4675,15 @@ impl Game<'_> { ) }); self.process_level_events()?; + // CakeBlock.useItemOn: the candle is used up and counted. + if adds_candle + && result == horizon_world::level::InteractionResult::Success + && let Some(used) = held.as_ref() + { + self.stats.add("used", &used.item, 1); + self.consume_held(); + self.trigger_block("minecraft:item_used_on_block", hit.pos, used); + } // NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung). if result == horizon_world::level::InteractionResult::Success { let name = self diff --git a/crates/horizon-world/src/level/interact.rs b/crates/horizon-world/src/level/interact.rs index bf5c15f52f..e1d071985b 100644 --- a/crates/horizon-world/src/level/interact.rs +++ b/crates/horizon-world/src/level/interact.rs @@ -732,6 +732,14 @@ impl Behaviours { { return InteractionResult::Pass; } + // CakeBlock.useItemOn: a candle on an uneaten cake turns it into a candle cake. + if let Some(candle_cake) = self.cake_candle_state(state, item) { + let mut sim = self.sim(level, ticks); + sim.sound(pos, &"minecraft:block.cake.add_candle".into(), 1.0, 1.0); + sim.set_block_and_update(pos, candle_cake); + sim.game_event(pos, "minecraft:block_change"); + return InteractionResult::Success; + } if self.data(state).chain.contains(&Class::CopperGolemStatue) { return self.turn_statue(level, ticks, pos, state, item); } @@ -777,6 +785,24 @@ impl Behaviours { self.use_without_item(level, ticks, pos, face, hit, player) } + /// `CakeBlock.useItemOn`: the unlit candle cake that `item` (a `#minecraft:candles` item) + /// turns the uneaten cake `state` into (`CandleCakeBlock.byCandle`), if any. + pub fn cake_candle_state( + &self, + state: BlockStateId, + item: Option<&str>, + ) -> Option { + if !self.data(state).chain.contains(&Class::Cake) + || self.table.int(state, self.props.bites) != 0 + { + return None; + } + // Every candle item is `[color_]candle` and its candle cake `[color_]candle_cake`. + let candle = item?.strip_suffix("candle")?; + let id = self.table.block_by_name(&format!("{candle}candle_cake"))?; + Some(self.default_of(id)) + } + /// `CopperGolemStatueBlock.useItemOn` (and its weathering subclass's): anything but an axe /// (`#minecraft:axes`, every one named `*_axe`), even an empty hand, turns the statue to its /// next pose; honeycomb waxes a statue that still weathers instead. diff --git a/crates/horizon-world/src/level/tests.rs b/crates/horizon-world/src/level/tests.rs index 248412336a..e7dfa8530e 100644 --- a/crates/horizon-world/src/level/tests.rs +++ b/crates/horizon-world/src/level/tests.rs @@ -1622,3 +1622,27 @@ fn lever_on_piston_back_survives_extend_and_retract() { ); } } + +#[test] +fn a_candle_on_an_uneaten_cake_makes_a_candle_cake() { + // CakeBlock.useItemOn: any candle on a cake with no bites turns it into the + // matching unlit candle cake; a bitten cake or another item does not. + let mut w = world!(); + let pos = BlockPos::new(0, 1, 0); + let cake = w.id("cake", &[("bites", "0")]); + for (item, block) in [ + ("minecraft:candle", "candle_cake"), + ("minecraft:red_candle", "red_candle_cake"), + ] { + w.put(pos, cake); + assert_eq!(w.use_item(pos, item), InteractionResult::Success); + assert_eq!(w.get(pos), w.id(block, &[("lit", "false")])); + } + let bitten = w.id("cake", &[("bites", "1")]); + w.put(pos, bitten); + w.use_item(pos, "minecraft:candle"); + assert_ne!(w.get(pos), w.id("candle_cake", &[("lit", "false")])); + w.put(pos, cake); + w.use_item(pos, "minecraft:stone"); + assert_eq!(w.get(pos), cake); +} diff --git a/tools/port_map/classes-03.tsv b/tools/port_map/classes-03.tsv index a07dea4d77..2ec300147a 100644 --- a/tools/port_map/classes-03.tsv +++ b/tools/port_map/classes-03.tsv @@ -108,7 +108,7 @@ BuddingAmethystBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim BushBlock.performBonemeal port crates/horizon-world/src/level/bonemeal.rs::Sim::perform_bonemeal CactusFlowerBlock.mayPlaceOn port crates/horizon-world/src/level/survive.rs::Sim::may_place_on CakeBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class -CakeBlock.useItemOn port crates/horizon-world/src/level/interact.rs::Behaviours::use_item_on +CakeBlock.useItemOn port crates/horizon-world/src/level/interact.rs::Behaviours::cake_candle_state a candle on an uneaten cake makes a candle cake; the client consumes the candle in game.rs::use_hand CakeBlock.useWithoutItem port crates/horizon-world/src/level/interact.rs::Sim::use_without_item CalibratedSculkSensorBlock.createBlockStateDefinition skip:data block state definition is data (blocks table in horizon-items) CalibratedSculkSensorBlock.getSignal port crates/horizon-world/src/level/sculk.rs::Behaviours::calibrated_back_signal From d3be73f9d056262003c4bb74a7bc6ab96ed040ea Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:07:10 +0100 Subject: [PATCH 329/968] Share one loot table map between Items and the loot data of the same root and packs Co-Authored-By: Claude Opus 5.5 --- crates/horizon-items/src/items.rs | 54 ++++-------------------- crates/horizon-world/src/loot.rs | 70 +++++++++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 51 deletions(-) diff --git a/crates/horizon-items/src/items.rs b/crates/horizon-items/src/items.rs index 93c89879d5..49bba827a2 100644 --- a/crates/horizon-items/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -263,7 +263,7 @@ pub struct Items { /// Item tags in vanilla's order (`TagLoader`: file order, nested tags in /// place, first occurrence kept), the order `TagSlotDisplay` resolves. pub item_tag_order: HashMap>, - loot: HashMap, + loot: Arc>, /// The `jukebox_song` registry. pub songs: HashMap, /// The enchantment registry: loot functions enchant the items they roll @@ -761,51 +761,11 @@ impl Items { ); order.push(id); } - let mut loot = HashMap::new(); // Every loot table, keyed by its id (blocks/stone, entities/sheep/white, - // gameplay/chicken_lay, shearing/sheep/red...). - fn walk_loot( - dir: &Path, - base: &Path, - namespace: &str, - loot: &mut HashMap, - ) -> Result<()> { - for entry in fs::read_dir(dir)?.flatten() { - let p = entry.path(); - if p.is_dir() { - walk_loot(&p, base, namespace, loot)?; - } else if p.extension().is_some_and(|e| e == "json") { - let id = p - .strip_prefix(base)? - .with_extension("") - .to_string_lossy() - .replace('\\', "/"); - loot.insert( - format!("{namespace}:{id}"), - serde_json::from_slice(&fs::read(&p)?)?, - ); - } - } - Ok(()) - } - let loot_root = root.join("data/minecraft/loot_table"); - walk_loot(&loot_root, &loot_root, "minecraft", &mut loot)?; - // Content mods' tables (docs/modpacks.md): `:blocks/...`. - for namespace in crate::content_namespaces::namespaces(root) { - let dir = root.join(format!("data/{namespace}/loot_table")); - if dir.is_dir() { - walk_loot(&dir, &dir, &namespace, &mut loot)?; - } - } - // The packs' tables over the game's own: the top pack's file for - // each id (`SimpleJsonResourceReloadListener` sees one resource per - // id, the resource stack's top). - for (id, files) in overlay.kind("loot_table") { - let Ok(v) = serde_json::from_slice::(PackOverlay::top(files)) else { - continue; - }; - loot.insert(id.to_owned(), v); - } + // gameplay/chicken_lay, shearing/sheep/red...), of every namespace + // (content mods' tables, docs/modpacks.md), with the packs' over the + // game's own: one map with the loot data of the same root and packs. + let loot = horizon_world::loot::shared_tables(root, overlay)?; let registry_order = registry_order(root, &order); Ok(Self { items, @@ -833,7 +793,7 @@ impl Items { block_tags: load_tags(root, "block", overlay)?, item_tags: load_tags(root, "item", overlay)?, item_tag_order: load_tag_lists(root, "item", overlay)?, - loot: HashMap::new(), + loot: Default::default(), songs: HashMap::new(), enchantments: Arc::new(crate::enchant::Enchantments::load(root)), }) @@ -851,7 +811,7 @@ impl Items { .map(|(k, v)| (k.clone(), v.iter().cloned().collect())) .collect(), item_tags, - loot: HashMap::new(), + loot: Default::default(), songs: HashMap::new(), enchantments: Arc::new(crate::enchant::Enchantments::default()), } diff --git a/crates/horizon-world/src/loot.rs b/crates/horizon-world/src/loot.rs index 55fbd63f2d..dff2351750 100644 --- a/crates/horizon-world/src/loot.rs +++ b/crates/horizon-world/src/loot.rs @@ -14,6 +14,7 @@ use std::{ collections::{BTreeMap, HashMap}, fs, io, path::Path, + sync::{Arc, Mutex, Weak}, }; /// Raw files a world's data packs add over the game's own data, by @@ -157,7 +158,7 @@ impl Enchantment { /// Loot tables, items, enchantments and the tags they reference. pub struct LootData { - tables: HashMap, + tables: Arc>, modifiers: HashMap, predicates: HashMap, items: HashMap, @@ -318,6 +319,40 @@ fn overlay_map(overlay: &PackOverlay, kind: &str, out: &mut HashMap io::Result>> { + static LOADED: Mutex>)>> = Mutex::new(Vec::new()); + let key = { + use std::hash::{DefaultHasher, Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + root.hash(&mut hasher); + overlay + .kind("loot_table") + .for_each(|file| file.hash(&mut hasher)); + hasher.finish() + }; + let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner()); + loaded.retain(|(_, tables)| tables.strong_count() > 0); + if let Some(tables) = loaded + .iter() + .find(|(k, _)| *k == key) + .and_then(|(_, tables)| tables.upgrade()) + { + return Ok(tables); + } + let mut tables = HashMap::new(); + load_kind(&root.join("data"), "loot_table", &mut tables)?; + overlay_map(overlay, "loot_table", &mut tables); + let tables = Arc::new(tables); + loaded.push((key, Arc::downgrade(&tables))); + Ok(tables) +} + fn load_kind(data: &Path, kind: &str, out: &mut HashMap) -> io::Result<()> { let Ok(entries) = fs::read_dir(data) else { return Ok(()); @@ -372,9 +407,7 @@ impl LootData { pub fn load(root: &Path, overlay: &PackOverlay) -> io::Result { let all = root.join("data"); let data = all.join("minecraft"); - let mut tables = HashMap::new(); - load_kind(&all, "loot_table", &mut tables)?; - overlay_map(overlay, "loot_table", &mut tables); + let tables = shared_tables(root, overlay)?; let mut modifiers = HashMap::new(); load_kind(&all, "item_modifier", &mut modifiers)?; overlay_map(overlay, "item_modifier", &mut modifiers); @@ -1587,6 +1620,32 @@ fn enchant(stack: &mut LootStack, name: &str, level: i32) { mod namespace_tests { use super::*; + /// The same root and packs share one map while it is held; other packs + /// get their own. + #[test] + fn loot_tables_are_shared_per_root_and_packs() { + let root = std::env::temp_dir().join(format!("horizon-loot-share-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("data/minecraft/loot_table/chests")).unwrap(); + fs::write( + root.join("data/minecraft/loot_table/chests/a.json"), + r#"{"pools": []}"#, + ) + .unwrap(); + let first = shared_tables(&root, &PackOverlay::NONE).unwrap(); + let again = shared_tables(&root, &PackOverlay::NONE).unwrap(); + assert!(Arc::ptr_eq(&first, &again)); + assert!(first.contains_key("minecraft:chests/a")); + let files = [( + "loot_table/minecraft:chests/b".to_owned(), + vec![br#"{"pools": []}"#.to_vec()], + )]; + let packs = shared_tables(&root, &PackOverlay { files: &files }).unwrap(); + assert!(!Arc::ptr_eq(&first, &packs)); + assert!(packs.contains_key("minecraft:chests/b")); + let _ = fs::remove_dir_all(&root); + } + /// A content mods' root: the mods' loot tables and tags load beside /// vanilla's, under their own namespaces. #[test] @@ -1618,6 +1677,9 @@ mod namespace_tests { assert!(data.has_table("kaisyn:village/forest/house")); assert!(!data.has_table("minecraft:village/forest/house")); assert!(data.in_tag("item", "kaisyn:food", "minecraft:bread")); + // The tables of a root are read once while a copy is held + // (`shared_tables`): let it go before changing a file. + drop(data); // Rolls and weights written as floats (Towns and Towers' tables). let table = r#"{"pools": [{"rolls": {"type": "minecraft:uniform", "min": 3.0, "max": 3.0}, "entries": [{"type": "minecraft:item", "name": "minecraft:bread", "weight": 2.0}]}]}"#; From fb8e62bc7e941de728135d4397f2bbd924dce204 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:38:04 +0530 Subject: [PATCH 330/968] Render the jeb_ sheep's wool colour, as vanilla does SheepRenderer gives a sheep named exactly "jeb_" the lerped dye colour (ColorLerper.Type.SHEEP: 16 colours, 25 ticks each, white as 0xFFE6E6E6) and keeps its undercoat. Horizon read no name at all, so the egg did nothing. The name test matches MobRenderer.checkMagicName (exact, custom name), the lerp is Mth.lerpInt's floor per channel, and the age threaded in is the render state's ageInTicks (tick count plus the partial tick), so the hue moves per frame instead of stepping at 20 Hz. The colour vectors came out of the 26.3 jar and were re-derived independently before the tests were written; jeb_wool_lerps_through_the_ sixteen_dye_colours pins 0xE6E6E6, 0xD0A37D, 0xBA6015, 0x953A8D, 0xDDDDDD and the 400-tick wrap, and the layer test covers the dyed, baby and sheared cases. Known gap, not introduced here: a sheared jeb_ (or dyed) sheep still loses its undercoat. Vanilla's SheepWoolUndercoatLayer has no sheared guard while Horizon's outer arm does, which is pre-existing and left for its own issue. --- crates/horizon-client/src/mobs/render.rs | 116 +++++++++++++++++++++-- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 68cdf77a6b..a3cc5abd89 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -100,6 +100,23 @@ pub fn sheep_wool_rgb(color: u8) -> u32 { let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255); f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255) } +/// `ColorLerper.getLerpedColor(ColorLerper.Type.SHEEP, ageInTicks)`: the 16 +/// wool colours in DyeColor order, one per 25 ticks, lerped in between. The +/// colour a `jeb_` sheep's wool takes. +pub fn jeb_wool_rgb(age: f32) -> u32 { + // ageInTicks is the tick count plus the partial tick: never negative. + let i = age.floor(); + let ticks = i as u64; + let j = ticks / 25; + let f = ((ticks % 25) as f32 + (age - i)) / 25.0; + let lo = sheep_wool_rgb((j % 16) as u8); + let hi = sheep_wool_rgb(((j + 1) % 16) as u8); + let mix = |shift: u32| { + let (lo, hi) = ((lo >> shift & 255) as f32, (hi >> shift & 255) as f32); + ((lo + (hi - lo) * f) as u32) << shift + }; + mix(16) | mix(8) | mix(0) +} pub const VILLAGER_TYPES: [&str; 7] = [ "plains", "desert", "jungle", "savanna", "snow", "swamp", "taiga", ]; @@ -459,8 +476,9 @@ pub fn body_color(m: &Mob, alpha: f32) -> u32 { _ => 0xFFFF_FFFF, } } -/// Extra render layers drawn over the body with the same pose. -pub fn layers(m: &Mob) -> Vec { +/// Extra render layers drawn over the body with the same pose. `age` is the +/// render state's `ageInTicks` (`Mob.age` plus the partial tick). +pub fn layers(m: &Mob, age: f32) -> Vec { use Species as S; let mut v = Vec::new(); if avatar(m) { @@ -657,8 +675,14 @@ pub fn layers(m: &Mob) -> Vec { // SlimeOuterLayer: the translucent outer cube. S::Slime => push(&mut v, "slime#outer", "entity/slime/slime", white, false), S::Sheep if !m.extra.sheared => { - let color = abgr(sheep_wool_rgb(m.extra.color)); - if !m.is_baby() && m.extra.color != 0 { + // MobRenderer.checkMagicName(entity, "jeb_"): an exact custom name. + let jeb = m.custom_name.as_deref() == Some("jeb_"); + let color = abgr(if jeb { + jeb_wool_rgb(age) + } else { + sheep_wool_rgb(m.extra.color) + }); + if !m.is_baby() && (jeb || m.extra.color != 0) { push( &mut v, "sheep#wool_undercoat", @@ -1370,7 +1394,7 @@ fn render_visible( for (part, uv, color, layer_overlay) in extra { draw(part, base, &poses, *uv, *color, light, *layer_overlay, out); } - let mut all_layers = layers(m); + let mut all_layers = layers(m, s.age); // WardenRenderer: the translucent emissive layers fade with the animations. if m.species() == Species::Warden { for (model, texture, a) in super::warden::emissive_layers(m, alpha) { @@ -1737,7 +1761,7 @@ mod tests { m.extra.age = if baby { -24000 } else { 0 }; (m.extra.variant, m.extra.variant2, m.extra.variant3) = (t as u8, p as u8, 5); - let layers = layers(&m); + let layers = layers(&m, m.age as f32); let group = if baby { "/baby/" } else { "/type/" }; assert!( layers.iter().any(|l| l.texture.contains(group)), @@ -2052,6 +2076,86 @@ mod tests { // A layer that is not dyeable is not tinted. assert_eq!(armor_tint(None, Some(&stack)), 0xFFFF_FFFF); } + + /// `ColorLerper.getLerpedColor(Type.SHEEP, ageInTicks)`: the points the + /// 26.3 jar gave — 0, half a step, one whole step, a step boundary, the + /// last step back into white, and the 400-tick (16-step) wrap. + #[test] + fn jeb_wool_lerps_through_the_sixteen_dye_colours() { + assert_eq!(jeb_wool_rgb(0.0), 0xE6E6E6); + assert_eq!(jeb_wool_rgb(12.5), 0xD0A37D); + assert_eq!(jeb_wool_rgb(25.0), 0xBA6015); + assert_eq!(jeb_wool_rgb(25.0), sheep_wool_rgb(1)); + assert_eq!(jeb_wool_rgb(50.0), 0x953A8D); + assert_eq!(jeb_wool_rgb(399.0), 0xDDDDDD); + assert_eq!(jeb_wool_rgb(400.0), 0xE6E6E6); + } + + fn sheep_layer(m: &Mob, model: &str, age: f32) -> Option { + layers(m, age).into_iter().find(|l| l.model == model) + } + + /// SheepRenderer.extractRenderState's `jeb_` magic name: the wool layer + /// takes the lerped colour and cycles with the age, a white `jeb_` sheep + /// keeps its undercoat (SheepWoolUndercoatLayer draws for it too), and + /// the dye colour, the baby model and the shears still win. + #[test] + fn a_jeb_sheep_cycles_its_wool_colour_and_keeps_the_undercoat() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("entity-models.json").exists() { + return; + } + let types = super::super::MobTypes::load(&root).unwrap(); + let kind = std::sync::Arc::new(types.types["minecraft:sheep"].clone()); + let mut mob = Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0); + mob.custom_name = Some("jeb_".into()); + let young = sheep_layer(&mob, "sheep#wool", 0.0) + .expect("an unsheared sheep draws its wool") + .color; + assert_eq!(young, abgr(0xE6E6E6)); + let older = sheep_layer(&mob, "sheep#wool", 100.0) + .expect("an unsheared sheep draws its wool") + .color; + assert_ne!(young, older, "the colour cycles with the age"); + let undercoat = sheep_layer(&mob, "sheep#wool_undercoat", 0.0) + .expect("a jeb_ sheep keeps its undercoat"); + assert_eq!(undercoat.color, abgr(0xE6E6E6)); + // A name that is not exactly "jeb_" leaves a dyed sheep's colour, and + // a non-white sheep has its undercoat too. + mob.custom_name = Some("jeb".into()); + mob.extra.color = 14; + let dyed = abgr(sheep_wool_rgb(14)); + assert_eq!( + sheep_layer(&mob, "sheep#wool", 0.0) + .expect("an unsheared sheep draws its wool") + .color, + dyed + ); + assert_eq!( + sheep_layer(&mob, "sheep#wool_undercoat", 0.0) + .expect("a dyed sheep keeps its undercoat") + .color, + dyed + ); + // A baby `jeb_` sheep gets the lerped colour on the baby wool and + // never an undercoat. + mob.custom_name = Some("jeb_".into()); + mob.extra.color = 0; + mob.extra.age = -24000; + assert!(mob.is_baby()); + assert_eq!( + sheep_layer(&mob, "sheep_baby#wool", 50.0) + .expect("a baby sheep draws its wool") + .color, + abgr(0x953A8D) + ); + assert!(sheep_layer(&mob, "sheep#wool_undercoat", 50.0).is_none()); + // Sheared: no wool layer at all. + mob.extra.age = 0; + mob.extra.sheared = true; + assert!(sheep_layer(&mob, "sheep#wool", 0.0).is_none()); + } } #[cfg(test)] From c361096ab0ad50482ed2b22e1f702fd662ec90bf Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:38:18 +0530 Subject: [PATCH 331/968] Draw thrown items where vanilla puts them Vanilla's thrown snowball, egg and ender pearl are drawn by ThrownItemRenderer, which only scales and billboards the item: the visible lift comes from item/generated's ground transform, translation [0, 2, 0] of 16 units, i.e. 0.125 blocks scaled by the renderer's scale (1.0 for the throwables, 0.75 and 3.0 for the fireballs). Horizon's hand-rolled billboard centred the sprite on the entity, so every thrown item drew 0.125 blocks low. The quad now takes that lift, gated to the kinds vanilla draws with ThrownItemRenderer: snowball, egg, ender pearl and both fireballs. Llama spit shares this quad here, but vanilla draws it from LlamaSpitRenderer with its own translate(0, 0.15, 0) along world up, so it is deliberately not lifted. Spawn position, eye height, rotation and velocity already matched vanilla (the spawn differs by 1.5e-9 blocks), so this is a rendering offset only. The test pins exact vertex bounds: 99.875..100.375 at scale 1.0, 99.625..101.125 at 3.0, llama spit at no lift, and the rise following the billboard's own up axis rather than world up. Not covered here: eye of ender has the same 0.125 offset at its own call site (game/end.rs eye_geometry) and is left as a follow-up. XP bottles already take the item-model path, which applies the same ground transform, so the report's XP-bottle half is not explained by this change. --- crates/horizon-client/src/game/creatures.rs | 85 ++++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..b18522dda5 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -5123,12 +5123,12 @@ impl Game<'_> { let Some(r) = atlas.sprites.get(name).copied() else { continue; }; - let s = 0.25 * a.sprite_scale(); - for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + let lift = is_item_billboard(&a.kind); + let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift); + for (v, uv) in quad .iter() .zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]]) { - let v = c + right * k.0 * s + up * k.1 * s; out.push(Vertex { normal: [0; 4], pos: v.to_array(), @@ -5158,6 +5158,36 @@ fn thrown_item_axes( let orientation = super::camera_rotation(camera_type, yaw, pitch); (orientation * glam::Vec3::X, orientation * glam::Vec3::Y) } +/// The four corners of the item billboard, in the order its UVs are laid out. +/// `ThrownItemRenderer` only scales and billboards it; the lift is +/// `item/generated`'s ground display transform, `translation [0, 2, 0]` of 16 +/// units = 0.125 blocks, which is `0.125 * scale` == `0.5 * s`. +fn thrown_item_quad( + at: glam::Vec3, + right: glam::Vec3, + up: glam::Vec3, + scale: f32, + lift: bool, +) -> [glam::Vec3; 4] { + let s = 0.25 * scale; + let rise = if lift { 0.5 * s } else { 0.0 }; + let centre = at + up * rise; + [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + .map(|k| centre + right * k.0 * s + up * k.1 * s) +} +/// Whether vanilla draws the kind as `ThrownItemRenderer`'s item billboard, the +/// only ones that take that lift. Llama spit shares this quad but +/// `LlamaSpitRenderer` is a model, with its own offset. +fn is_item_billboard(kind: &ProjectileKind) -> bool { + matches!( + kind, + ProjectileKind::Snowball + | ProjectileKind::Egg + | ProjectileKind::EnderPearl + | ProjectileKind::SmallFireball + | ProjectileKind::LargeFireball { .. } + ) +} /// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water /// bucket picks up. fn bucketable(species: Species) -> Option<(&'static str, &'static str)> { @@ -5319,6 +5349,55 @@ impl Game<'_> { mod tests { use super::super::perspective::CameraType; use super::thrown_item_axes; + use super::thrown_item_quad; + use super::{ProjectileKind, is_item_billboard}; + + /// The component-wise lowest and highest corner of the quad. + fn bounds(corners: &[glam::Vec3; 4]) -> (glam::Vec3, glam::Vec3) { + ( + corners.iter().copied().reduce(glam::Vec3::min).unwrap(), + corners.iter().copied().reduce(glam::Vec3::max).unwrap(), + ) + } + + #[test] + fn thrown_item_billboard_lifts_by_the_generated_ground_transform() { + // The kinds vanilla draws with `ThrownItemRenderer`: the only lifted ones. + assert!(is_item_billboard(&ProjectileKind::Snowball)); + assert!(is_item_billboard(&ProjectileKind::Egg)); + assert!(is_item_billboard(&ProjectileKind::EnderPearl)); + assert!(is_item_billboard(&ProjectileKind::SmallFireball)); + let large = ProjectileKind::LargeFireball { power: 1 }; + assert!(is_item_billboard(&large)); + // A projectile at y = 100 with right = +X, up = +Y: the sprite sits + // 0.125 blocks above the entity (2 of `item/generated`'s 16 units) and + // stays 0.5 blocks wide, so the quad's y runs 99.875 .. 100.375. + let at = glam::Vec3::Y * 100.0; + let (right, up) = (glam::Vec3::X, glam::Vec3::Y); + let lifted = is_item_billboard(&ProjectileKind::Snowball); + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted)); + assert_eq!((lo.y, hi.y), (99.875, 100.375)); + assert_eq!((lo.x, hi.x), (-0.25, 0.25)); + // A small fireball's 0.75 scale: the same quad scaled down, lifted by + // 0.09375, half extent 0.1875. + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 0.75, lifted)); + assert_eq!((lo.y, hi.y), (99.90625, 100.28125)); + // A large fireball's 3.0: lift 0.375, half extent 0.75. + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 3.0, lifted)); + assert_eq!((lo.y, hi.y), (99.625, 101.125)); + // Llama spit shares the quad here but is a model in vanilla + // (`LlamaSpitRenderer`): no lift at all. + let lifted = is_item_billboard(&ProjectileKind::LlamaSpit); + assert!(!lifted); + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted)); + assert_eq!((lo.y, hi.y), (99.75, 100.25)); + // The rise follows the billboard's own up axis, not world up. + let (right, up) = (glam::Vec3::Z, glam::Vec3::X); + let lifted = is_item_billboard(&ProjectileKind::Egg); + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted)); + assert_eq!((lo.x, hi.x), (-0.125, 0.375)); + assert_eq!((lo.y, hi.y), (100.0, 100.0)); + } #[test] fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() { From ca04a6de62b1d8385a5fb513ea28ccf53c6560c4 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 09:12:31 +0100 Subject: [PATCH 332/968] Draw creative tab and trash tooltips with the item tooltip box --- crates/horizon-client/src/game.rs | 14 +++++++------- crates/horizon-client/src/game/stations.rs | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 987a628d20..9dc2648572 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11671,15 +11671,15 @@ impl Game<'_> { horizon_client::creative::item_group_key(&tab.id) .map_or_else(|| tab.title.clone(), |key| self.translate(key, &tab.title)) } - /// Vanilla tooltip: dark box with a purple gradient border. + /// Vanilla tooltip: dark box with a purple gradient border, laid out and + /// drawn like an item tooltip (4 px of padding on every side). fn tooltip(&self, batch: &mut HudBatch, text: &str, x: f32, y: f32) { let atlas = &self.gui.atlas; - let w = HudBatch::text_width(atlas, text); - let (x, y) = (x + 12.0, y - 12.0); - batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 16.0, rgba(16, 0, 16, 240)); - batch.fill(atlas, x - 3.0, y - 3.0, w + 6.0, 1.0, rgba(80, 0, 255, 80)); - batch.fill(atlas, x - 3.0, y + 10.0, w + 6.0, 1.0, rgba(40, 0, 127, 80)); - batch.text(atlas, text, x, y, u32::MAX, true); + let lines = vec![(horizon_client::text::Text::literal(text), u32::MAX)]; + let (x, y, w, h) = + Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), 0.0); + Self::tooltip_box(batch, atlas, x, y, w, h); + Self::tooltip_text(batch, atlas, &lines, x, y); } /// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines /// that follow the item's name, none for a category tab's own grid slots diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index db8d54cb8d..49bec6ba08 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -2248,7 +2248,7 @@ impl Game<'_> { } /// The tooltip's text's top-left corner and the box's size: beside the /// cursor, kept on screen, `extra` pixels taller than the text. - fn tooltip_geometry( + pub(super) fn tooltip_geometry( atlas: &horizon_client::hud::HudAtlas, lines: &[(horizon_client::text::Text, u32)], (x, y): (f32, f32), @@ -2278,7 +2278,7 @@ impl Game<'_> { (x, y, w, h) } /// The box and its frame sprites. - fn tooltip_box( + pub(super) fn tooltip_box( batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, x: f32, @@ -2322,7 +2322,7 @@ impl Game<'_> { rgba(60, 0, 190, 80), ); } - fn tooltip_text( + pub(super) fn tooltip_text( batch: &mut HudBatch, atlas: &horizon_client::hud::HudAtlas, lines: &[(horizon_client::text::Text, u32)], From 01c6de15a39a8dc1c20514f4285ade0bb52ea2a6 Mon Sep 17 00:00:00 2001 From: ludovicthenot Date: Fri, 30 Oct 2026 09:19:37 +0100 Subject: [PATCH 333/968] Native mods: a hook point for item tooltips (API 4) native_tooltip hears every item tooltip the game shows, with the stack and its lines, which a mod can add to, change or take out of. Fixes #4087. --- crates/horizon-client/src/game/native_hooks.rs | 15 ++++++++++++++- crates/horizon-client/src/game/stations.rs | 5 +++++ docs/native-mods.md | 4 ++++ native-hooks.json | 5 +++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 4fb78b0383..f29158a43c 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -6,6 +6,8 @@ //! Horizon's own HUD extras do, and keep a mod's settings where the //! WebAssembly mod of the same id kept them. //! +//! API 4 adds item tooltips (`native_tooltip`). +//! //! API 3 adds the world lifecycle hook (`native_world_lifecycle`). //! //! API 2 adds the item panel beside container screens and its recipe pages @@ -28,6 +30,7 @@ use horizon_client::{ extras::Corner, extras_hud::Corners, hud::{HudBatch, rgba}, + items::ItemStack, keybinds, mod_list::{self, Installed, NativePage}, text::Text, @@ -38,7 +41,7 @@ use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 3; +pub(crate) const NATIVE_HOOKS_API: u32 = 4; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -334,6 +337,16 @@ impl Game<'_> { pub(super) fn native_panel_event(&mut self, mod_id: &str, event: &NativePanelEvent) { let _ = (mod_id, event); } + /// An item's tooltip, wherever the game shows one (slots, the creative + /// tabs, the recipe book, chat's hover): the stack, with its components + /// (`stack.data()`: a shulker box's contents...), and its lines, the name + /// first, each with a colour as `rgba` packs it (`u32::MAX` keeps the + /// text's own). Add, change or take out lines; `Text::legacy` reads `§` + /// codes. Asked each frame the tooltip shows, never for one the stack + /// hides. API 4. + pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) { + let _ = (stack, lines); + } } /// Helpers for native mods, kept as stable as the hook points. diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index db8d54cb8d..a4585829a1 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -2189,6 +2189,11 @@ impl Game<'_> { )); } } + // Native mods last, as Fabric's `ItemTooltipCallback` follows + // `getTooltipLines`; a tooltip the stack hides stays hidden. + if !nested && !lines.is_empty() { + self.native_tooltip(stack, &mut lines); + } lines } /// Menu state for the control socket. diff --git a/docs/native-mods.md b/docs/native-mods.md index f0ea4167b8..130734667a 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -42,6 +42,10 @@ mod shows one. API 3 adds authoritative world lifecycle events through `native_world_lifecycle`: open ready, open failed, and final close. Bundles that use these events need `"hooks": 3`. +API 4 adds item tooltips through `native_tooltip`: the hovered stack, with its components, and its tooltip's +lines, which a mod can add to, change or take out of, wherever the game shows an item's tooltip. Bundles that use it +need `"hooks": 4`. + ## What the launcher does In the window: install a native mod from the Mods page. The first one shows a notice once (the first build downloads diff --git a/native-hooks.json b/native-hooks.json index 72954e9fef..7101b09506 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -64,6 +64,11 @@ "name": "panel_event", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_panel_event" + }, + { + "name": "tooltip", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_tooltip" } ] } From 0c9468753d4118a4b44281317cf26c97250dfce5 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 11:26:11 +0300 Subject: [PATCH 334/968] Native mods: buttons, tooltips and item icons on mod screens native_screen_click turns a press on a mod screen into a menu button id, sent as ContainerButtonClick; native_menu_button runs it on the server for the mod block entity behind the menu. native_screen_tooltip shows lines under the mouse. NativeScreen gets its width, the inventory's left column and own_labels, so a mod can lay out a window that isn't vanilla's. Helpers: native_item (an item's icon, any size), native_cursor, native_screen_title, native_block_property, native_insert_item (into a container, as a hopper). API 9. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 15 +- .../horizon-client/src/game/lan_stations.rs | 13 ++ crates/horizon-client/src/game/machines.rs | 13 ++ .../horizon-client/src/game/native_hooks.rs | 205 +++++++++++++++++- native-hooks.json | 15 ++ 5 files changed, 249 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 5018592893..4fbae30f76 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6686,7 +6686,8 @@ impl Game<'_> { if self.screen == Screen::Creative { return Self::creative_origin(batch); } - let (x, y) = batch.centered_origin(176.0, self.screen_height()); + let width = self.native_screen_width().unwrap_or(176.0); + let (x, y) = batch.centered_origin(width, self.screen_height()); (x + self.recipe_book_shift(batch.width), y) } /// Slot ids: 0..36 player inventory, 100..109 crafting grid, 200 result. @@ -6777,10 +6778,11 @@ impl Game<'_> { (Some(rows), None) => 1.0 + (rows as f32 - 3.0) * 18.0, (None, None) => 0.0, }; + let inventory_x = self.native_screen_inventory_x().unwrap_or(8.0); match i { - 0..9 => (8.0 + i as f32 * 18.0, 142.0 + shift), + 0..9 => (inventory_x + i as f32 * 18.0, 142.0 + shift), 9..36 => ( - 8.0 + ((i - 9) % 9) as f32 * 18.0, + inventory_x + ((i - 9) % 9) as f32 * 18.0, 84.0 + shift + ((i - 9) / 9) as f32 * 18.0, ), 500..504 => (8.0, 8.0 + (i - 500) as f32 * 18.0), @@ -7066,6 +7068,7 @@ impl Game<'_> { } self.draw_carried(batch); self.draw_slot_tooltip(batch); + self.draw_native_screen_tooltip(batch); if self.container.is_none() && self.craft_size == 2 && !self.mount_active() { self.draw_inventory_effects(batch, (ox, oy), 176); } @@ -7817,6 +7820,10 @@ impl Game<'_> { if !right && self.sort_press() { return; } + if self.native_screen_press(right) { + self.inv_clicks.skip_release(); + return; + } let (slot, outside) = self.inventory_target(); let shift = self.has_shift_down(); let target = Target::of(slot, outside); @@ -8022,7 +8029,7 @@ impl Game<'_> { let (shift, width) = match self.station.as_ref().map(|s| s.kind) { Some(stations::Kind::Merchant) => (-50.0, 276), Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), - _ => (0.0, 176), + _ => (0.0, self.native_screen_width().map_or(176, |w| w as i32)), }; (shift, width, self.screen_height() as i32) }; diff --git a/crates/horizon-client/src/game/lan_stations.rs b/crates/horizon-client/src/game/lan_stations.rs index b056ee52fe..0767cb5446 100644 --- a/crates/horizon-client/src/game/lan_stations.rs +++ b/crates/horizon-client/src/game/lan_stations.rs @@ -392,6 +392,19 @@ impl Game<'_> { window: i32, id: i32, ) { + // A mod container's menu: its mod's buttons (`native_menu_button`), + // as handleContainerButtonClick checks them: the open menu, not a + // spectator, still valid. + if let Some(g) = host.hub.guest(conn) + && let Some(menu) = g.menu.as_ref().filter(|m| m.window == window) + && let Some(pos) = menu.pos + && self.mod_menu_at(pos).is_some() + { + if !g.spectator() && menu.slots.still_valid(&self.world, g, menu.pos) { + self.mod_menu_button(pos, id); + } + return; + } // LecternMenu's page buttons and Take Book. if self.lan_lectern_op(host, conn, window, |menu, env| { menu.button(env, id); diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 214a1bc29e..562afe781f 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -602,6 +602,19 @@ impl Game<'_> { } rest } + /// `stack` into the block container at `pos` through its side `face`, as + /// a hopper gives it (`addItem`); the remainder. + pub(super) fn insert_into_container( + &mut self, + pos: Pos, + stack: ItemStack, + face: Direction, + ) -> Option { + let Some(view) = self.containers().view(pos, false) else { + return Some(stack); + }; + self.add_item(None, &view, stack, Some(face)) + } /// Remove one item from a slot (`removeItem(slot, 1)`). fn take_one(&mut self, view: &View, slot: usize) -> Option { let target = self.slot_mut_at(view, slot)?; diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 10f4f0d669..8427fd2eb8 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -39,11 +39,12 @@ use horizon_client::{ extras_hud::Corners, fast_hash::FxHashMap, hud::{HudBatch, rgba}, + items::ItemStack, keybinds, mod_list::{self, Installed, NativePage}, text::Text, }; -use horizon_core::position::BlockPos; +use horizon_core::{geometry::Direction, position::BlockPos}; use horizon_world::{level::flags, nbt::Compound}; use std::{fs, path::PathBuf, sync::Arc, time::Duration}; use winit::{event::MouseButton, keyboard::KeyCode}; @@ -51,7 +52,7 @@ use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 8; +pub(crate) const NATIVE_HOOKS_API: u32 = 9; /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -75,16 +76,23 @@ pub(crate) struct NativeMinimap { pub(crate) round: bool, } -/// A mod block's screen over its menu (`native_screen`): its height (176 -/// wide, as vanilla's container screens), where each of the container's -/// slots is (`Slot.x`, `Slot.y`; none hides the slot, and so do slots past -/// the list), and the player inventory's first row (vanilla's is 84; the -/// hotbar is 58 below it), from its top left. +/// A mod block's screen over its menu (`native_screen`): its size, where +/// each of the container's slots is (`Slot.x`, `Slot.y`; none hides the +/// slot, and so do slots past the list), and the player inventory's first +/// row (vanilla's is 84; the hotbar is 58 below it) and left column, from +/// its top left. #[derive(Clone, Debug, Default, PartialEq)] pub struct NativeScreen { + /// Vanilla's container screens are 176 wide. + pub width: f32, pub height: f32, pub slots: Vec>, pub inventory_y: f32, + /// The inventory's left column (vanilla's is 8). + pub inventory_x: f32, + /// The mod draws the title and the "Inventory" label itself + /// (`native_screen_draw`); Horizon leaves them out. + pub own_labels: bool, } /// The mod screen open on this client: the block it is for, the key its @@ -452,6 +460,45 @@ impl Game<'_> { ) { let _ = (block, data, batch, origin); } + /// A left or right (`right`) press at `at` (GUI pixels from the screen's + /// top left, so outside it too) on block `block`'s screen, a mod's, over + /// no slot: set `button` to a menu button id when it is on one of yours. + /// Horizon sends it to the server (`ContainerButtonClick`, + /// `native_menu_button` there) and the press goes no further. API 9. + pub(super) fn native_screen_click( + &self, + block: &str, + data: &[i32], + at: [f32; 2], + right: bool, + button: &mut Option, + ) { + let _ = (block, data, at, right, button); + } + /// The tooltip at `at` (as in `native_screen_click`) on block `block`'s + /// screen, a mod's, over no item: push its lines when it is over + /// something of yours. Legacy colour codes (§) work. API 9. + pub(super) fn native_screen_tooltip( + &self, + block: &str, + data: &[i32], + at: [f32; 2], + lines: &mut Vec, + ) { + let _ = (block, data, at, lines); + } + /// A player pressed menu button `id` on the menu of the mod block entity + /// at `pos` (`AbstractContainerMenu.clickMenuButton`), on the server: + /// change `entity` when its id is yours. It is out of the world during + /// the call, as in `native_block_entity_tick`. API 9. + pub(super) fn native_menu_button( + &mut self, + pos: [i32; 3], + entity: &mut ModBlockEntity, + id: i32, + ) { + let _ = (pos, entity, id); + } } /// Helpers for native mods, kept as stable as the hook points. @@ -567,6 +614,56 @@ impl Game<'_> { batch.rect_uv(dst[0], dst[1], dst[2], dst[3], r, u32::MAX); true } + /// Item `item`'s icon (`mymod:machine`) with its top left at `at`, + /// `size` GUI pixels square (16 in a slot); false for an unknown item. + /// API 9. + pub(super) fn native_item( + &self, + batch: &mut HudBatch, + item: &str, + at: [f32; 2], + size: f32, + ) -> bool { + if self.res.items().get(item).is_none() { + return false; + } + let start = batch.vertices.len(); + self.draw_item_model(batch, &ItemStack::new(item, 1), at[0], at[1]); + batch.scale_since(start, at[0], at[1], size / 16.0, size / 16.0); + true + } + /// The open menu's title (the block's name, or the one it was given), + /// as plain text. API 9. + pub(super) fn native_screen_title(&self) -> Option { + self.remote_menu_title().map(|t| t.plain()) + } + /// Property `key` of the block at `pos` (a machine's `facing`), if it + /// has one. API 9. + pub(super) fn native_block_property(&self, pos: [i32; 3], key: &str) -> Option { + let state = self.world.block(pos[0], pos[1], pos[2]); + self.pack + .assets + .registry + .state(state)? + .properties + .get(key) + .cloned() + } + /// Puts `stack` into the container at `pos` (a chest, a furnace, a mod + /// container) through its side `face`, as a hopper would, and returns + /// what did not fit: a machine's auto-output. API 9. + pub(super) fn native_insert_item( + &mut self, + pos: [i32; 3], + face: Direction, + stack: ItemStack, + ) -> Option { + self.insert_into_container((pos[0], pos[1], pos[2]), stack, face) + } + /// Where the mouse is, in GUI pixels. API 9. + pub(super) fn native_cursor(&self) -> [f32; 2] { + [self.cursor.0, self.cursor.1] + } /// The size of sprite `name` in its own pixels, if there is one. API 8. pub(super) fn native_sprite_size(&self, name: &str) -> Option<[f32; 2]> { let r = self.gui.atlas.sprites.get(name)?; @@ -868,6 +965,14 @@ impl Game<'_> { pub(super) fn native_screen_inventory_y(&self) -> Option { self.shown_native_screen().map(|s| s.screen.inventory_y) } + /// The open mod screen's inventory's left column. + pub(super) fn native_screen_inventory_x(&self) -> Option { + self.shown_native_screen().map(|s| s.screen.inventory_x) + } + /// The open mod screen's width. + pub(super) fn native_screen_width(&self) -> Option { + self.shown_native_screen().map(|s| s.screen.width) + } /// The open mod screen: a plain panel with its slots' frames, the mod's /// drawing over it, then the title and the inventory's label. False when /// none is showing. @@ -876,7 +981,7 @@ impl Game<'_> { return false; }; let atlas = &self.gui.atlas; - let (w, h) = (176.0, s.screen.height); + let (w, h) = (s.screen.width, s.screen.height); batch.fill(atlas, ox, oy, w, h, rgba(0, 0, 0, 255)); batch.fill( atlas, @@ -909,6 +1014,9 @@ impl Game<'_> { ); } self.native_screen_draw(&s.block, &s.data, batch, [ox, oy]); + if s.screen.own_labels { + return true; + } let grey = rgba(64, 64, 64, 255); if let Some(title) = self.remote_menu_title() { batch.text_styled(atlas, &title, ox + 8.0, oy + 6.0, grey, false); @@ -942,6 +1050,16 @@ impl Game<'_> { taken.nbt.insert("Items".into(), items); } self.native_block_entity_tick([pos.0, pos.1, pos.2], &mut taken); + self.put_back_mod_block_entity(pos, taken, &items); + } + /// Puts back a mod block entity taken out for a call, unless the call + /// removed or replaced it, with what its `setChanged` does. + fn put_back_mod_block_entity( + &mut self, + pos: Pos, + taken: ModBlockEntity, + items: &[Option], + ) { if let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) && m.id.is_empty() { @@ -955,6 +1073,77 @@ impl Game<'_> { } } } + /// Menu button `id` on the mod container at `pos` (`clickMenuButton`). + pub(super) fn mod_menu_button(&mut self, pos: Pos, id: i32) { + if self.mod_menu_at(pos).is_none() { + return; + } + let Some(BlockEntity::Mod(m)) = self.world.block_entities.get_mut(&pos) else { + return; + }; + if m.id.is_empty() { + return; + } + let mut taken = std::mem::take(&mut **m); + let items = taken.items.clone(); + self.native_menu_button([pos.0, pos.1, pos.2], &mut taken, id); + self.put_back_mod_block_entity(pos, taken, &items); + } + /// The cursor from the open mod screen's top left, with its block and + /// data, for its press and tooltip hooks. + fn native_screen_at(&self) -> Option<(String, Vec, [f32; 2])> { + let s = self.shown_native_screen()?; + let r = self.renderer.as_ref()?; + let batch = HudBatch::new([r.config.width, r.config.height]); + let (ox, oy) = self.inventory_origin(&batch); + let at = [self.cursor.0 - ox, self.cursor.1 - oy]; + Some((s.block.clone(), s.data.clone(), at)) + } + /// A press on the open mod screen's own button (`native_screen_click`) + /// goes to the server as a menu button click (true). + pub(super) fn native_screen_press(&mut self, right: bool) -> bool { + if self.inventory_target().0.is_some() { + return false; + } + let Some((block, data, at)) = self.native_screen_at() else { + return false; + }; + let mut button = None; + self.native_screen_click(&block, &data, at, right, &mut button); + let Some(button) = button else { + return false; + }; + let window = self + .remote + .as_ref() + .and_then(|r| r.menu.as_ref()) + .map(|(w, _)| *w); + if let Some(container) = window { + self.send( + horizon_protocol::packets::play::Serverbound::ContainerButtonClick( + horizon_protocol::packets::play::ContainerButtonClick { container, button }, + ), + ); + } + self.audio.play_button_click(); + true + } + /// The open mod screen's tooltip under the cursor (`native_screen_tooltip`). + pub(super) fn draw_native_screen_tooltip(&self, batch: &mut HudBatch) { + if self.carried.is_some() || self.slot_at(batch, self.cursor.0, self.cursor.1).is_some() { + return; + } + let Some((block, data, at)) = self.native_screen_at() else { + return; + }; + let mut lines = Vec::new(); + self.native_screen_tooltip(&block, &data, at, &mut lines); + let lines: Vec<(horizon_client::text::Text, u32)> = lines + .into_iter() + .map(|l| (horizon_client::text::Text::literal(l), u32::MAX)) + .collect(); + self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1); + } /// Options > Mods' rows for the native mods, after the others. pub(super) fn list_native_mods(&mut self) { let mut mods = Vec::new(); diff --git a/native-hooks.json b/native-hooks.json index a7247a4899..dc2749d94b 100644 --- a/native-hooks.json +++ b/native-hooks.json @@ -94,6 +94,21 @@ "name": "screen_draw", "version": 1, "target": "horizon_client::game::native_hooks::Game<'_>::native_screen_draw" + }, + { + "name": "screen_click", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_screen_click" + }, + { + "name": "screen_tooltip", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_screen_tooltip" + }, + { + "name": "menu_button", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_menu_button" } ] } From a2d7a9f6c5a50cc74f08f6414b5c22ce1e73c16d Mon Sep 17 00:00:00 2001 From: Sticky-Bug Date: Fri, 30 Oct 2026 14:05:05 +0530 Subject: [PATCH 335/968] options: enable Monochrome Logo option (darkMojangStudiosBackground) Enable Opt::MonochromeLogo in options_ui so it can be toggled on the Accessibility Settings screen. In level_loading, define LoadingOverlay brand background colors (LOGO_BACKGROUND_COLOR = 0xEF323D, LOGO_BACKGROUND_COLOR_DARK = 0x000000) and draw_loading_overlay. Use draw_loading_overlay in startup_menu and game reload overlay to fill the frame with the brand background color selected by darkMojangStudiosBackground. --- .../src/level_loading.rs | 46 ++++++++++++++++++- .../horizon-client-render/src/options_ui.rs | 4 +- crates/horizon-client/src/game.rs | 3 +- .../horizon-client/src/game/startup_menu.rs | 3 +- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client-render/src/level_loading.rs b/crates/horizon-client-render/src/level_loading.rs index 983b59b570..360ca397e5 100644 --- a/crates/horizon-client-render/src/level_loading.rs +++ b/crates/horizon-client-render/src/level_loading.rs @@ -66,10 +66,25 @@ pub fn smooth(smoothed: f32, progress: f32) -> f32 { } /// `ARGB.opaque` as the batch's RGBA. -fn opaque(rgb: u32) -> u32 { +pub const fn opaque(rgb: u32) -> u32 { 0xFF00_0000 | (rgb & 0xFF) << 16 | (rgb & 0xFF00) | (rgb >> 16 & 0xFF) } +/// `LoadingOverlay.LOGO_BACKGROUND_COLOR` (RGB: 239, 50, 61). +pub const LOGO_BACKGROUND_COLOR: u32 = opaque(0xEF323D); +/// `LoadingOverlay.LOGO_BACKGROUND_COLOR_DARK` (black). +pub const LOGO_BACKGROUND_COLOR_DARK: u32 = opaque(0x000000); + +/// `LoadingOverlay.BRAND_BACKGROUND`: black when `darkMojangStudiosBackground` is set, +/// Mojang red otherwise. +pub fn brand_background(dark: bool) -> u32 { + if dark { + LOGO_BACKGROUND_COLOR_DARK + } else { + LOGO_BACKGROUND_COLOR + } +} + /// `LevelLoadingScreen.extractRenderState` over its background: the text, /// the progress bar and the chunk map, `status(x, z)` for x and z in /// `0..=2 * STATUS_RADIUS` from the map's north-west corner. @@ -115,6 +130,27 @@ pub fn draw( } } +/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND` +/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the +/// progress line and bar. +pub fn draw_loading_overlay( + batch: &mut HudBatch, + atlas: &HudAtlas, + line: &str, + progress: f32, + dark: bool, +) { + batch.fill( + atlas, + 0.0, + 0.0, + batch.width, + batch.height, + brand_background(dark), + ); + draw_progress(batch, atlas, line, progress); +} + /// The startup loading screen: the stage's line and a progress bar, centred /// like `LevelLoadingScreen`'s text and bar. pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progress: f32) { @@ -179,6 +215,14 @@ mod tests { assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6); } + #[test] + fn brand_background_matches_dark_mojang_studios_setting() { + assert_eq!(brand_background(true), LOGO_BACKGROUND_COLOR_DARK); + assert_eq!(brand_background(false), LOGO_BACKGROUND_COLOR); + assert_eq!(LOGO_BACKGROUND_COLOR_DARK, 0xFF00_0000); + assert_eq!(LOGO_BACKGROUND_COLOR, crate::hud::rgba(239, 50, 61, 255)); + } + #[test] fn colours_are_opaque_rgba() { assert_eq!(opaque(Status::Full.color()), u32::MAX); diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 3ec3fd8255..f0510eb5b6 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -128,7 +128,6 @@ impl Opt { | Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts - | Opt::MonochromeLogo | Opt::RotateWithMinecarts // Saved, but no Horizon system reads them yet: no Realms, no // friends list, no directional audio, no now-playing toast. @@ -802,6 +801,7 @@ impl Opt { | Opt::HideLightningFlashes | Opt::HideSplashTexts | Opt::HighContrastBlockOutlines + | Opt::MonochromeLogo | Opt::HideMatchedNames | Opt::OnlySecureChat | Opt::SaveChatDrafts @@ -3275,10 +3275,12 @@ mod tests { assert_eq!(loaded.file.double(opt.file_key().unwrap()), expected); } } + assert!(Opt::MonochromeLogo.available()); for opt in [ Opt::TextBackground, Opt::HideLightningFlashes, Opt::HideSplashTexts, + Opt::MonochromeLogo, ] { let key = opt.file_key().unwrap(); let before = s.file.bool(key); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c782c568fc..d67948c78e 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -12247,11 +12247,12 @@ impl Game<'_> { if let Some(reload) = &self.server_packs.reload { if let Some(renderer) = &mut self.renderer { let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); - horizon_client::level_loading::draw_progress( + horizon_client::level_loading::draw_loading_overlay( &mut hud, &self.gui.atlas, reload.progress.line(), reload.progress.fraction(), + self.settings.file.bool("darkMojangStudiosBackground"), ); return renderer.render(&loading_frame(&hud)); } diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index 95f7c6d847..22245e6393 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -661,11 +661,12 @@ impl Loading { pub(super) fn draw_menu(&mut self, batch: &mut HudBatch) { let Some(gui) = &self.gui else { return }; if self.menu.pending.is_some() { - horizon_client::level_loading::draw_progress( + horizon_client::level_loading::draw_loading_overlay( batch, &gui.atlas, self.progress.line(), self.progress.fraction(), + self.settings.file.bool("darkMojangStudiosBackground"), ); } else if let Some((win, frame)) = &mut self.menu.win { let now = Instant::now(); From 9465fb9cb759a117636db189cb7c65a6a665a248 Mon Sep 17 00:00:00 2001 From: hhamaj Date: Fri, 30 Oct 2026 11:41:22 +0300 Subject: [PATCH 336/968] A server's copy of a player jumps with them, so Lunge keeps the jump handlePlayerPositionChange calls jumpFromGround when a player on the ground moves up without being on the ground. Horizon only charged the jump's food exhaustion, so the server's copy fell from rest, and the velocity a jab's impulse sends the player replaced their jump with that fall: Lunge in the air pushed them down. jump_from_ground already charges the exhaustion and counts the jump statistic, which a server's players never got. Fixes #4278 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/lan_actions.rs | 7 ++- .../horizonlab/parity/LocatorBarClient.java | 54 +++++++++++++++++- tools/test_lunge_motion_parity.py | 55 +++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 tools/test_lunge_motion_parity.py diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4f574acd36..a832626f9f 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2719,9 +2719,10 @@ impl Game<'_> { for (moved, on_ground) in moves { let movement = Vec3::new(moved[0], moved[1], moved[2]); if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 { - let sprinting = w.player.sprinting; - w.player - .cause_food_exhaustion(if sprinting { 0.2 } else { 0.05 }); + // ServerGamePacketListenerImpl.handlePlayerPositionChange: + // the server's copy jumps with the player, so the velocity + // it sends them keeps the jump. + let _ = w.jump_from_ground(); } let landed = (|| -> horizon_client::Result<()> { w.set_on_ground_with_movement(on_ground, false, movement)?; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java index ec2c080124..ac60cd684d 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/LocatorBarClient.java @@ -87,7 +87,11 @@ import net.minecraft.world.phys.Vec3; * this player before the reply has been recorded), {@code respawn NAME} (the * death screen's button, ServerboundClientCommandPacket PERFORM_RESPAWN), * {@code use NAME PITCH} (the main hand's item used looking that way, - * ServerboundUseItemPacket), {@code pickup NAME SLOT} (a left click on that + * ServerboundUseItemPacket), {@code stab NAME} (a jab, ServerboundPlayerActionPacket + * STAB), {@code arc NAME TICK} (a jump straight ahead, one position a tick as + * LocalPlayer sends them, with a STAB ahead of the position of TICK, or none + * when negative), {@code sprint NAME} (START_SPRINTING, + * ServerboundPlayerCommandPacket), {@code pickup NAME SLOT} (a left click on that * slot of the player's inventory menu, claiming nothing), {@code quit NAME}, * {@code * players on|off} (whether each player also records the other players added @@ -95,7 +99,7 @@ import net.minecraft.world.phys.Vec3; * {@code chat on|off} (whether the system chat each player receives is * recorded too, as component JSON), {@code state on|off} (whether each player * also records what the server changes about it: its game mode, teleports, - * inventory slots, effects and death) and {@code end}, which writes each + * motion, inventory slots, effects and death) and {@code end}, which writes each * player's lines to OUT. */ public final class LocatorBarClient { @@ -138,6 +142,9 @@ public final class LocatorBarClient { case "respawn" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundClientCommandPacket( net.minecraft.network.protocol.game.ServerboundClientCommandPacket.Action.PERFORM_RESPAWN)); case "use" -> players.get(w[1]).use(Float.parseFloat(w[2])); + case "stab" -> players.get(w[1]).stab(); + case "arc" -> players.get(w[1]).arc(Integer.parseInt(w[2])); + case "sprint" -> players.get(w[1]).sprint(); case "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket( 0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP, new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY)); @@ -268,6 +275,44 @@ public final class LocatorBarClient { net.minecraft.world.InteractionHand.MAIN_HAND, ++sequence, position.yRot(), pitch)); } + void stab() throws Exception { + send(new net.minecraft.network.protocol.game.ServerboundPlayerActionPacket( + net.minecraft.network.protocol.game.ServerboundPlayerActionPacket.Action.STAB, + net.minecraft.core.BlockPos.ZERO, net.minecraft.core.Direction.DOWN)); + } + + /** The packet's constructor wants an entity, which this player doesn't have: read from its own bytes. */ + void sprint() throws Exception { + FriendlyByteBuf bytes = new FriendlyByteBuf(Unpooled.buffer()); + bytes.writeVarInt(id); + bytes.writeEnum(net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket.Action.START_SPRINTING); + bytes.writeVarInt(0); + send(net.minecraft.network.protocol.game.ServerboundPlayerCommandPacket.STREAM_CODEC.decode(bytes)); + } + + /** LivingEntity.jumpFromGround's 0.42, then gravity 0.08 and drag 0.98 a tick; the clicks come first in a tick. */ + void arc(int stabTick) throws Exception { + PositionMoveRotation at = position; + Vec3 start = at.position(); + double y = start.y; + double z = start.z; + double up = 0.42; + // A tick of its own, as in still: the join's position has none behind it. + send(net.minecraft.network.protocol.game.ServerboundClientTickEndPacket.INSTANCE); + for (int tick = 0; tick < 13; tick++) { + if (tick == stabTick) stab(); + y = Math.max(start.y, y + up); + up = (up - 0.08) * 0.98; + z += 0.3; + boolean landed = tick > 0 && y == start.y; + send(new ServerboundMovePlayerPacket.PosRot(new Vec3(start.x, y, z), at.yRot(), at.xRot(), landed, false)); + send(net.minecraft.network.protocol.game.ServerboundClientTickEndPacket.INSTANCE); + Thread.sleep(50); + if (landed) break; + } + position = new PositionMoveRotation(new Vec3(start.x, y, z), Vec3.ZERO, at.yRot(), at.xRot()); + } + void still() throws Exception { PositionMoveRotation at = position; // A tick of its own: the server takes one position per client tick (receivedPositionThisTick). @@ -380,6 +425,11 @@ public final class LocatorBarClient { replies++; replyLock.notifyAll(); } + } else if (p instanceof net.minecraft.network.protocol.game.ClientboundSetEntityMotionPacket m && m.id() == id) { + if (state) { + Vec3 v = m.movement(); + record(String.format(java.util.Locale.ROOT, "motion %.4f %.4f %.4f", v.x, v.y, v.z)); + } } else if (p instanceof ClientboundKeepAlivePacket k) { send(new ServerboundKeepAlivePacket(k.getId())); } else if (p instanceof ClientboundPingPacket k) { diff --git a/tools/test_lunge_motion_parity.py b/tools/test_lunge_motion_parity.py new file mode 100644 index 0000000000..52344874e6 --- /dev/null +++ b/tools/test_lunge_motion_parity.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""A spear's Lunge on the ground and in a jump, on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world +vanilla generated first, with Alice an operator in survival, holding a spear with Lunge III. LocatorBarClient (a +player made of vanilla's protocol code) jabs (ServerboundPlayerActionPacket STAB) standing, in the tick of a jump, +and a few ticks into one, sending one position a tick as LocalPlayer does. `ApplyEntityImpulse.apply` answers with a +ClientboundSetEntityMotionPacket, which replaces the client's velocity: the motions Alice is sent are compared. The +y keeps the jump because handlePlayerPositionChange makes the server's copy jump too (`jumpFromGround`). +""" +import sys + +try: + from . import test_locator_bar_parity as locator +except ImportError: + import test_locator_bar_parity as locator + +NAME = 'lunge-motion-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival') +SCRIPT = [ + # The jump ends on the ground: the join's position said otherwise, and a server's copy goes by what it is told. + ('setup', ['cmd Alice give @s minecraft:netherite_spear[enchantments={lunge:3}]', 'arc Alice -1']), + ('ground', ['stab Alice']), + ('same-tick', ['arc Alice 0']), + ('rising', ['arc Alice 3']), + ('falling', ['arc Alice 8']), + # jumpFromGround gives a sprinting server copy 0.2 forward too. + ('sprinting', ['sprint Alice', 'arc Alice 3']), +] + + +def shown(lines): + """The motions Alice is sent from the first jab on.""" + return [line for line in lines if len(parts := line.split(' ', 3)) >= 3 and parts[1] != 'step=setup' + and parts[2] == 'motion'] + + +def check(vanilla, vanilla_all): + steps = {line.split()[1] for line in vanilla} + missing = {f'step={step}' for step, _ in SCRIPT[1:]} - steps + return f'vanilla sent no motion in {sorted(missing)}' if missing else None + + +def main(): + lines = locator.one_player(NAME, SCRIPT, shown, check, "A spear's Lunge standing and at three points of a jump " + 'on a dedicated server: the motion the player is sent.', step_seconds=2.0) + print(f'lunge motion: {len(lines)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From 525d7366eb7f76f647bb346a9d980c519bd8689e Mon Sep 17 00:00:00 2001 From: Teiwazik Date: Fri, 30 Oct 2026 09:44:43 +0100 Subject: [PATCH 337/968] Pack overlay: the read flag has no use on Unix Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH --- crates/horizon-client-render/src/packs/cache.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/horizon-client-render/src/packs/cache.rs b/crates/horizon-client-render/src/packs/cache.rs index 5f369dfd25..780e528f36 100644 --- a/crates/horizon-client-render/src/packs/cache.rs +++ b/crates/horizon-client-render/src/packs/cache.rs @@ -85,6 +85,8 @@ fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> { #[cfg(unix)] { use std::os::unix::fs::MetadataExt; + // The change time stands in for the bytes here. + let _ = read; hash.update(meta.dev().to_le_bytes()); hash.update(meta.ino().to_le_bytes()); hash.update(meta.ctime().to_le_bytes()); From 57eac62cd2a8fc55cbc6d6d33c2901c196b1e5d4 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:47:34 +0530 Subject: [PATCH 338/968] Lift the thrown eye of ender by the item ground transform Vanilla draws eye of ender with ThrownItemRenderer(context, 1.0F, true), which only scales and billboards; the visible lift comes from the item model. item/ender_eye parents item/generated with no display override, so its ground transform's translation [0, 2, 0] applies: 2/16 of a block, outside the 0.5 scale, along the camera's up axis. Horizon's hand-rolled billboard centred on the entity, so the eye sat 0.125 blocks low. The quad maths moves into eye_quad with a named GROUND_LIFT and tests that pin both the offset and its axis (a tilted camera must move the quad along its own up, not world Y). --- crates/horizon-client/src/game/end.rs | 100 ++++++++++++++++++++------ 1 file changed, 80 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 60e98abfac..2bda9e0866 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -1435,28 +1435,9 @@ impl Game<'_> { pitch.cos(), yaw.cos() * pitch.sin(), ) * 0.25; - let full = (15 * 16) | ((15 * 16) << 8); for eye in &self.end.eyes { let p = eye.previous + (eye.position - eye.previous) * alpha - camera; - let c = p.as_vec3(); - let corners = [ - (c - right - up, [r.u0, r.v1]), - (c + right - up, [r.u1, r.v1]), - (c + right + up, [r.u1, r.v0]), - (c - right + up, [r.u0, r.v0]), - ]; - for (pos, uv) in corners { - out.push(Vertex { - normal: [0; 4], - pos: pos.to_array(), - uv, - layer: 0, - color: 0xFFFF_FFFF, - light: full, - - glint_uv: [0.0, 0.0], - }); - } + out.extend(eye_quad(p.as_vec3(), right, up, r)); } } @@ -1649,6 +1630,40 @@ impl Game<'_> { } } +/// item/generated's ground display `translation [0, 2, 0]` is 2/16 of a block, and +/// ThrownItemRenderer draws the eye at scale 1.0. +const GROUND_LIFT: f32 = 0.125; + +/// `ThrownItemRenderer.submit`: the four camera-facing vertices of an eye of ender at `at` (the +/// entity's position; the quad itself is lifted above it). `right` and `up` are the camera's axes +/// times the quad's 0.25 half extent. +fn eye_quad( + at: glam::Vec3, + right: glam::Vec3, + up: glam::Vec3, + r: horizon_client::hud::Rect, +) -> [Vertex; 4] { + // The lift is along the billboard's own up axis (the camera's up, so world +Y only at pitch 0): + // item/ender_eye parents item/generated with no display override, so its ground transform's + // `translation [0, 2, 0]` = 0.125 blocks applies here. + let c = at + up.normalize() * GROUND_LIFT; + [ + (c - right - up, [r.u0, r.v1]), + (c + right - up, [r.u1, r.v1]), + (c + right + up, [r.u1, r.v0]), + (c - right + up, [r.u0, r.v0]), + ] + .map(|(pos, uv)| Vertex { + normal: [0; 4], + pos: pos.to_array(), + uv, + layer: 0, + color: 0xFFFF_FFFF, + light: (15 * 16) | ((15 * 16) << 8), + glint_uv: [0.0, 0.0], + }) +} + #[cfg(test)] mod tests { use super::*; @@ -1740,4 +1755,49 @@ mod tests { ); } } + + /// The eye sprite's atlas region; only its UVs are used here. + fn eye_rect() -> horizon_client::hud::Rect { + horizon_client::hud::Rect { + u0: 0.0, + v0: 0.0, + u1: 1.0, + v1: 1.0, + w: 16, + h: 16, + } + } + + #[test] + fn a_thrown_eye_is_lifted_by_the_item_ground_transform() { + let right = glam::Vec3::X * 0.25; + let up = glam::Vec3::Y * 0.25; + let quad = eye_quad(glam::Vec3::new(0.0, 100.0, 0.0), right, up, eye_rect()); + let corners = quad.map(|v| glam::Vec3::from_array(v.pos)); + let centre = corners.iter().copied().sum::() / 4.0; + let low = corners.iter().copied().reduce(glam::Vec3::min).unwrap(); + let high = corners.iter().copied().reduce(glam::Vec3::max).unwrap(); + // The eye at y = 100 + item/generated's 0.125 lift. + assert!( + centre.abs_diff_eq(glam::Vec3::new(0.0, 100.125, 0.0), 1e-5), + "{centre}" + ); + assert_eq!((low.x, high.x), (-0.25, 0.25)); + assert_eq!((high.x - low.x) * 0.5, 0.25); + assert_eq!((high.y - low.y) * 0.5, 0.25); + // ThrownItemRenderer draws the eye full-bright. + assert!(quad.iter().all(|v| v.light == 0xF0F0)); + } + + #[test] + fn a_thrown_eye_is_lifted_along_the_cameras_up_axis() { + // A camera tilted down 45 degrees: its up axis is (0, 1, 1)/sqrt(2), so the lift must + // move the quad along that axis, not straight up world Y. + let up_dir = glam::Vec3::new(0.0, 1.0, 1.0).normalize(); + let at = glam::Vec3::new(0.0, 100.0, 0.0); + let quad = eye_quad(at, glam::Vec3::X * 0.25, up_dir * 0.25, eye_rect()); + let corners = quad.map(|v| glam::Vec3::from_array(v.pos)); + let centre = corners.iter().copied().sum::() / 4.0; + assert!((centre - at).abs_diff_eq(up_dir * 0.125, 1e-5), "{centre}"); + } } From dd0a1e4972864e755ce54693b4154c17f9b2e390 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:48:48 +0530 Subject: [PATCH 339/968] Keep the sheep undercoat when the sheep is sheared Vanilla's two wool layers disagree about shearing: SheepWoolLayer returns early on a sheared sheep, while SheepWoolUndercoatLayer has no sheared test at all (its guard is invisibility, jeb_ or a non-white colour, and adult). Horizon gated both pushes behind one !sheared arm, so a sheared dyed sheep drew nothing at all. The sheared guard now wraps only the wool push; the undercoat keeps its existing conditions and is pushed first, as before. Unsheared output is unchanged, and the test asserts (model, texture, colour) for the sheared dyed, sheared white, baby and unsheared cases. --- crates/horizon-client/src/mobs/render.rs | 86 +++++++++++++++++++----- 1 file changed, 69 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 68cdf77a6b..cabe02192e 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -656,7 +656,9 @@ pub fn layers(m: &Mob) -> Vec { } // SlimeOuterLayer: the translucent outer cube. S::Slime => push(&mut v, "slime#outer", "entity/slime/slime", white, false), - S::Sheep if !m.extra.sheared => { + // SheepWoolUndercoatLayer has no isSheared guard: a sheared dyed sheep + // keeps it, while only SheepWoolLayer stops. + S::Sheep => { let color = abgr(sheep_wool_rgb(m.extra.color)); if !m.is_baby() && m.extra.color != 0 { push( @@ -667,22 +669,14 @@ pub fn layers(m: &Mob) -> Vec { false, ); } - if m.is_baby() { - push( - &mut v, - "sheep_baby#wool", - "entity/sheep/sheep_wool_baby", - color, - false, - ); - } else { - push( - &mut v, - "sheep#wool", - "entity/sheep/sheep_wool", - color, - false, - ); + if !m.extra.sheared { + let model = baby_or(m, "sheep#wool", "sheep_baby#wool"); + let texture = if m.is_baby() { + "entity/sheep/sheep_wool_baby" + } else { + "entity/sheep/sheep_wool" + }; + push(&mut v, &model, texture, color, false); } } S::Creaking if super::creaking::eyes_glowing(m) => push( @@ -2052,6 +2046,64 @@ mod tests { // A layer that is not dyeable is not tinted. assert_eq!(armor_tint(None, Some(&stack)), 0xFFFF_FFFF); } + + /// `SheepWoolLayer.submit` returns early on a sheared sheep, but + /// `SheepWoolUndercoatLayer.submit` has no isSheared guard: a sheared + /// dyed sheep keeps its undercoat and loses only its wool. + #[test] + fn a_sheared_dyed_sheep_keeps_its_wool_undercoat() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("entity-models.json").exists() { + return; + } + let types = super::super::MobTypes::load(&root).unwrap(); + let kind = std::sync::Arc::new(types.types["minecraft:sheep"].clone()); + let sheep = |color: u8, sheared: bool, baby: bool| { + let mut m = Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0); + m.extra.color = color; + m.extra.sheared = sheared; + m.extra.age = if baby { -24000 } else { 0 }; + m + }; + // The sheep's wool layers, model, texture and colour, in submission order. + let wool = |m: &Mob| -> Vec<(String, String, u32)> { + layers(m) + .into_iter() + .filter(|l| l.model.contains("#wool")) + .map(|l| (l.model, l.texture, l.color)) + .collect() + }; + let dyed = abgr(sheep_wool_rgb(1)); + let layer = |model: &str, texture: &str| (model.to_owned(), texture.to_owned(), dyed); + + // The regression: the pre-fix single `!sheared` guard skipped both + // layers, so a sheared dyed sheep drew nothing. It must keep the + // undercoat alone, in its own colour and texture. + assert_eq!( + wool(&sheep(1, true, false)), + [layer( + "sheep#wool_undercoat", + "entity/sheep/sheep_wool_undercoat" + )] + ); + // Guards, already green before the fix: a sheared white sheep has no + // undercoat to keep, a baby has no undercoat at all, and an unsheared + // dyed sheep draws both layers as before. + assert!(wool(&sheep(0, true, false)).is_empty()); + assert_eq!( + wool(&sheep(1, false, true)), + [layer("sheep_baby#wool", "entity/sheep/sheep_wool_baby")] + ); + assert!(wool(&sheep(1, true, true)).is_empty()); + assert_eq!( + wool(&sheep(1, false, false)), + [ + layer("sheep#wool_undercoat", "entity/sheep/sheep_wool_undercoat"), + layer("sheep#wool", "entity/sheep/sheep_wool") + ] + ); + } } #[cfg(test)] From 48055aa98f085d9927207f3268d13b521b24719b Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 09:48:55 +0100 Subject: [PATCH 340/968] Fireworks: a plain rocket shot at an angle flies past the first block FireworkRocketEntity.onHitBlock explodes only a rocket with explosions (hasExplosion). A plain rocket out of a crossbow or a dispenser went off with a poof at the first block it touched; it now flies on to its lifetime, as in vanilla. Rockets with explosions still burst on impact. lan_e2e's rocket-walls check covers both. Fixes #4281 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/fireworks.rs | 9 ++--- tools/lan_e2e.py | 37 +++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index df6771d184..2ec6f57221 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -43,8 +43,8 @@ pub(super) struct Rocket { /// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows; /// boosting happens when it is the local player. pub(super) attached_to: Option, - /// `DATA_SHOT_AT_ANGLE` (dispensers): constant velocity, exploding on - /// the first block it hits. + /// `DATA_SHOT_AT_ANGLE` (dispensers, crossbows): constant velocity, + /// exploding on the first block it hits when it has explosions. pub(super) angled: bool, /// `DATA_ID_FIREWORKS_ITEM`: the stack it was launched from. pub(super) item: ItemStack, @@ -334,8 +334,9 @@ impl Game<'_> { binding: self.collision, subject: None, }; - if r.free_flight(&mut scene) && r.angled { - // hitTargetOrDeflectSelf: explode on the block. + if r.free_flight(&mut scene) && r.angled && !r.explosions.is_empty() { + // onHitBlock: only a rocket with explosions (hasExplosion) + // goes off on the block; a plain one flies on to its lifetime. r.life = r.lifetime; } } diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..a554cc29b2 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -328,7 +328,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sp 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', - 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks'] + 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks', 'rocket-walls'] class Game: @@ -3552,6 +3552,38 @@ def check_fireworks(host, guest, out): return {'flashes': flashes, 'red_burst': burst, 'red_trail_fade': trails, 'rockets': rockets} +def check_rocket_walls(host, guest, out): + """A rocket shot at an angle explodes on a block only with explosions (#4281). + + FireworkRocketEntity.onHitBlock: `hasExplosion()`; a plain one flies on to its lifetime (20-31 ticks). A + dispenser shoots at an angle as a crossbow does, 4 ticks after it is powered, at a wall three blocks away. + """ + x, y, z = (int(v // 1) for v in pos_of(host)) + y += 3 + host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y + 2} {z + 6} minecraft:air') + host('command', command=f'fill {x - 2} {y - 1} {z + 5} {x + 2} {y + 2} {z + 5} minecraft:stone') + plain = 'minecraft:firework_rocket' + star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]' + result = {} + for label, item in (('plain', plain), ('star', star)): + host('command', command=f'setblock {x} {y} {z + 1} minecraft:air') + host('command', command=f'setblock {x} {y} {z} minecraft:dispenser[facing=south]') + host('command', command=f'item replace block {x} {y} {z} container.0 with {item}') + host.wait(5) + host('command', command=f'setblock {x} {y} {z + 1} minecraft:air') + host('command', command=f'setblock {x} {y + 1} {z} minecraft:redstone_block') + host.wait(18) + hit = {g.name: g('status')['rockets'] for g in (host, guest)} + host.wait(30) + later = {g.name: g('status')['rockets'] for g in (host, guest)} + host('command', command=f'setblock {x} {y + 1} {z} minecraft:air') + result[label] = {'after_wall': hit, 'after_lifetime': later} + assert not any(later.values()), f'{label} rocket outlived its lifetime: {later}' + assert all(n == 1 for n in result['plain']['after_wall'].values()), f'plain rocket went off on the wall: {result}' + assert not any(result['star']['after_wall'].values()), f'star rocket did not explode on the wall: {result}' + return result + + def check_sparks(host, guest, out): spots = apart(host, guest) result = {} @@ -5407,7 +5439,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'hud': check_hud, 'sidebar': check_sidebar, 'border': check_border, - 'particles': check_particles, 'fireworks': check_fireworks} + 'particles': check_particles, 'fireworks': check_fireworks, + 'rocket-walls': check_rocket_walls} def run(ctl, out, resources, checks): From 5d4016ca61d43535ab545982fd8429bd9a32064d Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 10:06:55 +0100 Subject: [PATCH 341/968] lan_e2e rocket-walls: count the rocket every tick in one ctl process Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index a554cc29b2..c456302faf 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -3556,7 +3556,8 @@ def check_rocket_walls(host, guest, out): """A rocket shot at an angle explodes on a block only with explosions (#4281). FireworkRocketEntity.onHitBlock: `hasExplosion()`; a plain one flies on to its lifetime (20-31 ticks). A - dispenser shoots at an angle as a crossbow does, 4 ticks after it is powered, at a wall three blocks away. + dispenser shoots at an angle as a crossbow does, at a wall three blocks away (about 7 ticks of flight). The + host counts the rocket every tick in one ctl process, so how long it lived doesn't depend on the CLI's speed. """ x, y, z = (int(v // 1) for v in pos_of(host)) y += 3 @@ -3566,21 +3567,19 @@ def check_rocket_walls(host, guest, out): star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]' result = {} for label, item in (('plain', plain), ('star', star)): - host('command', command=f'setblock {x} {y} {z + 1} minecraft:air') + host('command', command=f'setblock {x} {y + 1} {z} minecraft:air') host('command', command=f'setblock {x} {y} {z} minecraft:dispenser[facing=south]') host('command', command=f'item replace block {x} {y} {z} container.0 with {item}') host.wait(5) - host('command', command=f'setblock {x} {y} {z + 1} minecraft:air') - host('command', command=f'setblock {x} {y + 1} {z} minecraft:redstone_block') - host.wait(18) - hit = {g.name: g('status')['rockets'] for g in (host, guest)} - host.wait(30) - later = {g.name: g('status')['rockets'] for g in (host, guest)} - host('command', command=f'setblock {x} {y + 1} {z} minecraft:air') - result[label] = {'after_wall': hit, 'after_lifetime': later} - assert not any(later.values()), f'{label} rocket outlived its lifetime: {later}' - assert all(n == 1 for n in result['plain']['after_wall'].values()), f'plain rocket went off on the wall: {result}' - assert not any(result['star']['after_wall'].values()), f'star rocket did not explode on the wall: {result}' + replies = host.script([f'command setblock {x} {y + 1} {z} minecraft:redstone_block'] + + ['wait 1', 'status'] * 45, every=True) + counts = [r['rockets'] for r in replies if 'rockets' in r] + result[label] = {'ticks_seen': sum(1 for n in counts if n), 'last': counts[-1], + 'guest_last': guest('status')['rockets']} + host('command', command=f'setblock {x} {y + 1} {z} minecraft:air') + assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), f'a rocket outlived its lifetime: {result}' + assert result['plain']['ticks_seen'] >= 18, f'plain rocket went off on the wall: {result}' + assert 0 < result['star']['ticks_seen'] < 15, f'star rocket did not explode on the wall: {result}' return result From 835733c7fcfb8e694f6fe02d6b95829b7e83b758 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 10:09:18 +0100 Subject: [PATCH 342/968] lan_e2e rocket-walls: measure each rocket's life in client ticks Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index c456302faf..3ac31dbf66 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -3557,7 +3557,8 @@ def check_rocket_walls(host, guest, out): FireworkRocketEntity.onHitBlock: `hasExplosion()`; a plain one flies on to its lifetime (20-31 ticks). A dispenser shoots at an angle as a crossbow does, at a wall three blocks away (about 7 ticks of flight). The - host counts the rocket every tick in one ctl process, so how long it lived doesn't depend on the CLI's speed. + host samples its status in one ctl process; a rocket's span is the client ticks between the first and the + last sample that saw it, never more than it lived, and short of it by at most two gaps between samples. """ x, y, z = (int(v // 1) for v in pos_of(host)) y += 3 @@ -3573,13 +3574,17 @@ def check_rocket_walls(host, guest, out): host.wait(5) replies = host.script([f'command setblock {x} {y + 1} {z} minecraft:redstone_block'] + ['wait 1', 'status'] * 45, every=True) - counts = [r['rockets'] for r in replies if 'rockets' in r] - result[label] = {'ticks_seen': sum(1 for n in counts if n), 'last': counts[-1], - 'guest_last': guest('status')['rockets']} + samples = [(r['ticks'], r['rockets']) for r in replies if 'rockets' in r] + seen = [tick for tick, n in samples if n] + result[label] = {'span': seen[-1] - seen[0] if seen else None, 'samples': len(samples), + 'last': samples[-1][1], 'guest_last': guest('status')['rockets']} host('command', command=f'setblock {x} {y + 1} {z} minecraft:air') - assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), f'a rocket outlived its lifetime: {result}' - assert result['plain']['ticks_seen'] >= 18, f'plain rocket went off on the wall: {result}' - assert 0 < result['star']['ticks_seen'] < 15, f'star rocket did not explode on the wall: {result}' + assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), ( + f'a rocket outlived its lifetime: {result}') + # A plain rocket lives 20-31 ticks; one with explosions is gone at the wall. + assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the wall: {result}' + assert result['star']['span'] is not None and result['star']['span'] < 14, ( + f'star rocket did not explode on the wall: {result}') return result From c0f16e65e2601d611e9f69b3758722a03b4bbd73 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:52:26 +0530 Subject: [PATCH 343/968] Hang nametags from the pose box, not the sneak flag Vanilla anchors a name tag at the entity's NAME_TAG attachment, (0, bbHeight, 0) of the box of the current pose, and the submitter lifts it 0.5 more. The sneak flag only decides isDiscrete (the see-through pass). Horizon took the height from the flag for players, so a player swimming or gliding with shift held (pose box 0.6, shared crouch bit set) got a tag at 2.0 instead of 1.1, and riding with the flag gave 2.0 against vanilla's 2.3. The height now comes from m.height(), which reads the pose box. That branch was only needed because the local player's model never filled extra.avatar_pose (guests and server players get it from DATA_POSE), so tick_player_model now stores the pose it already computes. Crouching and standing heights are unchanged at 2.0 and 2.3. --- crates/horizon-client/src/game/names.rs | 113 ++++++++++++++++-- crates/horizon-client/src/game/perspective.rs | 5 +- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index 4d1d1b0f72..6bb6cf8b12 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -122,17 +122,8 @@ impl Game<'_> { if !m.alive() { continue; } - // EntityAttachment.NAME_TAG: 0.5 above the (crouching) height. - let height = if player && m.extra.crouching { - 1.5 - } else { - f64::from(m.height()) - }; - let p = glam::DVec3::new( - m.previous.x + (m.position.x - m.previous.x) * alpha, - m.previous.y + (m.position.y - m.previous.y) * alpha + height + 0.5, - m.previous.z + (m.position.z - m.previous.z) * alpha, - ); + // EntityAttachment.NAME_TAG: 0.5 above the box of the current pose. + let p = name_tag_at(m, alpha); // The name tag distance (64) is measured to the entity. let distance_sqr = glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera); @@ -203,7 +194,7 @@ impl Game<'_> { lines.push((shown, lift)); let l = self.world.light( m.position.x.floor() as i32, - (m.position.y + height).floor() as i32, + (m.position.y + f64::from(m.height())).floor() as i32, m.position.z.floor() as i32, ); tags.push(NameTag { @@ -419,6 +410,19 @@ impl Game<'_> { } } +/// `EntityAttachment.NAME_TAG`: `(0, bbHeight, 0)` of the box of the entity's +/// current pose, which the submitter lifts 0.5 more, between the last two tick +/// positions. The player box is `Pose::dimensions(1.0)`. +/// shortcut: the scale attribute is not in the tag height, add `model_scale` +/// when tags on scaled players matter. +fn name_tag_at(m: &Mob, alpha: f64) -> glam::DVec3 { + glam::DVec3::new( + m.previous.x + (m.position.x - m.previous.x) * alpha, + m.previous.y + (m.position.y - m.previous.y) * alpha + f64::from(m.height()) + 0.5, + m.previous.z + (m.position.z - m.previous.z) * alpha, + ) +} + /// A name tag: where it hangs, its lines (text, lift), the packed light at /// the entity and whether it is discrete. struct NameTag { @@ -427,3 +431,88 @@ struct NameTag { light: u8, discrete: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use horizon_client::mobs::{Anim, Brain, MobType, Part, Species}; + use horizon_client::pose::Pose; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + /// The player model `perspective::player_type` builds from the prepared + /// resources, without them: its box only needs the id and the pointer. + fn player_mob() -> Mob { + let part = Part { + name: "root".into(), + pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0], + polygons: Vec::new(), + children: Vec::new(), + }; + let kind = MobType { + id: "minecraft:player".into(), + species: Species::Zombie, + width: 0.6, + height: 1.8, + eye_height: 1.62, + category: "misc".into(), + max_health: 20.0, + speed: 0.1, + flying_speed: 0.02, + attack_damage: 1.0, + attack_knockback: 0.0, + follow_range: 0.0, + step_height: 0.6, + armor: 0.0, + armor_toughness: 0.0, + knockback_resistance: 0.0, + jump_strength: 0.42, + attributes: Default::default(), + gravity: 0.08, + safe_fall_distance: 3.0, + fall_damage_multiplier: 1.0, + water_efficiency: 0.0, + water_slowdown: 0.8, + air_speed: 0.02, + baby_scale: 0.5, + head_rot_speed: 10.0, + max_head_x_rot: 40.0, + max_head_y_rot: 75.0, + ambient_interval: 80, + flags: Default::default(), + model: Arc::new(part), + layers: Default::default(), + texture: String::new(), + brain: Brain::Passive, + anim: Anim::Humanoid { zombie_arms: false }, + sounds: [ + "entity.player.hurt", + "entity.player.hurt", + "entity.player.death", + ], + step_sound: None, + food: Default::default(), + tempt: Default::default(), + }; + Mob::new(0, Arc::new(kind), Vec3::ZERO, 0.0) + } + + /// #4129: the tag hangs from the box of the pose (0.6 swimming, 1.5 + /// crouching, 1.8 standing) and never from the sneak flag, which a + /// sprint-swimming or gliding player still has set. Today's + /// `if player && m.extra.crouching { 1.5 }` gives 2.0 for the first. + #[test] + fn a_name_tag_hangs_from_the_pose_box_not_the_sneak_flag() { + let mut m = player_mob(); + let at_y = |m: &Mob| name_tag_at(m, 1.0).y; + m.extra.crouching = true; + m.extra.avatar_pose = Pose::Swimming; + assert!((at_y(&m) - 1.1).abs() < 1e-6, "0.6 + 0.5, not 2.0"); + // A crouching player's tag keeps the height it had: 1.5 + 0.5. + m.extra.avatar_pose = Pose::Crouching; + assert!((at_y(&m) - 2.0).abs() < 1e-6, "1.5 + 0.5, as before"); + m.extra.avatar_pose = Pose::Standing; + m.extra.crouching = false; + assert!((at_y(&m) - 2.3).abs() < 1e-6, "1.8 + 0.5"); + } +} diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 6655fd899f..5d15ef95f2 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -345,7 +345,8 @@ impl Game<'_> { m.head_pitch = self.pitch; self.player_model = Some(m); } - let crouching = p.pose == Pose::Crouching; + let pose = p.pose; + let crouching = pose == Pose::Crouching; let position = p.position; let (fall_flying, fly_ticks, velocity) = (p.fall_flying, p.fall_fly_ticks, p.velocity); let spinning = p.auto_spin_attack_ticks > 0; @@ -441,6 +442,8 @@ impl Game<'_> { // The worn stacks, for the elytra's glint (`WingsLayer`). m.extra.armor = armor; m.extra.crouching = crouching; + // Guests and server players take their pose from DATA_POSE, not here. + m.extra.avatar_pose = pose; m.extra.passenger = riding; m.velocity = velocity; m.extra.fall_flying = fall_flying; From 7c5de4663594fe14cdeaaecef37bc04197281247 Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 12:58:14 +0330 Subject: [PATCH 344/968] Render: tight chunk section bounds for resident frustum culling Compute the exact axis-aligned bounding box of a section's terrain mesh vertices during meshing and use it during resident frustum culling. Sections containing flat terrain (e.g. flat floors or water surfaces) or geometry that does not touch the camera frustum are culled without drawing, eliminating false positives from the fallback 16x16x16 bounding box. Co-Authored-By: Antigravity --- crates/horizon-client-render/src/gfx.rs | 2 +- .../horizon-client-render/src/gfx/resident.rs | 61 +++++++++++++++++-- crates/horizon-client-render/src/mesher.rs | 25 ++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 9fe2f75510..19235d4a13 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -3031,7 +3031,7 @@ impl Renderer { section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()), ); if let Some(section) = section { - self.sections.insert(key, section); + self.sections.insert_with_bounds(key, section, mesh.bounds); } } fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option { diff --git a/crates/horizon-client-render/src/gfx/resident.rs b/crates/horizon-client-render/src/gfx/resident.rs index eb5727caa4..2e5355bd25 100644 --- a/crates/horizon-client-render/src/gfx/resident.rs +++ b/crates/horizon-client-render/src/gfx/resident.rs @@ -30,6 +30,7 @@ pub struct Resident { /// The minimum Y of section index 0 (the level's column). min_y: i32, items: Vec<(SectionKey, T)>, + bounds: Vec>, slots: FxHashMap, columns: Vec, column_index: FxHashMap<(i32, i32), u32>, @@ -40,6 +41,7 @@ impl Default for Resident { Self { min_y: crate::chunk::MIN_Y, items: Vec::new(), + bounds: Vec::new(), slots: FxHashMap::default(), columns: Vec::new(), column_index: FxHashMap::default(), @@ -70,18 +72,26 @@ impl Resident { } pub fn clear(&mut self) { self.items.clear(); + self.bounds.clear(); self.slots.clear(); self.columns.clear(); self.column_index.clear(); } - /// Adds or replaces `key`'s value. + /// Adds or replaces `key`'s value with default 16x16x16 bounds. + #[allow(dead_code)] pub fn insert(&mut self, key: SectionKey, value: T) { + self.insert_with_bounds(key, value, None); + } + /// Adds or replaces `key`'s value with optional tight geometry bounds. + pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) { if let Some(&slot) = self.slots.get(&key) { self.items[slot as usize].1 = value; + self.bounds[slot as usize] = bounds; return; } let slot = self.items.len() as u32; self.items.push((key, value)); + self.bounds.push(bounds); self.slots.insert(key, slot); let column = match self.column_index.get(&(key.0, key.2)) { Some(&c) => &mut self.columns[c as usize], @@ -121,6 +131,7 @@ impl Resident { } else { column.bounds(); } + let _ = self.bounds.swap_remove(slot as usize); let (_, value) = self.items.swap_remove(slot as usize); // The last item now sits in `slot`: point its key and column at it. if let Some(&(moved, _)) = self.items.get(slot as usize) { @@ -176,7 +187,7 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op if !in_view_distance(camera_column(camera), radius, key.0, key.2) { return None; } - in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false) + in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false, None) } /// `in_view` without the distance test; `inside`: the section's column is @@ -187,10 +198,15 @@ fn in_frustum( camera: DVec3, frustum: &Frustum, inside: bool, + bounds: Option<[Vec3; 2]>, ) -> Option<(f64, Vec3)> { let origin = origin(key, min_y, camera); let min = origin.as_vec3(); - if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) { + let (bmin, bmax) = match bounds { + Some([bmin, bmax]) => (min + bmin, min + bmax), + None => (min, min + Vec3::splat(16.0)), + }; + if !inside && !frustum.intersects(bmin, bmax) { return None; } Some(((origin + DVec3::splat(8.0)).length_squared(), min)) @@ -236,7 +252,8 @@ impl Resident { if near && !reachable(key) { continue; } - if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) { + let bounds = self.bounds[slot as usize]; + if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside, bounds) { out.push(Visible { distance: d.to_bits(), key, @@ -294,7 +311,7 @@ mod tests { if near { in_view(k, camera, radius, &frustum).filter(|_| reachable(k)) } else if beyond(k.0, k.2) { - in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false) + in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false, None) } else { None } @@ -463,4 +480,38 @@ mod tests { list.len() ); } + + #[test] + fn tight_mesh_bounds_culls_section_outside_frustum() { + let mut r = Resident::default(); + let key = (0, 0, 0); + // Section geometry only occupies local Y in [0.0, 2.0] + let tight = Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, 2.0, 16.0)]); + r.insert_with_bounds(key, (), tight); + + let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 15), -2.0); + let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0); + let view = crate::camera::view_matrix(0.0, -60.0); + let frustum = Frustum { + planes: super::super::frustum(projection * view), + }; + + // Tight bounds culls the section whose geometry is entirely outside the frustum + let mut out = Vec::new(); + r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out); + assert!( + out.is_empty(), + "tight bounds should cull section whose geometry is below frustum" + ); + + // With fallback 16x16x16 bounds, the section intersects the frustum and is visible + r.insert_with_bounds(key, (), None); + out.clear(); + r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out); + assert_eq!( + out.len(), + 1, + "full 16x16x16 bounds intersect frustum and are accepted" + ); + } } diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs index dc40c0722b..5bd54c505b 100644 --- a/crates/horizon-client-render/src/mesher.rs +++ b/crates/horizon-client-render/src/mesher.rs @@ -158,6 +158,8 @@ pub struct SectionMesh { /// each frame by `block_entity_render` like vanilla's per-section /// `SectionCompiler.Results.blockEntities`. pub block_entities: Vec<([i32; 3], u32)>, + /// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16]. + pub bounds: Option<[glam::Vec3; 2]>, } impl Default for SectionMesh { fn default() -> Self { @@ -167,6 +169,7 @@ impl Default for SectionMesh { translucent: Vec::new(), visibility: ALL_VISIBLE, block_entities: Vec::new(), + bounds: None, } } } @@ -175,6 +178,28 @@ impl SectionMesh { /// leave out those facing away from the eye (`sides`). pub fn sort_solid(&mut self) { self.sides = sides::sort(&mut self.solid); + self.bounds = self.compute_bounds(); + } + + /// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices. + pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> { + if self.solid.is_empty() && self.translucent.is_empty() { + return None; + } + let mut min = glam::Vec3::splat(f32::INFINITY); + let mut max = glam::Vec3::splat(f32::NEG_INFINITY); + for v in &self.solid { + let p = glam::Vec3::from(v.pos); + min = min.min(p); + max = max.max(p); + } + for v in &self.translucent { + let p = glam::Vec3::from(v.pos); + min = min.min(p); + max = max.max(p); + } + let pad = glam::Vec3::splat(0.05); + Some([min - pad, max + pad]) } } pub const ALL_VISIBLE: u64 = (1 << 36) - 1; From 5455bc98f4190db60ce0a967adc9384d5bea56e4 Mon Sep 17 00:00:00 2001 From: Teiwazik Date: Fri, 30 Oct 2026 10:23:52 +0100 Subject: [PATCH 345/968] Resource pack screen: find packs added to the folder while it is open PackSelectionScreen watches resourcepacks/ and lists the packs again when its contents change. Horizon listed them once, when the screen opened: a pack copied in after needed the screen closed and opened again. The folder's entries (name, size, write time) are now compared once a second while the resource pack screen is open; on a change the screen is made again over the new list, keeping the selection made on it so far. Create World's data packs, on the same page, are left alone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH --- .../horizon-client-render/src/options_ui.rs | 27 +++++++++++- crates/horizon-client/src/game.rs | 7 ++++ crates/horizon-client/src/game/options.rs | 41 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 3ec3fd8255..c029610f95 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -1188,6 +1188,9 @@ pub struct OptionsUi { pub resource_error: Option, /// When the pack screen's search box took the focus (its cursor blink). pack_focus: Instant, + /// The selection carried over from a pack screen made again over a changed pack list + /// (`update_pack_infos`) differs from the settings'. + pack_reselected: bool, pub in_world: bool, /// Scroll the selected language into the middle on the next layout. center_language: bool, @@ -1223,6 +1226,7 @@ impl Default for OptionsUi { data_pack_stamp: (Vec::new(), Instant::now()), resource_error: None, pack_focus: Instant::now(), + pack_reselected: false, in_world: false, center_language: false, graphics: Default::default(), @@ -1308,6 +1312,23 @@ impl OptionsUi { self.settings_search.focused && matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds) } + /// Whether the resource pack screen is open (not Create World's data packs on the same + /// page). + pub fn resource_packs_open(&self) -> bool { + self.page() == Page::Packs && self.data_pack_dir.is_none() + } + /// The packs in `resourcepacks/` changed while the pack screen is open + /// (`PackSelectionScreen`'s directory watcher): the screen is made again over + /// `infos`, keeping the selection made on it so far. + pub fn update_pack_infos(&mut self, infos: Vec) { + if let Some(p) = self.pack_screen.take() + && p.changed + { + self.pack_selection = p.commit(); + self.pack_reselected = true; + } + self.pack_infos = infos; + } pub fn page(&self) -> Page { *self.stack.last().unwrap_or(&Page::Main) } @@ -3036,7 +3057,7 @@ impl OptionsUi { } else { "resourcePack.title" }; - self.pack_screen = Some(crate::pack_screen::PackScreen::new( + let mut screen = crate::pack_screen::PackScreen::new( &m, crate::text::Text::translate(title, &[]), w, @@ -3044,7 +3065,9 @@ impl OptionsUi { self.in_world, self.pack_infos.clone(), &self.pack_selection, - )); + ); + screen.changed = std::mem::take(&mut self.pack_reselected); + self.pack_screen = Some(screen); } } self.pack_screen.as_mut().expect("made above") diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c782c568fc..7c6fd67cd1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1521,6 +1521,12 @@ struct Game<'a> { pending_entry: Option, world_loader: Option, pending_pack_reload: bool, + /// When `resourcepacks/` was last looked at for the open pack screen, and what it held + /// (`watch_pack_folder`). + pack_folder: Option<( + std::time::Instant, + Vec<(String, u64, Option)>, + )>, /// A server's resource packs (game/server_packs.rs). server_packs: server_packs::ServerPackState, /// The link a chat click asks to open (`ConfirmLinkScreen`). @@ -14448,6 +14454,7 @@ impl<'a> Game<'a> { pending_entry: None, world_loader: None, pending_pack_reload: false, + pack_folder: None, server_packs: Default::default(), link_prompt: None, code_of_conduct: None, diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 9c9c93ffc3..993a1eb70e 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -112,7 +112,48 @@ impl Game<'_> { ui.languages = lang::available(&[&root]); self.refresh_pack_lists(); } + /// While the resource pack screen is open, `resourcepacks/` is looked at once a second + /// and the list found again when its packs changed (`PackSelectionScreen.tick`'s + /// watcher). + fn watch_pack_folder(&mut self) { + if !self.ui.options.resource_packs_open() { + self.pack_folder = None; + return; + } + let now = std::time::Instant::now(); + if self + .pack_folder + .as_ref() + .is_some_and(|(at, _)| now.duration_since(*at).as_secs_f32() < 1.0) + { + return; + } + let signature: Vec<(String, u64, Option)> = + std::fs::read_dir(super::packs::packs_dir()) + .into_iter() + .flatten() + .flatten() + .map(|e| { + let meta = e.metadata().ok(); + ( + e.file_name().to_string_lossy().into_owned(), + meta.as_ref().map_or(0, |m| m.len()), + meta.and_then(|m| m.modified().ok()), + ) + }) + .collect(); + let changed = self + .pack_folder + .as_ref() + .is_some_and(|(_, seen)| *seen != signature); + self.pack_folder = Some((now, signature)); + if changed { + let infos = super::packs::pack_infos(&self.vanilla_root()); + self.ui.options.update_pack_infos(infos); + } + } pub(super) fn draw_options(&mut self, batch: &mut HudBatch) { + self.watch_pack_folder(); let mut ui = std::mem::take(&mut self.ui.options); ui.draw(&self.settings, batch, &self.gui.atlas, self.cursor); self.ui.options = ui; From 3df5f5d5c4837c94ac834b2bb9a78eda9248dad6 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 12:34:16 +0300 Subject: [PATCH 346/968] Native mods: native_block, the block at a position (API 9) Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 8427fd2eb8..7921d38db2 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -637,6 +637,12 @@ impl Game<'_> { pub(super) fn native_screen_title(&self) -> Option { self.remote_menu_title().map(|t| t.plain()) } + /// The block at `pos` (`mymod:cable`; air where nothing is loaded). + /// API 9. + pub(super) fn native_block(&self, pos: [i32; 3]) -> String { + let state = self.world.block(pos[0], pos[1], pos[2]); + self.res.facts().get(state).name.to_string() + } /// Property `key` of the block at `pos` (a machine's `facing`), if it /// has one. API 9. pub(super) fn native_block_property(&self, pos: [i32; 3], key: &str) -> Option { From d24853c0a0858b955c5fc8ec7d08e96cd17b7651 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:35:54 +0100 Subject: [PATCH 347/968] Send a column once its neighbours reach INITIALIZE_LIGHT (#4294) A server sent a column as soon as it was lit, and its light layers counted only the neighbours already loaded. Vanilla's ChunkStatus.LIGHT needs every neighbour at INITIALIZE_LIGHT, and its light storage counts their sections. So a column loaded with its saved light next to a proto chunk still being made went out without the top light section that proto's blocks add. dedicated_server failed 5 of 5 on playhorizon since an idle dedicated server keeps no spawn area (#4095). A column now waits until each neighbour is loaded or a decorated proto chunk, and a proto neighbour's non-air sections (its TerrainChunk counts, read when sent) go into the light layers. Nothing is kept per column. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/streaming.rs | 22 ++++++ crates/horizon-client/src/game/lan.rs | 2 + crates/horizon-client/src/game/lan_actions.rs | 1 + .../horizon-client/src/game/server_travel.rs | 1 + crates/horizon-client/src/lan/chunks.rs | 67 +++++++++++++++++-- crates/horizon-client/src/lan/hub.rs | 33 ++++++++- crates/horizon-client/src/save.rs | 6 ++ crates/horizon-client/src/vanillagen.rs | 16 +++++ crates/horizon-client/tests/forced_chunks.rs | 12 ++++ crates/horizon-client/tests/lan_bot.rs | 1 + crates/horizon-world/src/worldgen/terrain.rs | 5 ++ 11 files changed, 159 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs index e754355ef1..f3df2edf20 100644 --- a/crates/horizon-client-render/src/streaming.rs +++ b/crates/horizon-client-render/src/streaming.rs @@ -3,6 +3,7 @@ //! hands immutable `Arc` snapshots to workers; results carry a sequence number //! so stale work is dropped. use crate::fast_hash::{FxHashMap, FxHashSet}; +use crate::section_mask::SectionMask; use crate::{ chunk::Chunk, light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk}, @@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static { fn light_view(&self, _proto: &Proto) -> Option { None } + /// A decorated proto chunk's column sections (`Chunk::sections` + /// indexes) holding a block other than air: what its neighbours' light + /// layers count once it is at INITIALIZE_LIGHT. + fn non_air_sections(&self, _proto: &Proto) -> Option { + None + } fn saved_proto(&self, _proto: &Proto) -> Option { None } @@ -275,6 +282,7 @@ impl Mailbox { pub struct Streaming { shared: Arc, light_view: Arc Option + Send + Sync>, + non_air_sections: Arc Option + Send + Sync>, threads: Vec>, order: u64, /// Ticks since the columns and protos that fell out of range were swept. @@ -653,9 +661,14 @@ impl Streaming { let generator = generator.clone(); Arc::new(move |p: &Proto| generator.light_view(p)) }; + let non_air_sections = { + let generator = generator.clone(); + Arc::new(move |p: &Proto| generator.non_air_sections(p)) + }; Self { shared, light_view, + non_air_sections, threads, order: 0, scan_age: 0, @@ -956,6 +969,15 @@ impl Streaming { pub fn is_lit(&self, x: i32, z: i32) -> bool { self.lit.contains(&(x, z)) } + /// The non-air sections of a column that is a decorated proto chunk + /// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level + /// chunk's light layers count them (#4294). + pub fn proto_sections(&self, x: i32, z: i32) -> Option { + if !self.decorated.contains(&(x, z)) { + return None; + } + (self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?) + } /// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the /// furthest step this column has reached, `None` before it is asked for. pub fn latest_status( diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 0cfca976ac..857ace285c 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -1613,6 +1613,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), }; host.hub.movement_checks = ( self.ui.rules.bool("player_movement_check"), @@ -1869,6 +1870,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), }; host.hub.place_new_player(conn, &mut ctx); if elsewhere && let Err(e) = self.enter_level(Some(host), back) { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4f574acd36..b8f4c546e4 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2232,6 +2232,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), }; host.hub.respawn(i, spawn, &ctx); // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index 639ed0f843..51e9c2df8d 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -725,6 +725,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), } } } diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index c900f10a29..f3b40b096f 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -559,13 +559,28 @@ impl ChunkEncoder { bits } - /// The non-air sections of the column and its neighbours, as bits. - fn around(&self, chunk: &Chunk, world: &crate::world::World) -> SectionMask { + /// The non-air sections of the column and its neighbours, as bits. A + /// neighbour still a proto chunk counts too, as vanilla's light storage + /// counts a neighbour at INITIALIZE_LIGHT (#4294). + fn around( + &self, + chunk: &Chunk, + world: &crate::world::World, + protos: &dyn Fn(i32, i32) -> Option, + ) -> SectionMask { let mut around = SectionMask::EMPTY; for dz in -1..=1 { for dx in -1..=1 { - if let Some(c) = world.chunks.get(&(chunk.x + dx, chunk.z + dz)) { + let (x, z) = (chunk.x + dx, chunk.z + dz); + if let Some(c) = world.chunks.get(&(x, z)) { around |= self.occupied(c); + } else if let Some(sections) = protos(x, z) { + // Column sections: this level's range from 0. + for i in sections.iter() { + if self.sections.contains(&i) { + around.insert(i - self.sections.start); + } + } } } } @@ -579,7 +594,21 @@ impl ChunkEncoder { /// sections and light layers go straight to it, with no packet built and /// copied again first. pub fn body_in(&self, chunk: &Chunk, world: &crate::world::World) -> Vec { - self.body(chunk, &world.block_entities, self.around(chunk, world)) + self.body_with(chunk, world, &|_, _| None) + } + /// [`Self::body_in`], with `protos` giving the non-air sections of a + /// neighbour that is still a proto chunk. + pub fn body_with( + &self, + chunk: &Chunk, + world: &crate::world::World, + protos: &dyn Fn(i32, i32) -> Option, + ) -> Vec { + self.body( + chunk, + &world.block_entities, + self.around(chunk, world, protos), + ) } /// Make what these columns need and is not cached yet (their heightmaps @@ -1313,6 +1342,36 @@ mod tests { assert_eq!(second, light_data(&chunk.sections, SectionMask::ALL)); } + /// A neighbour still a proto chunk counts for the light layers as it + /// will once loaded (#4294: vanilla counts a neighbour at + /// INITIALIZE_LIGHT). + #[test] + fn a_proto_neighbour_counts_for_the_light_layers() { + let encoder = encoder(); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + for x in -1..=1 { + for z in -1..=1 { + let mut chunk = Chunk::empty(x, z); + chunk.set(0, MIN_Y, 0, 1); + world.chunks.insert((x, z), Arc::new(chunk)); + } + } + // The neighbour east has a block ten sections up. + let mut tall = Chunk::empty(1, 0); + tall.set(0, MIN_Y, 0, 1); + tall.set(0, MIN_Y + 16 * 10, 0, 1); + world.chunks.insert((1, 0), Arc::new(tall)); + let centre = world.chunks[&(0, 0)].clone(); + let loaded = encoder.body_in(¢re, &world); + world.chunks.remove(&(1, 0)); + assert_ne!(encoder.body_in(¢re, &world), loaded); + let mut sections = SectionMask::EMPTY; + sections.insert(0); + sections.insert(10); + let proto = |x, z| ((x, z) == (1, 0)).then_some(sections); + assert_eq!(encoder.body_with(¢re, &world, &proto), loaded); + } + #[test] fn light_masks_cover_the_world_plus_borders() { let chunk = Chunk::empty(0, 0); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index c347f989cf..05cc235be4 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -137,6 +137,10 @@ pub struct TickCtx<'a> { pub collision: Option<&'a BlockCollisionRegistry<'a>>, /// The level's world border (PlayerList.sendLevelInfo sends it). pub border: &'a crate::worldborder::WorldBorder, + /// The level's columns still being generated: a column is sent once + /// each neighbour is loaded or a decorated proto chunk (#4294). None: + /// every column there is is loaded. + pub streaming: Option<&'a crate::streaming::Streaming>, } /// What the gameplay code has to do for a guest. @@ -1406,7 +1410,7 @@ impl Hub { for i in 0..self.guests.len() { if self.here(&self.guests[i]) { self.update_view(i); - if let Some(batch) = self.next_batch(i, world) { + if let Some(batch) = self.next_batch(i, world, ctx.streaming) { batches.push((i, batch)); } } @@ -1439,7 +1443,11 @@ impl Hub { self.encoder.warm(&columns); let mut bodies: Vec> = columns .iter() - .map(|c| self.encoder.body_in(c, world)) + .map(|c| { + self.encoder.body_with(c, world, &|x, z| { + ctx.streaming.and_then(|s| s.proto_sections(x, z)) + }) + }) .collect(); for (i, taken, batch) in batches { let g = &mut self.guests[i]; @@ -1498,7 +1506,12 @@ impl Hub { /// PlayerChunkSender.sendNextChunks' choice: ready chunks, closest first, /// as many as the batch quota the client's acknowledgements pace allows, /// with their places in the queue. - fn next_batch<'w>(&mut self, i: usize, world: &'w World) -> Option> { + fn next_batch<'w>( + &mut self, + i: usize, + world: &'w World, + streaming: Option<&crate::streaming::Streaming>, + ) -> Option> { let g = &mut self.guests[i]; if self.ticks <= g.placed_at + 1 || g.unacknowledged >= g.max_unacknowledged { return None; @@ -1513,6 +1526,20 @@ impl Hub { .iter() .enumerate() .filter_map(|(idx, key)| Some((idx, &**world.chunks.get(key).filter(|c| c.lit)?))) + // ChunkStatus.LIGHT needs its neighbours at INITIALIZE_LIGHT: a + // column whose light layers would leave one out waits for it. + .filter(|(_, c)| { + let Some(streaming) = streaming else { + return true; + }; + (-1..=1).all(|dz| { + (-1..=1).all(|dx| { + let (x, z) = (c.x + dx, c.z + dz); + world.chunks.contains_key(&(x, z)) + || streaming.proto_sections(x, z).is_some() + }) + }) + }) .take(budget) .collect(); (!batch.is_empty()).then_some(batch) diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index e5dc527f8d..b16b603256 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -4102,6 +4102,12 @@ impl ChunkGenerator for Persistent { fn light_view(&self, proto: &crate::streaming::Proto) -> Option { self.inner.light_view(proto) } + fn non_air_sections( + &self, + proto: &crate::streaming::Proto, + ) -> Option { + self.inner.non_air_sections(proto) + } fn save_protos(&self, protos: &[&crate::streaming::Proto]) { self.save .tables diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index 31527dbdbc..9a9f8e66ee 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -601,6 +601,22 @@ impl ChunkGenerator for VanillaTerrain { _ => None, } } + fn non_air_sections(&self, proto: &Proto) -> Option { + let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::() else { + return None; + }; + // The proto's sections from its own minimum Y; the column's from MIN_Y. + let below = (chunk.terrain.min_y() - MIN_Y) >> 4; + let mut mask = crate::section_mask::SectionMask::EMPTY; + for i in chunk.terrain.filled_sections() { + if let Ok(i) = usize::try_from(i as i32 + below) + && i < SECTIONS + { + mask.insert(i); + } + } + Some(mask) + } fn saved_proto(&self, proto: &Proto) -> Option { let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::() else { return None; diff --git a/crates/horizon-client/tests/forced_chunks.rs b/crates/horizon-client/tests/forced_chunks.rs index ae4ea92509..585a82de91 100644 --- a/crates/horizon-client/tests/forced_chunks.rs +++ b/crates/horizon-client/tests/forced_chunks.rs @@ -13,6 +13,7 @@ use horizon_client::{ light::LightTables, mesher::MeshTables, models, + section_mask::SectionMask, streaming::{ChunkGenerator, Proto, Streaming, WorkerContext}, world::World, }; @@ -71,6 +72,10 @@ impl ChunkGenerator for Plain { let column = proto.downcast_ref::()?; Some(Chunk::empty(column.x, column.z)) } + fn non_air_sections(&self, proto: &Proto) -> Option { + proto.downcast_ref::()?; + Some(SectionMask::single(4)) + } } #[test] @@ -121,10 +126,17 @@ fn forced_chunks_reach_vanillas_rings() { let (x, z) = (40 + dx, -40 + dz); let ring = dx.abs().max(dz.abs()); let status = streaming.latest_status(&world, x, z); + // A level chunk's light layers count a neighbour at FEATURES as + // vanilla's do one at INITIALIZE_LIGHT (#4294); not one at TERRAIN. + let proto = streaming.proto_sections(x, z); if ring == 3 { assert_eq!(status, Some(Status::Features), "({x}, {z})"); + assert_eq!(proto, Some(SectionMask::single(4)), "({x}, {z})"); } else if ring == 4 { assert_eq!(status, Some(Status::Terrain), "({x}, {z})"); + assert_eq!(proto, None, "({x}, {z})"); + } else { + assert_eq!(proto, None, "({x}, {z})"); } } } diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 244c4fd7ae..c6e7abb3a0 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -202,6 +202,7 @@ impl Server { tick_steps: (0, 0), collision: None, border: &horizon_client::worldborder::WorldBorder::default(), + streaming: None, }; let events = self.hub.tick(&mut ctx); for event in events { diff --git a/crates/horizon-world/src/worldgen/terrain.rs b/crates/horizon-world/src/worldgen/terrain.rs index 34697e1465..7fdf37c8a0 100644 --- a/crates/horizon-world/src/worldgen/terrain.rs +++ b/crates/horizon-world/src/worldgen/terrain.rs @@ -251,6 +251,11 @@ impl<'r, B: TerrainBlockStates> TerrainChunk<'r, B> { pub(crate) fn registry(&self) -> &BlockRegistry { self.blocks.registry } + /// The indexes of the sections holding a block other than air + /// (`LevelChunkSection.hasOnlyAir`, negated). + pub fn filled_sections(&self) -> impl Iterator + '_ { + (0..self.blocks.non_air.len()).filter(|&i| self.blocks.non_air[i] != 0) + } pub fn highest_filled_section_index(&self) -> Option { self.blocks.non_air.iter().rposition(|&count| count != 0) } From 4e86e200a59ad9c5008f578c3fa298abc0a5cf20 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 10:38:01 +0100 Subject: [PATCH 348/968] Fireworks: every free rocket with explosions goes off on a block FireworkRocketEntity.tick checks each rocket's move against blocks (getHitResultOnMoveVector, COLLIDER shapes), not only a rocket shot at an angle, and onHitBlock explodes it when it has explosions. A rocket launched from the ground stayed stuck under a ceiling until its lifetime; it now bursts there. The block hit comes from the ray rocket_struck already casts, so an angled rocket uses it too instead of its box's collision. lan_e2e's rocket-ceilings check covers it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/fireworks.rs | 53 +++++++++++---------- tools/lan_e2e.py | 37 +++++++++++++- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 2ec6f57221..f41cbcfbcb 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -21,6 +21,12 @@ use horizon_core::{ pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17; +/// What `ProjectileUtil.getHitResultOnMoveVector` found along a rocket's move. +enum RocketHit { + Entity(Who), + Block, +} + pub(super) struct Rocket { /// The entity id a server tracks it by (0 until it is tracked). pub id: u32, @@ -43,8 +49,7 @@ pub(super) struct Rocket { /// `DATA_ATTACHED_TO_TARGET`: the server entity the rocket follows; /// boosting happens when it is the local player. pub(super) attached_to: Option, - /// `DATA_SHOT_AT_ANGLE` (dispensers, crossbows): constant velocity, - /// exploding on the first block it hits when it has explosions. + /// `DATA_SHOT_AT_ANGLE` (dispensers, crossbows): constant velocity. pub(super) angled: bool, /// `DATA_ID_FIREWORKS_ITEM`: the stack it was launched from. pub(super) item: ItemStack, @@ -118,9 +123,8 @@ impl Rocket { } } /// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed - /// up (an angled one keeps its motion) and rise, then move. True when a - /// block stopped it. - fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool { + /// up (an angled one keeps its motion) and rise, then move. + fn free_flight(&mut self, scene: &mut SceneCollisions) { let h = 0.125; let bounds = Aabb::new( Vec3::new(self.position.x - h, self.position.y, self.position.z - h), @@ -132,11 +136,8 @@ impl Rocket { ); let wanted = self.next_motion(); let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else { - return false; + return; }; - let blocked = (moved.x - wanted.x).abs() > 1e-7 - || (moved.y - wanted.y).abs() > 1e-7 - || (moved.z - wanted.z).abs() > 1e-7; let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7; self.position = Vec3::new( self.position.x + moved.x, @@ -148,7 +149,6 @@ impl Rocket { } else { wanted }; - blocked } /// Position and motion (the dispenser parity fixture). pub(super) fn motion(&self) -> (Vec3, Vec3) { @@ -334,16 +334,14 @@ impl Game<'_> { binding: self.collision, subject: None, }; - if r.free_flight(&mut scene) && r.angled && !r.explosions.is_empty() { - // onHitBlock: only a rocket with explosions (hasExplosion) - // goes off on the block; a plain one flies on to its lifetime. - r.life = r.lifetime; - } + r.free_flight(&mut scene); } // hitTargetOrDeflectSelf on an entity: a breeze turns it back // (ProjectileDeflection.REVERSE), anything else sets it off. + // onHitBlock: only a rocket with explosions (hasExplosion) goes + // off on a block; a plain one flies on to its lifetime. match hit { - Some(Who::Mob(id)) + Some(RocketHit::Entity(Who::Mob(id))) if self .mobs .iter() @@ -351,8 +349,9 @@ impl Game<'_> { { r.velocity = r.velocity.scale(-0.5); } - Some(_) => r.life = r.lifetime, - None => {} + Some(RocketHit::Entity(_)) => r.life = r.lifetime, + Some(RocketHit::Block) if !r.explosions.is_empty() => r.life = r.lifetime, + Some(RocketHit::Block) | None => {} } let at = [r.position.x, r.position.y, r.position.z]; if r.life == 0 { @@ -511,25 +510,26 @@ impl Game<'_> { targets.extend(self.lan_projectile_targets()); targets } - /// `ProjectileUtil.getHitResultOnMoveVector`'s entity half: the nearest - /// target on the move, short of the block it clips, each box grown by - /// `computeMargin` (`tickCount` is the rocket's life plus one). + /// `ProjectileUtil.getHitResultOnMoveVector`: the nearest target on the + /// move, short of the block it clips (`ClipContext.Block.COLLIDER`), each + /// box grown by `computeMargin` (`tickCount` is the rocket's life plus + /// one); else that block. fn rocket_struck( &self, targets: &[(Who, Aabb)], r: &Rocket, motion: Vec3, skip: Option, - ) -> Option { + ) -> Option { let from = r.position; let mut to = from.add_vector(motion); let w = self.mob_world_base(&[]); - if let Some((at, ..)) = (super::creatures::ProjWorld { + let block = (super::creatures::ProjWorld { w: &w, targets: Vec::new(), }) - .clip(from, to) - { + .clip(from, to); + if let Some((at, ..)) = block { to = at; } let margin = (f64::from(r.life - 1) / 20.0).clamp(0.0, 0.3); @@ -543,7 +543,8 @@ impl Game<'_> { Some((*who, from.distance_squared(at))) }) .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(who, _)| who) + .map(|(who, _)| RocketHit::Entity(who)) + .or(block.map(|_| RocketHit::Block)) } /// FireworkRocketEntity.handleEntityEvent: use the synchronized item data. pub(super) fn remote_firework_explosion(&mut self, id: i32) { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 3ac31dbf66..76a0130cbc 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -328,7 +328,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sp 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', - 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks', 'rocket-walls'] + 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks', 'rocket-walls', 'rocket-ceilings'] class Game: @@ -3588,6 +3588,39 @@ def check_rocket_walls(host, guest, out): return result +def check_rocket_ceilings(host, guest, out): + """A rising rocket explodes on the block above only with explosions. + + FireworkRocketEntity.tick: every rocket that isn't attached checks its move against blocks + (`getHitResultOnMoveVector`), not only one shot at an angle; onHitBlock: `hasExplosion()`. Launched from + the floor under a ceiling two blocks up, it reaches it in about 9 ticks; a plain one lives 20-31. Spans + are measured as in check_rocket_walls. + """ + x, y, z = (int(v // 1) for v in pos_of(host)) + host('command', command='gamemode creative') + host('command', command=f'fill {x - 2} {y - 1} {z + 1} {x + 2} {y - 1} {z + 5} minecraft:stone') + host('command', command=f'fill {x - 2} {y} {z + 1} {x + 2} {y + 1} {z + 5} minecraft:air') + host('command', command=f'fill {x - 2} {y + 2} {z + 1} {x + 2} {y + 2} {z + 5} minecraft:stone') + plain = 'minecraft:firework_rocket' + star = plain + '[fireworks={flight_duration:1,explosions:[{shape:"ball",colors:[I;16711680]}]}]' + result = {} + for label, item in (('plain', plain), ('star', star)): + host('command', command=f'item replace entity @s weapon.mainhand with {item}') + host.wait(5) + replies = host.script([f'interact {x} {y - 1} {z + 3}'] + ['wait 1', 'status'] * 45, every=True) + samples = [(r['ticks'], r['rockets']) for r in replies if 'rockets' in r] + seen = [tick for tick, n in samples if n] + result[label] = {'span': seen[-1] - seen[0] if seen else None, 'samples': len(samples), + 'last': samples[-1][1], 'guest_last': guest('status')['rockets']} + host('command', command='item replace entity @s weapon.mainhand with minecraft:air') + assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), ( + f'a rocket outlived its lifetime: {result}') + assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the ceiling: {result}' + assert result['star']['span'] is not None and result['star']['span'] < 14, ( + f'star rocket did not explode on the ceiling: {result}') + return result + + def check_sparks(host, guest, out): spots = apart(host, guest) result = {} @@ -5444,7 +5477,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'sidebar': check_sidebar, 'border': check_border, 'particles': check_particles, 'fireworks': check_fireworks, - 'rocket-walls': check_rocket_walls} + 'rocket-walls': check_rocket_walls, 'rocket-ceilings': check_rocket_ceilings} def run(ctl, out, resources, checks): From 191fc3302d362f98178bfd95bd8d8bd6dc0e1141 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 10:40:50 +0100 Subject: [PATCH 349/968] lan_e2e rocket-ceilings: a star rocket must rise before it bursts Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 76a0130cbc..81913df452 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -3616,7 +3616,8 @@ def check_rocket_ceilings(host, guest, out): assert all(r['last'] == 0 and r['guest_last'] == 0 for r in result.values()), ( f'a rocket outlived its lifetime: {result}') assert (result['plain']['span'] or 0) >= 14, f'plain rocket went off on the ceiling: {result}' - assert result['star']['span'] is not None and result['star']['span'] < 14, ( + # It rises about 8 ticks first: gone at once would be a burst at launch. + assert result['star']['span'] is not None and 3 <= result['star']['span'] < 14, ( f'star rocket did not explode on the ceiling: {result}') return result From 1338b938159d1b9b101ea061a308e8b897a221f2 Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 13:25:05 +0330 Subject: [PATCH 350/968] test(resident): add tight bounds culling benchmark Co-Authored-By: Antigravity --- .../horizon-client-render/src/gfx/resident.rs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/crates/horizon-client-render/src/gfx/resident.rs b/crates/horizon-client-render/src/gfx/resident.rs index 2e5355bd25..df00062cac 100644 --- a/crates/horizon-client-render/src/gfx/resident.rs +++ b/crates/horizon-client-render/src/gfx/resident.rs @@ -514,4 +514,118 @@ mod tests { "full 16x16x16 bounds intersect frustum and are accepted" ); } + + #[test] + #[ignore = "benchmark"] + fn tight_bounds_benchmark() { + use std::time::Instant; + + let mut r_fallback = Resident::default(); + let mut r_tight = Resident::default(); + + let radius = 16; + for x in -radius..=radius { + for z in -radius..=radius { + // Section 0..4: underground rock + for y in 0..4 { + r_fallback.insert((x, y, z), ()); + r_tight.insert_with_bounds((x, y, z), (), None); + } + // Section 4: surface terrain with grass / hills (e.g. Y in [0, 4] or [0, 6]) + let height = 2.0 + (((x * 3 + z * 5).rem_euclid(5)) as f32) * 1.5; + let surface_bounds = + Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, height, 16.0)]); + r_fallback.insert((x, 4, z), ()); + r_tight.insert_with_bounds((x, 4, z), (), surface_bounds); + } + } + + println!("\n=== Tight Bounds Culling Benchmark ==="); + println!("Total Resident Sections: {}", r_tight.len()); + + let test_views = [ + ("Looking level across terrain (pitch 0°)", 0.0, 0.0), + ("Looking slightly up (pitch -15°)", 45.0, -15.0), + ("Looking up towards sky (pitch -35°)", 90.0, -35.0), + ("Looking down at ground (pitch 25°)", 180.0, 25.0), + ("Diagonal view (yaw 135°, pitch -10°)", 135.0, -10.0), + ]; + + let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 4 * 16 + 10), 8.0); + let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0); + + for (desc, yaw, pitch) in test_views { + let view = crate::camera::view_matrix(yaw, pitch); + let frustum = Frustum { + planes: super::super::frustum(projection * view), + }; + + let mut out_fallback = Vec::new(); + let mut out_tight = Vec::new(); + + r_fallback.visible( + camera, + radius, + &frustum, + |_| true, + |_, _| false, + &mut out_fallback, + ); + r_tight.visible( + camera, + radius, + &frustum, + |_| true, + |_, _| false, + &mut out_tight, + ); + + let culled_extra = out_fallback.len().saturating_sub(out_tight.len()); + let pct_saved = if !out_fallback.is_empty() { + (culled_extra as f64 / out_fallback.len() as f64) * 100.0 + } else { + 0.0 + }; + + // Timing over 1,000 passes + let iters = 1_000; + let t0 = Instant::now(); + for _ in 0..iters { + out_fallback.clear(); + r_fallback.visible( + camera, + radius, + &frustum, + |_| true, + |_, _| false, + &mut out_fallback, + ); + } + let dur_fallback = t0.elapsed(); + + let t1 = Instant::now(); + for _ in 0..iters { + out_tight.clear(); + r_tight.visible( + camera, + radius, + &frustum, + |_| true, + |_, _| false, + &mut out_tight, + ); + } + let dur_tight = t1.elapsed(); + + println!( + "{desc}\n Fallback 16x16: {} sections | {:?}\n Tight Bounds: {} sections | {:?}\n Reduction: -{} sections ({:.1}% culled)\n", + out_fallback.len(), + dur_fallback / iters, + out_tight.len(), + dur_tight / iters, + culled_extra, + pct_saved + ); + } + } } From 61066655feda64fd5ddba4ef64c8bb8a3926acb2 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 30 Oct 2026 19:55:21 +1000 Subject: [PATCH 351/968] Mirror the attacking client's Player.attack, not the server's, and add its harness The no-damage guard and the knockback check read the held item, effects and enchantments. On a client none of them count: items and effects only change attributes on a server, ATTACK_DAMAGE and ATTACK_KNOCKBACK aren't synced, only ServerPlayer overrides getEnchantedDamage, and getKnockback only adds enchantments on a ServerLevel. So an empty-hand sprint hit under Weakness does slow a vanilla client, and a Knockback sword alone doesn't. test_remote_attack_parity runs the real Player.attack on a LocalPlayer shell against a RemotePlayer and a zombie and compares the attack strength, velocity and sprint flag: 51,840 cases identical on 26.3. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/examples/probe/main.rs | 2 + .../examples/probe/remote_attack_probe.rs | 52 ++++++ crates/horizon-client/src/attack_strength.rs | 87 +++++---- crates/horizon-client/src/game/remote.rs | 45 +++-- .../parity/VanillaRemoteAttackOracle.java | 170 ++++++++++++++++++ tools/test_remote_attack_parity.py | 78 ++++++++ 6 files changed, 377 insertions(+), 57 deletions(-) create mode 100644 crates/horizon-client/examples/probe/remote_attack_probe.rs create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRemoteAttackOracle.java create mode 100644 tools/test_remote_attack_parity.py diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index 6b4c3d7dda..3471141ad7 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -73,6 +73,7 @@ mod recipe_book_fixture; mod recipe_book_render_fixture; mod recipe_book_view_fixture; mod recipe_fixture; +mod remote_attack_probe; mod scene_collision_probe; mod scene_motion_probe; mod scoreboard_fixture; @@ -162,6 +163,7 @@ probes! { recipe_book_render_fixture, recipe_book_view_fixture, recipe_fixture, + remote_attack_probe, scene_collision_probe, scene_motion_probe, scoreboard_fixture, diff --git a/crates/horizon-client/examples/probe/remote_attack_probe.rs b/crates/horizon-client/examples/probe/remote_attack_probe.rs new file mode 100644 index 0000000000..f41b7a5693 --- /dev/null +++ b/crates/horizon-client/examples/probe/remote_attack_probe.rs @@ -0,0 +1,52 @@ +//! The attacking client's side of a hit, as `Game::remote_player_attack` decides it, against +//! VanillaRemoteAttackOracle's real `Player.attack` (tools/test_remote_attack_parity.py). +#![forbid(unsafe_code)] +use horizon_client::{ + Result, + attack_strength::{ATTACKER_SLOWDOWN, AttackStrength, attacker_slows_after_hit}, +}; +use serde_json::Value; +use std::{ + fs::File, + io::{BufRead, BufReader, BufWriter, Write}, +}; + +fn double(v: &Value) -> f64 { + f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap()) +} + +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().skip(1).collect(); + let mut out = BufWriter::new(File::create(&args[1])?); + for line in BufReader::new(File::open(&args[0])?).lines() { + let c: Value = serde_json::from_str(&line?)?; + let mut attack = AttackStrength::default(); + attack.ticker = c["ticker"].as_i64().unwrap() as i32; + let strength = attack.scale(0.5, double(&c["speed"])); + let damage = match c["spin"].as_f64() { + Some(spin) => spin as f32, + None => double(&c["damage"]) as f32, + }; + let mut sprinting = c["sprint"].as_bool().unwrap(); + let velocity: Vec = c["vel"].as_array().unwrap().iter().map(double).collect(); + let (mut x, y, mut z) = (velocity[0], velocity[1], velocity[2]); + // `level_players` keeps a mob out of remote_player_attack: Entity.hurtClient is false. + if c["target"] == "player" + && attacker_slows_after_hit(damage, double(&c["knockback"]) as f32, strength, sprinting) + { + x *= ATTACKER_SLOWDOWN; + z *= ATTACKER_SLOWDOWN; + sprinting = false; + } + writeln!( + out, + "{:08x} {:016x} {:016x} {:016x} {}", + strength.to_bits(), + x.to_bits(), + y.to_bits(), + z.to_bits(), + sprinting + )?; + } + Ok(()) +} diff --git a/crates/horizon-client/src/attack_strength.rs b/crates/horizon-client/src/attack_strength.rs index 704e85cbd4..ef151292c8 100644 --- a/crates/horizon-client/src/attack_strength.rs +++ b/crates/horizon-client/src/attack_strength.rs @@ -68,34 +68,40 @@ impl AttackStrength { } } -/// `Player.attack`'s extra knockback from the weapon's knockback enchantment -/// (`getKnockback`, halved) and a sprinting full-strength hit (0.5), and -/// whether the hit is that sprinting knockback attack. -pub fn extra_knockback(enchanted: f32, strength: f32, sprinting: bool) -> (f32, bool) { +/// `Player.attack`'s extra knockback: `getKnockback` (`weapon`, halved: the +/// ATTACK_KNOCKBACK value, plus on a server the weapon's knockback enchantment) +/// and a sprinting full-strength hit (0.5), and whether the hit is that +/// sprinting knockback attack. +pub fn extra_knockback(weapon: f32, strength: f32, sprinting: bool) -> (f32, bool) { let knockback_attack = strength > 0.9 && sprinting; let bonus = if knockback_attack { 0.5 } else { 0.0 }; - (enchanted / 2.0 + bonus, knockback_attack) + (weapon / 2.0 + bonus, knockback_attack) } +/// `causeExtraKnockback`'s cut of the attacker's horizontal speed. +pub const ATTACKER_SLOWDOWN: f64 = 0.6; + /// What the attacking client's own `Player.attack` does to itself on a hit /// at another player, whose `RemotePlayer.hurtClient` lets the hit through: -/// whether `causeExtraKnockback` cuts its horizontal speed to 0.6 and stops -/// its sprint. `attack_damage` is the ATTACK_DAMAGE value before clamping -/// (the hand and weapon plus strength and weakness, or a riptide spin's), -/// `enchant_damage` the weapon's enchantment damage, `strength` -/// `getAttackStrengthScale(0.5)` and `knockback` from [`extra_knockback`]. +/// whether `causeExtraKnockback` cuts its horizontal speed and stops its +/// sprint. This runs on the client's player, not the server's: held items and +/// effects only change attributes on a server and neither of these two is +/// synced, so `attack_damage` and `knockback` are the client's own ATTACK_DAMAGE +/// (or a riptide spin's damage) and ATTACK_KNOCKBACK, whatever the weapon or +/// Weakness. Enchantments don't count either: only `ServerPlayer` overrides +/// `getEnchantedDamage`, and `getKnockback` asks for a `ServerLevel`. +/// `strength` is `getAttackStrengthScale(0.5)`. pub fn attacker_slows_after_hit( attack_damage: f32, - enchant_damage: f32, - strength: f32, knockback: f32, + strength: f32, + sprinting: bool, ) -> bool { - // The attribute's range is [0, 2048]: weakness can't make it negative. - let base = attack_damage.clamp(0.0, 2048.0) * (0.2 + strength * strength * 0.8); - let enchant = enchant_damage * strength; - // Player.attack stops before the hurt and the knockback when neither - // part of the damage is left. - (base > 0.0 || enchant > 0.0) && knockback > 0.0 + let base = attack_damage * (0.2 + strength * strength * 0.8); + let (knockback, _) = extra_knockback(knockback, strength, sprinting); + // Player.attack stops before the hurt and the knockback when no damage is + // left (`magicBoost` is 0 here). + base > 0.0 && knockback > 0.0 } #[cfg(test)] @@ -137,42 +143,35 @@ mod tests { s.reset_attack(); assert_eq!((s.ticker, s.swap_ticker), (0, 1)); } - /// The attacker's side of a hit on another player, as the client sees it. - fn slows(damage: f32, enchant: f32, strength: f32, sprinting: bool, kb_enchant: f32) -> bool { - let (knockback, _) = extra_knockback(kb_enchant, strength, sprinting); - attacker_slows_after_hit(damage, enchant, strength, knockback) - } + /// A client's own ATTACK_DAMAGE: Player's base value, as no item or effect + /// changes it there. + const CLIENT_DAMAGE: f32 = 1.0; #[test] fn a_charged_sprint_hit_slows_the_attacker() { - // A diamond sword (1 + 6), full strength, sprinting. - assert!(slows(7.0, 0.0, 1.0, true, 0.0)); + assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, true)); assert_eq!(extra_knockback(0.0, 1.0, true), (0.5, true)); } #[test] fn an_uncharged_sprint_hit_does_not() { - assert!(!slows(7.0, 0.0, 0.5, true, 0.0)); + assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.5, true)); assert!( - !slows(7.0, 0.0, 0.9, true, 0.0), + !attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.9, true), "the bonus needs more than 0.9" ); } #[test] fn a_hit_without_sprinting_or_knockback_does_not() { - assert!(!slows(7.0, 0.0, 1.0, false, 0.0)); + assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, false)); } #[test] - fn a_knockback_weapon_slows_even_uncharged() { - // Knockback I: getKnockback 1, halved; no sprint needed. - assert!(slows(7.0, 0.0, 0.2, false, 1.0)); + fn a_synced_knockback_attribute_slows_even_uncharged() { + assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 1.0, 0.2, false)); } #[test] fn a_hit_that_does_no_damage_stops_first() { - // An empty hand (1) under Weakness I (-4): the attribute clamps to 0, - // so Player.attack ends before causeExtraKnockback. - assert!(!slows(1.0 - 4.0, 0.0, 1.0, true, 0.0)); - assert!(!slows(0.0, 0.0, 1.0, true, 1.0)); - // Enchantment damage alone still lands the hit. - assert!(slows(0.0, 1.25, 1.0, true, 0.0)); + // Only a server that sends ATTACK_DAMAGE can leave a client without any. + assert!(!attacker_slows_after_hit(0.0, 0.0, 1.0, true)); + assert!(!attacker_slows_after_hit(0.0, 1.0, 1.0, true)); } #[test] fn a_server_attack_speed_charges_at_once() { @@ -181,8 +180,18 @@ mod tests { let mut s = AttackStrength::default(); s.reset_attack(); assert_eq!(s.scale(0.5, 1024.0), 1.0); - assert!(slows(7.0, 0.0, s.scale(0.5, 1024.0), true, 0.0)); + assert!(attacker_slows_after_hit( + CLIENT_DAMAGE, + 0.0, + s.scale(0.5, 1024.0), + true + )); // A sword's own 1.6 leaves it uncharged. - assert!(!slows(7.0, 0.0, s.scale(0.5, 1.6), true, 0.0)); + assert!(!attacker_slows_after_hit( + CLIENT_DAMAGE, + 0.0, + s.scale(0.5, 1.6), + true + )); } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 62fc03b660..72ccd835b4 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5452,39 +5452,48 @@ impl Game<'_> { // (`attack_ticks` only counts in the local world), and the attack // speed is the player's synced attribute, which a server may change. let speed = self - .player - .as_mut() - .and_then(|p| { - let key = p.attributes.key("minecraft:attack_speed")?; - p.attributes.lookup(Some(&key))?; - p.attributes - .value(Some(key), horizon_world::attribute_map::ValueKind::Computed) - .ok() - }) + .own_attribute("minecraft:attack_speed") .unwrap_or_else(|| self.main_hand_attack_speed()); - let scale = self.hand_strength.scale(0.5, speed); - // Player.attack's damage, in its order: the ATTACK_DAMAGE value (or a - // riptide spin's), the enchantment damage, then the knockback. + let strength = self.hand_strength.scale(0.5, speed); + // Player.attack's damage: a riptide spin's, or this client's own + // ATTACK_DAMAGE, which the held item and effects don't change here. let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg); let damage = if spin > 0.0 { spin } else { - 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage) - + self.effect_attack_bonus() + self.own_attribute("minecraft:attack_damage") + .map_or(1.0, |v| v as f32) }; - let enchant = self.enchant_damage_bonus("minecraft:player"); - let (knockback, _) = self.extra_knockback("minecraft:player", scale); + let knockback = self + .own_attribute("minecraft:attack_knockback") + .map_or(0.0, |v| v as f32); + let sprinting = self.player.as_ref().is_some_and(|p| p.sprinting); if !horizon_client::attack_strength::attacker_slows_after_hit( - damage, enchant, scale, knockback, + damage, knockback, strength, sprinting, ) { return; } if let Some(p) = self.player.as_mut() { - p.velocity = Vec3::new(p.velocity.x * 0.6, p.velocity.y, p.velocity.z * 0.6); + let slowdown = horizon_client::attack_strength::ATTACKER_SLOWDOWN; + p.velocity = Vec3::new( + p.velocity.x * slowdown, + p.velocity.y, + p.velocity.z * slowdown, + ); } self.stop_sprint_after_extra_knockback(); } + /// An attribute's value on this client's player, if its map has the attribute. + fn own_attribute(&mut self, id: &str) -> Option { + let p = self.player.as_mut()?; + let key = p.attributes.key(id)?; + p.attributes.lookup(Some(&key))?; + p.attributes + .value(Some(key), horizon_world::attribute_map::ValueKind::Computed) + .ok() + } + fn remote_continue_attack(&mut self, down: bool) { if !down { self.clicks.miss_time = 0; diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRemoteAttackOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRemoteAttackOracle.java new file mode 100644 index 0000000000..dc5ff630e8 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRemoteAttackOracle.java @@ -0,0 +1,170 @@ +package dev.horizonlab.parity; + +import com.google.gson.*; +import java.io.*; +import java.lang.reflect.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.function.Predicate; +import net.minecraft.client.multiplayer.ClientLevel; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.client.player.RemotePlayer; +import net.minecraft.core.RegistryAccess; +import net.minecraft.sounds.SoundEvent; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.damagesource.DamageSources; +import net.minecraft.world.effect.*; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.ai.attributes.AttributeMap; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.monster.zombie.Zombie; +import net.minecraft.world.entity.player.Abilities; +import net.minecraft.world.entity.player.Input; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.Vec3; + +/** + * Runs the real Player.attack on a LocalPlayer shell in a ClientLevel shell, as + * MultiPlayerGameMode.attack does on the attacking client, and prints what it + * leaves on the attacker: its velocity and sprint flag. The target is a + * RemotePlayer (hurtClient is true) or a zombie (Entity.hurtClient is false). + * Effects go through the real forceAddEffect, as ClientPacketListener adds them; + * the attribute values are what the client's map holds. The setSprinting call + * is recorded rather than run: its attribute and entity-data side is not what + * this compares. + */ +public final class VanillaRemoteAttackOracle { + static Object unsafe; + static Method allocate; + + @SuppressWarnings("unchecked") + static T make(Class type) throws Exception { + return (T) allocate.invoke(unsafe, type); + } + + static Field field(Class type, String name) throws Exception { + Field f = type.getDeclaredField(name); + f.setAccessible(true); + return f; + } + + static double d(JsonElement v) { + return Double.longBitsToDouble(Long.parseUnsignedLong(v.getAsString(), 16)); + } + + static final class Client extends ClientLevel { + DamageSources sources; + RegistryAccess registries; + + Client() { + super(null, null, null, null, 0, 0, null, false, 0L, 0); + } + + @Override public boolean isClientSide() { return true; } + @Override public DamageSources damageSources() { return sources; } + @Override public RegistryAccess registryAccess() { return registries; } + @Override public void playSound(Entity except, double x, double y, double z, SoundEvent sound, SoundSource source, float volume, float pitch) {} + @Override public List getEntitiesOfClass(Class type, AABB bounds, Predicate filter) { return List.of(); } + } + + static final class Attacker extends LocalPlayer { + AttributeMap map; + ItemStack hand; + boolean sprint; + boolean spin; + + Attacker() { + super(null, null, null, null, null, Input.EMPTY, false, null, null); + } + + @Override public AttributeMap getAttributes() { return map; } + @Override public ItemStack getMainHandItem() { return hand; } + @Override public ItemStack getItemBySlot(EquipmentSlot slot) { return slot == EquipmentSlot.MAINHAND ? hand : ItemStack.EMPTY; } + @Override public boolean isSprinting() { return sprint; } + @Override public void setSprinting(boolean value) { sprint = value; } + @Override public boolean isAutoSpinAttack() { return spin; } + @Override public boolean onGround() { return true; } + } + + static final class PlayerTarget extends RemotePlayer { + PlayerTarget() { + super(null, null); + } + + @Override public float getHealth() { return 20.0F; } + @Override public void knockback(double power, double x, double z, DamageSource source, float scale, boolean flag) {} + @Override public void knockback(double power, double x, double z, DamageSource source, float scale) {} + } + + static final class MobTarget extends Zombie { + MobTarget() { + super((Level) null); + } + + @Override public float getHealth() { return 20.0F; } + @Override public void knockback(double power, double x, double z, DamageSource source, float scale, boolean flag) {} + @Override public void knockback(double power, double x, double z, DamageSource source, float scale) {} + } + + static T entity(Class type, EntityType kind, Client level) throws Exception { + T e = make(type); + field(Entity.class, "type").set(e, kind); + field(Entity.class, "level").set(e, level); + field(Entity.class, "deltaMovement").set(e, Vec3.ZERO); + field(Entity.class, "position").set(e, new Vec3(2.0, 64.0, 0.0)); + return e; + } + + public static void main(String[] args) throws Exception { + ParityData.init(); + var u = Class.forName("sun.misc.Unsafe"); + unsafe = field(u, "theUnsafe").get(null); + allocate = u.getMethod("allocateInstance", Class.class); + Client level = make(Client.class); + level.registries = ParityData.access; + level.sources = new DamageSources(ParityData.access); + Map items = new HashMap<>(); + var ticker = field(LivingEntity.class, "attackStrengthTicker"); + var spinDamage = field(LivingEntity.class, "autoSpinAttackDmg"); + try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8)); + var lines = Files.lines(Path.of(args[0]))) { + for (var line : (Iterable) lines::iterator) { + var c = JsonParser.parseString(line).getAsJsonObject(); + Attacker player = entity(Attacker.class, EntityTypes.PLAYER, level); + field(Player.class, "abilities").set(player, new Abilities()); + field(LivingEntity.class, "activeEffects").set(player, new HashMap<>()); + player.map = new AttributeMap(Player.createAttributes().build()); + player.map.getInstance(Attributes.ATTACK_SPEED).setBaseValue(d(c.get("speed"))); + player.map.getInstance(Attributes.ATTACK_DAMAGE).setBaseValue(d(c.get("damage"))); + player.map.getInstance(Attributes.ATTACK_KNOCKBACK).setBaseValue(d(c.get("knockback"))); + String item = c.get("item").getAsString(); + if (!items.containsKey(item)) items.put(item, ParityData.stack(item)); + player.hand = items.get(item).copy(); + player.sprint = c.get("sprint").getAsBoolean(); + player.spin = !c.get("spin").isJsonNull(); + if (player.spin) spinDamage.setFloat(player, c.get("spin").getAsFloat()); + ticker.setInt(player, c.get("ticker").getAsInt()); + if (!c.get("effect").isJsonNull()) { + boolean weakness = c.get("effect").getAsString().equals("weakness"); + player.forceAddEffect(new MobEffectInstance(weakness ? MobEffects.WEAKNESS : MobEffects.STRENGTH, 1000, 0), null); + } + var v = c.getAsJsonArray("vel"); + player.setDeltaMovement(new Vec3(d(v.get(0)), d(v.get(1)), d(v.get(2)))); + float strength = player.getAttackStrengthScale(0.5F); + Entity target = c.get("target").getAsString().equals("player") + ? entity(PlayerTarget.class, EntityTypes.PLAYER, level) + : entity(MobTarget.class, EntityTypes.ZOMBIE, level); + player.attack(target); + Vec3 after = player.getDeltaMovement(); + out.printf("%08x %016x %016x %016x %s%n", Float.floatToRawIntBits(strength), Double.doubleToRawLongBits(after.x), + Double.doubleToRawLongBits(after.y), Double.doubleToRawLongBits(after.z), player.sprint); + } + } + System.exit(0); + } +} diff --git a/tools/test_remote_attack_parity.py b/tools/test_remote_attack_parity.py new file mode 100644 index 0000000000..4267fbe588 --- /dev/null +++ b/tools/test_remote_attack_parity.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Compare what a hit leaves on the attacking client with the pinned client. + +MultiPlayerGameMode.attack runs Player.attack on the attacking client as well as +sending the packet. The oracle runs that real method on a LocalPlayer shell in a +ClientLevel shell, against a RemotePlayer and a zombie; the probe asks what +Game::remote_player_attack asks. Their velocity and sprint flag after the hit, +and the attack strength the hit used, must be identical. + +A client's Player.attack is not a server's: held items and effects don't change +its attributes, enchantments don't count, and neither ATTACK_DAMAGE nor +ATTACK_KNOCKBACK is synced by a vanilla server. So the corpus crosses the weapons +and effects with the attribute values, to show which of them matter. +""" +import itertools +import json +import struct + +try: + from . import lab, oracle_harness as harness +except ImportError: + import lab, oracle_harness as harness + + +def bits(value): + return f"{struct.unpack('>Q', struct.pack('>d', value))[0]:016x}" + + +def stack(item, enchantments=None): + components = '' if enchantments is None else ',components:{"minecraft:enchantments":{%s}}' % ','.join( + f'"minecraft:{name}":{level}' for name, level in enchantments.items()) + return '{id:"minecraft:%s",count:1%s}' % (item, components) + + +ITEMS = ['-', stack('wooden_sword'), stack('diamond_sword'), stack('wooden_sword', {'knockback': 2}), + stack('diamond_sword', {'sharpness': 5}), stack('stick', {'knockback': 1})] +EFFECTS = [None, 'weakness', 'strength'] +# Around the 0.9 of a knockback attack, with the delays of the speeds below. +TICKERS = [0, 1, 2, 3, 4, 5, 11, 12, 13, 100] +# Player's base, a sword's 1.6 as a server syncs it, and a 1.8-style server's. +SPEEDS = [4.0, 1.6, 1024.0] +# Player's base; the others only reach a client from a server that sends them. +DAMAGES = [1.0, 0.0, 7.0] +KNOCKBACKS = [0.0, 1.0] +VELOCITIES = [(0.25, -0.0784000015258789, -0.1), (-0.3, 0.42, 0.0)] + + +def cases(): + for target, item, effect, ticker, speed, damage, knockback, sprint, spin, velocity in itertools.product( + ('player', 'zombie'), ITEMS, EFFECTS, TICKERS, SPEEDS, DAMAGES, KNOCKBACKS, (True, False), (None, 8.0), + VELOCITIES): + yield dict(target=target, item=item, effect=effect, ticker=ticker, speed=bits(speed), damage=bits(damage), + knockback=bits(knockback), sprint=sprint, spin=spin, vel=[bits(v) for v in velocity]) + + +def make_input(build): + path = build / 'inputs.jsonl' + corpus = list(cases()) + path.write_text(''.join(json.dumps(c, separators=(',', ':')) + '\n' for c in corpus)) + return path, len(corpus) + + +def main(): + harness.differential( + 'rust-remote-attack', 'VanillaRemoteAttackOracle', 'remote_attack_probe', make_input, + 'The real Player.attack on a LocalPlayer shell in a ClientLevel, against a RemotePlayer and a zombie: the ' + 'attack strength it used and the velocity and sprint flag it leaves, across targets, weapons (enchanted too), ' + 'Weakness and Strength through forceAddEffect, attack strength tickers, ATTACK_SPEED, ATTACK_DAMAGE and ' + 'ATTACK_KNOCKBACK values, sprinting and riptide spins. setSprinting is recorded rather than run. The packets ' + 'and the server\'s view of the hit are server_knockback\'s; the path through Game to this decision is ' + 'lan_e2e\'s.', + sources=[lab.ROOT / 'crates/horizon-client/src/attack_strength.rs', + lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java'], + expected_lines=sum(1 for _ in cases())) + + +if __name__ == '__main__': + main() From 3bf35d81c87628680d1380e81d1e6f436a231e6a Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 11:06:39 +0100 Subject: [PATCH 352/968] Draw the slider track outline only for focus, not hover --- crates/horizon-client-render/src/ui.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client-render/src/ui.rs b/crates/horizon-client-render/src/ui.rs index 9697ec2756..3da936033a 100644 --- a/crates/horizon-client-render/src/ui.rs +++ b/crates/horizon-client-render/src/ui.rs @@ -401,12 +401,18 @@ pub fn slider( // A controller's D-pad can focus it (Horizon's controller support). crate::widgets::target_as([x, y, w, 20.0], kind); } - let track = if hover && active { - "gui/sprites/widget/slider_highlighted" - } else { - "gui/sprites/widget/slider" - }; - nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX); + // Vanilla outlines the track (slider_highlighted) only for keyboard focus; + // hovering highlights just the handle. + nine_slice( + batch, + atlas, + "gui/sprites/widget/slider", + x, + y, + w, + 20.0, + u32::MAX, + ); let handle = if hover && active { "gui/sprites/widget/slider_handle_highlighted" } else { From adc6e8626efa7373a981d9b62c6e3030c5144ef3 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 11:11:18 +0100 Subject: [PATCH 353/968] Update port map note for the slider track outline --- tools/port_map/client2-009.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/port_map/client2-009.tsv b/tools/port_map/client2-009.tsv index 3910c1673a..a966275745 100644 --- a/tools/port_map/client2-009.tsv +++ b/tools/port_map/client2-009.tsv @@ -118,7 +118,7 @@ AbstractSelectionList.narrationPriority skip:other screen-reader priority; Hori AbstractSelectionList.removeEntry skip:other rows are rebuilt from the model when they change (update_list, server list), so there is no single-row removal AbstractSelectionList.narrateListElementPosition skip:other narration only AbstractSelectionList.TrackedList.set skip:java delegate list wrapper -AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: track highlight follows hover only; keyboard focus does not highlight +AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: the track outline (vanilla: while focused or dragged) is never drawn; hover highlights only the handle AbstractSliderButton.getHandleSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: handle highlight follows hover only AbstractSliderButton.updateWidgetNarration skip:other narration only AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider gap: minor: the label is clipped rather than scrolled From 66cc52fd9d7f6f9879fd5db1aafccf073a6c1300 Mon Sep 17 00:00:00 2001 From: v3nn7 <147159855+v3nn7@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:12:38 +0100 Subject: [PATCH 354/968] Keep debug overlay off the title screen Co-Authored-By: Codex --- crates/horizon-client/src/game/debug_keys.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index 6e8cd9fb8c..b545bec2e0 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -65,6 +65,9 @@ impl Game<'_> { /// own handling. Returns true when it is used up (a debug combination, /// F3 itself, F3+Escape). pub(super) fn debug_key(&mut self, key: KeyCode, action: KeyAction) -> bool { + if self.title.is_some() { + return false; + } let Some(name) = keybinds::key_name(key) else { return false; }; @@ -273,8 +276,8 @@ impl Game<'_> { /// entries, over everything the frame drew (menus too), at the debug GUI /// scale. F1 hides it unless a screen is open. pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) { - let screen_open = self.title.is_some() || self.screen != Screen::Playing; - if !self.debug || (self.ui.hide_gui && !screen_open) { + let screen_open = self.screen != Screen::Playing; + if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) { return; } let framebuffer = size.map(|v| v as i32); From 7090cd2d38bfb53940d55f7f716f9c54b52fc3a1 Mon Sep 17 00:00:00 2001 From: v3nn7 <147159855+v3nn7@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:20:04 +0100 Subject: [PATCH 355/968] Project entity shadows onto partial block bounds Co-Authored-By: Codex --- crates/horizon-client/src/game.rs | 9 ++++ crates/horizon-client/src/shadows.rs | 77 ++++++++++++++++++++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 88e090d90d..ea5f6a7d5e 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8555,6 +8555,15 @@ impl Game<'_> { horizon_client::shadows::shadow( &self.world, self.res.facts(), + &|state| { + self.res + .collision() + .cached_shapes() + .cached_shape(state) + .ok() + .flatten() + .and_then(|shape| shape.bounds()) + }, sprite, camera, pos, diff --git a/crates/horizon-client/src/shadows.rs b/crates/horizon-client/src/shadows.rs index 5b43639373..b5cb7c6b94 100644 --- a/crates/horizon-client/src/shadows.rs +++ b/crates/horizon-client/src/shadows.rs @@ -1,5 +1,5 @@ //! Entity shadows (`EntityRenderer.extractShadow` + `ShadowFeatureRenderer`): -//! `misc/shadow.png` projected onto the tops of full-collision blocks under +//! `misc/shadow.png` projected onto block collision bounds under //! an entity, faded by camera distance, depth below the entity and the light //! at each block. use crate::{blocks::BlockFacts, hud::Rect, mesher::Vertex, mobs::Mob, world::World}; @@ -111,6 +111,9 @@ pub fn mob_casters(mobs: &[Mob], alpha: f64) -> impl Iterator Option, sprite: Rect, camera: DVec3, pos: DVec3, @@ -139,11 +142,17 @@ pub fn shadow( ); for z in z0..=z1 { for x in x0..=x1 { - for y in y0..=y1 { - let below = facts.get(world.block(x, y - 1, z)); - if below.render_shape == crate::blocks::RenderShape::Invisible - || !below.full_collision - { + for y in y0..=y1.saturating_add(1) { + let state = world.block(x, y - 1, z); + let below = facts.get(state); + if below.render_shape == crate::blocks::RenderShape::Invisible { + continue; + } + let Some(bounds) = collision_bounds(state) else { + continue; + }; + let top = f64::from(y - 1) + bounds.max_position().y; + if top > pos.y + 1e-6 { continue; } let packed = world.light(x, y, z); @@ -151,14 +160,20 @@ pub fn shadow( if raw <= 3 { continue; } - let power_at_depth = pow - (pos.y - f64::from(y)) as f32 * 0.5; + let power_at_depth = pow - (pos.y - top) as f32 * 0.5; let alpha = (power_at_depth * 0.5 * crate::screen_effects::lightmap_brightness(ambient_light, raw as u8)) .clamp(0.0, 1.0); // The block top, clipped to the shadow square so the UVs stay inside the sprite. - let (bx0, bx1) = (f64::from(x).max(pos.x - r), f64::from(x + 1).min(pos.x + r)); - let (bz0, bz1) = (f64::from(z).max(pos.z - r), f64::from(z + 1).min(pos.z + r)); + let (bx0, bx1) = ( + (f64::from(x) + bounds.min_position().x).max(pos.x - r), + (f64::from(x) + bounds.max_position().x).min(pos.x + r), + ); + let (bz0, bz1) = ( + (f64::from(z) + bounds.min_position().z).max(pos.z - r), + (f64::from(z) + bounds.max_position().z).min(pos.z + r), + ); if bx1 <= bx0 || bz1 <= bz0 { continue; } @@ -169,7 +184,6 @@ pub fn shadow( // ShadowFeatureRenderer: relativeY + shapeBelow.minY, the block top // itself. A model's feet sit 0.001 above it (LivingEntityRenderer's // translate(0, -1.501, 0)), so a lifted shadow fights with them. - let top = f64::from(y); for (wx, wz) in [(bx0, bz0), (bx0, bz1), (bx1, bz1), (bx1, bz0)] { let p = DVec3::new(wx, top, wz) - camera; out.push(Vertex { @@ -231,7 +245,22 @@ mod tests { h: 16, }; shadow( - &world, &facts, sprite, camera, pos, 0.3, 1.0, 0.0, 0, &mut out, + &world, + &facts, + &|_| { + Some(horizon_core::geometry::Aabb::new( + horizon_core::geometry::Vec3::ZERO, + horizon_core::geometry::Vec3::new(1.0, 1.0, 1.0), + )) + }, + sprite, + camera, + pos, + 0.3, + 1.0, + 0.0, + 0, + &mut out, ); assert_eq!(out.len(), 4); let feet = (pos.y + (1.501 - 24.0 / 16.0) - camera.y) as f32; @@ -239,6 +268,32 @@ mod tests { assert_eq!(v.pos[1], (pos.y - camera.y) as f32); assert!(v.pos[1] < feet); } + let farmland = registry.default_state("minecraft:farmland").unwrap(); + world.set(8, 63, 8, farmland).unwrap(); + out.clear(); + let pos = DVec3::new(8.5, 63.9375, 8.5); + shadow( + &world, + &facts, + &|state| { + (state == farmland).then(|| { + horizon_core::geometry::Aabb::new( + horizon_core::geometry::Vec3::ZERO, + horizon_core::geometry::Vec3::new(1.0, 0.9375, 1.0), + ) + }) + }, + sprite, + camera, + pos, + 0.3, + 1.0, + 0.0, + 0, + &mut out, + ); + assert_eq!(out.len(), 4); + assert!(out.iter().all(|v| v.pos[1] == (pos.y - camera.y) as f32)); } #[test] fn invisible_mobs_cast_no_shadow() { From 8a378f441cb621580e2cb5b58274335f937e5b57 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:51:23 +0530 Subject: [PATCH 356/968] Pose a brushing arm like vanilla, in both hands Vanilla's AvatarRenderer builds the arm pose per hand and gives a brush that is in use the BRUSH pose, whose HumanoidModel case drops the arm twice as far as an ordinary item: xRot * 0.5 - pi/5 rather than - pi/10, with yRot zeroed. Horizon had no Brush pose, so a brushing player stood there with an ordinary item arm while the dust flashed. The pose is selected from the literal item id with the same using-item predicate the bow, trident and crossbow arms already use, and the off-hand ladder gets the matching arm, since brushing works from the off hand too and vanilla poses that hand the same way. Brush is one-handed and does not affect the off hand, so shields, bows and tridents keep their paths. The first-person sweep is not here: PR #3951 already implements it. --- crates/horizon-client/src/mobs/anim.rs | 70 +++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index be7d592cc0..5fec28e47d 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -246,6 +246,8 @@ pub enum ArmPose { ThrowTrident, /// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging. Spear, + /// A brush being used (`HumanoidModel.ArmPose.BRUSH`). + Brush, } /// HumanoidModel.setupAnim with empty hands (endermen). pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) { @@ -378,6 +380,12 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) { p.xrot(arm, x * 0.5 - PI / 10.0); p.yrot(arm, 0.0); } + // Vanilla's BRUSH case: the ITEM pose with twice the drop. + ArmPose::Brush => { + let x = p.get(arm)[3]; + p.xrot(arm, x * 0.5 - PI / 5.0); + p.yrot(arm, 0.0); + } ArmPose::Block => { let x = p.get(arm)[3] * 0.5 - 0.942_477_9 + hx.clamp(-1.396_263_4, 0.436_332_32); p.xrot(arm, x); @@ -761,6 +769,7 @@ pub fn setup( ArmPose::Empty }; let using_main = m.extra.using_item > 0 && !m.extra.using_off_hand; + let using_off = m.extra.using_item > 0 && m.extra.using_off_hand; // AvatarRenderer.getArmPose: an arm holding a spear (at rest too). let spear_in = |hand: &Option>| { hand.as_deref() @@ -780,6 +789,7 @@ pub fn setup( Some("minecraft:crossbow") if m.extra.charged && s.swing == 0.0 => { ArmPose::CrossbowHold } + Some("minecraft:brush") if using_main => ArmPose::Brush, Some(_) if main_spear.is_some() => ArmPose::Spear, Some(_) => ArmPose::Item, None => ArmPose::Empty, @@ -790,8 +800,10 @@ pub fn setup( let off = if player && m.extra.off_hand.is_some() && !pose.affects_offhand() { if off_spear.is_some() { ArmPose::Spear - } else if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking { + } else if using_off && m.extra.blocking { ArmPose::Block + } else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") { + ArmPose::Brush } else { ArmPose::Item } @@ -1158,4 +1170,60 @@ mod flight_pose_tests { setup(&m, &state, &mut p, &HashMap::new()); assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6); } + + #[test] + fn a_brush_being_used_sweeps_the_arm_lower_than_a_plain_item() { + // AvatarRenderer.getArmPose: a brush being used is the BRUSH pose, and + // HumanoidModel.poseRightArm drops that arm twice as far as ITEM + // (#4273: brushing had no third-person arm pose at all). + let state = State { + pos: 0.0, + speed: 0.0, + age: 0.0, + y_rot: 0.0, + x_rot: 0.0, + swing: 0.0, + baby: false, + partial: 0.0, + }; + let make = || Poses { + map: ["head", "right_arm", "left_arm"] + .into_iter() + .map(|n| (n.to_string(), [0.0; 9])) + .collect(), + dummy: [0.0; 9], + skipped: Default::default(), + hidden: Default::default(), + }; + let mut m = player(); + m.extra.main_hand = Some("minecraft:brush".into()); + m.extra.using_item = 20; + let mut p = make(); + setup(&m, &state, &mut p, &HashMap::new()); + // HumanoidModel.setupAnim's BRUSH pose. + assert!((p.get("right_arm")[3] + PI / 5.0).abs() < 1e-6); + assert_eq!(p.get("right_arm")[4], 0.0); + // An item with no use animation keeps the ITEM pose: BRUSH cannot leak. + let mut m = player(); + m.extra.main_hand = Some("minecraft:stick".into()); + m.extra.using_item = 20; + let mut p = make(); + setup(&m, &state, &mut p, &HashMap::new()); + assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6); + assert_eq!(p.get("right_arm")[4], 0.0); + // AvatarRenderer.getArmPose builds the pose per hand: brushing with the + // brush in the off hand is BRUSH on the left arm, and the main hand + // keeps its ITEM pose (the two must not swap). + let mut m = player(); + m.extra.main_hand = Some("minecraft:stick".into()); + m.extra.off_hand = Some("minecraft:brush".into()); + m.extra.using_item = 20; + m.extra.using_off_hand = true; + let mut p = make(); + setup(&m, &state, &mut p, &HashMap::new()); + assert!((p.get("left_arm")[3] + PI / 5.0).abs() < 1e-6); + assert_eq!(p.get("left_arm")[4], 0.0); + assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6); + assert_eq!(p.get("right_arm")[4], 0.0); + } } From 1bea2676344d06b539567ebe80744e2f2a34052d Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 13:55:07 +0330 Subject: [PATCH 357/968] perf(resident): bypass bounds lookup for inside columns and precompute column coordinates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a column is completely inside the view frustum (inside == true), skip bounds lookup and intersection testing for all of its sections. Additionally, precompute column-invariant world coordinates (ox, oz, min_x, min_z, and xz_sq) outside the section loop. This eliminates the diagonal view frustum culling regression, reducing diagonal traversal time from 84.5 µs (fallback) down to 79.5 µs. Co-Authored-By: Antigravity --- .../horizon-client-render/src/gfx/resident.rs | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client-render/src/gfx/resident.rs b/crates/horizon-client-render/src/gfx/resident.rs index df00062cac..254bd84cb5 100644 --- a/crates/horizon-client-render/src/gfx/resident.rs +++ b/crates/horizon-client-render/src/gfx/resident.rs @@ -192,6 +192,7 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op /// `in_view` without the distance test; `inside`: the section's column is /// entirely inside the frustum, so the section is too. +#[cfg(test)] fn in_frustum( key: SectionKey, min_y: i32, @@ -202,12 +203,14 @@ fn in_frustum( ) -> Option<(f64, Vec3)> { let origin = origin(key, min_y, camera); let min = origin.as_vec3(); - let (bmin, bmax) = match bounds { - Some([bmin, bmax]) => (min + bmin, min + bmax), - None => (min, min + Vec3::splat(16.0)), - }; - if !inside && !frustum.intersects(bmin, bmax) { - return None; + if !inside { + let (bmin, bmax) = match bounds { + Some([bmin, bmax]) => (min + bmin, min + bmax), + None => (min, min + Vec3::splat(16.0)), + }; + if !frustum.intersects(bmin, bmax) { + return None; + } } Some(((origin + DVec3::splat(8.0)).length_squared(), min)) } @@ -247,18 +250,53 @@ impl Resident { if !inside && !frustum.intersects(min, max) { continue; } - for &(y, slot) in &column.sections { - let key = (column.x, y, column.z); - if near && !reachable(key) { - continue; - } - let bounds = self.bounds[slot as usize]; - if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside, bounds) { + let ox = f64::from(column.x * 16) - camera.x; + let oz = f64::from(column.z * 16) - camera.z; + let min_x = ox as f32; + let min_z = oz as f32; + let dx8 = ox + 8.0; + let dz8 = oz + 8.0; + let xz_sq = dx8 * dx8 + dz8 * dz8; + + if inside { + for &(y, slot) in &column.sections { + let key = (column.x, y, column.z); + if near && !reachable(key) { + continue; + } + let oy = f64::from(self.min_y + y * 16) - camera.y; + let min = Vec3::new(min_x, oy as f32, min_z); + let dy8 = oy + 8.0; + let distance = xz_sq + dy8 * dy8; out.push(Visible { - distance: d.to_bits(), + distance: distance.to_bits(), key, slot, - origin: o.to_array(), + origin: min.to_array(), + }); + } + } else { + for &(y, slot) in &column.sections { + let key = (column.x, y, column.z); + if near && !reachable(key) { + continue; + } + let oy = f64::from(self.min_y + y * 16) - camera.y; + let min = Vec3::new(min_x, oy as f32, min_z); + let (bmin, bmax) = match self.bounds[slot as usize] { + Some([bmin, bmax]) => (min + bmin, min + bmax), + None => (min, min + Vec3::splat(16.0)), + }; + if !frustum.intersects(bmin, bmax) { + continue; + } + let dy8 = oy + 8.0; + let distance = xz_sq + dy8 * dy8; + out.push(Visible { + distance: distance.to_bits(), + key, + slot, + origin: min.to_array(), }); } } From b3f866c5a612a9bb37011a585102e2dd2598d0e7 Mon Sep 17 00:00:00 2001 From: unknown <162871325+WhyFovius@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:42:57 +0300 Subject: [PATCH 358/968] Combat: the hurt player hears the own hurt sound again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server thread plays every guest's MotionEvent::HurtSound into its own silent Audio, so single player and multiplayer heard nothing (#4149); the client's ClientboundDamageEventPacket handler never played it either, unlike vanilla's LivingEntity.handleDamageEvent. Vanilla splits the sound in two: the hurt player's own client plays it from handleDamageEvent (Player.getHurtSound: the damage type's effects, volume 1.0, a pitch that walks 1.0 ± 0.2 on the level random), while the server's Entity.playSound reaches every other player around but that one. The DamageEvent handler now resolves the packet's damage type through the synchronized registry and plays hurt_sound(effects) at the player, and tick_guests sends the server's sound to the hub for everyone but the hurt player (Some(conn), the same split the death sound and the honey slide already use). Fixes #4149 Co-Authored-By: Claude --- crates/horizon-client/src/game/lan_actions.rs | 8 +- crates/horizon-client/src/game/remote.rs | 108 +++++++++++++++++- 2 files changed, 113 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4f574acd36..9e38784ccf 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2838,7 +2838,13 @@ impl Game<'_> { }); for event in events { match event { - MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at), + // LivingEntity.playHurtSound: the server's `Entity.playSound` + // reaches every player around but the hurt one, whose own + // client plays it from handleDamageEvent (#4149). + MotionEvent::HurtSound(sound) => { + let source = horizon_client::settings::sound_source(sound) as i32; + host.hub.sound(sound, at, 1.0, 1.0, source, Some(conn)); + } // LivingEntity.checkFallDamage: the crumbs reach every player. MotionEvent::Fall(horizon_client::landing::FallEvent::Particles { state, diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 9a38d268bd..37f6c457dd 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -57,6 +57,21 @@ fn remote_text(tag: &Tag) -> horizon_client::text::Text { comp_text(&tag_to_json(tag)) } +/// The hurt sound of a `ClientboundDamageEventPacket`'s damage type, by its +/// synchronized registry id: `Player.getHurtSound` picks the type's effects. +fn hurt_sound_for( + registries: &Dynamic, + damage: &horizon_world::damage_source::Catalog, + source_type: i32, +) -> &'static str { + registries + .name("minecraft:damage_type", source_type) + .and_then(|name| damage.id(name).and_then(|id| damage.definition(id))) + .map_or(horizon_client::health::hurt_sound("hurt"), |d| { + horizon_client::health::hurt_sound(&d.effects) + }) +} + /// `ClientboundOpenScreenPacket.title` as a container screen draws it: the /// component's styles, and the `§` codes a literal holds (`StringDecomposer`). fn menu_title(tag: &Tag) -> horizon_client::text::Text { @@ -305,6 +320,16 @@ impl Game<'_> { }), } } + /// The hurt sound the hurt player's own client plays from + /// `ClientboundDamageEventPacket` (`LivingEntity.handleDamageEvent`): + /// `Player.getHurtSound` is the damage type's effects, with the codec's + /// `hurt` default when the type is unknown. + pub(super) fn remote_hurt_sound(&self, source_type: i32) -> &'static str { + match self.remote.as_ref() { + Some(r) => hurt_sound_for(&r.registries, &self.motion.health.damage, source_type), + None => horizon_client::health::hurt_sound("hurt"), + } + } /// Leave the title for a server (ConnectScreen): an empty world fed by the connection. pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> { self.connect_server_with_cookies(address, name, None) @@ -2596,15 +2621,35 @@ impl Game<'_> { // The same packet for another entity: Entity.lerpMotion. C::SetEntityMotion(p) => self.remote_motion(p.id, p.velocity), // ClientPacketListener.handleDamageEvent: LivingEntity.handleDamageEvent - // jolts the walk animation and restarts the hurt flash. Its hurt - // sound comes as a sound from Horizon's host, which plays it itself. + // jolts the walk animation and restarts the hurt flash, and the + // hurt player's own client plays its hurt sound (`Player.getHurtSound`: + // the damage type's effects) at `getSoundVolume` with a pitch that + // walks 1.0 ± 0.2 on the level random. The server's sound packet + // reaches every other player around, never this one (#4149). // This player's own hurt tilt and flash come from the server too // (sent to the player itself): its own moves never hurt it. C::DamageEvent(p) => { if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { + let at = self + .player + .as_ref() + .map(|pl| [pl.position.x, pl.position.y, pl.position.z]); if let Some(pl) = self.player.as_mut() { pl.health.animate_hurt(); } + if let Some(at) = at { + let sound = self.remote_hurt_sound(p.source_type); + let pitch = 1.0 + + (self.world.random.next_float() - self.world.random.next_float()) + * 0.2; + self.audio.play_on( + horizon_client::settings::sound_source(sound), + sound, + Some(at), + 1.0, + pitch, + ); + } } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.limb_speed = 1.5; m.hurt_time = 10; @@ -7573,6 +7618,65 @@ mod tests { assert_eq!(pitch(0.0, 120.0, false), 90.0); } + /// The hurt sound of `ClientboundDamageEventPacket` follows its damage + /// type (`Player.getHurtSound`: the type's effects), with the codec's + /// `hurt` default for an unknown type (#4149). + #[test] + fn the_hurt_sound_follows_the_damage_events_type() { + use super::hurt_sound_for; + use horizon_protocol::registry::Dynamic; + use horizon_world::damage_source::Catalog; + let types = [ + ("minecraft:in_fire", "burning"), + ("minecraft:drown", "drowning"), + ("minecraft:sweet_berry_bush", "poking"), + ("minecraft:freeze", "freezing"), + ("minecraft:arrow", "hurt"), + ]; + // The types the server synchronized, in registry order. + let mut registries = Dynamic::default(); + registries.add(horizon_protocol::packets::configuration::RegistryData { + registry: "minecraft:damage_type".into(), + entries: types + .iter() + .map( + |(id, _)| horizon_protocol::packets::configuration::RegistryEntry { + id: (*id).into(), + data: None, + }, + ) + .collect(), + }); + // The damage catalog: one entry per type above, each with its effects. + let bits = |f: f32| format!("{:08x}", f.to_bits()); + let catalog = Catalog::from_json( + &serde_json::json!({ + "data_version": i64::from(horizon_world::DATA_VERSION), + "types": types.iter().map(|(id, effects)| serde_json::json!({ + "id": id, + "message_id": id.trim_start_matches("minecraft:"), + "exhaustion": bits(0.1), + "scaling": "when_caused_by_living_non_player", + "effects": effects, + "death_message_type": "default", + })).collect::>(), + }), + &serde_json::json!({}), + ) + .unwrap(); + for (id, (name, effects)) in types.iter().enumerate() { + assert_eq!( + hurt_sound_for(®istries, &catalog, id as i32), + horizon_client::health::hurt_sound(effects), + "{name}" + ); + } + // An unknown id falls back to the codec's default effects. + assert_eq!( + hurt_sound_for(®istries, &catalog, types.len() as i32), + "minecraft:entity.player.hurt" + ); + } /// Q over a slot of the inventory on a server (#3357): the predicted THROW /// drops the stack, so the player's hand swings, as `LivingEntity.drop` /// does for `Prediction.PREDICTED`; a plain pick up drops nothing. From cf5290e83540d7af2091db7f000a4d632e0b46a4 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 11:44:54 +0100 Subject: [PATCH 359/968] Outline the slider track while dragged --- .../src/graphics_style.rs | 14 +++++- .../horizon-client-render/src/graphics_ui.rs | 1 + .../horizon-client-render/src/options_ui.rs | 1 + crates/horizon-client-render/src/ui.rs | 47 ++++++++++++------- crates/horizon-client/src/game/dialogs.rs | 1 + tools/port_map/client2-009.tsv | 2 +- 6 files changed, 47 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client-render/src/graphics_style.rs b/crates/horizon-client-render/src/graphics_style.rs index bf0ffbc0cd..5751751759 100644 --- a/crates/horizon-client-render/src/graphics_style.rs +++ b/crates/horizon-client-render/src/graphics_style.rs @@ -131,8 +131,20 @@ impl Style { value: f32, hover: bool, active: bool, + dragging: bool, ) { - ui::slider(batch, atlas, x, y, w, label, value, hover && active, active); + ui::slider( + batch, + atlas, + x, + y, + w, + label, + value, + hover && active, + active, + dragging && active, + ); } /// The cost tag and the description beside a control, wrapped to two lines. pub fn description( diff --git a/crates/horizon-client-render/src/graphics_ui.rs b/crates/horizon-client-render/src/graphics_ui.rs index 3bb558ab1d..2c5113cf9f 100644 --- a/crates/horizon-client-render/src/graphics_ui.rs +++ b/crates/horizon-client-render/src/graphics_ui.rs @@ -855,6 +855,7 @@ impl GraphicsUi { value, hover || self.dragging == Some(item), live, + self.dragging == Some(item), ), None => STYLE.button( batch, diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 3ec3fd8255..1d1122026a 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -2532,6 +2532,7 @@ impl OptionsUi { shown, hover || dragging, true, + dragging, ); } WKind::Text { diff --git a/crates/horizon-client-render/src/ui.rs b/crates/horizon-client-render/src/ui.rs index 3da936033a..31a9ddc5e3 100644 --- a/crates/horizon-client-render/src/ui.rs +++ b/crates/horizon-client-render/src/ui.rs @@ -390,6 +390,7 @@ pub fn slider( value: f32, hover: bool, active: bool, + dragging: bool, ) { let kind = crate::widgets::Kind::Slider { value, @@ -401,23 +402,8 @@ pub fn slider( // A controller's D-pad can focus it (Horizon's controller support). crate::widgets::target_as([x, y, w, 20.0], kind); } - // Vanilla outlines the track (slider_highlighted) only for keyboard focus; - // hovering highlights just the handle. - nine_slice( - batch, - atlas, - "gui/sprites/widget/slider", - x, - y, - w, - 20.0, - u32::MAX, - ); - let handle = if hover && active { - "gui/sprites/widget/slider_handle_highlighted" - } else { - "gui/sprites/widget/slider_handle" - }; + let (track, handle) = slider_sprites(hover, active, dragging); + nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX); nine_slice( batch, atlas, @@ -435,6 +421,21 @@ pub fn slider( }; centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color); } +/// Track and handle sprites: the track outline shows only while the slider is +/// dragged, hovering highlights just the handle. +fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) { + let track = if dragging { + "gui/sprites/widget/slider_highlighted" + } else { + "gui/sprites/widget/slider" + }; + let handle = if hover && active { + "gui/sprites/widget/slider_handle_highlighted" + } else { + "gui/sprites/widget/slider_handle" + }; + (track, handle) +} /// Slider value for a click at `cx` (AbstractSliderButton.setValueFromMouse). pub fn slider_value(x: f32, w: f32, cx: f32) -> f32 { ((cx - (x + 4.0)) / (w - 8.0)).clamp(0.0, 1.0) @@ -1350,6 +1351,18 @@ mod tests { parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap() } #[test] + fn slider_track_is_outlined_only_while_dragged() { + let (track, handle) = slider_sprites(true, true, false); + assert_eq!(track, "gui/sprites/widget/slider"); + assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted"); + let (track, handle) = slider_sprites(true, true, true); + assert_eq!(track, "gui/sprites/widget/slider_highlighted"); + assert_eq!(handle, "gui/sprites/widget/slider_handle_highlighted"); + let (track, handle) = slider_sprites(false, true, false); + assert_eq!(track, "gui/sprites/widget/slider"); + assert_eq!(handle, "gui/sprites/widget/slider_handle"); + } + #[test] fn separator_tiles_sample_both_rows_and_clip_the_last_column() { let mut batch = HudBatch::new([640, 480]); tile( diff --git a/crates/horizon-client/src/game/dialogs.rs b/crates/horizon-client/src/game/dialogs.rs index f5cba043bd..b82284ed55 100644 --- a/crates/horizon-client/src/game/dialogs.rs +++ b/crates/horizon-client/src/game/dialogs.rs @@ -422,6 +422,7 @@ impl Game<'_> { position, hover || held, true, + held, ); } Part::Item { body, rect } => { diff --git a/tools/port_map/client2-009.tsv b/tools/port_map/client2-009.tsv index a966275745..04717d147b 100644 --- a/tools/port_map/client2-009.tsv +++ b/tools/port_map/client2-009.tsv @@ -118,7 +118,7 @@ AbstractSelectionList.narrationPriority skip:other screen-reader priority; Hori AbstractSelectionList.removeEntry skip:other rows are rebuilt from the model when they change (update_list, server list), so there is no single-row removal AbstractSelectionList.narrateListElementPosition skip:other narration only AbstractSelectionList.TrackedList.set skip:java delegate list wrapper -AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: the track outline (vanilla: while focused or dragged) is never drawn; hover highlights only the handle +AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: the track outline is drawn while dragged only (vanilla: focused or dragged); keyboard focus does not highlight; hover highlights only the handle AbstractSliderButton.getHandleSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: handle highlight follows hover only AbstractSliderButton.updateWidgetNarration skip:other narration only AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider gap: minor: the label is clipped rather than scrolled From d3d714e9eb8d429a84fcaf15d9d41bcb025f6bf1 Mon Sep 17 00:00:00 2001 From: Sylviromi Date: Fri, 30 Oct 2026 13:45:33 +0300 Subject: [PATCH 360/968] Native mods: native_data, a data file of the resources, content mods' included (API 9) Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_hooks.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 7921d38db2..b42b3a199a 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -637,6 +637,16 @@ impl Game<'_> { pub(super) fn native_screen_title(&self) -> Option { self.remote_menu_title().map(|t| t.plain()) } + /// A data file of the game's resources, content mods' included + /// (`data/mymod/machines.json`), when there is one: what a mod's content + /// says, read at runtime. Only relative paths of plain names. API 9. + pub(super) fn native_data(&self, path: &str) -> Option> { + let plain = |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\'); + if !path.starts_with("data/") || !path.split('/').all(plain) { + return None; + } + fs::read(self.options.resources.join(path)).ok() + } /// The block at `pos` (`mymod:cable`; air where nothing is loaded). /// API 9. pub(super) fn native_block(&self, pos: [i32; 3]) -> String { From c172ab7dae49e73e2931ae06881f6113068ac0cb Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 11:51:52 +0100 Subject: [PATCH 361/968] Drop unverified vanilla claim from the slider getSprite ledger line --- tools/port_map/client2-009.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/port_map/client2-009.tsv b/tools/port_map/client2-009.tsv index 04717d147b..1f20d713ed 100644 --- a/tools/port_map/client2-009.tsv +++ b/tools/port_map/client2-009.tsv @@ -118,7 +118,7 @@ AbstractSelectionList.narrationPriority skip:other screen-reader priority; Hori AbstractSelectionList.removeEntry skip:other rows are rebuilt from the model when they change (update_list, server list), so there is no single-row removal AbstractSelectionList.narrateListElementPosition skip:other narration only AbstractSelectionList.TrackedList.set skip:java delegate list wrapper -AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: the track outline is drawn while dragged only (vanilla: focused or dragged); keyboard focus does not highlight; hover highlights only the handle +AbstractSliderButton.getSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: the track outline is drawn while dragged only; keyboard focus does not highlight it; hover highlights only the handle AbstractSliderButton.getHandleSprite inline crates/horizon-client-render/src/ui.rs::slider gap: minor: handle highlight follows hover only AbstractSliderButton.updateWidgetNarration skip:other narration only AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider gap: minor: the label is clipped rather than scrolled From cfcd6c9112a205a0f17834956c401d7c4e24fa40 Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 11:53:06 +0100 Subject: [PATCH 362/968] Smithing table: the empty slots cycle their icons, as CyclingSlotBackground does The template slot cycles the armour trim and netherite upgrade template icons; with a template in the table the base slot cycles what it takes (the 11 tools and armour of the netherite upgrade, the four armour pieces of a trim) and the additions slot the ingot or the seven trim materials, one icon every 30 ticks with a 4 tick fade. It showed one fixed netherite icon. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/stations.rs | 195 ++++++++++++++++++++- tools/port_map/game2-009.tsv | 6 +- 2 files changed, 193 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index db8d54cb8d..5462408aae 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -127,6 +127,123 @@ impl Kind { } } +/// `CyclingSlotBackground`: the icons an empty slot cycles through, one every +/// 30 ticks, the next fading in over 4 (`CyclingSlotBackground.tick` in +/// `advance`, `CyclingSlotBackground.extractRenderState` in `shown`). +#[derive(Default)] +struct CyclingSlot { + icons: Vec<&'static str>, + tick: u32, + index: usize, + /// The client tick it was last advanced to. + at: Option, +} +impl CyclingSlot { + const CHANGE_TICKS: u32 = 30; + const FADE_TICKS: f32 = 4.0; + /// `CyclingSlotBackground.tick(newIcons)` for each client tick since the last call. + fn advance(&mut self, now: u64, icons: &[&'static str]) { + let ticks = self.at.map_or(0, |at| now.saturating_sub(at).min(600)); + self.at = Some(now); + if self.icons != icons { + self.icons = icons.to_vec(); + self.index = 0; + } + for _ in 0..ticks { + if !self.icons.is_empty() { + self.tick += 1; + if self.tick % Self::CHANGE_TICKS == 0 { + self.index = (self.index + 1) % self.icons.len(); + } + } + } + } + /// What `CyclingSlotBackground.extractRenderState` draws for an empty slot: the icon before + /// this one fading out while this one fades in, with their alphas. + fn shown(&self, partial: f32) -> Vec<(&'static str, f32)> { + if self.icons.is_empty() { + return Vec::new(); + } + let fading = self.icons.len() > 1 && self.tick >= Self::CHANGE_TICKS; + let alpha = if fading { + (((self.tick % Self::CHANGE_TICKS) as f32 + partial).min(Self::FADE_TICKS)) + / Self::FADE_TICKS + } else { + 1.0 + }; + let mut shown = Vec::new(); + if alpha < 1.0 { + let previous = (self.index + self.icons.len() - 1) % self.icons.len(); + shown.push((self.icons[previous], 1.0 - alpha)); + } + shown.push((self.icons[self.index], alpha)); + shown + } +} +/// `SmithingScreen`'s `EMPTY_SLOT_SMITHING_TEMPLATES`. +const EMPTY_TEMPLATE_ICONS: [&str; 2] = [ + "smithing_template_armor_trim", + "smithing_template_netherite_upgrade", +]; +/// The base and additions slot icons of the template in the smithing table: +/// `SmithingTemplateItem.createNetheriteUpgradeTemplate` (the 11 tools and +/// armour of `SmithingTemplateItem.createNetheriteUpgradeIconList`, then the +/// ingot) and `SmithingTemplateItem.createArmorTrimTemplate` (the four armour +/// pieces, then the seven trim materials), as `getBaseSlotEmptyIcons` and +/// `getAdditionalSlotEmptyIcons` give them. +fn smithing_slot_icons( + template: Option<&str>, +) -> ([&'static str; 11], usize, [&'static str; 7], usize) { + let none = ([""; 11], 0, [""; 7], 0); + match template.map(|t| t.trim_start_matches("minecraft:")) { + Some("netherite_upgrade_smithing_template") => ( + [ + "helmet", + "sword", + "chestplate", + "pickaxe", + "leggings", + "axe", + "boots", + "hoe", + "shovel", + "nautilus_armor", + "spear", + ], + 11, + ["ingot", "", "", "", "", "", ""], + 1, + ), + Some(t) if t.ends_with("_armor_trim_smithing_template") => ( + [ + "helmet", + "chestplate", + "leggings", + "boots", + "", + "", + "", + "", + "", + "", + "", + ], + 4, + [ + "ingot", + "redstone_dust", + "lapis_lazuli", + "quartz", + "diamond", + "emerald", + "amethyst_shard", + ], + 7, + ), + _ => none, + } +} + pub(super) struct Station { pub(super) kind: Kind, pub(super) pos: [i32; 3], @@ -161,6 +278,8 @@ pub(super) struct Station { pub(super) beacon_levels: Option, /// The loom's patterns, selection and scroller (game/loom.rs). pub(super) loom: super::loom::LoomState, + /// The smithing table's three `CyclingSlotBackground`s: the template, base and additions slots. + cycling: [CyclingSlot; 3], } impl Station { fn new(kind: Kind, pos: [i32; 3]) -> Self { @@ -186,6 +305,7 @@ impl Station { beacon: [None; 2], beacon_levels: None, loom: Default::default(), + cycling: Default::default(), } } pub(super) fn new_kind(kind: Kind, pos: [i32; 3]) -> Self { @@ -1221,6 +1341,21 @@ impl Game<'_> { if self.station.is_none() && !brewing { return false; } + // `SmithingScreen.containerTick`: the icons of the empty slots follow the template. + let (now, partial) = (self.ticks, self.clock.camera_fraction() as f32); + if let Some(s) = self.station.as_mut().filter(|s| s.kind == Kind::Smithing) { + let template = s.inputs[0].as_ref().map(|t| t.item.clone()); + let (base, base_len, additions, additions_len) = + smithing_slot_icons(template.as_deref()); + let lists: [&[&'static str]; 3] = [ + &EMPTY_TEMPLATE_ICONS, + &base[..base_len], + &additions[..additions_len], + ]; + for (cycling, icons) in s.cycling.iter_mut().zip(lists) { + cycling.advance(now, icons); + } + } let (ox, oy) = self.inventory_origin(batch); let local = (self.cursor.0 - ox, self.cursor.1 - oy); let mut tooltip: Option> = None; @@ -1609,15 +1744,23 @@ impl Game<'_> { } } Kind::Smithing => { - let icons = ["smithing_template_netherite_upgrade", "", ""]; - for (i, icon) in icons.iter().enumerate() { - if s.inputs[i].is_none() && !icon.is_empty() { - let (sx, sy) = kind.input_pos(i); - batch.sprite( + // `CyclingSlotBackground.extractRenderState`: an empty slot shows the + // icons of what the template in the table takes. + for (i, cycling) in s.cycling.iter().enumerate() { + if s.inputs[i].is_some() { + continue; + } + let (sx, sy) = kind.input_pos(i); + for (icon, alpha) in cycling.shown(partial) { + batch.sprite_scaled( atlas, &format!("gui/sprites/container/slot/{icon}"), ox + sx, oy + sy, + 16.0, + 16.0, + ((alpha.clamp(0.0, 1.0) * 255.0).round() as u32) << 24 + | 0x00FF_FFFF, ); } } @@ -2605,3 +2748,45 @@ mod tests { assert!(!s.stonecutter_scrolls()); } } + +#[cfg(test)] +mod cycling_tests { + use super::*; + + #[test] + fn an_empty_slot_cycles_its_icons_every_30_ticks_and_fades_the_next_in_over_4() { + let icons = ["helmet", "sword", "chestplate"]; + let mut slot = CyclingSlot::default(); + slot.advance(100, &icons); + // The first icon, fully shown, until the 30th tick. + assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]); + slot.advance(129, &icons); + assert_eq!(slot.shown(0.5), vec![("helmet", 1.0)]); + // The 30th tick moves to the next icon, which fades in over 4 ticks while the + // one before fades out. + slot.advance(130, &icons); + assert_eq!(slot.shown(0.0), vec![("helmet", 1.0), ("sword", 0.0)]); + assert_eq!(slot.shown(2.0), vec![("helmet", 0.5), ("sword", 0.5)]); + assert_eq!(slot.shown(4.0), vec![("sword", 1.0)]); + // A new list starts again from its first icon, keeping the tick count. + slot.advance(131, &["ingot"]); + assert_eq!(slot.shown(0.0), vec![("ingot", 1.0)]); + // No icons, nothing shown. + slot.advance(132, &[]); + assert!(slot.shown(0.0).is_empty()); + } + + #[test] + fn the_icons_of_a_smithing_slot_follow_the_template() { + let (base, n, additions, m) = + smithing_slot_icons(Some("minecraft:netherite_upgrade_smithing_template")); + assert_eq!(base[..n].len(), 11); + assert_eq!(additions[..m], ["ingot"]); + let (base, n, additions, m) = + smithing_slot_icons(Some("minecraft:coast_armor_trim_smithing_template")); + assert_eq!(base[..n], ["helmet", "chestplate", "leggings", "boots"]); + assert_eq!(additions[..m].len(), 7); + assert_eq!(smithing_slot_icons(Some("minecraft:stick")).1, 0); + assert_eq!(smithing_slot_icons(None).3, 0); + } +} diff --git a/tools/port_map/game2-009.tsv b/tools/port_map/game2-009.tsv index f685966902..faab67c4b3 100644 --- a/tools/port_map/game2-009.tsv +++ b/tools/port_map/game2-009.tsv @@ -1,6 +1,6 @@ -SmithingTemplateItem.createArmorTrimTemplate inline crates/horizon-client/src/game/stations.rs::Game::draw_station gap: player: the empty template slot cycles the armour icons in vanilla (SmithingScreen); Horizon draws one fixed netherite icon, and the additions slot gets no icon -SmithingTemplateItem.createNetheriteUpgradeTemplate inline crates/horizon-client/src/game/stations.rs::Game::draw_station gap: player: no cycling tool and armour icons and no ingot icon for the additions slot; Horizon draws one fixed netherite icon in the template slot -SmithingTemplateItem.createNetheriteUpgradeIconList missing player: an empty template slot in the smithing table does not cycle through the 11 tool and armour icons (SmithingScreen ticks baseIcon); Horizon shows one fixed netherite icon +SmithingTemplateItem.createArmorTrimTemplate port crates/horizon-client/src/game/stations.rs::smithing_slot_icons the empty template slot cycles the armour trim and netherite upgrade template icons, the base slot the helmet to boots and the additions slot the ingot to amethyst shard, as CyclingSlotBackground (30 ticks, a 4 tick fade) +SmithingTemplateItem.createNetheriteUpgradeTemplate port crates/horizon-client/src/game/stations.rs::smithing_slot_icons the netherite upgrade template: the 11 tool and armour icons in the base slot and the ingot in the additions slot +SmithingTemplateItem.createNetheriteUpgradeIconList port crates/horizon-client/src/game/stations.rs::smithing_slot_icons the 11 tool and armour icons the base slot cycles for a netherite upgrade template (smithing_slot_icons) SpawnEggItem.spawnMob port crates/horizon-client/src/game/creatures.rs::Game::use_spawn_egg gap: minor: no ENTITY_PLACE game event (sculk sensors do not hear the egg); consumes the egg and spawns the mob on the clicked face SpawnEggItem.getType port crates/horizon-client/src/game/creatures.rs::Game::use_spawn_egg kind taken from the item id suffix (*_spawn_egg), not an ENTITY_DATA component TridentItem.createAttributes skip:data attack damage 8 and attack speed -2.9 are item table data (horizon-items attack_speed modifier) From 6a03966205362979f59c737010dc2b9e64769f35 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:26:42 +0530 Subject: [PATCH 363/968] Encode cooking recipes into the recipe book AbstractCookingRecipe.display() gives smelting, blasting, smoking and campfire one and the same FurnaceRecipeDisplay: the ingredient, any fuel, the result, the station, the cooking time and the experience. Only the station and the book category differ, and the category comes from the recipe, not from the client. book_entries encoded shaped and shapeless crafting only and returned an empty list for everything else, so the furnace, blast furnace and smoker books were empty for every player whose window is a client: single player on its server thread, LAN guests and dedicated players alike. One shared arm now writes that display, and the category switches follow the jar: smelting food/blocks/other, blasting blocks and one misc tab, smoking, and campfire (which no book tab lists, as in vanilla). --- crates/horizon-client/src/lan/advancements.rs | 302 ++++++++++++++++-- crates/horizon-client/src/recipe_book.rs | 4 +- 2 files changed, 272 insertions(+), 34 deletions(-) diff --git a/crates/horizon-client/src/lan/advancements.rs b/crates/horizon-client/src/lan/advancements.rs index 3c2d4cb18d..b88c294900 100644 --- a/crates/horizon-client/src/lan/advancements.rs +++ b/crates/horizon-client/src/lan/advancements.rs @@ -6,7 +6,7 @@ use super::data::{SyncData, json_to_nbt}; use crate::{ advancements::{Progress, Tree}, - recipe_book::{RecipeIndex, Settings}, + recipe_book::{Category, RecipeIndex, Settings, crafting_category, crafting_station}, }; use horizon_protocol::buf::Writer; use serde_json::Value; @@ -320,7 +320,34 @@ impl ServerAdvancements { let slot = |n: &str| sync.id("minecraft:slot_display", n).unwrap_or(0); let mut w = Writer::new(); w.varint(ids.start); - let (ingredients, category): (Vec>, &str) = match kind { + // The ingredients' TagSlotDisplays, an empty cell as an empty slot. + let slots = |w: &mut Writer, cells: &[Option<&Value>]| { + w.varint(cells.len() as i32); + for cell in cells { + match cell { + Some(v) => { + w.varint(slot("minecraft:tag")); + holder_set(w, v, &item); + } + None => w.varint(slot("minecraft:empty")), + } + } + }; + // The result as its ItemStackSlotDisplay, the station as its item. + let result = |w: &mut Writer| { + w.varint(slot("minecraft:item_stack")); + w.varint(item(r["result"]["id"].as_str().unwrap_or("minecraft:air"))); + w.varint(r["result"]["count"].as_i64().unwrap_or(1) as i32); + w.varint(0); + w.varint(0); + }; + let station = |w: &mut Writer, name: &str| { + w.varint(slot("minecraft:item")); + w.varint(item(name)); + }; + // The display, then the ingredients the craftable filter checks and + // the category of the tab that lists it. + let (category, placed): (&str, Vec<&Value>) = match kind { "minecraft:crafting_shaped" => { let pattern = shrink(r["pattern"].as_array().map_or(&[][..], |p| p)); let (width, height) = (pattern.first().map_or(0, Vec::len), pattern.len()); @@ -332,53 +359,61 @@ impl ServerAdvancements { .flatten() .map(|c| (*c != ' ').then(|| &r["key"][c.to_string()])) .collect(); - (cells, "misc") + slots(&mut w, &cells); + result(&mut w); + station(&mut w, "minecraft:crafting_table"); + let placed = cells.iter().flatten().copied().collect(); + ( + crafting_category(r["category"].as_str().unwrap_or("")).name(), + placed, + ) } "minecraft:crafting_shapeless" => { w.varint(sync.id("minecraft:recipe_display", kind).unwrap_or(0)); - let cells = r["ingredients"] + let cells: Vec> = r["ingredients"] .as_array() .map_or(Vec::new(), |a| a.iter().map(Some).collect()); - (cells, "misc") + slots(&mut w, &cells); + result(&mut w); + station(&mut w, "minecraft:crafting_table"); + let placed = cells.iter().flatten().copied().collect(); + ( + crafting_category(r["category"].as_str().unwrap_or("")).name(), + placed, + ) + } + // AbstractCookingRecipe.display: the furnace, blast furnace, + // smoker and campfire share one FurnaceRecipeDisplay; only the + // station and the category differ. + "minecraft:smelting" + | "minecraft:blasting" + | "minecraft:smoking" + | "minecraft:campfire_cooking" => { + w.varint( + sync.id("minecraft:recipe_display", "minecraft:furnace") + .unwrap_or(0), + ); + w.varint(slot("minecraft:tag")); + holder_set(&mut w, &r["ingredient"], &item); + w.varint(slot("minecraft:any_fuel")); + result(&mut w); + station(&mut w, crafting_station(kind)); + w.varint(r["cookingtime"].as_i64().unwrap_or(0) as i32); + w.f32(r["experience"].as_f64().unwrap_or(0.0) as f32); + (cooking_category(kind, r), vec![&r["ingredient"]]) } _ => return Vec::new(), }; - // ShapedCraftingRecipeDisplay / ShapelessCraftingRecipeDisplay: - // the ingredients' TagSlotDisplays, the result, the crafting table. - w.varint(ingredients.len() as i32); - for cell in &ingredients { - match cell { - Some(v) => { - w.varint(slot("minecraft:tag")); - holder_set(&mut w, v, &item); - } - None => w.varint(slot("minecraft:empty")), - } - } - w.varint(slot("minecraft:item_stack")); - w.varint(item(r["result"]["id"].as_str().unwrap_or("minecraft:air"))); - w.varint(r["result"]["count"].as_i64().unwrap_or(1) as i32); - w.varint(0); - w.varint(0); - w.varint(slot("minecraft:item")); - w.varint(item("minecraft:crafting_table")); // The group, the recipe book category and the placement's ingredients. match r["group"].as_str().and_then(|g| self.groups.get(g)) { Some(g) => w.varint(g + 1), None => w.varint(0), } - let category = format!( - "minecraft:crafting_{}", - match r["category"].as_str().unwrap_or(category) { - "building" => "building_blocks", - c => c, - } - ); + let category = format!("minecraft:{category}"); w.varint( sync.id("minecraft:recipe_book_category", &category) .unwrap_or(0), ); - let placed: Vec<&Value> = ingredients.iter().flatten().copied().collect(); w.bool(true); w.varint(placed.len() as i32); for v in placed { @@ -405,6 +440,28 @@ fn book_add(entries: Vec>, replace: bool) -> Vec { w.data } +/// `SmeltingRecipe.recipeBookCategory` and the other cooking recipes': the +/// category of the station's book, from the recipe's `CookingBookCategory`. +fn cooking_category(kind: &str, r: &Value) -> &'static str { + let category = match kind { + // BlastingRecipe: a blocks tab, and one misc tab for the rest. + "minecraft:blasting" => match r["category"].as_str() { + Some("blocks") => Category::BlastBlocks, + _ => Category::BlastMisc, + }, + "minecraft:smoking" => Category::SmokerFood, + // CampfireCookingRecipe: its own category, which no book's tab lists. + "minecraft:campfire_cooking" => return "campfire", + // SmeltingRecipe: blocks, food and misc, the default. + _ => match r["category"].as_str() { + Some("food") => Category::FurnaceFood, + Some("blocks") => Category::FurnaceBlocks, + _ => Category::FurnaceMisc, + }, + }; + category.name() +} + /// ShapedRecipePattern.shrink: the pattern without blank border rows and columns. fn shrink(rows: &[Value]) -> Vec> { let rows: Vec> = rows @@ -456,3 +513,184 @@ fn holder_set(w: &mut Writer, v: &Value, item: &dyn Fn(&str) -> i32) { _ => w.varint(1), } } + +#[cfg(test)] +mod tests { + use super::*; + use horizon_protocol::{ + buf::Reader, + item::Stack, + packets::recipes::{HolderSet, RecipeBookEntry, RecipeDisplay, SlotDisplay}, + }; + use std::path::PathBuf; + + /// The registries `book_entries` looks up, in the game's order + /// (`RecipeBookCategories` and the two display registries). + const REGISTRIES: &str = r#"{ + "minecraft:item": {"entries": { + "minecraft:raw_copper": {"protocol_id": 0}, + "minecraft:copper_ingot": {"protocol_id": 1}, + "minecraft:redstone_ore": {"protocol_id": 2}, + "minecraft:redstone": {"protocol_id": 3}, + "minecraft:furnace": {"protocol_id": 4}, + "minecraft:blast_furnace": {"protocol_id": 5} + }}, + "minecraft:slot_display": {"entries": { + "minecraft:empty": {"protocol_id": 0}, + "minecraft:any_fuel": {"protocol_id": 1}, + "minecraft:with_any_potion": {"protocol_id": 2}, + "minecraft:only_with_component": {"protocol_id": 3}, + "minecraft:item": {"protocol_id": 4}, + "minecraft:item_stack": {"protocol_id": 5}, + "minecraft:tag": {"protocol_id": 6}, + "minecraft:dyed": {"protocol_id": 7}, + "minecraft:smithing_trim": {"protocol_id": 8}, + "minecraft:with_remainder": {"protocol_id": 9}, + "minecraft:composite": {"protocol_id": 10} + }}, + "minecraft:recipe_display": {"entries": { + "minecraft:crafting_shapeless": {"protocol_id": 0}, + "minecraft:crafting_shaped": {"protocol_id": 1}, + "minecraft:furnace": {"protocol_id": 2}, + "minecraft:stonecutter": {"protocol_id": 3}, + "minecraft:smithing": {"protocol_id": 4} + }}, + "minecraft:recipe_book_category": {"entries": { + "minecraft:crafting_building_blocks": {"protocol_id": 0}, + "minecraft:crafting_redstone": {"protocol_id": 1}, + "minecraft:crafting_equipment": {"protocol_id": 2}, + "minecraft:crafting_misc": {"protocol_id": 3}, + "minecraft:furnace_food": {"protocol_id": 4}, + "minecraft:furnace_blocks": {"protocol_id": 5}, + "minecraft:furnace_misc": {"protocol_id": 6}, + "minecraft:blast_furnace_blocks": {"protocol_id": 7}, + "minecraft:blast_furnace_misc": {"protocol_id": 8}, + "minecraft:smoker_food": {"protocol_id": 9}, + "minecraft:stonecutter": {"protocol_id": 10}, + "minecraft:smithing": {"protocol_id": 11}, + "minecraft:campfire": {"protocol_id": 12} + }} + }"#; + + /// A resources dir with those registries and the given recipe files. + fn resources(name: &str, recipes: &[(&str, &str)]) -> PathBuf { + let root = + std::env::temp_dir().join(format!("horizon-recipe-book-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("data/minecraft/recipe"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(root.join("registries.json"), REGISTRIES).unwrap(); + for (id, json) in recipes { + std::fs::write(dir.join(format!("{id}.json")), json).unwrap(); + } + root + } + + /// The server's sync over `root`, and the one entry of `id` read back as + /// the client's `RecipeBookEntry::read` reads it. + fn entry(root: &std::path::Path, id: &str) -> (SyncData, RecipeBookEntry) { + let sync = SyncData::load(root, &horizon_world::loot::PackOverlay::NONE).unwrap(); + let entries = sync + .advancements + .book_entries(id, &sync, NOTIFY_AND_HIGHLIGHT); + assert_eq!(entries.len(), 1, "{id} has one display"); + let mut r = Reader::new(&entries[0]); + let entry = RecipeBookEntry::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0, "the entry ends with its flags"); + (sync, entry) + } + + #[test] + fn smelting_reaches_the_furnace_book() { + let root = resources( + "smelting", + &[( + "copper_ingot_from_smelting_raw_copper", + r#"{"type":"minecraft:smelting","cookingtime":200,"experience":0.7, + "group":"copper_ingot","ingredient":"minecraft:raw_copper", + "result":{"id":"minecraft:copper_ingot"}}"#, + )], + ); + let (sync, e) = entry(&root, "minecraft:copper_ingot_from_smelting_raw_copper"); + let item = |n: &str| sync.id("minecraft:item", n).unwrap(); + // The fixture's only recipe: display ids are numbered over the sorted ids. + assert_eq!(e.id, 0); + assert_eq!( + e.category, + sync.id("minecraft:recipe_book_category", "minecraft:furnace_misc") + .unwrap() + ); + assert_eq!(e.group, 1, "copper_ingot is the first group"); + assert_eq!(e.flags, NOTIFY_AND_HIGHLIGHT); + assert_eq!( + e.requirements, + Some(vec![HolderSet::Items(vec![item("minecraft:raw_copper")])]) + ); + let RecipeDisplay::Furnace { + ingredient, + fuel, + result, + station, + duration, + experience, + } = e.display + else { + panic!("a smelting recipe shows a furnace display"); + }; + assert_eq!( + ingredient, + SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:raw_copper")])) + ); + assert_eq!(fuel, SlotDisplay::AnyFuel); + assert_eq!( + result, + SlotDisplay::ItemStack(Stack::new(item("minecraft:copper_ingot"), 1)) + ); + assert_eq!(station, SlotDisplay::Item(item("minecraft:furnace"))); + assert_eq!(duration, 200); + assert_eq!(experience, 0.7); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn blasting_reaches_the_blast_furnace_book() { + let root = resources( + "blasting", + &[( + "redstone_from_blasting_redstone_ore", + r#"{"type":"minecraft:blasting","category":"blocks","cookingtime":200, + "experience":0.7,"group":"redstone", + "ingredient":"minecraft:redstone_ore","result":{"id":"minecraft:redstone"}}"#, + )], + ); + let (sync, e) = entry(&root, "minecraft:redstone_from_blasting_redstone_ore"); + let item = |n: &str| sync.id("minecraft:item", n).unwrap(); + assert_eq!(e.id, 0); + assert_eq!( + e.category, + sync.id( + "minecraft:recipe_book_category", + "minecraft:blast_furnace_blocks" + ) + .unwrap() + ); + let RecipeDisplay::Furnace { + ingredient, + station, + duration, + experience, + .. + } = e.display + else { + panic!("a blasting recipe shows a furnace display"); + }; + assert_eq!( + ingredient, + SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:redstone_ore")])) + ); + assert_eq!(station, SlotDisplay::Item(item("minecraft:blast_furnace"))); + assert_eq!(duration, 200); + assert_eq!(experience, 0.7); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/crates/horizon-client/src/recipe_book.rs b/crates/horizon-client/src/recipe_book.rs index 77b06a32a7..14f0cdd060 100644 --- a/crates/horizon-client/src/recipe_book.rs +++ b/crates/horizon-client/src/recipe_book.rs @@ -287,7 +287,7 @@ pub struct Book { /// Item id -> registry position, to order ingredient choices. pub order: HashMap, } -fn crafting_category(name: &str) -> Category { +pub(crate) fn crafting_category(name: &str) -> Category { match name { "building" => Category::CraftingBuilding, "redstone" => Category::CraftingRedstone, @@ -647,7 +647,7 @@ struct Known { result: Option, } /// `RecipeDisplay.craftingStation` of a recipe type. -fn crafting_station(kind: &str) -> &'static str { +pub(crate) fn crafting_station(kind: &str) -> &'static str { match kind { "minecraft:smelting" => "minecraft:furnace", "minecraft:blasting" => "minecraft:blast_furnace", From 1c27a0c66df45bbc3c8f1b1b58fcd106c4a8a7be Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 11:58:10 +0100 Subject: [PATCH 364/968] clippy: is_multiple_of in CyclingSlot Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/stations.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 5462408aae..e4ff387273 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -152,7 +152,7 @@ impl CyclingSlot { for _ in 0..ticks { if !self.icons.is_empty() { self.tick += 1; - if self.tick % Self::CHANGE_TICKS == 0 { + if self.tick.is_multiple_of(Self::CHANGE_TICKS) { self.index = (self.index + 1) % self.icons.len(); } } From 015fbc82c11c8ec6e34eed40ab43b76d2a602aee Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:29:13 +0530 Subject: [PATCH 365/968] Make the client-loaded immunity term real ServerPlayer.isInvulnerableTo has three terms, and the third is !connection.hasClientLoaded: from a death until the client reports the world loaded, nothing hurts the player. That is what spares a respawned player the void in the tick it is still standing where it died. Horizon implemented all three terms, but the only production implementation of the trait returned Ok(true) for this one, so it was dead code. A guest now carries the flag: cleared by a respawn, set by ServerboundPlayerLoadedPacket, copied onto the body each turn, and read by the damage environment (including the starvation path, which builds one too). A level change does not clear it, because vanilla resets the loaded state on death and respawn only. --- crates/horizon-client/src/game/lan_actions.rs | 2 + crates/horizon-client/src/health.rs | 38 ++++++++++++- crates/horizon-client/src/hunger.rs | 3 ++ crates/horizon-client/src/lan/guest.rs | 5 ++ crates/horizon-client/src/lan/hub.rs | 10 +++- crates/horizon-client/src/motion.rs | 5 ++ crates/horizon-client/tests/lan_bot.rs | 54 +++++++++++++++++++ 7 files changed, 115 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4f574acd36..23b0abb2ac 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -352,6 +352,8 @@ impl Game<'_> { // FireworkRocketItem.use boosts only a gliding player. motion.fall_flying = g.fall_flying; motion.invulnerable = motion.may_fly; + // `ServerPlayer.isInvulnerableTo`'s connection term, as of now. + motion.client_loaded = g.client_loaded; let _ = motion.set_health(g.health, false); motion.health.food.food = g.food; motion.health.food.saturation = g.saturation; diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 28391c4964..f3c440b6c9 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -342,6 +342,7 @@ impl PlayerMotion { let h = &mut self.health; let mut environment = Environment { invulnerable, + client_loaded: self.client_loaded, rules, source, living_cause, @@ -425,6 +426,9 @@ impl PlayerMotion { pub(crate) struct Environment<'a> { /// Abilities.invulnerable. pub(crate) invulnerable: bool, + /// The player's client has loaded the world since its last respawn or + /// level change (`ServerGamePacketListenerImpl.hasClientLoaded`). + pub(crate) client_loaded: bool, pub(crate) rules: HealthRules, pub(crate) source: DamageSource<'a, ()>, /// The causing entity is a living non-player (a mob, or its projectile's owner). @@ -663,7 +667,7 @@ impl ImmunityWorld for Environment<'_> { Ok(self.source.is_type("minecraft:ender_pearl")) } fn client_loaded(&mut self) -> io::Result { - Ok(true) + Ok(self.client_loaded) } } impl HurtWorld for Environment<'_> { @@ -905,6 +909,38 @@ mod tests { assert!(player.health.dead_or_dying(), "the player must be dead"); } + /// #4301: `ServerPlayer.isInvulnerableTo`'s `!connection.hasClientLoaded` + /// term spares a respawned player the void until the client has loaded the + /// world; once it has, the void hurts again. + #[test] + fn the_void_spares_a_player_whose_client_has_not_loaded() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let resources = crate::motion::MotionResources::load(&root, &assets).unwrap(); + let mut player = + crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO) + .unwrap(); + let (health, rules) = (&resources.health, super::HealthRules::default()); + let id = health.damage.id("minecraft:out_of_world").unwrap(); + player.client_loaded = false; + assert!( + !player.hurt(health, rules, id, 4.0).unwrap(), + "an unloaded client must not take void damage" + ); + assert_eq!(player.health.health(), 20.0); + player.client_loaded = true; + assert!( + player.hurt(health, rules, id, 4.0).unwrap(), + "a loaded client takes the void's 4.0" + ); + assert_eq!(player.health.health(), 16.0); + } + #[test] fn hurt_sounds_follow_damage_effects() { // DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING. diff --git a/crates/horizon-client/src/hunger.rs b/crates/horizon-client/src/hunger.rs index 34f414d502..a7f7bba914 100644 --- a/crates/horizon-client/src/hunger.rs +++ b/crates/horizon-client/src/hunger.rs @@ -63,6 +63,7 @@ impl PlayerMotion { resources, rules, invulnerable: self.invulnerable || self.spectator, + client_loaded: self.client_loaded, attributes: &mut self.attributes, data: &mut h.data, ids: h.ids, @@ -80,6 +81,7 @@ struct TickWorld<'a> { resources: &'a HealthResources, rules: HealthRules, invulnerable: bool, + client_loaded: bool, attributes: &'a mut Attributes, data: &'a mut SyncedFloats, ids: PlayerFloatIds, @@ -145,6 +147,7 @@ impl FoodWorld for TickWorld<'_> { .push(MotionEvent::Health(HealthEvent::StarvationDamage(amount))); let mut environment = Environment { invulnerable: false, + client_loaded: self.client_loaded, rules: self.rules, source: self .resources diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 649e57e8a6..0a027bc4fd 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -151,6 +151,10 @@ pub struct Guest { pub moved: bool, /// The client finished loading terrain (ServerboundPlayerLoadedPacket). pub loaded: bool, + /// The client has loaded the world since the last respawn or level change + /// (`ServerGamePacketListenerImpl.hasClientLoaded`): the damage-immunity + /// term that hides the ticks a respawned player spends at the old spot. + pub client_loaded: bool, // Chunks. pub center: Option<(i32, i32)>, pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>, @@ -324,6 +328,7 @@ impl Guest { position_this_tick: false, moved: false, loaded: false, + client_loaded: true, center: None, sent_chunks: Default::default(), chunk_queue: Vec::new(), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 3d9808e461..635bc12369 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -2236,7 +2236,11 @@ impl Hub { S::MovePlayerStatus(p) => { self.move_player(i, None, None, p.flags, ctx, events); } - S::PlayerLoaded(_) => self.guests[i].loaded = true, + S::PlayerLoaded(_) => { + let g = &mut self.guests[i]; + g.loaded = true; + g.client_loaded = true; + } // PlayerChunkSender.onChunkBatchReceivedByClient. S::ChunkBatchReceived(p) => { let g = &mut self.guests[i]; @@ -2657,6 +2661,8 @@ impl Hub { let g = &mut self.guests[i]; // PlayerList.respawn: into the level in place (the respawn level). g.dimension = self.config.dimension.clone(); + // The client loads a whole new level before it reports it loaded. + g.client_loaded = false; // ServerLevel.removePlayerImmediately: the dead player's // Entity.setRemoved (KILLED) stops its riding; the new player rides // nothing. @@ -2779,6 +2785,8 @@ impl Hub { self.close_container_for(i); let g = &mut self.guests[i]; g.dimension = self.config.dimension.clone(); + // Not cleared: vanilla resets the client's loaded state on death and respawn only, and + // covers a level change with its separate `isChangingDimension` term. g.tracker.clear(); g.sent_chunks.clear(); g.chunk_queue.clear(); diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 9f18b105b3..d600e4bb89 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -387,6 +387,10 @@ pub struct PlayerMotion { /// server hurts it (`Entity.hurt` does nothing on a client) and ticks its /// food (`ServerPlayer.tick`). Its hurt animation comes from the server. pub client_side: bool, + /// The player's client has loaded the world since its last respawn or + /// level change (`ServerGamePacketListenerImpl.hasClientLoaded`); a guest's + /// turn takes it from its connection. + pub client_loaded: bool, /// `Entity.applyEffectsFromBlocks` runs after travel (always in the game; /// differential fixtures turn it off where their reference does). pub block_effects: bool, @@ -561,6 +565,7 @@ impl PlayerMotion { server_copy: false, wall_hit_events: None, client_side: false, + client_loaded: true, block_effects: true, powder_snow_entered: Vec::new(), hurt_log: Vec::new(), diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 244c4fd7ae..16c8696380 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -924,3 +924,57 @@ fn a_spectators_container_click_is_taken_back() { assert!(s.hub.guests[0].carried.is_none()); run(&mut s, move || content(&mut bot)); } + +/// #4301: the damage-immunity term `ServerPlayer.isInvulnerableTo` takes from +/// its connection (`!hasClientLoaded`) is cleared by a respawn or a level +/// change, and comes back with ServerboundPlayerLoadedPacket: a respawned +/// guest cannot be hurt at the spot it died in before its client is back. +#[test] +fn a_respawned_guest_is_immune_until_its_client_loads_the_world() { + let Some(resources) = resources() else { + return; + }; + let mut s = server(&resources); + let port = s.hub.lan.port; + let _bot = run(&mut s, move || connect(port, "Respawned", true)); + let deadline = Instant::now() + Duration::from_secs(5); + while s.hub.guests.is_empty() { + s.tick(&[]); + assert!(Instant::now() < deadline, "guest did not join"); + thread::yield_now(); + } + assert!(s.hub.guests[0].client_loaded); + let border = horizon_client::worldborder::WorldBorder::default(); + let at = [8.5, f64::from(MIN_Y + 5), 8.5]; + let ctx = TickCtx { + world: &mut s.world, + host: None, + entities: &[], + next_entity: &mut s.next_entity, + spawn: at, + difficulty: 2, + difficulty_locked: false, + clock_rate: 1.0, + tick_rate: 20.0, + tick_frozen: false, + tick_steps: (0, 0), + collision: None, + border: &border, + }; + s.hub.respawn(0, at, &ctx); + assert!( + !s.hub.guests[0].client_loaded, + "a respawn unloads the client" + ); + // A level change does not clear it: vanilla resets the loaded state on death and respawn + // only, and covers a portal with its separate `isChangingDimension` term. + s.hub.change_level(0, at, 0.0, None, false, 0, &ctx); + assert!( + !s.hub.guests[0].client_loaded, + "a level change keeps the cleared state from the respawn" + ); + // The client's ServerboundPlayerLoadedPacket once the new level is in. + s.hub.guests[0].inbox.push_back((S::PlayerLoaded(Empty), 0)); + s.tick(&[]); + assert!(s.hub.guests[0].client_loaded, "the packet marks it loaded"); +} From dec8429c3d50dd61dcfa6589c6a2ea93ad5c9d00 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:29:35 +0530 Subject: [PATCH 366/968] Wire /place structure to the live level PlaceCommand.placeStructure generates a start for the position's chunk with a constant-true biome predicate, checks that every chunk of the start's box is loaded, and runs placeInChunk over that box in the live level. Horizon implemented /place template, /place feature and /place jigsaw, and the structure arm validated the id and then always failed with command.failed, which is the "unexpected error" the report shows. The arm now generates the start through the existing structure machinery with every biome valid (the command's own predicate, not worldgen's), reuses place_in_chunk over the live-level feature world, and reports vanilla's success and failure messages. LevelFeatureWorld also stores a template's block-entity data now, so a placed chest keeps its loot table instead of losing it. --- .../horizon-client/src/game/commands/place.rs | 31 ++++++++- crates/horizon-client/src/vanillagen.rs | 12 ++++ .../horizon-world/src/level/feature_world.rs | 11 ++++ crates/horizon-world/src/level/growth.rs | 63 +++++++++++++++++++ .../horizon-world/src/worldgen/generator.rs | 48 +++++++++++++- .../src/worldgen/generator_structures.rs | 24 ++++++- crates/horizon-world/src/worldgen/live.rs | 10 +++ .../src/worldgen/structure/start.rs | 33 ++++++---- docs/rust-parity.md | 4 +- tools/command_exec_corpus.py | 20 ++++++ tools/port_map/rest-015.tsv | 2 +- 11 files changed, 239 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/commands/place.rs b/crates/horizon-client/src/game/commands/place.rs index 394551d633..d91b680def 100644 --- a/crates/horizon-client/src/game/commands/place.rs +++ b/crates/horizon-client/src/game/commands/place.rs @@ -177,9 +177,34 @@ impl Game<'_> { { return Err(err("commands.place.structure.invalid", &[&structure])); } - self.place_pos(ctx, "pos")?; - // Structure.generate is not wired to the live level. - Err(err("command.failed", &[])) + let pos = self.place_pos(ctx, "pos")?; + // PlaceCommand.placeStructure: Structure.generate for the + // position's chunk with its `biome -> true` predicate, then + // placeInChunk for every chunk of the start's box. + let terrain = self + .end + .locator() + .ok_or_else(|| err("command.failed", &[]))?; + let start = terrain + .structure_start(&structure, pos[0] >> 4, pos[2] >> 4) + .ok_or_else(|| err("commands.place.structure.failed", &[]))?; + let (min, max) = (start.bounding_box.min, start.bounding_box.max); + self.place_check_loaded([min[0] >> 4, min[2] >> 4], [max[0] >> 4, max[2] >> 4])?; + let placed = + self.with_level(|b, level, ticks| b.place_structure(level, ticks, &start)); + if !placed { + return Err(err("commands.place.structure.failed", &[])); + } + self.feedback(Text::translate( + "commands.place.structure.success", + &[ + Text::literal(structure), + num(pos[0]), + num(pos[1]), + num(pos[2]), + ], + )); + Ok(1) } } } diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index 31527dbdbc..cb2eb55562 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -369,6 +369,18 @@ impl VanillaTerrain { let worker = worker.get_or_insert_with(|| self.generator.worker()); worker.structure_starts(ChunkPos::new(x, z)) } + /// `/place structure`: `Structure.generate` for `structure` in chunk + /// `(x, z)`, whatever the chunk's structure sets place there. + pub fn structure_start( + &self, + structure: &str, + x: i32, + z: i32, + ) -> Option> { + let mut worker = self.query.lock().unwrap_or_else(|e| e.into_inner()); + let worker = worker.get_or_insert_with(|| self.generator.worker()); + worker.structure_start(structure, ChunkPos::new(x, z)) + } /// `/locate structure`: the nearest start of one of `names` within /// `radius` placement sectors of block `from`. pub fn locate( diff --git a/crates/horizon-world/src/level/feature_world.rs b/crates/horizon-world/src/level/feature_world.rs index de2bb0ecee..0acd3afde8 100644 --- a/crates/horizon-world/src/level/feature_world.rs +++ b/crates/horizon-world/src/level/feature_world.rs @@ -136,6 +136,17 @@ impl PlacementContext for LevelFeatureWorld<'_, '_, L> self.sim.level.load_block_entity(pos(position), nbt); Ok(()) } + /// `StructureTemplate` block-entity data at a just-placed block: a chest's + /// loot table, a spawner's entity. The live level already loads saved + /// block-entity data for generated chunks. + fn set_block_entity( + &mut self, + position: [i32; 3], + nbt: crate::nbt::Compound, + ) -> Result<(), PlacementQueryError> { + self.sim.level.load_block_entity(pos(position), nbt); + Ok(()) + } /// Live levels hold no loot-table containers placed by these features. fn set_block_entity_loot_table( &mut self, diff --git a/crates/horizon-world/src/level/growth.rs b/crates/horizon-world/src/level/growth.rs index 91d0a1a12b..89e03fbf16 100644 --- a/crates/horizon-world/src/level/growth.rs +++ b/crates/horizon-world/src/level/growth.rs @@ -68,6 +68,69 @@ impl Behaviours { *sim.random() = random; true } + /// `/place structure`: `PlaceCommand.placeStructure` after the start is + /// generated, `StructureStart.placeInChunk` for every chunk of the start's + /// bounding box (`ChunkPos.rangeClosed` of its corners), each clipped to + /// the chunk's whole height, drawing from the level random. False when the + /// level has no structure resources, or a piece failed to place. + pub fn place_structure( + &self, + level: &mut L, + ticks: &mut super::Ticks, + start: &crate::worldgen::structure::start::StructureStart, + ) -> bool { + let Some(features) = self.live_features() else { + return false; + }; + let (Some(resources), Some(blocks)) = (features.structures(), features.tree_blocks()) + else { + return false; + }; + let (min, max) = (start.bounding_box.min, start.bounding_box.max); + let (cx0, cx1) = (min[0] >> 4, max[0] >> 4); + let (cz0, cz1) = (min[2] >> 4, max[2] >> 4); + let mut sim = self.sim(level, ticks); + // PlaceCommand's per-chunk clip box: BoundingBox(minX, getMinY(), minZ, + // maxX, getMaxY() + 1, maxZ), the level's whole height of the chunk + // (`max_y` is exclusive here, so the top layer is `max_y - 1`). + let (low_y, high_y) = (sim.level.min_y(), sim.level.max_y() - 1); + let mut random = sim.random().clone(); + let mut placed = true; + { + let mut world = LevelFeatureWorld { + sim: &mut sim, + features, + }; + let mut place_feature = + |w: &mut LevelFeatureWorld<'_, '_, L>, + r: &mut horizon_core::random::LegacyRandom, + name: &str, + p: [i32; 3]| { Ok(features.place_placed(name, w, r, p)) }; + for cx in cx0..=cx1 { + for cz in cz0..=cz1 { + let chunk_box = crate::worldgen::structure::BoundingBox::new( + [cx * 16, low_y, cz * 16], + [cx * 16 + 15, high_y, cz * 16 + 15], + ); + if crate::worldgen::structure::start::place_in_chunk( + start, + &mut world, + resources, + &blocks, + &mut random, + chunk_box, + &mut place_feature, + ) + .is_err() + { + placed = false; + } + } + } + } + *sim.random() = random; + placed + } } /// The four (state, position) pairs of a 2x2 sapling square. diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index 803901380e..22a4942aed 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -1709,8 +1709,15 @@ where }) .collect() } - fn set_valid_biomes(&mut self, biomes: &BTreeSet) { - self.valid = biomes.clone(); + /// `None` is `/place structure`'s `biome -> true`: every biome the bundle + /// knows counts as valid. + fn set_valid_biomes(&mut self, biomes: Option<&BTreeSet>) { + self.valid = match biomes { + Some(biomes) => biomes.clone(), + None => (0..self.pipeline.data.biome_names().len() as u32) + .map(BiomeId) + .collect(), + }; } } @@ -2051,6 +2058,14 @@ trait SeededPipeline: Send + Sync { worker: &mut WorkerState, pos: ChunkPos, ) -> Vec>; + /// `Structure.generate` for one structure in one chunk (`/place + /// structure`), outside any structure set's placement. + fn structure_start( + &self, + worker: &mut WorkerState, + structure: &str, + chunk: ChunkPos, + ) -> Option>; fn unsupported_features(&self) -> Vec<(String, String)>; fn use_saved_start( &self, @@ -2154,6 +2169,25 @@ where None => Vec::new(), } } + fn structure_start( + &self, + worker: &mut WorkerState, + structure: &str, + chunk: ChunkPos, + ) -> Option> { + self.structures + .as_ref()? + .structure_start( + &mut StructureQueries { + pipeline: self, + worker, + valid: BTreeSet::new(), + }, + structure, + chunk, + ) + .map(Arc::new) + } fn unsupported_features(&self) -> Vec<(String, String)> { self.decoration .as_ref() @@ -3066,6 +3100,16 @@ impl GeneratorWorker { ) -> Vec> { self.pipeline.structure_starts(&mut self.state, pos) } + /// `Structure.generate` for `/place structure`: the start `structure` + /// builds in `chunk`, whatever its placement set says about that chunk. + pub fn structure_start( + &mut self, + structure: &str, + chunk: ChunkPos, + ) -> Option> { + self.pipeline + .structure_start(&mut self.state, structure, chunk) + } /// Puts a saved start of `structure` (its pieces as a save lists them, in /// JSON) in `chunk`, in place of the one generation builds there, until /// the starts are forgotten. Harnesses place another game's layout. diff --git a/crates/horizon-world/src/worldgen/generator_structures.rs b/crates/horizon-world/src/worldgen/generator_structures.rs index 9787948f64..605276baf1 100644 --- a/crates/horizon-world/src/worldgen/generator_structures.rs +++ b/crates/horizon-world/src/worldgen/generator_structures.rs @@ -8,7 +8,7 @@ use super::{ jigsaw::PoolPiece, start::{ Piece, Rings, StartContext, StructureRegistry, StructureStart, beardifier, - create_starts, + create_starts, try_generate, }, stronghold::RingSearch, }, @@ -315,6 +315,28 @@ impl StructureState { ); self.starts.insert_first((chunk.x, chunk.z), starts) } + /// `Structure.generate` for `/place structure`: the start `structure` + /// builds in `chunk`, whether or not a structure set would place one + /// there (vanilla calls the structure's own generate, not the set's + /// placement, with its `biome -> true` predicate). Nothing is cached: the + /// chunk's own starts are untouched. + pub(super) fn structure_start( + &self, + context: &mut impl StartContext, + structure: &str, + chunk: ChunkPos, + ) -> Option { + let index = (self.registry.structures.iter()).position(|s| s.name == structure)?; + try_generate( + &self.registry, + &self.resources, + &self.bindings, + context, + index, + chunk, + true, + ) + } /// Puts a saved start of `structure` in `chunk`, in place of the one /// this generator builds there from the same structure sets (a set /// starts one structure per chunk): `pieces` as a save lists them (see diff --git a/crates/horizon-world/src/worldgen/live.rs b/crates/horizon-world/src/worldgen/live.rs index ec3a5d3dd8..3fc2f1f40d 100644 --- a/crates/horizon-world/src/worldgen/live.rs +++ b/crates/horizon-world/src/worldgen/live.rs @@ -216,6 +216,16 @@ impl LiveFeatures { pub fn data(&self) -> &Arc { &self.data } + /// The structure templates and processor lists of this bundle, for the + /// pieces a level places live (`/place structure`). + pub(crate) fn structures(&self) -> Option<&super::structure::StructureResources> { + self.bindings.structures.as_deref() + } + /// `BlockState.isSolidRender` facts, which legacy structure pieces ask of + /// the blocks around them. + pub(crate) fn tree_blocks(&self) -> Option> { + self.bindings.tree_blocks().ok() + } pub fn biome_id(&self, name: &str) -> Option { self.data.biome_id(name) } diff --git a/crates/horizon-world/src/worldgen/structure/start.rs b/crates/horizon-world/src/worldgen/structure/start.rs index 502d57799e..b6b906d1d2 100644 --- a/crates/horizon-world/src/worldgen/structure/start.rs +++ b/crates/horizon-world/src/worldgen/structure/start.rs @@ -831,7 +831,9 @@ pub trait StartContext: JigsawContext { /// BiomeSource.findBiomeHorizontal samples: each climate lookup starts /// from the previous result, which a tie keeps. fn noise_biomes(&mut self, quarts: &[[i32; 3]]) -> Vec; - fn set_valid_biomes(&mut self, biomes: &BTreeSet); + /// `GenerationContext.validBiome`'s set: `None` is the `biome -> true` + /// predicate `/place structure` passes, so every biome counts as valid. + fn set_valid_biomes(&mut self, biomes: Option<&BTreeSet>); } /// ChunkGenerator.createStructures for one chunk: the starts it owns. @@ -869,6 +871,7 @@ pub fn create_starts( context, set.structures[0].0, chunk, + false, ) { starts.push(start); } @@ -889,9 +892,9 @@ pub fn create_starts( index += 1; } let selected = options[index]; - if let Some(start) = - try_generate(registry, resources, bindings, context, selected.0, chunk) - { + if let Some(start) = try_generate( + registry, resources, bindings, context, selected.0, chunk, false, + ) { starts.push(start); break; } @@ -901,16 +904,21 @@ pub fn create_starts( } starts } -fn try_generate( +/// `Structure.generate` of one structure for one chunk: what the chunk's +/// structure set would build there, or, for `/place structure`, the start this +/// structure builds in any chunk (the set's placement is not consulted, and +/// `any_biome` is vanilla's `biome -> true` predicate there). +pub(crate) fn try_generate( registry: &StructureRegistry, resources: &StructureResources, bindings: &FeatureBlockBindings<'_>, context: &mut impl StartContext, structure: usize, chunk: ChunkPos, + any_biome: bool, ) -> Option { let def = ®istry.structures[structure]; - context.set_valid_biomes(&def.biomes); + context.set_valid_biomes((!any_biome).then_some(&def.biomes)); let seed = context.seed(); let mut random = WorldgenRandom::new(LegacyRandom::new(0)); random.set_large_feature_seed(seed, chunk.x, chunk.z); @@ -920,6 +928,8 @@ fn try_generate( .start_height .sample(&mut random, context.generation()) .ok()?; + // A carried mod's own start rules keep the structure's biome set: + // they read it directly, not through the context's predicate. if !jigsaw .checks .pass(context, &def.biomes, chunk, height, jigsaw.project_to) @@ -1163,9 +1173,10 @@ fn try_generate( return None; } let start = [x, y, z]; - if !def - .biomes - .contains(&context.noise_biome(start.map(|v| v >> 2))) + if !any_biome + && !def + .biomes + .contains(&context.noise_biome(start.map(|v| v >> 2))) { return None; } @@ -1195,7 +1206,7 @@ fn try_generate( )?; let position = [x, y, z]; let quart = position.map(|v| v >> 2); - if !def.biomes.contains(&context.noise_biome(quart)) { + if !any_biome && !def.biomes.contains(&context.noise_biome(quart)) { return None; } let piece = super::nether_fossil::piece(resources, &mut random, position)?; @@ -1216,7 +1227,7 @@ fn try_generate( StructureKind::Unsupported(_) => return None, }; let quart = position.map(|v| v >> 2); - if !def.biomes.contains(&context.noise_biome(quart)) || pieces.is_empty() { + if (!any_biome && !def.biomes.contains(&context.noise_biome(quart))) || pieces.is_empty() { return None; } Some(StructureStart::new(structure, def, chunk, pieces)) diff --git a/docs/rust-parity.md b/docs/rust-parity.md index 12dd076f5a..0cc6d427d4 100644 --- a/docs/rust-parity.md +++ b/docs/rust-parity.md @@ -247,7 +247,9 @@ structure stages remain pending. The [structure-density sampler](structure-density.md) matches **1,360,095 output lines per reference JAR**, including ordered rigid-piece/junction contributions -and scalar/bulk clipping. Live structure discovery and placement remain pending. +and scalar/bulk clipping. Live placement of a start in a running level is ported +(`/place structure`, through `StructureStart.placeInChunk`); structure discovery +during chunk generation is compared by the structure-layout suite. The [vertical-anchor suite](vertical-anchors.md) matches **181,632 output lines per reference JAR**, including four anchor codecs, runtime overflow, nested exclusive diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 30500cfa4b..e5157e7291 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -1212,6 +1212,25 @@ def ender_dragon(): ] +def place_structure(): + """`/place structure` (PlaceCommand.placeStructure): the id is resolved, a + `StructureStart` is generated for the position's chunk with vanilla's + `biome -> true` predicate, and its `placeInChunk` runs for every chunk of the + start's box in the live level. Buried treasure's one piece digs down from the + ocean floor and leaves a single chest, so a chest count in its chunk compares + the generated start and its placement without knowing the terrain height.""" + return [ + *setup('/gamemode creative', '/tp @s 8.5 230 8.5 0 0'), + {'op': 'settle'}, + *setup('/kill @e[type=!minecraft:player]', + '/fill 0 0 0 15 120 15 minecraft:air replace minecraft:chest'), + cmd('/place structure minecraft:buried_treasure 4 210 4'), + # One chest between y 0 and 120 of chunk (0, 0): the piece's column is + # inside that chunk whatever height its dig reached. + cmd('/fill 0 0 0 15 120 15 minecraft:air replace minecraft:chest'), + ] + + def groups(): return [ ('setup', group_setup()), @@ -1238,4 +1257,5 @@ def groups(): ('test', test_command()), ('wither breaks', wither_breaks()), ('ender dragon', ender_dragon()), + ('place structure', place_structure()), ] diff --git a/tools/port_map/rest-015.tsv b/tools/port_map/rest-015.tsv index c328616fe0..3298bf34f1 100644 --- a/tools/port_map/rest-015.tsv +++ b/tools/port_map/rest-015.tsv @@ -71,7 +71,7 @@ PerfCommand.stopProfilingDedicatedServer skip:server profiler for the dedicated PerfCommand.saveResults skip:server profiler for the dedicated server; not a game behaviour PerfCommand.whenStopped skip:server profiler for the dedicated server; not a game behaviour PlaceCommand.placeJigsaw port crates/horizon-client/src/game/commands/place.rs::Game::cmd_place_generated place.jigsaw branch: pool check, generation, success message -PlaceCommand.placeStructure missing player: /place structure checks the id then always fails (Structure.generate is not wired to the live level) +PlaceCommand.placeStructure port crates/horizon-client/src/game/commands/place.rs::Game::cmd_place_generated place.structure branch: id check, Structure.generate with the biome -> true predicate, checkLoaded over the start's box, per-chunk placeInChunk, success/failed messages PlaceCommand.placeTemplate port crates/horizon-client/src/game/commands/place.rs::Game::cmd_place template placement with rotation, mirror, integrity and strict PlaySoundCommand.register port crates/horizon-command/src/tree_world.rs::sounds playsound literal with a sound-source literal per source PlaySoundCommand.source port crates/horizon-command/src/tree_world.rs::sounds per-source literal with targets, pos, volume, pitch, minVolume From 5f7338178c11a1688377f03e3852562d34b6284b Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 12:02:31 +0100 Subject: [PATCH 367/968] Port map: four rows of #3515 that are already ported; an item frame keeps to the build height OminousBottleAmplifier.addToTooltip, SuspiciousStewEffects.addToTooltip and CrossbowItem.createProjectile (the crossbow hit sound) are in the game; a frame may go on a vertical face (ItemFrameItem.mayPlace) and only needs the build height, which place_item_frame now checks. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/frames.rs | 10 +++++++++- tools/port_map/classes-02.tsv | 4 ++-- tools/port_map/game2-008.tsv | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index aa108c01a7..828229c6e8 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -138,7 +138,15 @@ impl Game<'_> { && horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0 }) }); - let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]); + let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]); + // `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not + // placed on the top face of the highest block or under the lowest one. + if !(horizon_client::chunk::MIN_Y + ..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT) + .contains(&pos[1]) + { + return false; + } let frame = ItemFrame { id: self.next_entity + 1, pos, diff --git a/tools/port_map/classes-02.tsv b/tools/port_map/classes-02.tsv index 09d86ced96..ebc39cafe4 100644 --- a/tools/port_map/classes-02.tsv +++ b/tools/port_map/classes-02.tsv @@ -32,11 +32,11 @@ ItemAttributeModifiers.forEach inline crates/horizon-client/src/item_tooltip.rs: ItemAttributeModifiers.withModifierAdded missing minor: the loot set_attributes function (SetAttributesFunction) is not ported; no loot path adds a modifier to a stack ItemAttributeModifiers.Builder.add skip:java ItemAttributeModifiers.Entry.matches inline crates/horizon-world/src/attributes/mod.rs::Instance::add_or_replace_permanent modifier identity by id within one attribute -OminousBottleAmplifier.addToTooltip missing player: an ominous bottle's tooltip shows no Bad Omen line for its level; Horizon's potion tooltip covers potion items only +OminousBottleAmplifier.addToTooltip port crates/horizon-client/src/item_tooltip.rs::ominous_bottle_line the Bad Omen line of an ominous bottle, listed in the stack tooltip (stack_tooltip_lines) SimpleMutableContainer.modifySlots port crates/horizon-client/src/game/commands/items.rs::Game::cmd_item_modify each slot is visited and set SimpleMutableContainer.replaceSlotItems port crates/horizon-client/src/game/commands/items.rs::Game::cmd_item /item replace fills slots from the item list SulfurCubeContent.addToTooltip missing minor: Horizon never writes sulfur_cube_content, so no item shows the absorbed block line -SuspiciousStewEffects.addToTooltip missing player: in creative, a suspicious stew's tooltip should list its effects; Horizon's stack tooltip shows none +SuspiciousStewEffects.addToTooltip port crates/horizon-client/src/item_tooltip.rs::stew_lines the effects of a suspicious stew, in the stack tooltip (stack_tooltip_lines) TypedEntityData.addToTooltip missing player: in Peaceful, a hostile mob's spawn egg should show a red Peaceful line; Horizon shows only the OP-only warning TypedEntityData.equals skip:java TypedEntityData.stripId skip:other the stored entity data keeps its id; block_entity_item.rs reads it from the tag diff --git a/tools/port_map/game2-008.tsv b/tools/port_map/game2-008.tsv index a33788e091..9f02428e34 100644 --- a/tools/port_map/game2-008.tsv +++ b/tools/port_map/game2-008.tsv @@ -50,7 +50,7 @@ CompassItem.isFoil port crates/horizon-assets/src/item_model.rs::Resolve::has_fo CrossbowItem.tryLoadProjectiles port crates/horizon-client/src/game/crossbow.rs::Game::tick_crossbow the drawn ammo is stored on the crossbow once charged CrossbowItem.shootProjectile port crates/horizon-client/src/game/crossbow.rs::Game::shoot_crossbow CrossbowItem.getProjectileShotVector inline crates/horizon-client/src/game/crossbow.rs::Game::shoot_crossbow the multishot spread is rotated inline from the view and up vectors -CrossbowItem.createProjectile inline crates/horizon-client/src/game/crossbow.rs::Game::shoot_crossbow gap: player: crossbow arrows keep the normal arrow hit sound; the crossbow hit sound (item.crossbow.hit) is never set +CrossbowItem.createProjectile port crates/horizon-client/src/arrows.rs::Ticker::hit_sound a crossbow arrow hits with item.crossbow.hit (the arrow keeps fired_from_weapon, set in shoot_crossbow) CrossbowItem.getShotPitch inline crates/horizon-client/src/game/crossbow.rs::Game::shoot_crossbow the shot pitch per index is inline CrossbowItem.getRandomShotPitch inline crates/horizon-client/src/game/crossbow.rs::Game::shoot_crossbow the random pitch formula is inline CrossbowItem.getPowerForTime inline crates/horizon-client/src/game/crossbow.rs::Game::tick_crossbow the charge fraction capped at one is the tick check against the charge time @@ -58,7 +58,7 @@ DyeColor.getMixedColor missing player: a baby sheep, wolf or cat from parents o DyeColor.findColorMixInRecipes missing player: part of the same breeding dye mix; the crafting recipe lookup for two dyes is not done for offspring FireworkRocketItem.createDispenseConfig port crates/horizon-client/src/game/dispenser.rs::Game::dispense_projectile rocket config: offset 0.5000099, uncertainty 1, power 0.5, event 1004 FireworkRocketItem.getEntityJustOutsideOfBlockPos port crates/horizon-client/src/game/dispenser.rs::Game::dispense_position the position just outside the dispenser on the facing side -HangingEntityItem.mayPlace port crates/horizon-client/src/game/paintings.rs::Game::place_painting gap: player: the horizontal-face rule is checked for paintings only; place_item_frame has no vertical-face test +HangingEntityItem.mayPlace port crates/horizon-client/src/game/paintings.rs::Game::place_painting paintings take no vertical face; an item frame (ItemFrameItem.mayPlace) may go on one and only needs the build height, which place_item_frame checks HangingEntityItem.appendHoverText missing player: hovering a painting item shows no title, author or size line in its tooltip Item.getDestroySpeed port crates/horizon-items/src/items.rs::Items::mining_speed Item.finishUsingItem port crates/horizon-client/src/game.rs::Game::consume_held the consumable effects are applied by apply_consume_effect From 21bdf4f9a3bce2ff562216fdb99dbc5d55e753f8 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 12:02:53 +0100 Subject: [PATCH 368/968] Accept committed IME text in the focused text field Allow the window's input method only while a text box has focus and route WindowEvent::Ime(Commit) to the same sinks as typed text. Preedit drawing and the candidate window position are not handled yet. --- crates/horizon-client/src/game.rs | 8 +++ crates/horizon-client/src/game/dialogs.rs | 8 +++ crates/horizon-client/src/game/ime.rs | 72 +++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 crates/horizon-client/src/game/ime.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1deb9a2b6c..0e9b166203 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -115,6 +115,7 @@ mod glowing; mod golems; mod growth; mod hand; +mod ime; mod initial_spawn; mod integrated; mod item_draw; @@ -1615,6 +1616,8 @@ struct Game<'a> { /// This view's corner in its shared window; cursor warps include the offset. view_at: [u32; 2], focused: bool, + /// Whether the window currently accepts input method events. + ime_allowed: bool, /// When the window lost focus, while it is still without /// (`Minecraft.lastActiveTime`: pausing waits 500 ms). focus_lost: Option, @@ -13350,6 +13353,7 @@ impl ApplicationHandler for Game<'_> { } } self.window = Some(window); + self.ime_allowed = false; self.apply_settings(); self.previous = Instant::now(); } @@ -13504,6 +13508,8 @@ impl ApplicationHandler for Game<'_> { WindowEvent::CloseRequested => { self.begin_closing_save(true)?; } + // Input method composition: the committed text types like a key's text. + WindowEvent::Ime(winit::event::Ime::Commit(text)) => self.ime_commit(&text), WindowEvent::Resized(size) => { if let Some(r) = &mut self.renderer { r.resize(size.width, size.height); @@ -14120,6 +14126,7 @@ impl ApplicationHandler for Game<'_> { } WindowEvent::RedrawRequested => { self.observe_fullscreen(); + self.sync_ime(); let now = Instant::now(); let frame_cpu = self .options @@ -14553,6 +14560,7 @@ impl<'a> Game<'a> { keyboard_mouse: true, view_at: [0, 0], focused: true, + ime_allowed: false, focus_lost: None, modifiers: winit::keyboard::ModifiersState::empty(), fullscreen: horizon_client::window::FullscreenState::new(false), diff --git a/crates/horizon-client/src/game/dialogs.rs b/crates/horizon-client/src/game/dialogs.rs index f5cba043bd..d4ad24f148 100644 --- a/crates/horizon-client/src/game/dialogs.rs +++ b/crates/horizon-client/src/game/dialogs.rs @@ -782,6 +782,14 @@ impl Game<'_> { Ok(()) } + /// Whether the open dialog has a text input. + pub(super) fn dialog_has_text_field(&self) -> bool { + self.dialogs + .open + .as_ref() + .is_some_and(|open| open.fields.iter().any(Option::is_some)) + } + /// `type TEXT` with a dialog open: into the focused text input, or the /// first one. pub(super) fn dialog_type(&mut self, text: &str) -> bool { diff --git a/crates/horizon-client/src/game/ime.rs b/crates/horizon-client/src/game/ime.rs new file mode 100644 index 0000000000..8cdfbc7a0a --- /dev/null +++ b/crates/horizon-client/src/game/ime.rs @@ -0,0 +1,72 @@ +//! Input method (IME) text: committed composition goes to the focused text +//! field through the same sinks the keyboard and `horizon-ctl type` use. +//! The window only asks the OS for an input method while such a field has +//! focus, so movement keys keep arriving as plain key events. +use super::{Game, Screen, books::BookScreen}; + +impl Game<'_> { + /// Whether a text box has focus right now (the fields `ime_commit` can fill). + pub(super) fn text_field_focused(&mut self) -> bool { + if self.dialog_open() { + return self.dialog_has_text_field(); + } + if let Some(title) = self.title.as_mut() { + return title.focused_field().is_some(); + } + if self.sign_edit.is_some() || matches!(self.book, Some(BookScreen::Edit { .. })) { + return true; + } + match self.screen { + Screen::Chat | Screen::CommandBlock | Screen::WorldOptions => true, + Screen::Creative => self.creative_screen.tab().search, + Screen::Inventory => { + self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing() + } + _ => false, + } + } + + /// Allow the input method only while a text box has focus. + pub(super) fn sync_ime(&mut self) { + let want = self.text_field_focused(); + if want != self.ime_allowed + && let Some(window) = &self.window + { + window.set_ime_allowed(want); + self.ime_allowed = want; + } + } + + /// Text the input method committed (`WindowEvent::Ime(Commit)`). + pub(super) fn ime_commit(&mut self, text: &str) { + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + if text.is_empty() || !self.text_field_focused() { + return; + } + if self.dialog_type(&text) { + return; + } + if self.title.is_some() { + self.title_edit(Some(&text), false); + } else if self.book.is_some() { + self.book_input(None, Some(&text)); + } else if self.sign_edit.is_some() { + self.sign_input(None, Some(&text)); + } else { + match self.screen { + Screen::Chat => self.chat_type(&text), + Screen::CommandBlock => self.command_block_type(&text), + Screen::WorldOptions => self.world_options_text(Some(&text)), + Screen::Creative => self.creative_search_edit(Some(&text)), + Screen::Inventory if self.mod_browser_typing() => { + self.mod_browser_text(Some(&text), false) + } + Screen::Inventory if self.recipe_search_focused() => { + self.recipe_text(Some(&text), false) + } + Screen::Inventory => self.station_text(Some(&text), false), + _ => {} + } + } + } +} From e2dbf8a03f16b65fa583c5f63de85696b6181723 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 12:11:18 +0100 Subject: [PATCH 369/968] Enable the input method in World Options only while a text box has focus --- crates/horizon-client/src/game/ime.rs | 3 ++- crates/horizon-client/src/game/lan_screen.rs | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/ime.rs b/crates/horizon-client/src/game/ime.rs index 8cdfbc7a0a..7127471630 100644 --- a/crates/horizon-client/src/game/ime.rs +++ b/crates/horizon-client/src/game/ime.rs @@ -17,7 +17,8 @@ impl Game<'_> { return true; } match self.screen { - Screen::Chat | Screen::CommandBlock | Screen::WorldOptions => true, + Screen::Chat | Screen::CommandBlock => true, + Screen::WorldOptions => self.world_options_typing(), Screen::Creative => self.creative_screen.tab().search, Screen::Inventory => { self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing() diff --git a/crates/horizon-client/src/game/lan_screen.rs b/crates/horizon-client/src/game/lan_screen.rs index 57835fad5b..a9ff5dce72 100644 --- a/crates/horizon-client/src/game/lan_screen.rs +++ b/crates/horizon-client/src/game/lan_screen.rs @@ -989,6 +989,13 @@ impl Game<'_> { self.world_options_text(event.text.as_deref()); } } + /// Whether a World Options text box has focus (the port or a game rule box). + pub(super) fn world_options_typing(&self) -> bool { + self.world_options.as_ref().is_some_and(|o| { + o.rules.as_ref().is_some_and(|r| r.typing()) + || (o.port.focused && o.view == View::Options) + }) + } /// Digits typed into the port field (the keyboard, or a controller's /// on-screen keyboard), and its backspace. pub(super) fn world_options_text(&mut self, text: Option<&str>) { From fe8c64382df09a3540c1e0a53e73bd839012ca9d Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 12:16:37 +0100 Subject: [PATCH 370/968] Loot: set_instrument puts the picked instrument on the item (a goat horn's tune) SetInstrumentFunction drew the random option and dropped it, so a goat horn from a loot table (pillager outpost chests) had no instrument. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-world/src/loot.rs | 50 ++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/horizon-world/src/loot.rs b/crates/horizon-world/src/loot.rs index 55fbd63f2d..02ecb1ebb0 100644 --- a/crates/horizon-world/src/loot.rs +++ b/crates/horizon-world/src/loot.rs @@ -1236,12 +1236,22 @@ impl Context<'_, R> { "minecraft:set_ominous_bottle_amplifier" => { stack.ominous_amplifier = Some(self.int(&f["amplifier"])); } + // SetInstrumentFunction: `options.getRandomElement(random)` becomes the item's + // `instrument` component (a goat horn's tune). "minecraft:set_instrument" => { let options = data.holder_set("instrument", &f["options"]); if !options.is_empty() { - self.random + let pick = self + .random .next_int_bound(options.len() as i32) - .unwrap_or(0); + .unwrap_or(0) as usize; + stack + .components + .retain(|(k, _)| k != "minecraft:instrument"); + stack.components.push(( + "minecraft:instrument".to_owned(), + Value::String(options[pick].clone()), + )); } } "minecraft:set_item" => { @@ -1632,4 +1642,40 @@ mod namespace_tests { assert_eq!(count, 3); let _ = fs::remove_dir_all(&root); } + + /// `SetInstrumentFunction`: a goat horn from a loot table gets one of the options as its instrument. + #[test] + fn set_instrument_picks_one_of_its_options() { + let root = std::env::temp_dir().join(format!("horizon-loot-horn-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let table = r##"{"pools": [{"rolls": 1, "entries": [{"type": "minecraft:item", "name": "minecraft:goat_horn", + "modifier": [{"type": "minecraft:set_instrument", "options": ["minecraft:ponder_goat_horn", "minecraft:sing_goat_horn"]}]}]}]}"##; + let files = [ + ("items.json", r#"{"minecraft:goat_horn": {}}"#), + ("data/minecraft/loot_table/chests/horn.json", table), + ]; + for (name, text) in files { + let path = root.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, text).unwrap(); + } + let data = LootData::load(&root, &PackOverlay::NONE).unwrap(); + let mut seen = std::collections::BTreeSet::new(); + for seed in (0..40).map(|n| n * 7_919) { + let filled = data.fill("minecraft:chests/horn", seed, 27, None); + let horn = filled.iter().flatten().next().expect("a horn"); + let instrument: Vec<_> = horn + .components + .iter() + .filter(|(k, _)| k == "minecraft:instrument") + .collect(); + assert_eq!(instrument.len(), 1, "{:?}", horn.components); + seen.insert(instrument[0].1.as_str().unwrap().to_owned()); + } + assert_eq!( + seen.into_iter().collect::>(), + ["minecraft:ponder_goat_horn", "minecraft:sing_goat_horn"] + ); + let _ = fs::remove_dir_all(&root); + } } From da4db61ba95292b6d78d0ef324f8c16f8ead8078 Mon Sep 17 00:00:00 2001 From: CocofireHD Date: Fri, 30 Oct 2026 12:16:58 +0100 Subject: [PATCH 371/968] Shulker boxes show their contents to the player who holds them `ItemContainerContents` travels as the `minecraft:container` component, and the client already decodes it into the tooltip's contents lines. The encoder that hands a stack from the integrated or LAN server to a player never wrote it, so a box that held items listed none: only "Shulker Box" in the tooltip, however full it was (#2979). Write the component as `ItemContainerContents.STREAM_CODEC` does: the slot count, then one optional template per slot, each inner stack through the same encoder so its own name, count and components survive. Co-Authored-By: Hermes Agent --- crates/horizon-client/src/lan/guest.rs | 72 ++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 649e57e8a6..36d2702bac 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -788,6 +788,41 @@ fn network_stack( horizon_protocol::item::Component::Stacks(charged), )); } + // `ItemContainerContents`: a shulker box's items, for its tooltip + // (ClientContainerTooltip). The wire form is the slot count, then one + // optional template per slot, so the guest's stack lists the same items + // the host's holds. + if !d.container.is_empty() { + let slots: Vec<(i32, Stack)> = d + .container + .iter() + .filter_map(|(slot, s)| { + Some(( + i32::from(*slot), + network_stack(item_id, synced_id, &Some(s.clone()))?, + )) + }) + .collect(); + // The saved list names only the filled slots; the codec counts them + // all, from the first to the last that holds one. + let size = slots.iter().map(|(slot, _)| slot + 1).max().unwrap_or(0); + let mut w = horizon_protocol::buf::Writer::new(); + w.varint(size); + for slot in 0..size { + let stack = slots.iter().find(|(at, _)| *at == slot).map(|(_, s)| s); + w.bool(stack.is_some()); + if let Some(stack) = stack { + >::write( + stack, + &mut w, + ); + } + } + stack.added.push(( + horizon_protocol::item::component::CONTAINER, + horizon_protocol::item::Component::Raw(w.data), + )); + } // What a bundle holds, for its tooltip (ClientBundleTooltip). if !d.bundle_contents.is_empty() { let contents = d @@ -1002,4 +1037,41 @@ mod tests { g.sneaking = true; assert!(!g.may_reach_block([0, 71, 0])); } + + /// A shulker box's `minecraft:container` crosses to a guest, so its + /// tooltip can list the items it holds (#2979). + #[test] + fn shulker_contents_survive_inventory_sync() { + let name = Tag::compound([("text", Tag::String("Gold bit".into()))]); + let mut inner = ItemStack::new("minecraft:diamond", 2); + inner.data_mut().custom_name = Some(name); + let mut boxed = ItemStack::new("minecraft:shulker_box", 1); + boxed.data_mut().container = vec![(0, inner)]; + let id = |name: &str| if name.ends_with(":diamond") { 22 } else { 7 }; + + let sent = stack_to_network(id, |_, _| None, &Some(boxed)); + let mut writer = Writer::new(); + horizon_protocol::item::Slot::write(&sent, &mut writer); + let mut reader = Reader::new(&writer.data); + let received = horizon_protocol::item::Slot::read(&mut reader) + .unwrap() + .unwrap(); + assert_eq!(reader.remaining(), 0); + + let Some(Component::Raw(bytes)) = received.component(component::CONTAINER) else { + panic!("the stack carried no container component"); + }; + // ItemContainerContents.STREAM_CODEC: the slot count, then one + // optional template per slot. + let mut r = Reader::new(bytes); + assert_eq!(r.count(256).unwrap(), 1); + assert!(r.bool().unwrap()); + let template = horizon_protocol::item::Template::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0); + assert_eq!((template.item, template.count), (22, 2)); + assert!(matches!( + template.component(component::CUSTOM_NAME), + Some(Component::Text(_)) + )); + } } From 58666111765a36fd34a7987a08e79380531212ac Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 12:19:59 +0100 Subject: [PATCH 372/968] Port map: SetInstrumentFunction.run is ported Co-Authored-By: Claude Sonnet 5.5 --- tools/port_map/classes-08.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/port_map/classes-08.tsv b/tools/port_map/classes-08.tsv index 47b6a61972..8237a25060 100644 --- a/tools/port_map/classes-08.tsv +++ b/tools/port_map/classes-08.tsv @@ -139,7 +139,7 @@ SetEnchantmentsFunction.validate skip:data validation at datapack load SetEnchantmentsFunction.Builder skip:java builder SetFireworkExplosionFunction.apply missing minor: no vanilla loot table uses set_firework_explosion SetFireworksFunction.apply missing minor: no vanilla loot table uses set_fireworks -SetInstrumentFunction.run port crates/horizon-world/src/loot.rs::Context::run gap: player: only draws the random option; the goat horn's instrument component is never set (pillager outpost horns) +SetInstrumentFunction.run port crates/horizon-world/src/loot.rs::Context::run the random option becomes the item's minecraft:instrument component SetItemCountFunction.run port crates/horizon-world/src/loot.rs::Context::run set_count arm SetItemCountFunction.setCount skip:java builder factory SetItemDamageFunction.run port crates/horizon-world/src/loot.rs::Context::run set_damage arm From 1a37932efd0785c04563a18f55df79c30530154c Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 12:39:15 +0100 Subject: [PATCH 373/968] Make crossbow piglins shoot instead of meleeing register_piglin ran before the spawn weapon was equipped, so the crossbow goal was never added and the melee goal took over. Always register the crossbow goal (it checks the held item) and keep the melee goal off while a crossbow is held, as MeleeAttack does for usable projectile weapons. Fixes #4356. --- crates/horizon-client/src/mobs/piglin.rs | 37 ++++++++++++++++++--- crates/horizon-client/src/mobs/spear_use.rs | 8 +++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index aab7cf2d94..1ea18831c6 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -1042,15 +1042,14 @@ fn hostile_to_player(m: &Mob, w: &MobWorld) -> bool { } } pub fn register_piglin(m: &mut Mob) { - let crossbow = m.extra.main_hand.as_deref() == Some("minecraft:crossbow"); let g = &mut m.ai.goals; g.add(0, Float); g.add(1, Admire); // `PiglinAi.initRetreatActivity`: `SetWalkTargetAwayFrom.entity(AVOID_TARGET, 1.0, 12, true)`. g.add(2, WalkAway::retreat(1.0, 12.0, true)); - if crossbow { - g.add(3, super::illager::crossbow_goal(1.0, 8.0)); - } + // Registered before the spawn weapon is equipped, so the goal checks the + // held item itself (it only starts while a crossbow is in the main hand). + g.add(3, super::illager::crossbow_goal(1.0, 8.0)); // `PiglinAi.initFightActivity`: the spear behaviours come before the melee one. g.add(3, super::spear_use::PiglinSpear::new()); g.add( @@ -1860,3 +1859,33 @@ pub fn hoglin_anim(m: &Mob, s: &State, p: &mut Poses) { p.xrot("right_hind_leg", (pos + PI).mcos() * 1.2 * sp); p.xrot("left_hind_leg", pos.mcos() * 1.2 * sp); } + +#[cfg(test)] +mod crossbow_tests { + use super::*; + use crate::mobs::sensor_scan_tests::kind; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + fn piglin() -> Mob { + let mut kind = kind(); + let k = Arc::make_mut(&mut kind); + k.id = "minecraft:piglin".into(); + k.species = Species::Piglin; + Mob::new(1, kind, Vec3::ZERO, 0.0) + } + + /// The spawn weapon is equipped after the goals are registered, so the + /// crossbow goal must exist on a piglin that holds nothing yet, and the + /// melee goal must be off once a crossbow is held. + #[test] + fn a_piglin_with_a_crossbow_shoots_instead_of_meleeing() { + let mut m = piglin(); + assert!(format!("{:?}", m.ai.goals).contains("CrossbowAttack")); + assert!(!super::super::spear_use::holds_non_melee_weapon(&m)); + m.extra.set_main_hand(Some("minecraft:crossbow".into())); + assert!(super::super::spear_use::holds_non_melee_weapon(&m)); + m.extra.set_main_hand(Some("minecraft:golden_sword".into())); + assert!(!super::super::spear_use::holds_non_melee_weapon(&m)); + } +} diff --git a/crates/horizon-client/src/mobs/spear_use.rs b/crates/horizon-client/src/mobs/spear_use.rs index cec5325c91..d07e0551b9 100644 --- a/crates/horizon-client/src/mobs/spear_use.rs +++ b/crates/horizon-client/src/mobs/spear_use.rs @@ -849,6 +849,10 @@ impl Goal for PiglinSpear { /// weapon (`canUseNonMeleeWeapon`) never melees; its spear behaviours attack. #[derive(Clone, Debug)] pub struct NoMeleeWithSpear(pub G); +/// A kinetic weapon or a crossbow in the main hand: the melee goal stays off. +pub(super) fn holds_non_melee_weapon(m: &Mob) -> bool { + main_hand_kinetic(m).is_some() || m.extra.main_hand.as_deref() == Some("minecraft:crossbow") +} impl Goal for NoMeleeWithSpear { goal_clone!(); fn flags(&self) -> u8 { @@ -858,10 +862,10 @@ impl Goal for NoMeleeWithSpear { self.0.name() } fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { - main_hand_kinetic(m).is_none() && self.0.can_use(m, cx) + !holds_non_melee_weapon(m) && self.0.can_use(m, cx) } fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { - main_hand_kinetic(m).is_none() && self.0.can_continue(m, cx) + !holds_non_melee_weapon(m) && self.0.can_continue(m, cx) } fn interruptable(&self) -> bool { self.0.interruptable() From 737d74f592b50cb474539bba4ffb55639cded157 Mon Sep 17 00:00:00 2001 From: iLostName Date: Thu, 29 Oct 2026 14:35:38 +0100 Subject: [PATCH 374/968] joining a world: the player model and command registries are read once, not at every join Each join parsed entity-models.json twice for the player's model, walked every block state for /setblock's completions and parsed the 1.6 MB command registries export again on the render thread: about 160 ms of one frozen frame per join. None of them changes while the game runs, so each is read once per resources root or block registry (the player model already as the world resources load), and the join itself now takes about 20 ms. --- crates/horizon-client/src/game/chat.rs | 96 ++++++++++++++----- crates/horizon-client/src/game/perspective.rs | 25 ++++- 2 files changed, 96 insertions(+), 25 deletions(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 5a02072650..62c6a3d08d 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -12,6 +12,7 @@ use horizon_client::{ screen_draw::Measure, text::{ClickEvent, Style, Text}, }; +use horizon_world::registry::{BlockRegistry, BlockStateId}; use std::{ collections::BTreeMap, path::{Path, PathBuf}, @@ -162,6 +163,61 @@ fn resource_ids(root: &Path) -> Arc { ids } +/// Every block's properties and their values, as `/setblock` completes them. +type BlockProperties = BTreeMap)>>; +static BLOCK_PROPERTIES: Mutex)>> = Mutex::new(Vec::new()); + +/// `BlockProperties` of a block registry, listed once: a registry never +/// changes once built, and walking its tens of thousands of states took +/// about 30 ms of every world opening. +fn block_properties(registry: &'static BlockRegistry) -> Arc { + let key = std::ptr::from_ref(registry) as usize; + let mut cache = BLOCK_PROPERTIES.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, properties)) = cache.iter().find(|(cached, _)| *cached == key) { + return properties.clone(); + } + let mut blocks: BTreeMap>> = BTreeMap::new(); + for id in 0..registry.len() { + let Some(state) = registry.state(BlockStateId(id as u32)) else { + continue; + }; + let props = blocks.entry(state.name.clone()).or_default(); + for (k, v) in &state.properties { + let values = props.entry(k.clone()).or_default(); + if !values.contains(v) { + values.push(v.clone()); + } + } + } + let properties: Arc = Arc::new( + blocks + .into_iter() + .map(|(k, v)| (k, v.into_iter().collect())) + .collect(), + ); + cache.push((key, properties.clone())); + properties +} + +static COMMAND_EXPORTS: Mutex>)>> = + Mutex::new(Vec::new()); + +/// A folder's `command-registries.json` (vanilla's built-in registries), +/// read once like the resource ids: parsing its 1.6 MB again took about +/// 15 ms of every world opening. +fn command_export(dir: &Path) -> Option> { + let mut cache = COMMAND_EXPORTS.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, export)) = cache.iter().find(|(cached, _)| cached == dir) { + return export.clone(); + } + let export = std::fs::read(dir.join("command-registries.json")) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .map(Arc::new); + cache.push((dir.to_owned(), export.clone())); + export +} + /// The `minecraft:` ids of the `extension` files under `root`, sorted. fn ids_under(root: &Path, extension: &str) -> Vec { ids_in(root, extension, "minecraft") @@ -306,25 +362,7 @@ impl Game<'_> { .map(|k| horizon_client::command::args::normalize(k)) .collect(); reg.block_tags.sort(); - let registry = self.pack.assets.registry; - let mut blocks: BTreeMap>> = BTreeMap::new(); - for id in 0..registry.len() { - let Some(state) = registry.state(horizon_world::registry::BlockStateId(id as u32)) - else { - continue; - }; - let props = blocks.entry(state.name.clone()).or_default(); - for (k, v) in &state.properties { - let values = props.entry(k.clone()).or_default(); - if !values.contains(v) { - values.push(v.clone()); - } - } - } - reg.blocks = blocks - .into_iter() - .map(|(k, v)| (k, v.into_iter().collect())) - .collect(); + reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry)); let mut summonable: Vec = self .res .mob_types() @@ -438,10 +476,8 @@ impl Game<'_> { } // Vanilla's built-in registries (mob effects, components, dialogs...). for dir in [&self.options.resources, &self.ui.vanilla_root] { - if let Ok(bytes) = std::fs::read(dir.join("command-registries.json")) - && let Ok(v) = serde_json::from_slice::(&bytes) - { - self.ui.registries.fill_from_export(&v); + if let Some(export) = command_export(dir) { + self.ui.registries.fill_from_export(&export); break; } } @@ -1344,4 +1380,18 @@ mod tests { assert!(Arc::ptr_eq(&first, &resource_ids(&root))); let _ = std::fs::remove_dir_all(&root); } + + /// The export is read once per folder; a folder without one stays without. + #[test] + fn command_exports_are_read_once_per_folder() { + let dir = std::env::temp_dir().join(format!("horizon-export-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("command-registries.json"), r#"{"effects":["a"]}"#).unwrap(); + let first = command_export(&dir).unwrap(); + assert_eq!(first["effects"][0], "a"); + std::fs::write(dir.join("command-registries.json"), "{}").unwrap(); + assert!(Arc::ptr_eq(&first, &command_export(&dir).unwrap())); + assert!(command_export(&dir.join("missing")).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 6655fd899f..13b24205e6 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -24,7 +24,11 @@ use horizon_world::{ registry::BlockStateId, world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world}, }; -use std::{io, path::Path, sync::Arc}; +use std::{ + io, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; /// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids: /// `BlockBehaviour.getVisualShape` is the collision shape except for the @@ -134,8 +138,25 @@ fn body_visible( let invisible = effects.get("invisibility").is_some(); !horizon_client::scoreboard::invisible_to(team, team, false, invisible) } -/// The player's model type (`PlayerModel` with the default skin). +/// The player's model type for each resources root it was asked for. +static PLAYER_TYPES: Mutex>)>> = Mutex::new(Vec::new()); + +/// The player's model type (`PlayerModel` with the default skin), read once +/// per resources root: `entity-models.json` doesn't change while the game +/// runs, and parsing it again held every world join for about 100 ms. The +/// world resources read it as the game starts (`load_world_rest`), so a join +/// finds it; a resource pack change's new root is read once. pub(super) fn player_type(root: &Path) -> Option> { + let mut cache = PLAYER_TYPES.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, kind)) = cache.iter().find(|(cached, _)| cached == root) { + return kind.clone(); + } + let kind = load_player_type(root); + cache.push((root.to_owned(), kind.clone())); + kind +} +/// `player_type` from the document: the model and its layers from one parse. +fn load_player_type(root: &Path) -> Option> { let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?; models.model("minecraft:player#main").map(|model| { Arc::new(MobType { From 7ee787f8de8c75755dda1a7350b9e5b4705c68fd Mon Sep 17 00:00:00 2001 From: iLostName Date: Fri, 30 Oct 2026 11:46:24 +0100 Subject: [PATCH 375/968] item sprites: another namespace's animated texture packs its first frame and animates A server pack's animated item texture (lobby:item/..., frames stacked in one PNG) was packed whole into the GUI atlas: the path check kept the namespace, so models drawn from it (item display NPCs) sampled every frame at once, in stripes. It now cuts the mcmeta's first frame and animates like vanilla's own. --- crates/horizon-client-render/src/hud.rs | 145 +++++++++++++++++------- 1 file changed, 104 insertions(+), 41 deletions(-) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 04c93929a3..73bfb32dae 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -168,6 +168,31 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve } out } +/// The `animation` section of a texture's `.mcmeta`. +fn animation_of(mcmeta: Option<&str>) -> Option { + let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?; + AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok() +} +/// An animated strip's first frame: the region the mcmeta's frame size picks +/// (`AnimationMetadataSection.frameSize`), which is the top-left square unless +/// it names other dimensions or the frames run along a row. +fn first_frame( + (w, h, pixels): (u32, u32, Vec), + animation: Option<&AnimationMetadata>, +) -> (u32, u32, Vec) { + let [fw, fh] = animation.map_or([w.min(h) as i32; 2], |meta| { + meta.frame_size(w as i32, h as i32) + }); + match (u32::try_from(fw), u32::try_from(fh)) { + (Ok(fw), Ok(fh)) + if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) => + { + let side = fw.max(fh); + (side, side, strip_frame(&pixels, w, 0, 0, fw, fh)) + } + _ => (w, h, pixels), + } +} /// `image` inside a one-texel border copied from its opposite edges, the /// texels repeat addressing reads past each edge. fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage { @@ -184,8 +209,9 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage { } /// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture /// files already packed: generated ones (`paletted_permutations` such as the -/// armour trim sprites, `unstitch` regions) and files listed under another id. -fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec { +/// armour trim sprites, `unstitch` regions) and files listed under another id, +/// such as other namespaces' item textures; and the animated ones' names. +fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec, Vec) { use crate::atlas_sources::{self, SpriteOrigin}; let have: std::collections::HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect(); let textures = atlas_sources::list_textures(root); @@ -229,29 +255,45 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec { }; let first = |(name, origins): &(String, Vec<(usize, SpriteOrigin)>)| { origins.iter().find_map(|(order, origin)| { + let mut animated = false; let image = match origin { SpriteOrigin::File(texture) => { - let path = texture.strip_prefix("minecraft:").unwrap_or(texture); - load(texture, "textures").map(|(w, h, p)| { - // Animated strips keep their first frame, like the packed files. - if h > w && (path.starts_with("block/") || path.starts_with("item/")) { - (w, w, p[..(w * w * 4) as usize].to_vec()) + // Its path under `textures/` in any namespace (a server + // pack's `lobby:item/...` as well as vanilla's). + let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p); + let file = atlas_sources::texture_path(root, texture, "textures"); + decode(&file).ok().map(|image| { + // Animated strips keep their first frame and animate, + // like the packed files. + if path.starts_with("block/") || path.starts_with("item/") { + let mcmeta = fs::read_to_string(file.with_extension("png.mcmeta")).ok(); + let animation = animation_of(mcmeta.as_deref()); + animated = animation.is_some(); + first_frame(image, animation.as_ref()) } else { - (w, h, p) + image } }) } generated => atlas_sources::generate(generated, &load), }; - image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels))) + image.map(|(w, h, pixels)| (*order, (name.clone(), w, h, pixels), animated)) }) }; let mut out: Vec<_> = crate::par::par_map(&names, first) .into_iter() .flatten() .collect(); - out.sort_unstable_by_key(|(order, _)| *order); - out.into_iter().map(|(_, image)| image).collect() + out.sort_unstable_by_key(|(order, ..)| *order); + let animated = out + .iter() + .filter(|(.., animated)| *animated) + .map(|(_, image, _)| image.0.clone()) + .collect(); + ( + out.into_iter().map(|(_, image, _)| image).collect(), + animated, + ) } impl HudAtlas { /// Keep a ready profile skin when another source only has its default. @@ -425,37 +467,15 @@ impl HudAtlas { .with_extension("") .to_string_lossy() .replace('\\', "/"); - let (mut w, mut h, pixels) = decode(path).ok()?; + let image = decode(path).ok()?; let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok(); - let animation = mcmeta - .as_deref() - .and_then(|json| serde_json::from_str::(json).ok()) - .and_then(|meta| { - meta.get("animation") - .and_then(|section| AnimationMetadata::from_json(§ion.to_string()).ok()) - }); - // An animated strip keeps its first frame: the region the mcmeta's - // frame size picks (`AnimationMetadataSection.frameSize`), which is - // the top-left square unless it names other dimensions or the - // frames run along a row. + let animation = animation_of(mcmeta.as_deref()); + // An animated strip keeps its first frame. let frames = name.starts_with("block/") || name.starts_with("item/"); - let pixels = if frames { - let stride = w; - let [fw, fh] = animation.as_ref().map_or([w.min(h) as i32; 2], |meta| { - meta.frame_size(w as i32, h as i32) - }); - match (u32::try_from(fw), u32::try_from(fh)) { - (Ok(fw), Ok(fh)) - if fw > 0 && fh > 0 && w % fw == 0 && h % fh == 0 && (fw, fh) != (w, h) => - { - let side = fw.max(fh); - (w, h) = (side, side); - strip_frame(&pixels, stride, 0, 0, fw, fh) - } - _ => pixels, - } + let (w, h, pixels) = if frames { + first_frame(image, animation.as_ref()) } else { - pixels + image }; // The default texture's size, when a pack's differs from it. let gui_size = default_textures @@ -507,7 +527,9 @@ impl HudAtlas { images.push(image); } if full { - images.extend(atlas_sprites(root, &images)); + let (more, more_animated) = atlas_sprites(root, &images); + images.extend(more); + animated.extend(more_animated); } // The title screen's "JAVA EDITION" banner reads "RUST EDITION". if let Some(edition) = images @@ -579,7 +601,15 @@ impl HudAtlas { let Some(rect) = sprites.get(&name).copied() else { continue; }; - let layer = loaded.insert(root, &format!("minecraft:{name}"))?; + // Another namespace's sprite is named with it (`lobby:item/...`). + let id = if name.contains(':') { + name.clone() + } else { + format!("minecraft:{name}") + }; + let Ok(layer) = loaded.insert(root, &id) else { + continue; + }; let Some(animation) = loaded.animations.iter().find(|a| a.layer == layer).cloned() else { continue; @@ -1890,6 +1920,39 @@ mod tests { assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16)); assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]); } + #[test] + fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() { + let root = + std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id())); + let atlases = root.join("assets/minecraft/atlases"); + let item = root.join("assets/lobby/textures/item/npc"); + fs::create_dir_all(&atlases).unwrap(); + fs::create_dir_all(&item).unwrap(); + fs::write( + atlases.join("items.json"), + r#"{"sources": [{"type": "directory", "source": "item", "prefix": "item/"}]}"#, + ) + .unwrap(); + // A server pack's NPC skin: two 16x16 frames, green then red. + write_strip( + &item.join("skin.png"), + 16, + 32, + &[[0, 255, 0, 255], [255, 0, 0, 255]], + Some(r#"{"animation": {"frametime": 3}}"#), + ); + let atlas = HudAtlas::build(&root).unwrap(); + let rect = atlas.sprites["lobby:item/npc/skin"]; + // One frame, not the whole strip, which a model's UVs would span. + assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16)); + assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 255, 0, 255]); + let (_, animation) = atlas + .sprite_animations + .iter() + .find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0) + .expect("the namespaced sprite animates"); + assert_eq!(animation.frame_count(), 2); + } /// `count` images of `w x h`; layout reads only their sizes. fn sized(count: usize, w: u32, h: u32) -> Vec { (0..count) From e759c905f1863bd04529adef310164215c52ef9c Mon Sep 17 00:00:00 2001 From: iLostName Date: Fri, 30 Oct 2026 10:32:02 +0100 Subject: [PATCH 376/968] resource packs: a pack's core/text shader draws the GUI's text, so server HUDs show Servers place HUDs with their pack's text.vsh (glyphs far right of a boss bar title, moved to the screen's corners) and colour effects in text.fsh. horizon-shaders translates the pack's GLSL (#include or #moj_import) to naga; the HUD draws GUI text runs with it. --- crates/horizon-client-render/src/hud.rs | 76 +- .../src/hud/pack_text.rs | 325 +++++++++ crates/horizon-client/src/game.rs | 7 + crates/horizon-shaders/src/core_shader.rs | 653 ++++++++++++++++++ crates/horizon-shaders/src/lib.rs | 3 + docs/resource-packs.md | 8 +- 6 files changed, 1063 insertions(+), 9 deletions(-) create mode 100644 crates/horizon-client-render/src/hud/pack_text.rs create mode 100644 crates/horizon-shaders/src/core_shader.rs diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index 04c93929a3..e61c5be19f 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -10,6 +10,8 @@ use std::{ }; use wgpu::util::DeviceExt; +mod pack_text; + /// A sprite's atlas region, and its size in GUI pixels: the size of the /// default resources' texture of that name, whatever the resolution of the /// pack that replaced it (vanilla's GUI code draws in those units: `blit` @@ -78,6 +80,9 @@ pub struct HudAtlas { pub glint: (Vec, u32, u32), /// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`. pub glint_armor: (Vec, u32, u32), + /// A resource pack's own `core/text` shader for the GUI's text, translated; + /// none draws the text natively. + pub text_shader: Option>, white: Rect, } /// Atlas rows reserved for glyphs rasterized at run time. @@ -288,7 +293,7 @@ impl HudAtlas { /// The atlas of a resource root that layers packs over `defaults` (the /// game's own resources), whose textures give the sprites' GUI sizes. pub fn build_over(root: &Path, defaults: &Path) -> Result { - Self::pack( + let mut atlas = Self::pack( root, defaults, &[ @@ -305,7 +310,9 @@ impl HudAtlas { "map", ], true, - ) + )?; + atlas.text_shader = pack_text::load(root, defaults); + Ok(atlas) } /// Empty slots written at run time: map textures (game/maps.rs), the /// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text. @@ -618,6 +625,7 @@ impl HudAtlas { sprite_animations, glint, glint_armor, + text_shader: None, font, white, }) @@ -643,6 +651,9 @@ pub struct HudVertex { /// The `GLINT_TEXTURING` UV into the repeating glint texture, unused /// unless `uv.x >= 2`. pub glint_uv: [f32; 2], + /// 1 for the GUI's text (`RenderPipelines.GUI_TEXT`), which a resource + /// pack's text shader draws when it has one. + pub text: u32, } /// The GUI Scale option (0 = auto). pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); @@ -660,6 +671,9 @@ pub struct HudBatch { pub end_portal: EndPortalFill, /// `options.glintStrength` for the glint pass (`GlintAlpha`). pub glint_alpha: f32, + /// A resource pack text shader's `GameTime`: the day's fraction + /// (`gameTime % 24000` and the partial tick, over 24000). + pub game_time: f32, /// Screen's first vertex: the preceding HUD is part of its blurred background. pub blur_at: u32, /// Player object profiles encountered while painting text this frame. @@ -714,6 +728,7 @@ impl HudBatch { blends: Vec::new(), end_portal: EndPortalFill::default(), glint_alpha: 0.75, + game_time: 0.0, blur_at: 0, inline_profiles: Vec::new(), } @@ -791,6 +806,7 @@ impl HudBatch { color, depth: 0.0, glint_uv: [0.0, 0.0], + text: 0, }); } } @@ -810,6 +826,7 @@ impl HudBatch { color, depth: 0.0, glint_uv: glint_uv[i], + text: 0, }); } } @@ -821,6 +838,7 @@ impl HudBatch { color, depth: p[i][2], glint_uv: [0.0, 0.0], + text: 0, }); } } @@ -941,6 +959,10 @@ impl HudBatch { [uv(cx0, cy0), uv(cx1, cy0), uv(cx1, cy1), uv(cx0, cy1)], tl.color, ); + let end = self.vertices.len(); + for v in &mut self.vertices[end - 6..] { + v.text = tl.text; + } } } /// `blit` of the top-left `w x h` texels of a `tw x th` texture. @@ -992,6 +1014,7 @@ impl HudBatch { color: c[i], depth: 0.0, glint_uv: [0.0, 0.0], + text: 0, }); } } @@ -1220,6 +1243,7 @@ impl HudBatch { } } }; + let glyph = self.vertices.len(); let start = self.vertices.len(); draw(self, 0.0); lift(self, start, base); @@ -1237,6 +1261,11 @@ impl HudBatch { self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col); } lift(self, start, base + 0.01); + if !world { + for v in &mut self.vertices[glyph..] { + v.text = 1; + } + } } } cursor - x @@ -1372,6 +1401,10 @@ pub struct HudRenderer { uniform: wgpu::Buffer, buffer: wgpu::Buffer, capacity: u64, + format: wgpu::TextureFormat, + atlas_view: wgpu::TextureView, + /// The resource pack's text shader, when it has one that works. + pack_text: Option, } impl HudRenderer { pub fn new( @@ -1598,8 +1631,21 @@ impl HudRenderer { uniform, buffer, capacity, + format, + atlas_view: view.clone(), + pack_text: None, }) } + /// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively. + pub fn set_text_shader( + &mut self, + device: &wgpu::Device, + shader: Option<&horizon_shaders::CoreProgram>, + ) { + self.pack_text = shader.and_then(|shader| { + pack_text::PackText::new(device, self.format, &self.atlas_view, shader) + }); + } pub fn animate_sprites( &mut self, uploads: &mut crate::gfx::upload::Uploads, @@ -1716,6 +1762,9 @@ impl HudRenderer { batch.end_portal, )), ); + if let Some(pack_text) = &self.pack_text { + pack_text.prepare(uploads, encoder, batch, size); + } } /// Draw the batch `prepare` staged. pub fn draw( @@ -1797,15 +1846,31 @@ impl HudRenderer { pass.set_scissor_rect(x, y, w, h); } for (range, blend) in &runs[at..end] { - pass.set_pipeline(match blend { + let pipeline = match blend { Blend::Alpha => &self.pipeline, Blend::Invert => &self.invert_pipeline, Blend::Additive => &self.additive_pipeline, Blend::Depth(_) => &self.depth_pipeline, Blend::Vignette => &self.vignette_pipeline, Blend::EndPortal => &self.end_portal_pipeline, - }); - pass.draw(range.clone(), 0..1); + }; + match &self.pack_text { + Some(pack_text) if *blend == Blend::Alpha => { + for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) { + if text { + pack_text.draw(&mut pass, part); + pass.set_bind_group(0, &self.group, &[]); + } else { + pass.set_pipeline(pipeline); + pass.draw(part, 0..1); + } + } + } + _ => { + pass.set_pipeline(pipeline); + pass.draw(range.clone(), 0..1); + } + } } at = end; } @@ -2392,6 +2457,7 @@ mod tests { blends: Vec::new(), end_portal: EndPortalFill::default(), glint_alpha: 0.75, + game_time: 0.0, blur_at: 0, inline_profiles: Vec::new(), }; diff --git a/crates/horizon-client-render/src/hud/pack_text.rs b/crates/horizon-client-render/src/hud/pack_text.rs new file mode 100644 index 0000000000..81b92b9914 --- /dev/null +++ b/crates/horizon-client-render/src/hud/pack_text.rs @@ -0,0 +1,325 @@ +//! A resource pack's `core/text` shader on the GUI's text (`RenderPipelines.GUI_TEXT`, the shader +//! with `IS_GUI`): servers' HUDs that their pack's vertex shader moves to the screen's corners, +//! and text effects in its fragment shader. Without one the text draws natively. + +use std::path::Path; +use std::sync::Arc; + +use horizon_shaders::{CoreProgram, Glue}; + +use super::{HudBatch, HudVertex}; + +/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two +/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom +/// left; vanilla's quads go top left, bottom left, bottom right, top right, which is what +/// `gl_VertexID % 4` tells a pack's shader. The draw passes its first vertex as its instance. +const GLUE: Glue = Glue { + inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")], + prelude: "layout(location = 0) in vec2 hz_pos;\n\ + layout(location = 1) in vec2 hz_uv;\n\ + layout(location = 2) in vec4 hz_color;\n\ + const int hz_corner[6] = int[6](0, 3, 2, 0, 2, 1);\n\ + int hz_vertex_id() {\n\ + int i = int(gl_VertexIndex) - int(gl_InstanceIndex);\n\ + return i / 6 * 4 + hz_corner[i % 6];\n\ + }", + setup: "Position = vec3(hz_pos, 0.0); Color = hz_color; UV0 = hz_uv;", + // GL's clip depth runs from -w to w, wgpu's from 0 to w; GUI text has no depth test. + finish: "gl_Position.z = 0.5 * gl_Position.w;", + vertex_id: "hz_vertex_id()", +}; + +const VERTEX: &str = "assets/minecraft/shaders/core/text.vsh"; +const FRAGMENT: &str = "assets/minecraft/shaders/core/text.fsh"; + +/// The packs' text shader over `defaults` (the game's own resources, which have vanilla's and +/// its includes), when a pack replaces either stage. +pub(super) fn load(root: &Path, defaults: &Path) -> Option> { + if root == defaults { + return None; + } + let read = |base: &Path, path: &str| std::fs::read_to_string(base.join(path)).ok(); + let own = + |path: &str| read(root, path).filter(|text| read(defaults, path).as_ref() != Some(text)); + let (own_vertex, own_fragment) = (own(VERTEX), own(FRAGMENT)); + if own_vertex.is_none() && own_fragment.is_none() { + return None; + } + let vertex = own_vertex.or_else(|| read(defaults, VERTEX))?; + let fragment = own_fragment.or_else(|| read(defaults, FRAGMENT))?; + let include = |id: &str| { + let (namespace, path) = id.split_once(':')?; + let path = format!("assets/{namespace}/{path}"); + read(root, &path).or_else(|| read(defaults, &path)) + }; + match horizon_shaders::translate_core(&vertex, &fragment, &["IS_GUI"], &include, &GLUE) { + Ok(program) => { + log::info!("the resource pack's text shader draws the GUI's text"); + Some(Arc::new(program)) + } + Err(e) => { + log::warn!("the resource pack's text shader is not used: {e}"); + None + } + } +} + +/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order). +const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768]; +const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48]; + +/// The pipeline and its resources. +pub(super) struct PackText { + pipeline: wgpu::RenderPipeline, + group: wgpu::BindGroup, + uniform: wgpu::Buffer, +} + +impl PackText { + pub(super) fn new( + device: &wgpu::Device, + format: wgpu::TextureFormat, + atlas: &wgpu::TextureView, + program: &CoreProgram, + ) -> Option { + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let module = |label, module: &naga::Module| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())), + }) + }; + let vertex = module("Resource pack text (vertex)", &program.vertex); + let fragment = module("Resource pack text (fragment)", &program.fragment); + let visibility = wgpu::ShaderStages::VERTEX_FRAGMENT; + let mut entries: Vec = (0..4) + .map(|binding| wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }) + .collect(); + for (_, binding) in horizon_shaders::SAMPLERS { + entries.push(wgpu::BindGroupLayoutEntry { + binding, + visibility, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }); + entries.push(wgpu::BindGroupLayoutEntry { + binding: binding + 1, + visibility, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }); + } + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Resource pack text"), + entries: &entries, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Resource pack text uniforms"), + size: 1024, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + // The font textures' nearest filtering; every sampler a pack names reads the atlas. + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Resource pack text"), + ..Default::default() + }); + let mut resources: Vec = (0..4) + .map(|i| wgpu::BindGroupEntry { + binding: i as u32, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform, + offset: BLOCK_OFFSETS[i], + size: wgpu::BufferSize::new(BLOCK_SIZES[i]), + }), + }) + .collect(); + for (_, binding) in horizon_shaders::SAMPLERS { + resources.push(wgpu::BindGroupEntry { + binding, + resource: wgpu::BindingResource::TextureView(atlas), + }); + resources.push(wgpu::BindGroupEntry { + binding: binding + 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }); + } + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Resource pack text"), + layout: &layout, + entries: &resources, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Resource pack text"), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4]; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Resource pack text"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vertex, + entry_point: Some("main"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &attributes, + })], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &fragment, + entry_point: Some("main"), + compilation_options: Default::default(), + // BlendFunction.TRANSLUCENT. + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + if let Some(e) = pollster::block_on(scope.pop()) { + log::warn!("the resource pack's text shader is not used: {e}"); + return None; + } + Some(Self { + pipeline, + group, + uniform, + }) + } + + /// The frame's blocks, as `GuiRenderer` sets them for the GUI. + pub(super) fn prepare( + &self, + uploads: &mut crate::gfx::upload::Uploads, + encoder: &mut wgpu::CommandEncoder, + batch: &HudBatch, + size: [u32; 2], + ) { + uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size)); + } + + /// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own + /// pipeline and bind group again after. + pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range) { + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.group, &[]); + pass.draw(range.clone(), range.start..range.start + 1); + } +} + +/// The std140 blocks: `Globals` (`GlobalSettingsUniform.update`: the camera at the origin, +/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI +/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view +/// moving 11000 back, colour modulator white) and an empty `Fog`. +fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec { + let mut data = vec![0f32; 256]; + let globals = 0; + data[globals + 3] = batch.glint_alpha; + data[globals + 7] = batch.game_time; + data[globals + 8] = size[0] as f32; + data[globals + 9] = size[1] as f32; + // Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns. + let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale); + let (near, far) = (1000.0f32, 11000.0f32); + let projection = 64; + data[projection] = 2.0 / w; + data[projection + 5] = -2.0 / h; + data[projection + 10] = 2.0 / (near - far); + data[projection + 12] = -1.0; + data[projection + 13] = 1.0; + data[projection + 14] = (far + near) / (near - far); + data[projection + 15] = 1.0; + let transforms = 128; + for (matrix, z) in [(transforms, -11000.0), (transforms + 16, 0.0)] { + data[matrix] = 1.0; + data[matrix + 5] = 1.0; + data[matrix + 10] = 1.0; + data[matrix + 14] = z; + data[matrix + 15] = 1.0; + } + data[transforms + 32..transforms + 36].fill(1.0); + bytemuck::cast_slice(&data).to_vec() +} + +/// `range` split into runs of GUI text and of the rest, in order. +pub(super) fn text_runs( + vertices: &[HudVertex], + range: std::ops::Range, +) -> Vec<(std::ops::Range, bool)> { + let mut runs: Vec<(std::ops::Range, bool)> = Vec::new(); + for i in range { + let text = vertices.get(i as usize).is_some_and(|v| v.text != 0); + match runs.last_mut() { + Some((run, t)) if *t == text => run.end = i + 1, + _ => runs.push((i..i + 1, text)), + } + } + runs +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_gui_projection_maps_gui_pixels_to_the_screen() { + let mut batch = HudBatch::new([1920, 1080]); + batch.scale = 4.0; + let data: Vec = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec(); + let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4]; + let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] { + std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum()) + }; + // ProjMat * ModelViewMat * (x, y, 0, 1) for the GUI's corners: 480 x 270 GUI pixels. + for ([x, y], [cx, cy]) in [([0.0, 0.0], [-1.0, 1.0]), ([480.0, 270.0], [1.0, -1.0])] { + let clip = apply(64, apply(128, [x, y, 0.0, 1.0])); + assert!( + (clip[0] - cx).abs() < 1e-5 && (clip[1] - cy).abs() < 1e-5, + "{clip:?}" + ); + // At GL's far plane, as vanilla's GUI is. + assert!((clip[2] - 1.0).abs() < 1e-4, "{clip:?}"); + } + // A pack finds the GUI scale as ScreenSize.x * ProjMat[0][0] / 2. + assert!((data[8] * data[64] / 2.0 - 4.0).abs() < 1e-4); + } + + #[test] + fn text_splits_from_the_rest() { + let vertex = |text| HudVertex { + pos: [0.0; 2], + uv: [0.0; 2], + color: 0, + depth: 0.0, + glint_uv: [0.0; 2], + text, + }; + let vertices: Vec = [0, 0, 1, 1, 1, 0].into_iter().map(vertex).collect(); + assert_eq!( + text_runs(&vertices, 1..6), + [(1..2, false), (2..5, true), (5..6, false)] + ); + } +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index caf6956c6a..ad2e323bce 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6370,6 +6370,10 @@ impl Game<'_> { } fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch { let mut batch = HudBatch::new(size); + // GlobalSettingsUniform's GameTime, for a resource pack's text shader. + batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32 + + self.clock.fraction() as f32) + / 24000.0; let (w, h) = (batch.width, batch.height); if self.screen == Screen::Win { self.draw_win_screen(&mut batch); @@ -15613,6 +15617,9 @@ fn build_renderer( .iter() .map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0))) .collect(); + renderer + .hud + .set_text_shader(&renderer.device, atlas.text_shader.as_deref()); Ok(renderer) } diff --git a/crates/horizon-shaders/src/core_shader.rs b/crates/horizon-shaders/src/core_shader.rs new file mode 100644 index 0000000000..8a4f96db86 --- /dev/null +++ b/crates/horizon-shaders/src/core_shader.rs @@ -0,0 +1,653 @@ +//! Resource packs' core shaders (`shaders/core/*.vsh` and `.fsh`: the GLSL vanilla compiles for its +//! pipelines) as naga modules, so a pack that reshapes text from `core/text` (a HUD its vertex +//! shader moves to the screen's corners, effects in its fragment shader) draws as in vanilla. +//! +//! The pack's GLSL (26.3's `#include` or the older `#moj_import`, with explicit locations or +//! without) is written again as the Vulkan-flavoured GLSL 450 naga reads: +//! - includes are inlined, each file once (as `GlslPreprocessor` imported them), from `include`; +//! - vanilla's uniform blocks get the bindings of [`BLOCKS`], and each of [`SAMPLERS`] is split +//! into a texture and a sampler object, combined again where the sampler is named; +//! - the vertex inputs become globals the caller's [`Glue`] fills before the pack's `main` +//! (renamed `hz_main`) runs, and `gl_VertexID` reads the glue's expression; +//! - varyings get locations by name across the stages, the fragment output location 0. + +use std::collections::HashSet; +use std::fmt::Write; + +use naga::ShaderStage; +use naga::front::glsl::{Frontend, Options}; +use naga::valid::{Capabilities, ValidationFlags, Validator}; + +/// Vanilla's uniform blocks (`shaders/include/*.glsl`) and their bindings in set 0. +pub const BLOCKS: [(&str, u32); 4] = [ + ("Globals", 0), + ("Projection", 1), + ("DynamicTransforms", 2), + ("Fog", 3), +]; + +/// Vanilla's samplers: the texture's binding in set 0, the sampler object's the next one. +pub const SAMPLERS: [(&str, u32); 3] = [("Sampler0", 4), ("Sampler1", 6), ("Sampler2", 8)]; + +/// The first binding of the blocks and samplers vanilla has for other pipelines (OIT's behind +/// `#ifdef OIT`): fine while the preprocessor drops them, an error when a stage uses one. +const OTHER_BINDINGS: u32 = 16; + +/// What the caller's pipeline gives the pack's vertex shader. +pub struct Glue<'a> { + /// The inputs of the pipeline's vanilla vertex format: name and GLSL type. + pub inputs: &'a [(&'a str, &'a str)], + /// Declarations before the pack's code: the real attributes, helpers. + pub prelude: &'a str, + /// Statements before the pack's `main`: `inputs` filled from the attributes. + pub setup: &'a str, + /// Statements after it, on `gl_Position`. + pub finish: &'a str, + /// The expression `gl_VertexID` reads. + pub vertex_id: &'a str, +} + +/// A pipeline's two stages, validated. +#[derive(Debug)] +pub struct CoreProgram { + pub vertex: naga::Module, + pub fragment: naga::Module, +} + +/// The shaders of one pipeline (`text.vsh`, `text.fsh`) with vanilla's `defines` for it. +/// `include` answers a file id (`minecraft:shaders/include/fog.glsl`) with its text. +pub fn translate_core( + vertex: &str, + fragment: &str, + defines: &[&str], + include: &dyn Fn(&str) -> Option, + glue: &Glue, +) -> Result { + const CORE: &str = "minecraft:shaders/core/"; + let vertex_text = strip_comments(&expand(vertex, CORE, include)?); + let fragment_text = strip_comments(&expand(fragment, CORE, include)?); + let mut names = Names::default(); + let vertex_code = rewrite(&vertex_text, ShaderStage::Vertex, glue, &mut names) + .map_err(|e| format!("vertex shader: {e}"))?; + let fragment_code = rewrite(&fragment_text, ShaderStage::Fragment, glue, &mut names) + .map_err(|e| format!("fragment shader: {e}"))?; + let vertex = compile(ShaderStage::Vertex, &vertex_code, defines) + .map_err(|e| format!("vertex shader: {e}"))?; + let fragment = compile(ShaderStage::Fragment, &fragment_code, defines) + .map_err(|e| format!("fragment shader: {e}"))?; + let given = locations(&vertex, false); + if let Some(missing) = locations(&fragment, true) + .into_iter() + .find(|l| !given.contains(l)) + { + return Err(format!( + "the fragment shader reads location {missing}, which the vertex shader doesn't write" + )); + } + Ok(CoreProgram { vertex, fragment }) +} + +/// The file with its includes inlined (`#include `, `#include "relative"` and the same +/// with `#moj_import`), each file once, `#version` and `#extension` lines dropped. +fn expand( + text: &str, + dir: &str, + include: &dyn Fn(&str) -> Option, +) -> Result { + fn walk( + text: &str, + dir: &str, + include: &dyn Fn(&str) -> Option, + seen: &mut HashSet, + depth: u32, + out: &mut String, + ) -> Result<(), String> { + if depth > 16 { + return Err("includes nest more than 16 deep".into()); + } + for line in text.lines() { + let directive = line.trim_start().strip_prefix('#').map(str::trim_start); + let target = directive.and_then(|d| { + d.strip_prefix("include") + .or_else(|| d.strip_prefix("moj_import")) + .map(str::trim) + }); + if let Some(target) = target { + let id = include_id(target, dir).ok_or_else(|| format!("bad include {target}"))?; + if seen.insert(id.clone()) { + let file = include(&id).ok_or_else(|| format!("{id} not found"))?; + let parent = &id[..id.rfind('/').map_or(id.len(), |i| i + 1)]; + walk(&file, parent, include, seen, depth + 1, out)?; + } + out.push('\n'); + } else if directive + .is_some_and(|d| d.starts_with("version") || d.starts_with("extension")) + { + out.push('\n'); + } else { + out.push_str(line); + out.push('\n'); + } + } + Ok(()) + } + let mut out = String::new(); + walk(text, dir, include, &mut HashSet::new(), 0, &mut out)?; + Ok(out) +} + +/// `` under `shaders/include` (`minecraft` when there's no namespace), or `"path"` from +/// the including file's folder. +fn include_id(target: &str, dir: &str) -> Option { + if let Some(inner) = target.strip_prefix('<').and_then(|t| t.strip_suffix('>')) { + let (ns, path) = inner.split_once(':').unwrap_or(("minecraft", inner)); + return Some(format!("{ns}:shaders/include/{path}")); + } + let relative = target.strip_prefix('"')?.strip_suffix('"')?; + let (ns, base) = dir.split_once(':')?; + let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect(); + for part in relative.split('/') { + match part { + "." | "" => {} + ".." => { + parts.pop()?; + } + _ => parts.push(part), + } + } + Some(format!("{ns}:{}", parts.join("/"))) +} + +/// Comments as spaces, line breaks kept. +fn strip_comments(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match (c, chars.peek()) { + ('/', Some('/')) => { + while chars.peek().is_some_and(|&c| c != '\n') { + chars.next(); + } + } + ('/', Some('*')) => { + chars.next(); + let mut last = ' '; + for c in chars.by_ref() { + if c == '\n' { + out.push('\n'); + } + if last == '*' && c == '/' { + break; + } + last = c; + } + out.push(' '); + } + _ => out.push(c), + } + } + out +} + +fn is_ident(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +/// `text` with each whole identifier `from` written as `to`. +fn replace_ident(text: &str, from: &str, to: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find(from) { + let before = rest[..at].chars().next_back(); + let after = rest[at + from.len()..].chars().next(); + out.push_str(&rest[..at]); + if before.is_some_and(is_ident) || after.is_some_and(is_ident) { + out.push_str(from); + } else { + out.push_str(to); + } + rest = &rest[at + from.len()..]; + } + out.push_str(rest); + out +} + +/// A global declaration on one line: `layout(...) qualifiers storage type name;`. +struct Decl<'a> { + /// `layout(location = N)`'s N. + location: Option, + qualifiers: Vec<&'a str>, + storage: &'a str, + ty: &'a str, + name: &'a str, +} + +fn declaration(line: &str) -> Option> { + let mut rest = line.trim(); + let mut location = None; + if let Some(after) = rest.strip_prefix("layout") { + let after = after.trim_start().strip_prefix('(')?; + let close = after.find(')')?; + location = after[..close].split(',').find_map(|q| { + let (key, value) = q.split_once('=')?; + (key.trim() == "location").then(|| value.trim().parse().ok())? + }); + rest = after[close + 1..].trim_start(); + } + let body = rest.strip_suffix(';')?.trim_end(); + let words: Vec<&str> = body.split_whitespace().collect(); + let storage_at = words + .iter() + .position(|w| matches!(*w, "in" | "out" | "uniform"))?; + let [ty, name] = words[storage_at + 1..] else { + return None; + }; + if !name.chars().all(is_ident) || !ty.chars().all(is_ident) { + return None; + } + Some(Decl { + location, + qualifiers: words[..storage_at].to_vec(), + storage: words[storage_at], + ty, + name, + }) +} + +/// The block a `uniform Name {` line opens, with the line's text before `uniform`. +fn block_name(line: &str) -> Option<&str> { + let at = line.find("uniform")?; + let head = line[..at].trim(); + if !(head.is_empty() || head.starts_with("layout")) { + return None; + } + let rest = line[at + "uniform".len()..].trim(); + let name_end = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len()); + let (name, tail) = rest.split_at(name_end); + (!name.is_empty() && matches!(tail.trim(), "" | "{")).then_some(name) +} + +/// What the two stages share: the vertex stage's outputs in location order, and the names of +/// the blocks and samplers of other pipelines in binding order. +#[derive(Default)] +struct Names { + varyings: Vec, + other: Vec, +} + +impl Names { + fn location(&self, varying: &str) -> Option { + self.varyings + .iter() + .position(|v| v == varying) + .map(|i| i as u32) + } + + /// A block or sampler of another pipeline: its binding (two for a sampler). + fn other_binding(&mut self, name: &str) -> u32 { + let index = match self.other.iter().position(|n| n == name) { + Some(index) => index, + None => { + self.other.push(name.to_owned()); + self.other.len() - 1 + } + }; + OTHER_BINDINGS + 2 * index as u32 + } +} + +/// One stage written again for naga (see the module's notes). +fn rewrite( + text: &str, + stage: ShaderStage, + glue: &Glue, + names: &mut Names, +) -> Result { + let vertex = stage == ShaderStage::Vertex; + let mut out = String::from("#version 450 core\n"); + if vertex { + out.push_str(glue.prelude); + out.push('\n'); + for (name, ty) in glue.inputs { + writeln!(out, "{ty} {name};").unwrap(); + } + } + let mut outputs: Vec<&str> = Vec::new(); + for line in text.lines() { + if let Some(name) = block_name(line) { + let binding = BLOCKS + .iter() + .find(|(block, _)| *block == name) + .map_or_else(|| names.other_binding(name), |(_, b)| *b); + let tail = line[line.find("uniform").unwrap_or(0)..].trim(); + writeln!(out, "layout(std140, set = 0, binding = {binding}) {tail}").unwrap(); + continue; + } + let Some(decl) = declaration(line) else { + out.push_str(line); + out.push('\n'); + continue; + }; + let qualifiers = decl.qualifiers.join(" "); + match decl.storage { + "uniform" if decl.ty == "sampler2D" => { + let binding = SAMPLERS + .iter() + .find(|(sampler, _)| *sampler == decl.name) + .map_or_else(|| names.other_binding(decl.name), |(_, b)| *b); + let name = decl.name; + writeln!( + out, + "layout(set = 0, binding = {binding}) uniform texture2D hz_tex_{name};\n\ + layout(set = 0, binding = {}) uniform sampler hz_smp_{name};\n\ + #define {name} sampler2D(hz_tex_{name}, hz_smp_{name})", + binding + 1 + ) + .unwrap(); + } + "uniform" => { + return Err(format!( + "uniform {} outside a block (26.x's shaders keep them in blocks)", + decl.name + )); + } + "in" if vertex => { + if glue.inputs.iter().any(|(name, _)| *name == decl.name) { + // Declared by the glue as a global. + out.push('\n'); + } else { + // Another format's input (`UV2` behind `#if !defined(IS_GUI)`): the + // preprocessor drops it when it is for another pipeline, else it reads 0. + writeln!(out, "{} {};", decl.ty, decl.name).unwrap(); + } + } + "out" if vertex => { + // One location each: naga takes scalars and vectors only. + let location = names.location(decl.name).unwrap_or_else(|| { + names.varyings.push(decl.name.to_owned()); + names.varyings.len() as u32 - 1 + }); + writeln!( + out, + "layout(location = {location}) {qualifiers} out {} {};", + decl.ty, decl.name + ) + .unwrap(); + } + "in" => { + let location = names.location(decl.name).ok_or_else(|| { + format!("input {} isn't written by the vertex shader", decl.name) + })?; + writeln!( + out, + "layout(location = {location}) {qualifiers} in {} {};", + decl.ty, decl.name + ) + .unwrap(); + } + _ => { + // Pipelines with more targets (OIT's, behind `#ifdef OIT`) name them; the + // colour this pipeline draws is the one at location 0. + let location = decl.location.unwrap_or_else(|| { + let at = outputs.iter().position(|o| *o == decl.name); + at.unwrap_or_else(|| { + outputs.push(decl.name); + outputs.len() - 1 + }) as u32 + }); + writeln!( + out, + "layout(location = {location}) out {} {};", + decl.ty, decl.name + ) + .unwrap(); + } + } + } + if !vertex { + return Ok(out); + } + let mut code = replace_ident( + &rename_main(&out), + "gl_VertexID", + &format!("({})", glue.vertex_id), + ); + write!( + code, + "void main() {{\n{}\nhz_main();\n{}\n}}\n", + glue.setup, glue.finish + ) + .unwrap(); + Ok(code) +} + +/// `void main()` becomes `void hz_main()`. +fn rename_main(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find("main") { + let before = rest[..at].trim_end(); + let after = rest[at + 4..].trim_start(); + let whole = !rest[..at].chars().next_back().is_some_and(is_ident) + && !rest[at + 4..].chars().next().is_some_and(is_ident); + out.push_str(&rest[..at]); + if whole && after.starts_with('(') && before.ends_with("void") { + out.push_str("hz_main"); + } else { + out.push_str("main"); + } + rest = &rest[at + 4..]; + } + out.push_str(rest); + out +} + +/// Parses and validates one stage; errors name the line of the rewritten code. +fn compile(stage: ShaderStage, code: &str, defines: &[&str]) -> Result { + let mut options = Options::from(stage); + for define in defines { + options.defines.insert((*define).to_owned(), String::new()); + } + let module = Frontend::default().parse(&options, code).map_err(|e| { + e.errors + .iter() + .map(|e| { + let start = e.meta.to_range().map_or(0, |r| r.start); + let line = code[..start.min(code.len())].matches('\n').count() + 1; + let source = code.lines().nth(line - 1).unwrap_or("").trim(); + format!("line {line} ({source}): {}", e.kind) + }) + .collect::>() + .join("; ") + })?; + let info = Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&module) + .map_err(|e| { + let inner = e.into_inner(); + let mut message = inner.to_string(); + let mut source = std::error::Error::source(&inner); + while let Some(s) = source { + write!(message, ": {s}").unwrap(); + source = s.source(); + } + message + })?; + let entry = info.get_entry_point(0); + for (handle, global) in module.global_variables.iter() { + let other = global + .binding + .as_ref() + .is_some_and(|b| b.binding >= OTHER_BINDINGS); + if other && !entry[handle].is_empty() { + let name = global.name.as_deref().unwrap_or("?"); + let name = name + .strip_prefix("hz_tex_") + .or_else(|| name.strip_prefix("hz_smp_")) + .unwrap_or(name); + return Err(format!("{name} is not a block or sampler of this pipeline")); + } + } + Ok(module) +} + +/// The locations the entry point reads (`inputs`) or writes. +fn locations(module: &naga::Module, inputs: bool) -> Vec { + let mut out = Vec::new(); + let mut add = |ty: naga::Handle, binding: Option<&naga::Binding>| { + if let Some(naga::Binding::Location { location, .. }) = binding { + out.push(*location); + } else if let naga::TypeInner::Struct { members, .. } = &module.types[ty].inner { + for m in members { + if let Some(naga::Binding::Location { location, .. }) = &m.binding { + out.push(*location); + } + } + } + }; + for entry in &module.entry_points { + if inputs { + for arg in &entry.function.arguments { + add(arg.ty, arg.binding.as_ref()); + } + } else if let Some(result) = &entry.function.result { + add(result.ty, result.binding.as_ref()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const INCLUDES: [(&str, &str); 4] = [ + ( + "minecraft:shaders/include/dynamictransforms.glsl", + "#ifndef DT\n#define DT\nlayout(std140) uniform DynamicTransforms {\n mat4 ModelViewMat;\n mat4 TextureMat;\n vec4 ColorModulator;\n vec3 ModelOffset;\n};\n#endif\n", + ), + ( + "minecraft:shaders/include/projection.glsl", + "layout(std140) uniform Projection {\n mat4 ProjMat;\n};\n", + ), + ( + "minecraft:shaders/include/globals.glsl", + "layout(std140) uniform Globals {\n ivec3 CameraBlockPos;\n float GlintAlpha;\n vec3 CameraOffset;\n float GameTime;\n vec2 ScreenSize;\n int MenuBlurRadius;\n int UseRgss;\n};\n", + ), + ( + "minecraft:shaders/include/fog.glsl", + "layout(std140) uniform Fog {\n vec4 FogColor;\n float FogEnvironmentalStart;\n};\nvec4 apply_fog(vec4 c) { return mix(c, FogColor, FogEnvironmentalStart); }\n", + ), + ]; + + fn include(id: &str) -> Option { + INCLUDES + .iter() + .find(|(path, _)| *path == id) + .map(|(_, text)| (*text).to_owned()) + } + + const GLUE: Glue = Glue { + inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")], + prelude: "layout(location = 0) in vec2 hz_pos;\nlayout(location = 1) in vec2 hz_uv;\nlayout(location = 2) in vec4 hz_color;\n\ + const int hz_corner[6] = int[6](0, 3, 2, 0, 2, 1);\n\ + int hz_vertex_id() { int i = int(gl_VertexIndex) - int(gl_InstanceIndex); return i / 6 * 4 + hz_corner[i % 6]; }", + setup: "Position = vec3(hz_pos, 0.0); Color = hz_color; UV0 = hz_uv;", + finish: "gl_Position.z = 0.5 * gl_Position.w;", + vertex_id: "hz_vertex_id()", + }; + + /// 26.3's `text.vsh` and `text.fsh` in shape: `#include`, explicit locations, an extension. + const VANILLA_VSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\ + #if !defined(IS_GUI)\n#include \n#endif\n\ + #include \n#include \n\ + layout(location = 0) in vec3 Position;\nlayout(location = 1) in vec4 Color;\nlayout(location = 2) in vec2 UV0;\n\ + layout(location = 2) out vec4 vertexColor;\nlayout(location = 3) out vec2 texCoord0;\n\ + void main() {\n gl_Position = ProjMat * ModelViewMat * vec4(Position, 1.0);\n vertexColor = Color;\n texCoord0 = UV0;\n}\n"; + const VANILLA_FSH: &str = "#version 330\n#extension GL_ARB_separate_shader_objects : require\n\ + #include \n\ + uniform sampler2D Sampler0;\n\ + layout(location = 2) in vec4 vertexColor;\nlayout(location = 3) in vec2 texCoord0;\n\ + layout(location = 0) out vec4 fragColor;\n\ + void main() {\n vec4 color = texture(Sampler0, texCoord0) * vertexColor * ColorModulator;\n if (color.a < 0.1) {\n discard;\n }\n fragColor = color;\n}\n"; + + #[test] + fn vanilla_26_3_text_shaders_translate() { + let program = + translate_core(VANILLA_VSH, VANILLA_FSH, &["IS_GUI"], &include, &GLUE).unwrap(); + assert_eq!(program.vertex.entry_points.len(), 1); + assert_eq!(program.fragment.entry_points.len(), 1); + } + + /// A 26.2-style pack shader as a HUD pack writes it: `#moj_import`, no locations, a flat + /// varying, `gl_VertexID`, `texelFetch` and `textureSize` on the glyph atlas, `GameTime`. + #[test] + fn a_hud_pack_text_shader_translates() { + let vsh = "#version 330\n\ + #moj_import \n#moj_import \n\ + in vec3 Position;\nin vec4 Color;\nin vec2 UV0;\n\ + out vec4 vertexColor;\nout vec2 texCoord0;\n\ + #ifdef IS_GUI\n#moj_import \nuniform sampler2D Sampler0; // the glyphs\nflat out vec4 effect;\n#endif\n\ + /* moved\n glyphs */\n\ + void main() {\n vec3 p = Position;\n#ifdef IS_GUI\n int corner = gl_VertexID % 4;\n\ + ivec2 size = textureSize(Sampler0, 0);\n\ + if (ivec4(round(texelFetch(Sampler0, ivec2(UV0 * vec2(size)), 0) * 255.0)) == ivec4(177, 126, 91, 1)) {\n\ + p.x += float(corner) + ScreenSize.x * GameTime;\n }\n effect = vec4(float((ivec3(Color.rgb * 255.0) >> 4) == ivec3(3, 14, 10)));\n#endif\n\ + gl_Position = ProjMat * ModelViewMat * vec4(p, 1.0);\n vertexColor = Color;\n texCoord0 = UV0;\n}\n"; + let fsh = "#version 330\n#moj_import \n#moj_import \n\ + uniform sampler2D Sampler0;\nin vec4 vertexColor;\nin vec2 texCoord0;\nflat in vec4 effect;\nout vec4 fragColor;\n\ + void main() {\n vec4 color = texture(Sampler0, texCoord0) * vertexColor;\n if (effect.x > 0.5) {\n fragColor = vertexColor * ColorModulator;\n return;\n }\n\ + if (color.a < 0.1) {\n discard;\n }\n fragColor = color * sin(GameTime);\n}\n"; + translate_core(vsh, fsh, &["IS_GUI"], &include, &GLUE).unwrap(); + } + + #[test] + fn varyings_meet_at_the_same_locations() { + let vsh = "#version 330\nin vec3 Position;\nout float b;\nout vec2 m;\nout vec4 a;\n\ + void main() { gl_Position = vec4(Position, 1.0); b = 1.0; m = vec2(1.0); a = vec4(b); }\n"; + let fsh = "#version 330\nin vec4 a;\nin float b;\nout vec4 fragColor;\nvoid main() { fragColor = a * b; }\n"; + let program = translate_core(vsh, fsh, &[], &include, &GLUE).unwrap(); + let mut read = locations(&program.fragment, true); + read.sort_unstable(); + assert_eq!(read, [0, 2]); + } + + #[test] + fn what_horizon_does_not_provide_is_an_error() { + let fsh = "#version 330\nin vec4 vertexColor;\nout vec4 fragColor;\nvoid main() { fragColor = vertexColor; }\n"; + // Another format's input is a global the shader may read: not an error. + let normal = "#version 330\nin vec3 Normal;\nout vec4 vertexColor;\nvoid main() { vertexColor = vec4(Normal, 1.0); }\n"; + translate_core(normal, fsh, &[], &include, &GLUE).unwrap(); + let lightmap = "#version 330\nuniform sampler2D Lightmap;\nout vec4 vertexColor;\nvoid main() { vertexColor = texelFetch(Lightmap, ivec2(0), 0); }\n"; + let error = translate_core(lightmap, fsh, &[], &include, &GLUE).unwrap_err(); + assert!( + error.contains("Lightmap is not a block or sampler"), + "{error}" + ); + // Behind a define the pipeline doesn't have, it is no error. + let oit = "#version 330\n#ifdef OIT\nuniform sampler2D Coeff0;\n#endif\nout vec4 vertexColor;\n\ + void main() {\n#ifdef OIT\n vertexColor = texelFetch(Coeff0, ivec2(0), 0);\n#endif\n}\n"; + translate_core(oit, fsh, &[], &include, &GLUE).unwrap(); + let missing = "#version 330\n#moj_import \nvoid main() {}\n"; + let error = translate_core(missing, fsh, &[], &include, &GLUE).unwrap_err(); + assert!( + error.contains("minecraft:shaders/include/nothing.glsl not found"), + "{error}" + ); + } + + #[test] + fn includes_resolve_by_id_or_from_the_including_folder() { + assert_eq!( + include_id("", "minecraft:shaders/core/").as_deref(), + Some("minecraft:shaders/include/fog.glsl") + ); + assert_eq!( + include_id("", "minecraft:shaders/core/").as_deref(), + Some("bitmade:shaders/include/hud/lanes.glsl") + ); + assert_eq!( + include_id("\"../include/fog.glsl\"", "minecraft:shaders/core/").as_deref(), + Some("minecraft:shaders/include/fog.glsl") + ); + } +} diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 4aa481e818..014fe567c8 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -8,6 +8,7 @@ //! - [`Lang`]: the pack's translated names (8.4). //! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). //! - [`translate_composite`], [`translate_gbuffers`], [`translate_shadow`]: a program into naga modules and its interface (1.6, 2.2). +//! - [`translate_core`]: a resource pack's vanilla core shader (`shaders/core/`) into naga modules. //! - [`Config`] and [`draw_buffers`]: the buffer settings and output lists stage files carry (1.7, 6.1). //! - [`load_post`]: a dimension's `prepare`, `deferred`, `composite` and `final` programs, in order (2.1). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). @@ -16,6 +17,7 @@ mod biomes; mod block_ids; mod condition; mod config; +mod core_shader; mod custom; mod discover; mod lang; @@ -31,6 +33,7 @@ pub use biomes::{category as biome_category, precipitation as biome_precipitatio pub use block_ids::BlockIds; pub use condition::eval_condition; pub use config::{BUFFERS, Buffer, Config, Format, Kind, Shadow, buffer_index, draw_buffers}; +pub use core_shader::{BLOCKS, CoreProgram, Glue, SAMPLERS, translate_core}; pub use custom::{Custom, Val}; pub use discover::{Found, discover}; pub use lang::Lang; diff --git a/docs/resource-packs.md b/docs/resource-packs.md index 716615a655..f02f98ad31 100644 --- a/docs/resource-packs.md +++ b/docs/resource-packs.md @@ -72,11 +72,11 @@ harness named checks that loader against vanilla). | Paintings, mob effect icons | `atlases/paintings.json`, `textures/mob_effect/` | works | loaded from the overlay | | Locator bar styles | `waypoint_style/` | **no** | the locator bar isn't drawn yet | | Post effects (spectating a creeper, spider or enderman; glowing outlines) | `post_effect/`, `shaders/post/` | **no** | Horizon's effects are WGSL; a pack's GLSL post chain isn't run | -| Core shaders | `shaders/core/`, `shaders/include/` | **no** | Horizon draws with WGSL; a pack's GLSL core shaders aren't run | +| Core shaders | `shaders/core/`, `shaders/include/` | partly | `core/text` draws the GUI's text (HUDs moved by its vertex shader, text effects), translated by naga (`horizon-shaders` `core_shader.rs`, `hud/pack_text.rs`); the other core shaders aren't run | | GPU warn list, regional compliance | `gpu_warnlist.json`, `regional_compliancies.json` | n/a | OpenGL driver warnings and play-time notices for some regions; not used | -Summary: 45 rows; 32 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem, -villager hats, trims on worn armour, locator bar styles, post effects, core shaders), 2 don't apply. Except the +Summary: 45 rows; 32 work, 4 partly, 7 don't (interpolated animation, TrueType fonts, credits and end poem, +villager hats, trims on worn armour, locator bar styles, post effects), 2 don't apply. Except the shaders, the missing pieces are missing vanilla features too, and each vanilla parity fix covers the pack case as well. ## Gaps and their work @@ -91,7 +91,7 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p | Trims on worn armour (paletted textures) | medium | [#337](https://github.com/horizon-rust/horizon/issues/337) | | Credits and end poem screen | medium | [#338](https://github.com/horizon-rust/horizon/issues/338) | | Locator bar | medium | [#339](https://github.com/horizon-rust/horizon/issues/339) | -| Post effects and core shaders | large | none yet | +| Post effects, core shaders but GUI text | large | none yet | ## Testing a pack From 59a1f51d346b5bbea210f48ebe4419fb19bdb370 Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 15:29:45 +0330 Subject: [PATCH 377/968] Controls: implement Quit Shortcuts toggle and window-close blocking In vanilla Minecraft 26.3, the Quit Shortcuts option (options.quitShortcuts, default true) controls whether keyboard shortcuts (Alt+F4 on Windows/Linux, Cmd+Q on macOS) can close the game window. When disabled, close requests initiated via quit shortcuts are ignored to prevent accidental quits, while window close buttons continue to work. Horizon already loaded and serialized quitShortcuts in options.txt and had the cycle button wired in options_ui, but the setting was excluded from Opt::available and the close handler never checked InputQuirks.isQuitShortcutDown. - Add is_quit_shortcut_down in horizon-client-input::input checking platform modifiers and physical keys (Command+Q on macOS, Alt+F4 elsewhere). - Check is_quit_shortcut_down on WindowEvent::CloseRequested in game.rs and startup_menu.rs when quit_shortcuts is disabled. - Enable Opt::QuitShortcuts in Opt::available so the toggle is clickable in Controls with its vanilla tooltip. - Add unit tests in horizon-client-input, horizon-client-render, and horizon-client. Fixes #4336. Part of #3518. Co-Authored-By: Antigravity --- crates/horizon-client-input/src/input.rs | 45 +++++++++++++++ .../horizon-client-render/src/options_ui.rs | 15 ++++- crates/horizon-client/src/game.rs | 57 +++++++++++++++++++ .../horizon-client/src/game/startup_menu.rs | 8 +++ 4 files changed, 122 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client-input/src/input.rs b/crates/horizon-client-input/src/input.rs index 74481d51cc..f8b2c10b7f 100644 --- a/crates/horizon-client-input/src/input.rs +++ b/crates/horizon-client-input/src/input.rs @@ -2,6 +2,9 @@ //! Item-use and sneak multipliers come from the player's current state; this //! module does not implement sprint eligibility, poses or the input lifecycle. +use std::collections::HashSet; +use winit::keyboard::{KeyCode, ModifiersState}; + fn length(v: [f32; 2]) -> f32 { f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32 } @@ -65,3 +68,45 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] { }, ) } + +/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing +/// keyboard shortcut is held. +/// - On macOS: Command (`super_key()`) + `Q`. +/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`. +pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet) -> bool { + if cfg!(target_os = "macos") { + modifiers.super_key() && keys.contains(&KeyCode::KeyQ) + } else { + modifiers.alt_key() && keys.contains(&KeyCode::F4) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quit_shortcut_follows_platform_modifiers_and_keys() { + let mut keys = HashSet::new(); + let alt = ModifiersState::ALT; + let super_key = ModifiersState::SUPER; + + assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys)); + + keys.insert(KeyCode::F4); + if cfg!(target_os = "macos") { + assert!(!is_quit_shortcut_down(alt, &keys)); + keys.remove(&KeyCode::F4); + keys.insert(KeyCode::KeyQ); + assert!(is_quit_shortcut_down(super_key, &keys)); + assert!(!is_quit_shortcut_down(alt, &keys)); + } else { + assert!(is_quit_shortcut_down(alt, &keys)); + assert!(!is_quit_shortcut_down(super_key, &keys)); + keys.remove(&KeyCode::F4); + keys.insert(KeyCode::KeyQ); + assert!(!is_quit_shortcut_down(alt, &keys)); + assert!(!is_quit_shortcut_down(super_key, &keys)); + } + } +} diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 3ec3fd8255..eca76f5ec1 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -141,10 +141,8 @@ impl Opt { | Opt::ChunkFade | Opt::ImprovedTransparency | Opt::ChunkBuilder - // Saved, but the creative tabs, the quit keys and the mouse - // don't read them yet. + // Saved, but the creative tabs and the mouse don't read them yet. | Opt::OperatorItemsTab - | Opt::QuitShortcuts | Opt::CtrlClickEmulatesRightClick ) } @@ -3415,6 +3413,17 @@ mod tests { assert!(loaded.toggle_attack && loaded.toggle_use); } + #[test] + fn quit_shortcuts_option_can_be_switched() { + assert!(Opt::QuitShortcuts.available()); + let s = Settings::default(); + assert_eq!(Opt::QuitShortcuts.caption(), "options.quitShortcuts"); + assert_eq!( + Opt::QuitShortcuts.tooltip_key(&s), + "options.quitShortcuts.tooltip" + ); + } + #[test] fn render_distance_slider_round_trips_through_extended_options_range() { let mut s = Settings::default(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1deb9a2b6c..7d80f6d4b0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -13476,6 +13476,14 @@ impl ApplicationHandler for Game<'_> { if self.pending_save.is_some() { match event { WindowEvent::CloseRequested => { + if !self.settings.quit_shortcuts + && horizon_client::input::is_quit_shortcut_down( + self.modifiers, + &self.physical_keys, + ) + { + return; + } self.pending_save.as_mut().unwrap().quit = true; } WindowEvent::Focused(focused) => { @@ -13502,6 +13510,14 @@ impl ApplicationHandler for Game<'_> { let result: Result<()> = (|| { match event { WindowEvent::CloseRequested => { + if !self.settings.quit_shortcuts + && horizon_client::input::is_quit_shortcut_down( + self.modifiers, + &self.physical_keys, + ) + { + return Ok(()); + } self.begin_closing_save(true)?; } WindowEvent::Resized(size) => { @@ -16830,4 +16846,45 @@ mod tests { } } } + + #[test] + fn quit_shortcut_blocks_window_close_when_option_disabled() { + use winit::keyboard::ModifiersState; + + let mut keys = HashSet::new(); + let alt = ModifiersState::ALT; + let super_key = ModifiersState::SUPER; + + let quit_shortcuts_enabled = true; + let quit_shortcuts_disabled = false; + + keys.insert(if cfg!(target_os = "macos") { + KeyCode::KeyQ + } else { + KeyCode::F4 + }); + let mods = if cfg!(target_os = "macos") { + super_key + } else { + alt + }; + + let should_ignore = + |quit_shortcuts: bool, modifiers: ModifiersState, keys: &HashSet| { + !quit_shortcuts && horizon_client::input::is_quit_shortcut_down(modifiers, keys) + }; + + // When enabled, close requests from shortcuts proceed normally. + assert!(!should_ignore(quit_shortcuts_enabled, mods, &keys)); + + // When disabled and the quit shortcut is held, close requests are ignored. + assert!(should_ignore(quit_shortcuts_disabled, mods, &keys)); + + // When disabled but shortcut NOT held (e.g. 'X' button clicked), close request proceeds. + assert!(!should_ignore( + quit_shortcuts_disabled, + ModifiersState::empty(), + &HashSet::new() + )); + } } diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index 95f7c6d847..3fbd3ec137 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -495,6 +495,14 @@ impl Loading { } match event { WindowEvent::CloseRequested => { + if !self.settings.quit_shortcuts + && horizon_client::input::is_quit_shortcut_down( + self.menu.modifiers, + &self.menu.physical_keys, + ) + { + return Ok(()); + } self.save_menu_settings(); event_loop.exit(); } From 1e14e337044c0f387fe2919bef7fb36c9f8bbf35 Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 13:00:05 +0100 Subject: [PATCH 378/968] Explosive sulfur cubes light their fuse beside redstone power SulfurCube.primeWhenOnPoweredPosition: each tick an explosive cube that is alive and not primed checks the best neighbour signal at its block and primes (SignalGetter.getBestOwnOrNeighbourSignal; no block has an own signal). lan_e2e check sulfur_cube_power. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/creatures.rs | 1 + .../horizon-client/src/game/sulfur_cubes.rs | 30 ++++++++++++- tools/lan_e2e.py | 44 +++++++++++++++++++ tools/port_map/game2-003.tsv | 2 +- tools/port_map/game2-011.tsv | 2 +- 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index e27459dcbe..54d255c500 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -800,6 +800,7 @@ impl Game<'_> { self.tick_sniffer_effects(); self.piglin_pickups(); self.sulfur_cube_pickups(); + self.sulfur_cube_power_priming(); // Hurt and death sounds of damage dealt outside the mob ticks. let mut queued = Vec::new(); for m in &mut self.mobs { diff --git a/crates/horizon-client/src/game/sulfur_cubes.rs b/crates/horizon-client/src/game/sulfur_cubes.rs index df06a5d16b..556bfdd0eb 100644 --- a/crates/horizon-client/src/game/sulfur_cubes.rs +++ b/crates/horizon-client/src/game/sulfur_cubes.rs @@ -8,7 +8,7 @@ use horizon_client::{ mesher::Vertex, mobs::{Mob, Species, sulfur_cube}, }; -use horizon_core::random::RandomSource; +use horizon_core::{position::BlockPos, random::RandomSource}; fn block_transform(base: glam::Mat4, baby: bool) -> glam::Mat4 { base * glam::Mat4::from_scale(glam::Vec3::new(1.0, -1.0, -1.0)) @@ -150,6 +150,34 @@ impl Game<'_> { } false } + /// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in + /// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`; + /// no block has an own signal, so the neighbour signal is all there is). + pub(super) fn sulfur_cube_power_priming(&mut self) { + if !self.ui.rules.bool("tnt_explodes") { + return; + } + for i in 0..self.mobs.len() { + let m = &self.mobs[i]; + if m.species() != Species::SulfurCube + || !sulfur_cube::archetype(m).is_some_and(|a| a.explosion.is_some()) + || !m.alive() + || sulfur_cube::primed(m) + { + continue; + } + let at = BlockPos::new( + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ); + let signal = + self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at)); + if signal != 0 { + sulfur_cube::prime(&mut self.mobs[i], false); + } + } + } /// `SulfurCubeSearchForItemsGoal` and `pickUpItem`: an adult with nothing /// inside hops to a block it can swallow within 8 and takes it in. pub(super) fn sulfur_cube_pickups(&mut self) { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..a96fcf8626 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -271,6 +271,8 @@ address. The script then checks, from both sides, what a guest's game used to ge - merchant-close: the host and the guest each open a trade screen with the payment in its slot; the screen closes and the payment returns when they walk out of reach (7 blocks in survival, 9 in creative) or the villager dies, and stays open in range. +- sulfur_cube_power: an explosive sulfur cube (TNT inside) stays calm on stone, lights its fuse when a redstone + block is put beside it, and blows up when the fuse runs out; the guest sees it flash. - riptide_rain: the host and the guest each launch with a Riptide trident in rain, with an elytra worn or not (#3458). - hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I and 12 with Hero III; clearing the effect restores the ordinary price (#1365). @@ -6961,6 +6963,48 @@ def check_riptide_rain(host, guest, out): CHECKS += ['riptide_rain'] CHECK_FNS['riptide_rain'] = check_riptide_rain + +def check_sulfur_cube_power(host, guest, out): + """SulfurCube.primeWhenOnPoweredPosition: an explosive cube standing in or beside redstone power lights + its fuse (getBestOwnOrNeighbourSignal), then explodes when the fuse is out.""" + x, y, z = (int(v // 1) for v in pos_of(host)) + tag = 'power_cube' + result = {} + host('command', command=f'fill {x - 3} {y - 1} {z - 3} {x + 3} {y + 3} {z + 3} minecraft:stone') + host('command', command=f'fill {x - 2} {y} {z - 2} {x + 2} {y + 2} {z + 2} minecraft:air') + host('command', command=f'fill {x - 2} {y + 3} {z - 2} {x + 2} {y + 3} {z + 2} minecraft:glowstone') + guest('command', command=f'tp @s {x + 0.5} {y} {z + 1.5} 180 25') + host('command', command=f'tp @s {x + 40} {y} {z + 40}') + guest.raw('key', 'f1', 'tap') + host('command', command='gamerule minecraft:mob_griefing false') + host('command', command=f'summon minecraft:sulfur_cube {x + 0.5} {y} {z - 1.5} ' + f'{{Tags:["{tag}"],Size:1,PersistenceRequired:1b,equipment:{{body:{{id:"minecraft:tnt",count:1}}}}}}') + host.wait(40) + calm = guest('entities', radius=12, type='sulfur_cube')['entities'] + result['calm'] = calm + assert len(calm) == 1, f'the cube is missing: {result}' + guest.shot(out, 'sulfur-cube-calm') + host('command', command=f'setblock {x} {y - 1} {z - 1} minecraft:redstone_block') + host('command', command=f'setblock {x} {y - 1} {z - 2} minecraft:redstone_block') + host('command', command=f'setblock {x + 1} {y - 1} {z - 1} minecraft:redstone_block') + host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:redstone_block') + host.wait(8) + guest.shot(out, 'sulfur-cube-primed') + data = host('command', command=f'data get entity @e[tag={tag},limit=1]')['chat'][0] + result['fuse'] = data.split('fuse: ')[1].split(',')[0] + assert int(result['fuse']) > 0 and 'Invulnerable: 1b' in data, f'the powered cube is not primed: {result}' + host.wait(100) + guest.shot(out, 'sulfur-cube-flash') + host.wait(40) + gone = guest('entities', radius=12, type='sulfur_cube')['entities'] + result['after'] = gone + assert not gone, f'the powered cube did not explode: {result}' + return result + + +CHECKS += ['sulfur_cube_power'] +CHECK_FNS['sulfur_cube_power'] = check_sulfur_cube_power + CHECKS += ['hero-trading'] CHECK_FNS.update({'hero-trading': check_hero_trading}) diff --git a/tools/port_map/game2-003.tsv b/tools/port_map/game2-003.tsv index 6d774b2437..3a0137b3bb 100644 --- a/tools/port_map/game2-003.tsv +++ b/tools/port_map/game2-003.tsv @@ -101,7 +101,7 @@ SulfurCube.requiresCustomPersistence port crates/horizon-client/src/mobs/ai.rs:: SulfurCube.hurtServer port crates/horizon-client/src/mobs/sulfur_cube.rs::accepts_damage fire or explosion primes; a held block shrugs off blows and only knocks back SulfurCube.matchingArchetypes port crates/horizon-client/src/mobs/sulfur_cube.rs::archetype_of one archetype per item, not a list SulfurCube.tickFuse inline crates/horizon-client/src/mobs/sulfur_cube.rs::ai_step gap: minor: fuse countdown and blast, but no leash drop or on-death effects at the blast -SulfurCube.primeWhenOnPoweredPosition missing player: an explosive cube sitting on a powered block does not prime itself; no signal check here +SulfurCube.primeWhenOnPoweredPosition port crates/horizon-client/src/game/sulfur_cubes.rs::Game::sulfur_cube_power_priming lit when the best neighbour signal at the cube is not 0; no block has an own signal SulfurCube.customServerAiStep port crates/horizon-client/src/mobs/sulfur_cube.rs::ai_step timers, buoyancy, facing, growing up and player push SulfurCube.collectEquipmentChanges inline crates/horizon-client/src/mobs/sulfur_cube.rs::ai_step goals are disabled while a block is held; archetype effects are read on demand, not applied as modifiers SulfurCube.equipItem port crates/horizon-client/src/game/sulfur_cubes.rs::Game::interact_sulfur_cube swallows the item; the old block is ejected diff --git a/tools/port_map/game2-011.tsv b/tools/port_map/game2-011.tsv index eee71629f6..64e37f9430 100644 --- a/tools/port_map/game2-011.tsv +++ b/tools/port_map/game2-011.tsv @@ -14,7 +14,7 @@ ServerExplosion.interactWithBlocks inline crates/horizon-client/src/game/explosi ServerExplosion.createFire inline crates/horizon-client/src/game/explosion.rs::Game::server_explode_as one in three chance on air above a solid block, with the block's fire state ServerExplosion.canTriggerBlocks inline crates/horizon-client/src/game/wind.rs::Game::wind_burst breeze charges need mob griefing before the trigger pass; the trigger-only check is implicit because only wind bursts reach it ServerExplosion.StackCollector.tryMerge port crates/horizon-client/src/game/explosion.rs::add_or_append_stack merges up to 16 of the same item and damage -SignalGetter.getBestOwnOrNeighbourSignal missing player: an explosive sulfur cube on or next to redstone power does not light its fuse every tick; Horizon's sulfur cube only primes on fire or explosion damage and best_neighbor_signal has no own-signal part +SignalGetter.getBestOwnOrNeighbourSignal inline crates/horizon-client/src/game/sulfur_cubes.rs::Game::sulfur_cube_power_priming best_neighbor_signal at the cube; no block overrides ownSignal SpawnData.CustomSpawnRules.checkLightBoundaries skip:java codec validation of the light limit range; Horizon decodes the limits in spawner.rs StructureManager.forWorldGenRegion skip:other wraps the level as a worldgen region view; Horizon passes the level into worldgen fns, no manager object StructureManager.startsForStructure inline crates/horizon-client/src/game/structures.rs::Game::structures_reaching_where gap: minor: returns starts whose box holds the position, not every start the section references From bbc9c46c17a8da48e1de7275defd3286f4f432dd Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:10:19 +0100 Subject: [PATCH 379/968] Launcher: Home's hero in the night look (R4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected instance's name in the display cut, a caption naming what it plays and whether it is ready, when it was played, and the update line. Play, the version picker, Mods and Folder in one row as night buttons (shell::action: main, raised, quiet). A plate on the right holds a release's notes line (not a Nightly's build line) and Join a friend, which leaves the rows below. "Host a server" goes: Servers is in the top bar. The picker names a Nightly as "Nightly · ba6344a" instead of its tag. Co-Authored-By: Claude Opus 5.5 --- docs/design-system.md | 5 + launcher/src/home.rs | 332 ++++++++++++++++++++++++++++++ launcher/src/instances_page.rs | 2 +- launcher/src/main.rs | 358 +++++---------------------------- launcher/src/shell.rs | 81 ++++++++ launcher/src/theme.rs | 2 +- 6 files changed, 465 insertions(+), 315 deletions(-) create mode 100644 launcher/src/home.rs diff --git a/docs/design-system.md b/docs/design-system.md index a74275aa7b..18b676b982 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -252,6 +252,11 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it. pages has no plate, only a rule. A selected row or tab sits on `card_hi` cut in steps (two for rows, one for tabs and icon buttons); a hovered one gets the same shape at half its opacity, eased in. A row's second line is its state, after a small square in the state's colour. +- **Home** (`launcher/src/home.rs`): the selected instance's name in the display cut over the bare + sky, a caption above it naming what it plays and its state (the same square), and when it was + played below. Play is the main action (`night::PLAY` tall, cut in one step); the version picker + is a raised control of the same height; Mods and Folder are quiet buttons. A Horizon instance + gets a plate on the right: a release's notes in one line (not a Nightly's) and Join a friend. ## The mark diff --git a/launcher/src/home.rs b/launcher/src/home.rs new file mode 100644 index 0000000000..d7fcaeb90a --- /dev/null +++ b/launcher/src/home.rs @@ -0,0 +1,332 @@ +//! Home in the night look (docs/design-system.md, "Night"): the selected instance's name over the +//! sky with what it plays and whether it is ready, Play and the actions beside it, and a card with +//! what's new and joining a friend. +use super::*; +use crate::plate::alpha; +use crate::shell::{action, c, label, Action}; +use theme::night; + +impl Launcher { + /// The focal point: what will start and its state, the instance's name, when it was played, + /// and an update when one waits. No `tag`: a Java instance's Minecraft (`game` then names its + /// loader too). + pub(super) fn hero( + &self, + tag: &str, + game: &str, + prepared: bool, + cx: &mut Context, + ) -> Div { + let t = self.theme; + let n = t.night; + let instance = self.selected_instance(); + let name = instance.map_or( + if tag.is_empty() { + "Minecraft" + } else { + "Horizon" + }, + |i| i.name.as_str(), + ); + let mut what = vec![format!("Minecraft: Java Edition {game}")]; + if !tag.is_empty() { + what.push(format!( + "Horizon {}", + horizon_launcher::nightly::label(tag) + .unwrap_or_else(|| tag.trim_start_matches('v').to_string()) + )); + } + what.push(tr(if prepared { "Ready" } else { "Needs preparing" }).to_string()); + if let Some(playing) = self.playing { + what.push(format!("{} playing now", thousands(playing))); + } + let state = div() + .flex() + .items_center() + .gap(px(8.)) + .child( + div() + .flex_none() + .size(px(6.)) + .bg(if prepared { t.success } else { t.warn }), + ) + .child( + shell::text(night::CAPTION, c(n.ink_soft)) + .font_weight(FontWeight::MEDIUM) + .child(what.join(" · ")), + ); + // Never squeezed by the rows below: the ping notice can make it tall. + let mut hero = div() + .flex() + .flex_none() + .flex_col() + .gap(px(10.)) + .child(state) + .child( + shell::text(night::DISPLAY, c(n.ink)) + .truncate() + .child(name.to_string()), + ) + .children(instance.map(|i| { + shell::text(night::UI, c(n.ink_soft)).child(instances_page::played_line(i)) + })); + // A Java instance (no Horizon tag) isn't offered Horizon's updates. + if let Some(release) = self.updates.horizon.as_ref().filter(|_| !tag.is_empty()) { + let installing = self.installing.as_ref().filter(|(t, _)| *t == release.tag); + let line = shell::text(night::CAPTION, c(n.muted)) + .flex() + .items_center() + .gap(px(8.)) + .child(div().size(px(6.)).bg(c(n.accent_hi))) + .child(format!("{} available", update_name(release))); + hero = hero.child(match installing { + Some((_, p)) => line + .child( + div() + .w(px(120.)) + .flex() + .child(progress_bar(Some(self.bar), &t)), + ) + .child(format!("{:.0}%", p * 100.)), + None => line.child( + div() + .id("install-update") + .text_color(c(n.accent_hi)) + .font_weight(FontWeight::SEMIBOLD) + .cursor_pointer() + .hover(|s| s.opacity(0.8)) + .child("Install") + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.install_update(); + cx.notify(); + })), + ), + }); + } + // Players who answered the first-run question before the ping existed read about it + // once here. + if !self.settings.ping_notice_seen && !self.collection.consent.needs_asking() { + hero = hero.child( + crate::plate::plate(&n) + .flex() + .flex_none() + .flex_col() + .gap(px(6.)) + .p(px(16.)) + .w_full() + .max_w(px(560.)) + .child( + shell::text(night::UI, c(n.ink)) + .relative() + .child(telemetry::PING_NAME), + ) + .child( + shell::text(night::CAPTION, c(n.muted)) + .relative() + .child(telemetry::PING), + ) + .child( + shell::text(night::UI, c(n.accent_hi)) + .relative() + .id("ping-notice-ok") + .font_weight(FontWeight::SEMIBOLD) + .cursor_pointer() + .hover(|s| s.opacity(0.8)) + .child("Got it") + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.settings.ping_notice_seen = true; + this.settings.save(); + cx.notify(); + })), + ), + ); + } + hero + } + + /// Play, then what else the instance offers: its version (Horizon), its mods, its folder. + /// `ready`: prepared, or a Java instance (its game comes with Play). + pub(super) fn home_actions(&self, ready: bool, java: bool, cx: &mut Context) -> Div { + let t = self.theme; + let running = self.client.is_some() || self.starting_client.is_some(); + let mut play = action("play", Action::Main, &t) + .min_w(px(180.)) + .child( + div() + .relative() + .child(blocks::icon("play", 20., c(t.night.on_accent))), + ) + .child(label( + tr(if running { "Running" } else { "Play" }).to_string(), + )); + match play_press(ready, self.owner, self.preparing || running) { + PlayPress::Nothing => play = disabled(play), + PlayPress::Play => { + play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.picker_open = false; + this.play(); + cx.notify(); + })) + } + PlayPress::PrepareThenPlay => { + play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.picker_open = false; + this.play_when_prepared = this.selected().cloned(); + this.prepare(backend::MinecraftFrom::Install); + cx.notify(); + })) + } + } + let folder = self.selected_instance().map(|i| i.home.clone()); + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(10.)) + .child(play) + // Horizon's versions don't apply to a Java instance. + .children((!java).then(|| self.picker(cx))) + .child( + action("home-mods", Action::Quiet, &t) + .child(label(tr("Mods").to_string())) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.go(Page::Mods); + cx.notify(); + })), + ) + .children(folder.map(|home| { + action("home-folder", Action::Quiet, &t) + .child(label(tr("Folder").to_string())) + .on_click(move |_, _, cx| { + let _ = std::fs::create_dir_all(&home); + cx.open_with_system(&home); + }) + })) + } + + /// The card beside the hero, for a Horizon version: a line from a release's notes, and + /// joining a friend with their invite code. + pub(super) fn home_card(&self, tag: &str, cx: &mut Context) -> Div { + let t = self.theme; + let n = t.night; + let summary = match &self.releases { + Load::Ready(list) => list + .iter() + .find(|r| r.tag == tag) + // A Nightly's notes name its build, nothing a player reads for. + .filter(|r| r.nightly.is_none()) + .and_then(|r| backend::notes_summary(&r.body)), + _ => None, + }; + let heading = |s: &str| { + shell::text(night::CAPTION, c(n.muted)) + .relative() + .font_weight(FontWeight::SEMIBOLD) + .child(tr(s).to_string()) + }; + let mut card = crate::plate::plate(&n) + .flex() + .flex_none() + .flex_col() + .gap(px(8.)) + .w(px(320.)) + .p(px(20.)); + if let Some(line) = summary { + card = card + .child(heading("What's new")) + .child( + shell::text(night::UI, c(n.ink)) + .relative() + .font_weight(FontWeight::BOLD) + .child( + horizon_launcher::nightly::label(tag) + .unwrap_or_else(|| tag.trim_start_matches('v').to_string()), + ), + ) + .child( + shell::text(night::CAPTION, c(n.ink_soft)) + .relative() + .line_clamp(3) + .child(line), + ) + .child( + div() + .relative() + .my(px(8.)) + .h(px(1.)) + .bg(alpha(n.stroke, n.rule_alpha)), + ); + } + card.child(heading("Join a friend")) + .child(self.friend_box(self.can_start(tag), cx)) + } + + /// The invite code field and Join. + fn friend_box(&self, can_play: bool, cx: &mut Context) -> Div { + let t = self.theme; + let n = t.night; + let focused = self.friend_focused; + let field = div() + .id("friend-code") + .track_focus(&self.friend_focus) + .flex() + .flex_1() + .min_w_0() + .items_center() + .h(px(theme::CONTROL)) + .px(px(12.)) + .overflow_hidden() + .rounded(px(night::RADIUS_CONTROL)) + .border_1() + .border_color(if focused { + c(n.accent_hi) + } else { + alpha(n.stroke, 2. * n.stroke_alpha).into() + }) + .bg(alpha(n.page, 0.6)) + .font_family(theme::MONO) + .text_size(px(12.)) + .cursor_text() + .child(line_edit::content( + &self.friend_code, + &t, + focused, + false, + false, + "Invite code", + )) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + window.focus(&this.friend_focus); + cx.notify(); + })) + .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { + match line_edit::handle(&mut this.friend_code, ev, cx) { + line_edit::Key::Pass => return, + line_edit::Key::Enter => this.join_friend(), + line_edit::Key::Escape => window.blur(), + _ => {} + } + cx.stop_propagation(); + cx.notify(); + })); + let field = line_edit::mouse(field, cx, |this| &mut this.friend_code); + let valid = horizon_friends::invite::parse(self.friend_code.text()).is_some(); + let mut join = + action("join-friend", Action::Raised, &t).child(label(tr("Join").to_string())); + if can_play && valid { + join = join.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.join_friend(); + cx.notify(); + })); + } else { + join = disabled(join); + } + div() + .relative() + .flex() + .items_center() + .gap(px(8.)) + .child(field) + .child(join) + } +} diff --git a/launcher/src/instances_page.rs b/launcher/src/instances_page.rs index 5105524393..d76592b854 100644 --- a/launcher/src/instances_page.rs +++ b/launcher/src/instances_page.rs @@ -717,7 +717,7 @@ impl Launcher { } /// "3 h 05 min in total · last played today", or "Not played yet". -fn played_line(instance: &Instance) -> String { +pub(super) fn played_line(instance: &Instance) -> String { match instance.last_played { Some(when) => trf( "{time} in total · last played {when}", diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 84734f61fa..165990c000 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -16,6 +16,7 @@ mod datapacks_page; mod dev_options; mod export_page; mod game_tools; +mod home; mod import_page; mod instances_page; mod internet; @@ -2226,43 +2227,39 @@ impl Launcher { fn picker(&self, cx: &mut Context) -> Div { let t = self.theme; - let label = self - .selected() - .cloned() - .unwrap_or_else(|| "No version".into()); - // A block as tall as Play: "VERSION" over the tag, and a chevron. + // "Nightly · ba6344a", a release's tag: the menu lists the full tags. + let label = self.selected().map_or_else( + || "No version".into(), + |tag| horizon_launcher::nightly::label(tag).unwrap_or_else(|| tag.clone()), + ); + // As tall as Play: "Version" over the tag, and a chevron. + let n = t.night; let mut wrap = div().relative().child( keyboard(div().id("picker"), &t) .relative() .flex() .items_center() - .gap(px(16.)) - .h(px(60.)) + .gap(px(14.)) + .h(px(theme::night::PLAY)) .pl(px(14.)) .pr(px(12.)) - .bg(t.panel) - .border_2() - .border_color(t.outline) - .children(blocks::edges(t.bevel, t.lip, theme::BEVEL)) + .rounded(px(theme::night::RADIUS_CONTROL)) + .bg(plate::alpha(n.card_hi, n.card_hi_alpha)) + .border_1() + .border_color(plate::alpha(n.stroke, n.stroke_alpha)) .cursor_pointer() - .hover(move |s| s.bg(t.raised)) + .hover(move |s| s.border_color(plate::alpha(n.stroke, 3. * n.stroke_alpha))) .child( div() .flex() .flex_col() - .gap(px(4.)) + .gap(px(2.)) .child( - blocks::pixel(11., FontWeight::MEDIUM) - .text_color(t.muted) - .child("VERSION"), + shell::text(theme::night::CAPTION, t.muted) + .font_weight(FontWeight::SEMIBOLD) + .child(tr("Version").to_string()), ) - .child( - div() - .font_family(theme::MONO) - .text_size(px(15.)) - .text_color(t.ink) - .child(label), - ), + .child(shell::text(theme::night::UI, t.ink).child(label)), ) .child(blocks::icon("chevron-down", 20., t.muted)) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -2273,12 +2270,13 @@ impl Launcher { if self.picker_open { let mut menu = div() .absolute() - .top(px(66.)) - .right_0() + .top(px(theme::night::PLAY + 6.)) + .left_0() .w(px(300.)) .p(px(6.)) - .border_2() - .border_color(t.outline) + .rounded(px(theme::night::RADIUS_CONTROL)) + .border_1() + .border_color(plate::alpha(n.stroke, 2. * n.stroke_alpha)) .bg(t.surface) .flex() .flex_col(); @@ -2393,66 +2391,7 @@ impl Launcher { } None => self.hero(&tag, &version, prepared, cx), }; - let running = self.client.is_some() || self.starting_client.is_some(); - let play_label = if running { "Running" } else { "Play" }; - // The label clears the icon in every language ("Играть" is wider than "Play"). - let mut play = button("play", play_label, Kind::Primary, &t) - .min_w(px(200.)) - .h(px(60.)) - .pl(px(60.)) - .pr(px(28.)) - .text_size(px(28.)) - .font_weight(FontWeight::SEMIBOLD) - .child( - div() - .absolute() - .left(px(26.)) - .top(px(17.)) - .child(blocks::icon("play", 22., t.on_face)), - ); - match play_press( - prepared || java.is_some(), - self.owner, - self.preparing || running, - ) { - PlayPress::Nothing => play = disabled(play), - PlayPress::Play => { - play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.picker_open = false; - this.play(); - cx.notify(); - })) - } - PlayPress::PrepareThenPlay => { - play = play.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.picker_open = false; - this.play_when_prepared = this.selected().cloned(); - this.prepare(backend::MinecraftFrom::Install); - cx.notify(); - })) - } - } - let actions = div() - .flex() - .items_center() - .justify_between() - .child( - button("host", "Host a server", Kind::Ghost, &t).on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.go(Page::Servers); - cx.notify(); - }, - )), - ) - .child( - div() - .flex() - .items_center() - .gap(px(8.)) - // Horizon's versions don't apply to a Java instance. - .children(java.is_none().then(|| self.picker(cx))) - .child(play), - ); + let actions = self.home_actions(prepared || java.is_some(), java.is_some(), cx); // Not prepared: an owner can have Horizon download Minecraft from Mojang; without an // owner there is nothing to play, so signing in comes first. @@ -2602,9 +2541,6 @@ impl Launcher { )), }; { - if java.is_none() { - rows = rows.child(self.join_friend_row(self.can_start(&tag), cx)); - } // The top bar shows who plays; a row only when there is something to do about it: // no account that owns the game (Play needs one), or offline. if self.owner_shown() == Some(false) { @@ -2688,8 +2624,25 @@ impl Launcher { .gap(px(24.)) .overflow_hidden() .children(picture.into_iter().flatten()) - .child(div().relative().child(hero)) - .child(div().relative().child(actions)); + .child( + div() + .relative() + .flex() + .items_end() + .gap(px(32.)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(28.)) + .child(hero) + .child(actions), + ) + // Notes and friends are Horizon's. + .children(java.is_none().then(|| self.home_card(&tag, cx))), + ); let mut page = div().flex().flex_col().gap(px(28.)).child(banner); // The preparation's progress and error right under the buttons, where the player // looks after pressing Prepare, not below the fold (#666). @@ -2876,155 +2829,6 @@ impl Launcher { .child(body) } - /// The Play page's focal point: what will start, and a line from its notes. No `tag`: a Java - /// instance's Minecraft. - fn hero(&self, tag: &str, version: &str, prepared: bool, cx: &mut Context) -> Div { - let t = self.theme; - let summary = match &self.releases { - Load::Ready(list) => list - .iter() - .find(|r| r.tag == tag) - .and_then(|r| backend::notes_summary(&r.body)), - _ => None, - }; - let title = if tag.is_empty() { - "Minecraft" - } else { - "Horizon" - }; - // Never squeezed by the rows below: the ping notice can make it tall. - let mut hero = div() - .flex() - .flex_none() - .flex_col() - .gap(px(12.)) - .child( - div() - .flex() - .child( - div() - .flex() - .flex_none() - .items_center() - .gap(px(10.)) - .px(px(10.)) - .py(px(5.)) - .bg(t.side.opacity(0.85)) - .border_2() - .border_color(t.outline) - .child( - div() - .size(px(8.)) - .bg(if prepared { t.success } else { t.warn }), - ) - .child(caps(format!("Minecraft {version}")).text_color(t.ink_soft)) - .child(div().size(px(4.)).bg(t.muted)) - .child( - caps(tr(if prepared { "Ready" } else { "Needs preparing" })) - .text_color(if prepared { t.success } else { t.warn }), - ), - ) - .children(self.playing.map(|n| { - div() - .flex() - .items_center() - .gap(px(10.)) - .child(div().size(px(4.)).rounded_full().bg(t.rule)) - .child( - caps(format!("{} playing now", thousands(n))).text_color(t.muted), - ) - })), - ) - .child( - blocks::pixel(60., FontWeight::BOLD) - .line_height(px(62.)) - .text_color(t.ink) - .child(title), - ) - // The version is in the picker beside Play: here only what it brings. - .children(summary.map(|line| { - text(theme::BODY) - .max_w(px(560.)) - .text_color(t.ink_soft) - .child(line) - })); - // A Java instance (no Horizon tag) isn't offered Horizon's updates. - if let Some(release) = self.updates.horizon.as_ref().filter(|_| !tag.is_empty()) { - let installing = self.installing.as_ref().filter(|(t, _)| *t == release.tag); - let line = div() - .flex() - .items_center() - .gap(px(10.)) - .text_size(px(theme::CAPTION.px)) - .text_color(t.muted) - .child(div().size(px(6.)).rounded_full().bg(t.accent)) - .child(format!("{} available", update_name(release))); - hero = hero.child(match installing { - Some((_, p)) => line - .child( - div() - .w(px(120.)) - .flex() - .child(progress_bar(Some(self.bar), &t)), - ) - .child(format!("{:.0}%", p * 100.)), - None => line.child( - div() - .id("install-update") - .text_color(t.accent) - .font_weight(FontWeight::MEDIUM) - .cursor_pointer() - .hover(|s| s.opacity(0.8)) - .child("Install") - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.install_update(); - cx.notify(); - })), - ), - }); - } - // Players who answered the first-run question before the ping existed read about it - // once here. - if !self.settings.ping_notice_seen && !self.collection.consent.needs_asking() { - hero = hero.child( - plate(&t) - .flex() - .flex_none() - .flex_col() - .gap(px(8.)) - .p(px(16.)) - .w_full() - .max_w(px(640.)) - .child( - text(theme::UI) - .text_color(t.ink) - .child(telemetry::PING_NAME), - ) - .child( - text(theme::CAPTION) - .text_color(t.muted) - .child(telemetry::PING), - ) - .child( - div() - .id("ping-notice-ok") - .text_size(px(theme::UI.px)) - .text_color(t.accent) - .font_weight(FontWeight::MEDIUM) - .cursor_pointer() - .hover(|s| s.opacity(0.8)) - .child("Got it") - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.settings.ping_notice_seen = true; - this.settings.save(); - cx.notify(); - })), - ), - ); - } - hero - } - fn settings_page(&mut self, cx: &mut Context) -> Div { let t = self.theme; let toggle = switch("auto-install", self.settings.auto_install(), &t).on_click( @@ -3690,78 +3494,6 @@ impl Launcher { /// Join a friend: a code field and Join, which starts the game straight /// into the friend's world (errors show in the game). - fn join_friend_row(&self, can_play: bool, cx: &mut Context) -> Div { - let t = self.theme; - let focused = self.friend_focused; - let field = div() - .id("friend-code") - .track_focus(&self.friend_focus) - .flex() - .items_center() - .w(px(260.)) - .h(px(30.)) - .px(px(12.)) - .overflow_hidden() - .border_2() - .border_color(if focused { t.accent } else { t.outline }) - .bg(t.well) - .font_family(theme::MONO) - .text_size(px(12.)) - .cursor_text() - .child(line_edit::content( - &self.friend_code, - &t, - focused, - false, - false, - "Paste an invite code", - )) - .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { - window.focus(&this.friend_focus); - cx.notify(); - })) - .on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| { - match line_edit::handle(&mut this.friend_code, ev, cx) { - line_edit::Key::Pass => return, - line_edit::Key::Enter => this.join_friend(), - line_edit::Key::Escape => window.blur(), - _ => {} - } - cx.stop_propagation(); - cx.notify(); - })); - let field = line_edit::mouse(field, cx, |this| &mut this.friend_code); - let valid = horizon_friends::invite::parse(self.friend_code.text()).is_some(); - let mut join = button("join-friend", "Join", Kind::Secondary, &t); - if can_play && valid { - join = join.on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.join_friend(); - cx.notify(); - })); - } else { - join = disabled(join); - } - index_row( - 4, - tr("Join a friend"), - Some( - div() - .child(tr("Paste the code a friend shows when they invite you.")) - .into_any_element(), - ), - Some( - div() - .flex() - .items_center() - .gap(px(8.)) - .child(field) - .child(join) - .into_any_element(), - ), - &t, - ) - } - /// Plays the selected instance straight into a world (`--world`) or a server (`--server`): /// flags every Horizon release takes. pub(crate) fn quick_play(&mut self, flag: &'static str, value: String) { diff --git a/launcher/src/shell.rs b/launcher/src/shell.rs index aba87e38b8..3aed277fc8 100644 --- a/launcher/src/shell.rs +++ b/launcher/src/shell.rs @@ -231,3 +231,84 @@ pub fn chip(id: impl Into, t: &Theme) -> Stateful

{ .child(hovered(&id, 1, t)); keyboard(track_hover(d, id), t) } + +/// What a night button is: the one main action (accent, cut in one step), a raised one +/// (`card_hi`), or only its label until the pointer is over it. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Action { + Main, + Raised, + Quiet, +} + +/// A night button, its label in the UI step: [`Action::Main`] is [`night::PLAY`] tall, the others +/// [`theme::CONTROL`] and round at [`night::RADIUS_CONTROL`]. Icons and the label go in as +/// children. +pub fn action(id: impl Into, kind: Action, t: &Theme) -> Stateful
{ + let id = id.into(); + let n = t.night; + let d = div() + .id(id.clone()) + .relative() + .flex() + .flex_none() + .items_center() + .justify_center() + .gap(px(10.)) + .px(px(16.)) + .font_family(theme::FONT) + .font_weight(FontWeight::MEDIUM) + .text_size(px(night::UI.px)) + .cursor_pointer(); + let fill = |d: Div| d.absolute().top_0().left_0().size_full(); + let round = move || fill(div()).rounded(px(night::RADIUS_CONTROL)); + let d = match kind { + Action::Main => d + .h(px(night::PLAY)) + .px(px(28.)) + .text_size(px(night::TITLE.px)) + .font_weight(FontWeight::BOLD) + .text_color(c(n.on_accent)) + .child(fill(cut( + night::PIXEL_CORNER, + 1, + Some(alpha(n.accent, 1.)), + None, + ))) + .child(hover_fade( + &id, + move || { + fill(cut( + night::PIXEL_CORNER, + 1, + Some(alpha(n.on_accent, 0.12)), + None, + )) + }, + |d, a| d.opacity(a), + )), + Action::Raised => d + .h(px(theme::CONTROL)) + .rounded(px(night::RADIUS_CONTROL)) + .bg(alpha(n.card_hi, n.card_hi_alpha)) + .border_1() + .border_color(alpha(n.stroke, n.stroke_alpha)) + .text_color(c(n.ink)) + .child(hover_fade(&id, round, move |d, a| { + d.bg(alpha(n.ink, 0.06 * a)) + })), + Action::Quiet => d + .h(px(theme::CONTROL)) + .rounded(px(night::RADIUS_CONTROL)) + .text_color(c(n.ink_soft)) + .child(hover_fade(&id, round, move |d, a| { + d.bg(alpha(n.card_hi, n.card_hi_alpha * 0.5 * a)) + })), + }; + keyboard(track_hover(d, id), t).active(|s| s.opacity(theme::PRESSED)) +} + +/// A label for an [`action`], over its hover layer. +pub fn label(text_: impl Into) -> Div { + div().relative().child(text_.into()) +} diff --git a/launcher/src/theme.rs b/launcher/src/theme.rs index 70f1364a45..093df3331d 100644 --- a/launcher/src/theme.rs +++ b/launcher/src/theme.rs @@ -9,7 +9,7 @@ use horizon_theme as tokens; use std::time::Duration; pub use tokens::night::{self, Night}; -pub use tokens::{Step, BODY, CAPTION, DISPLAY, HEADING, LABEL, MICRO, TITLE, UI, WORDMARK}; +pub use tokens::{Step, CAPTION, DISPLAY, HEADING, LABEL, MICRO, TITLE, UI, WORDMARK}; pub const FONT: &str = night::FONT; pub const DISPLAY_FONT: &str = night::DISPLAY_FONT; pub const MONO: &str = tokens::MONO; From b32541cb88dbe43c1544d2ed86024ff524844278 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:17:24 +0100 Subject: [PATCH 380/968] Map proto sections through the column layout Main's level-shape work replaced vanillagen's fixed MIN_Y and SECTIONS with ColumnLayout, so the branch merged with main no longer built. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/vanillagen.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index f88d77af53..6341681ec3 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -610,12 +610,14 @@ impl ChunkGenerator for VanillaTerrain { let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::() else { return None; }; - // The proto's sections from its own minimum Y; the column's from MIN_Y. - let below = (chunk.terrain.min_y() - MIN_Y) >> 4; + // The proto's sections from its own minimum Y; the column's from the + // layout's. + let layout = self.layout(); + let below = (chunk.terrain.min_y() - layout.min_y()) >> 4; let mut mask = crate::section_mask::SectionMask::EMPTY; for i in chunk.terrain.filled_sections() { if let Ok(i) = usize::try_from(i as i32 + below) - && i < SECTIONS + && i < layout.sections() { mask.insert(i); } From 8a196eeb770075ff0a68566f664972710367fd97 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 13:20:50 +0100 Subject: [PATCH 381/968] Copper golems: send the chest lid block event to guests too --- crates/horizon-client/src/game/copper_golems.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/horizon-client/src/game/copper_golems.rs b/crates/horizon-client/src/game/copper_golems.rs index 1bb73d51d5..c3265042ce 100644 --- a/crates/horizon-client/src/game/copper_golems.rs +++ b/crates/horizon-client/src/game/copper_golems.rs @@ -36,6 +36,8 @@ impl Game<'_> { self.world.chest_openers.remove(&key); } self.block_entity_event(chest, 1, count.max(0)); + // The guests get the same `ClientboundBlockEventPacket` (the lid). + self.lan_block_event(chest, 1, count.max(0)); } /// A copper golem took from or filled a chest slot. pub(super) fn golem_chest_slot( @@ -100,6 +102,7 @@ impl Game<'_> { o.0 = count; } self.block_entity_event(p, 1, count); + self.lan_block_event(p, 1, count); } } /// `ChestBlockEntity.playSound` for a chest the recheck closed. From bc92841fe68d0b43fe3b6f9f1747b01a969c4b9f Mon Sep 17 00:00:00 2001 From: Federico Tagliani Date: Fri, 30 Oct 2026 13:27:46 +0100 Subject: [PATCH 382/968] Exploration maps skip the structures a map already pointed at ChunkGenerator.findNearestMapStructure with createReference: a map with skip_existing_chunks (the default, and every cartographer trade) points at the nearest start no map pointed at yet and counts it as pointed at (StructureStart.addReference, getMaxReferences 1), so two maps made in a row no longer lead to the same structure. The starts are kept per dimension in data/horizon_structure_references_.json. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/maps.rs | 4 +- crates/horizon-client/src/game/trading.rs | 4 +- .../horizon-client/src/game/world_loading.rs | 4 ++ .../horizon-client/src/mod_content_tests.rs | 54 ++++++++++++++++ crates/horizon-client/src/vanillagen.rs | 61 +++++++++++++++++++ .../horizon-world/src/worldgen/generator.rs | 18 +++++- .../src/worldgen/generator_structures.rs | 18 +++--- tools/lan_e2e.py | 40 ++++++++++++ tools/port_map/classes-07.tsv | 6 +- 9 files changed, 195 insertions(+), 14 deletions(-) diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index f164f9c203..3eb4a617a4 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -557,7 +557,9 @@ impl Game<'_> { .end .locator() .filter(|_| !names.is_empty()) - .and_then(|t| t.locate(&names, from, map.search_radius)); + .and_then(|t| { + t.locate_for_map(&names, from, map.search_radius, map.skip_existing_chunks) + }); let Some((_, at)) = found else { return false; }; diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 9ae9668328..9e6fd53423 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -83,7 +83,9 @@ impl merchant::MapMaker for GameMaps<'_> { origin[1].floor() as i32, origin[2].floor() as i32, ]; - let Some((_, at)) = terrain.locate(&names, from, map.search_radius) else { + let Some((_, at)) = + terrain.locate_for_map(&names, from, map.search_radius, map.skip_existing_chunks) + else { return stack; }; let id = self.next_id.get(); diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index 9eeecdd6f7..eb98674f03 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -401,6 +401,10 @@ pub(super) fn make_streaming( if dim == Dim::Overworld && structures { inner.search_rings_behind(); } + inner.set_reference_file(world_root.join("data").join(format!( + "horizon_structure_references_{}.json", + dim.id().replace(':', "_") + ))); locator = Some(inner.clone()); Streaming::new( Arc::new(Persistent { diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index f63d31f0d2..0cef69badf 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -1081,3 +1081,57 @@ fn a_replacing_mod_tag_reaches_the_root() { ); let _ = fs::remove_dir_all(&scratch); } + +/// `ChunkGenerator.findNearestMapStructure` with `createReference`: an +/// exploration map that skips known structures points at a start no map +/// pointed at before, and a reopened world remembers which. +#[test] +#[ignore = "full content root: CI and pr_ready run it"] +fn exploration_maps_skip_the_structures_maps_pointed_at() { + let Some(base) = prepared() else { + return; + }; + let scratch = std::env::temp_dir().join(format!("horizon-map-refs-{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let assets = Assets::load(&base).unwrap(); + let biomes = crate::biome::Biomes::load(&base).unwrap(); + let open = || { + let terrain = crate::vanillagen::VanillaTerrain::new( + &base.join("worldgen"), + assets.registry, + &biomes, + 1234, + ) + .unwrap(); + terrain.set_reference_file(scratch.join("refs.json")); + terrain + }; + let names = ["minecraft:buried_treasure".to_owned()]; + let terrain = open(); + let first = terrain + .locate_for_map(&names, [0, 64, 0], 50, true) + .unwrap(); + assert_eq!( + terrain.locate(&names, [0, 64, 0], 50).unwrap(), + first, + "/locate still finds the nearest" + ); + let second = terrain + .locate_for_map(&names, [0, 64, 0], 50, true) + .unwrap(); + assert_ne!(second.1, first.1, "the second map points elsewhere"); + let again = terrain + .locate_for_map(&names, [0, 64, 0], 50, false) + .unwrap(); + assert_eq!( + again, first, + "without skipping the nearest is still the first" + ); + drop(terrain); + let third = open().locate_for_map(&names, [0, 64, 0], 50, true).unwrap(); + assert!( + third.1 != first.1 && third.1 != second.1, + "a reopened world remembers {first:?} and {second:?}, found {third:?}" + ); + let _ = fs::remove_dir_all(&scratch); +} diff --git a/crates/horizon-client/src/vanillagen.rs b/crates/horizon-client/src/vanillagen.rs index 20c9bdd197..45d4b164d8 100644 --- a/crates/horizon-client/src/vanillagen.rs +++ b/crates/horizon-client/src/vanillagen.rs @@ -48,6 +48,9 @@ pub struct VanillaTerrain { /// Block state id -> the block entity type of its block, if it has one. block_entity_types: Vec>, seed: i64, + /// The starts (structure name, chunk) an exploration map pointed at. + referenced: std::sync::Mutex>, + reference_file: std::sync::Mutex>, /// The world preset the terrain was generated with (`minecraft:normal` /// unless a saved world says otherwise); far decoration uses it too. preset: String, @@ -242,6 +245,8 @@ impl VanillaTerrain { tick_states, block_entity_types, seed, + referenced: Default::default(), + reference_file: Default::default(), preset: preset.to_owned(), dimension: dimension.to_owned(), last_proto: std::sync::atomic::AtomicU64::new(0), @@ -384,6 +389,62 @@ impl VanillaTerrain { ) -> Option<(String, [i32; 3])> { self.generator.worker().locate(names, from, radius) } + /// `ChunkGenerator.findNearestMapStructure` for an exploration map: with + /// `skip_known` (`skip_existing_chunks`) the nearest start no map pointed + /// at yet, which then counts as pointed at (`StructureStart.addReference`, + /// at most one: `getMaxReferences`). + pub fn locate_for_map( + &self, + names: &[String], + from: [i32; 3], + radius: i32, + skip_known: bool, + ) -> Option<(String, [i32; 3])> { + if !skip_known { + return self.locate(names, from, radius); + } + let mut referenced = self.referenced.lock().unwrap_or_else(|e| e.into_inner()); + let found = + self.generator + .worker() + .locate_skipping(names, from, radius, &|name, chunk| { + referenced.contains(&(name.to_owned(), chunk.x, chunk.z)) + })?; + referenced.insert((found.0.clone(), found.1[0] >> 4, found.1[2] >> 4)); + self.save_references(&referenced); + Some(found) + } + /// Where the starts maps pointed at are kept, and the ones kept there: + /// vanilla keeps the count in each start's chunk (`references`), which a + /// start nobody visited has no file for. + pub fn set_reference_file(&self, path: std::path::PathBuf) { + let mut referenced = self.referenced.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(list) = std::fs::read(&path) + .ok() + .and_then(|b| serde_json::from_slice::>(&b).ok()) + { + referenced.extend(list); + } + *self + .reference_file + .lock() + .unwrap_or_else(|e| e.into_inner()) = Some(path); + } + fn save_references(&self, referenced: &std::collections::HashSet<(String, i32, i32)>) { + let guard = self + .reference_file + .lock() + .unwrap_or_else(|e| e.into_inner()); + let Some(path) = guard.as_ref() else { return }; + let mut list: Vec<_> = referenced.iter().collect(); + list.sort(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Err(e) = serde_json::to_vec(&list).map(|b| std::fs::write(path, b)) { + log::warn!("Horizon: could not save {}: {e}", path.display()); + } + } /// `/locate biome`: `BiomeSource.findClosestBiome3d`. pub fn find_closest_biome( &self, diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index fcf6d8202a..cbb136fd19 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -2071,6 +2071,7 @@ trait SeededPipeline: Send + Sync { names: &[String], from: [i32; 3], radius: i32, + skip: &dyn Fn(&str, ChunkPos) -> bool, ) -> Option<(String, [i32; 3])>; fn find_closest_biome( &self, @@ -2217,6 +2218,7 @@ where names: &[String], from: [i32; 3], radius: i32, + skip: &dyn Fn(&str, ChunkPos) -> bool, ) -> Option<(String, [i32; 3])> { self.structures.as_ref()?.locate( &mut StructureQueries { @@ -2227,6 +2229,7 @@ where names, from, radius, + skip, ) } fn find_closest_biome( @@ -3032,7 +3035,20 @@ impl GeneratorWorker { from: [i32; 3], radius: i32, ) -> Option<(String, [i32; 3])> { - self.pipeline.locate(&mut self.state, names, from, radius) + self.locate_skipping(names, from, radius, &|_, _| false) + } + /// `ChunkGenerator.findNearestMapStructure`: like `locate`, past the + /// starts `skip` says can't be referenced again (an explorer map that + /// skips known structures). + pub fn locate_skipping( + &mut self, + names: &[String], + from: [i32; 3], + radius: i32, + skip: &dyn Fn(&str, ChunkPos) -> bool, + ) -> Option<(String, [i32; 3])> { + self.pipeline + .locate(&mut self.state, names, from, radius, skip) } /// Direct far sampling: work scales with rendered vertical resolution, /// without allocating chunks, carving masks or propagated aquifers. diff --git a/crates/horizon-world/src/worldgen/generator_structures.rs b/crates/horizon-world/src/worldgen/generator_structures.rs index 9787948f64..eeec7bdf6a 100644 --- a/crates/horizon-world/src/worldgen/generator_structures.rs +++ b/crates/horizon-world/src/worldgen/generator_structures.rs @@ -357,13 +357,16 @@ impl StructureState { /// ChunkGenerator.findNearestMapStructure: the nearest start of one of /// `names` from block `from` (rings: every ring chunk; random spreads: /// sectors in growing squares up to `radius`). Returns the structure and - /// its locate position (the start chunk's corner at y 0). + /// its locate position (the start chunk's corner at y 0). `skip` says a + /// start (structure name, its chunk) can't be referenced again + /// (`StructureStart.canBeReferenced`): the search goes on past it. pub(super) fn locate( &self, context: &mut impl super::structure::start::StartContext, names: &[String], from: [i32; 3], radius: i32, + skip: &dyn Fn(&str, ChunkPos) -> bool, ) -> Option<(String, [i32; 3])> { let seed = context.seed(); let rings = self.rings(context).clone(); @@ -376,7 +379,7 @@ impl StructureState { let found_at = |this: &Self, context: &mut _, chunk: ChunkPos| -> Option { this.starts_at(context, chunk) .iter() - .find(|s| wanted(s.structure)) + .find(|s| wanted(s.structure) && !skip(&s.name, chunk)) .map(|s| s.name.clone()) }; for &si in &self.possible_sets { @@ -390,20 +393,19 @@ impl StructureState { let Some(chunks) = rings.get(&set.name) else { continue; }; - let mut nearest: Option<(f64, ChunkPos)> = None; + let mut nearest: Option<(f64, ChunkPos, String)> = None; for &c in chunks { let d = dist([c.x * 16 + 8, 32, c.z * 16 + 8]); - if nearest.is_none_or(|(n, _)| d < n) + if nearest.as_ref().is_none_or(|(n, ..)| d < *n) && self .registry .is_structure_chunk(set, seed, c.x, c.z, &rings) + && let Some(name) = found_at(self, context, c) { - nearest = Some((d, c)); + nearest = Some((d, c, name)); } } - if let Some((_, c)) = nearest - && let Some(name) = found_at(self, context, c) - { + if let Some((_, c, name)) = nearest { let p = set.locate_pos(c); let d = dist(p); if best.as_ref().is_none_or(|(b, ..)| d < *b) { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..80325f30d3 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -271,6 +271,8 @@ address. The script then checks, from both sides, what a guest's game used to ge - merchant-close: the host and the guest each open a trade screen with the payment in its slot; the screen closes and the payment returns when they walk out of reach (7 blocks in survival, 9 in creative) or the villager dies, and stays open in range. +- explorer_map: three shipwreck maps from a loot table point at three different buried treasures (an exploration + map skips the structures a map already pointed at), and the host and the guest each see their map. - riptide_rain: the host and the guest each launch with a Riptide trident in rain, with an elytra worn or not (#3458). - hero-trading: the host and the guest each trade 20 wheat for an emerald, paying 14 with Hero I and 12 with Hero III; clearing the effect restores the ordinary price (#1365). @@ -6961,6 +6963,44 @@ def check_riptide_rain(host, guest, out): CHECKS += ['riptide_rain'] CHECK_FNS['riptide_rain'] = check_riptide_rain + +def check_explorer_map(host, guest, out): + """TreasureMapForEmeralds / ExplorationMapFunction with skip_existing_chunks (a trade's default): + findNearestMapStructure with createReference adds a reference to the start it finds (StructureStart.addReference, + at most one), so the next map from the same place points at another structure. Three cartographers' offers + are made, one after the other: each adds as many new starts as the first did, none is pointed at twice.""" + x, y, z = (int(v // 1) for v in pos_of(host)) + file = host.work / 'saves' / 'Lan' / 'data' / 'horizon_structure_references_minecraft_overworld.json' + host('command', command=f'fill {x - 4} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone') + host('command', command=f'fill {x - 4} {y} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air') + guest('command', command=f'tp @s {x - 3.5} {y} {z + 0.5}') + host('command', command='gamemode survival') + host('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}') + sizes = [] + for n in range(3): + host('command', command=f'summon minecraft:villager {x + 2.5} {y} {z + 0.5} {{Tags:["cartographer{n}"],' + 'NoAI:1b,PersistenceRequired:1b,Xp:1,VillagerData:{type:"minecraft:plains",' + 'profession:"minecraft:cartographer",level:5}}') + host.wait(20) + villager = next(e['id'] for e in host.raw('entities', 8, 'minecraft:villager')['entities'] + if math.dist(e['pos'], [x + 2.5, y, z + 0.5]) < 0.5) + host.raw('interact-entity', villager) + host.raw('wait-for', 'screen', 'station', '--timeout', 10) + host.wait(20) + if n == 0: + host.shot(out, 'explorer-map-trades') + host.raw('key', 'esc', 'tap') + host.wait(10) + host('command', command=f'kill @e[tag=cartographer{n}]') + sizes.append(len(json.loads(file.read_text())) if file.exists() else 0) + assert sizes[0] > 0, f'the first cartographer pointed no map at a structure: {sizes}' + assert sizes == [sizes[0] * k for k in (1, 2, 3)], f'a map pointed at a structure twice: {sizes}' + return {'starts_after_each_cartographer': sizes, 'pointed_at': json.loads(file.read_text())} + + +CHECKS += ['explorer_map'] +CHECK_FNS['explorer_map'] = check_explorer_map + CHECKS += ['hero-trading'] CHECK_FNS.update({'hero-trading': check_hero_trading}) diff --git a/tools/port_map/classes-07.tsv b/tools/port_map/classes-07.tsv index d86223f7cd..1a839d786d 100644 --- a/tools/port_map/classes-07.tsv +++ b/tools/port_map/classes-07.tsv @@ -8,9 +8,9 @@ WorldPresets.Bootstrap skip:data datagen bootstrap that registers the presets SinglePieceStructure.findGenerationPoint port crates/horizon-world/src/worldgen/structure/start.rs::try_generate DesertPyramid/JungleTemple arm: valid biome, sea-level check, stub on the surface SinglePieceStructure.generatePieces port crates/horizon-world/src/worldgen/structure/start.rs::try_generate same arm builds the piece at the chunk corner structure/StructureCheck.canCreateStructure port crates/horizon-world/src/worldgen/structure/start.rs::try_generate no start when the arm returns None -structure/StructureCheck.checkStart missing player: an explorer map (skip_existing_chunks, default on) can point at a structure the player already reached; the reference check is not ported -structure/StructureCheck.checkStructureInfo missing player: requireUnreferenced is ignored, so explorer maps do not skip known structures (see checkStart) -structure/StructureCheck.incrementReference missing player: no reference counts per chunk, so explorer maps do not skip structures already reached +structure/StructureCheck.checkStart inline crates/horizon-client/src/vanillagen.rs::VanillaTerrain::locate_for_map gap: the references live in a sidecar file (data/horizon_structure_references_.json), not in the start's chunk +structure/StructureCheck.checkStructureInfo inline crates/horizon-client/src/vanillagen.rs::VanillaTerrain::locate_for_map gap: the references live in a sidecar file (data/horizon_structure_references_.json), not in the start's chunk +structure/StructureCheck.incrementReference inline crates/horizon-client/src/vanillagen.rs::VanillaTerrain::locate_for_map gap: the references live in a sidecar file (data/horizon_structure_references_.json), not in the start's chunk structure/StructureCheck.loadStructures skip:other reads saved starts for the storage cache; Horizon places saved starts with use_saved_start structure/StructureCheck.onStructureLoad missing player: starts loaded in a chunk do not record references (see incrementReference) structure/StructureCheck.storeFullResults skip:other cache write for the storage check From 95825f818ce2fb1dafe775952f4c4afb0e906c35 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:32:07 +0100 Subject: [PATCH 383/968] Launcher: translate Folder and Version, drop the strings Home no longer has Co-Authored-By: Claude Opus 5.5 --- launcher/lang/de.json | 6 +++--- launcher/lang/es.json | 6 +++--- launcher/lang/fr.json | 6 +++--- launcher/lang/pl.json | 6 +++--- launcher/lang/ru.json | 6 +++--- launcher/lang/uk.json | 6 +++--- launcher/lang/zh.json | 6 +++--- launcher/src/home.rs | 2 +- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/launcher/lang/de.json b/launcher/lang/de.json index c31dd48145..9d4deaf58a 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -95,7 +95,6 @@ "Start the game, or run a server for others to join.": "Starte das Spiel oder betreibe einen Server, dem andere beitreten können.", "No version installed yet.": "Noch keine Version installiert.", "Choose a version": "Version auswählen", - "Host a server": "Server hosten", "Preparing…": "Vorbereitung…", "Prepare": "Vorbereiten", "Prepare again": "Erneut vorbereiten", @@ -139,7 +138,6 @@ "Open folder": "Ordner öffnen", "Join a friend": "Einem Freund beitreten", "Paste an invite code": "Einladungscode einfügen", - "Paste the code a friend shows when they invite you.": "Füge den Code ein, den dir ein Freund beim Einladen zeigt.", "Join": "Beitreten", "Shortcut": "Verknüpfung", "Remove shortcut": "Verknüpfung entfernen", @@ -192,5 +190,7 @@ "Keep": "Behalten", "Move to Trash": "In den Papierkorb", "Install and turn on native mods": "Installieren und native Mods einschalten", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.", + "Folder": "Ordner", + "Version": "Version" } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 4c350b3926..9bafdb9805 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -95,7 +95,6 @@ "Start the game, or run a server for others to join.": "Inicia el juego o ejecuta un servidor para que otros se unan.", "No version installed yet.": "Aún no hay ninguna versión instalada.", "Choose a version": "Elegir una versión", - "Host a server": "Alojar un servidor", "Preparing…": "Preparando…", "Prepare": "Preparar", "Prepare again": "Preparar de nuevo", @@ -139,7 +138,6 @@ "Open folder": "Abrir carpeta", "Join a friend": "Unirse a un amigo", "Paste an invite code": "Pega un código de invitación", - "Paste the code a friend shows when they invite you.": "Pega el código que un amigo muestra cuando te invita.", "Join": "Unirse", "Shortcut": "Acceso directo", "Remove shortcut": "Eliminar acceso directo", @@ -192,5 +190,7 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.", "Install and turn on native mods": "Instalar y activar los mods nativos", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.", + "Folder": "Carpeta", + "Version": "Versión" } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 9b69bd8420..28353362e8 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -95,7 +95,6 @@ "Start the game, or run a server for others to join.": "Lancez le jeu, ou ouvrez un serveur que d'autres peuvent rejoindre.", "No version installed yet.": "Aucune version installée pour l'instant.", "Choose a version": "Choisir une version", - "Host a server": "Héberger un serveur", "Preparing…": "Préparation…", "Prepare": "Préparer", "Prepare again": "Préparer à nouveau", @@ -139,7 +138,6 @@ "Open folder": "Ouvrir le dossier", "Join a friend": "Rejoindre un ami", "Paste an invite code": "Collez un code d'invitation", - "Paste the code a friend shows when they invite you.": "Collez le code qu'un ami affiche quand il vous invite.", "Join": "Rejoindre", "Shortcut": "Raccourci", "Remove shortcut": "Retirer le raccourci", @@ -192,5 +190,7 @@ "Delete…": "Supprimer…", "Keep": "Garder", "Move to Trash": "Mettre à la corbeille", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", + "Folder": "Dossier", + "Version": "Version" } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 0798c0cf5a..42a2657a74 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -91,7 +91,6 @@ "Start the game, or run a server for others to join.": "Uruchom grę lub serwer, do którego mogą dołączyć inni.", "No version installed yet.": "Nie zainstalowano jeszcze żadnej wersji.", "Choose a version": "Wybierz wersję", - "Host a server": "Uruchom serwer", "Preparing…": "Przygotowywanie…", "Prepare": "Przygotuj", "Prepare again": "Przygotuj ponownie", @@ -127,7 +126,6 @@ "Open folder": "Otwórz folder", "Join a friend": "Dołącz do znajomego", "Paste an invite code": "Wklej kod zaproszenia", - "Paste the code a friend shows when they invite you.": "Wklej kod, który znajomy pokazał podczas zapraszania.", "Join": "Dołącz", "Shortcut": "Skrót", "Remove shortcut": "Usuń skrót", @@ -192,5 +190,7 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Za pierwszym razem Graj pobiera go od Mojang dla Twojego konta, razem z potrzebną Javą.", "Move to Trash": "Przenieś do kosza", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.", + "Folder": "Folder", + "Version": "Wersja" } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 5b1986d52a..49fb094700 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -95,7 +95,6 @@ "Start the game, or run a server for others to join.": "Запустите игру или сервер, к которому смогут подключиться другие.", "No version installed yet.": "Пока не установлено ни одной версии.", "Choose a version": "Выбрать версию", - "Host a server": "Открыть сервер", "Preparing…": "Подготовка…", "Prepare": "Подготовить", "Prepare again": "Подготовить заново", @@ -139,7 +138,6 @@ "Open folder": "Открыть папку", "Join a friend": "Присоединиться к другу", "Paste an invite code": "Вставьте код приглашения", - "Paste the code a friend shows when they invite you.": "Вставьте код, который друг показывает, когда приглашает вас.", "Join": "Войти", "Shortcut": "Ярлык", "Remove shortcut": "Убрать ярлык", @@ -192,5 +190,7 @@ "Delete…": "Удалить…", "Keep": "Оставить", "Move to Trash": "В корзину", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", + "Folder": "Папка", + "Version": "Версия" } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index cf4b3497d4..99feb23662 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -95,7 +95,6 @@ "Start the game, or run a server for others to join.": "Запустіть гру або сервер, до якого зможуть приєднатися інші.", "No version installed yet.": "Ще не встановлено жодної версії.", "Choose a version": "Виберіть версію", - "Host a server": "Запустити сервер", "Preparing…": "Підготовка…", "Prepare": "Підготувати", "Prepare again": "Підготувати знову", @@ -139,7 +138,6 @@ "Open folder": "Відкрити теку", "Join a friend": "Приєднатися до друга", "Paste an invite code": "Вставте код запрошення", - "Paste the code a friend shows when they invite you.": "Вставте код, який показує друг, коли запрошує вас.", "Join": "Приєднатися", "Shortcut": "Ярлик", "Remove shortcut": "Прибрати ярлик", @@ -192,5 +190,7 @@ "Keep": "Залишити", "Move to Trash": "Перемістити до кошика", "Install and turn on native mods": "Встановити й увімкнути нативні моди", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.", + "Folder": "Тека", + "Version": "Версія" } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index d0bc4fbbf7..c06cdfef8c 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -95,7 +95,6 @@ "Start the game, or run a server for others to join.": "启动游戏,或开一个服务器让别人加入。", "No version installed yet.": "还没有安装任何版本。", "Choose a version": "选择版本", - "Host a server": "开服务器", "Preparing…": "正在准备…", "Prepare": "准备", "Prepare again": "重新准备", @@ -137,7 +136,6 @@ "Open folder": "打开文件夹", "Join a friend": "加入好友", "Paste an invite code": "粘贴邀请码", - "Paste the code a friend shows when they invite you.": "粘贴好友邀请你时显示的邀请码。", "Join": "加入", "Shortcut": "快捷方式", "Remove shortcut": "移除快捷方式", @@ -192,5 +190,7 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "要将 {name} 连同其世界和设置一起移到回收站吗?你之后可以从那里恢复。", "Install and turn on native mods": "安装并启用原生模组", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。" + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。", + "Folder": "文件夹", + "Version": "版本" } diff --git a/launcher/src/home.rs b/launcher/src/home.rs index d7fcaeb90a..a55896e366 100644 --- a/launcher/src/home.rs +++ b/launcher/src/home.rs @@ -293,7 +293,7 @@ impl Launcher { focused, false, false, - "Invite code", + "Paste an invite code", )) .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { window.focus(&this.friend_focus); From 2b78aa173533eee0c92a4f42231f7b1b98b2a819 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:25:16 +0100 Subject: [PATCH 384/968] Launcher: Play's steps while it gets ready, in the night look (R4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a version prepares, Play reads "Getting ready…" with the download's percentage, Cancel beside it while a download from Mojang runs. Under them: the bar, what is happening ("Downloading Minecraft 26.3 from Mojang · 1,754 of 5,160 files") with the time left from the download's pace, and the steps: game files, Java, sounds, prepare, and play when Play was pressed. backend::PrepareStep is set by the preparation as it moves on. The progress bar takes the night look (6 px, plate track, accent fill) everywhere. HORIZON_LAUNCHER_PAGE=getting-ready shows the state for screenshots. Co-Authored-By: Claude Opus 5.5 --- docs/design-system.md | 4 + launcher/src/backend.rs | 38 +++++++++ launcher/src/home.rs | 170 ++++++++++++++++++++++++++++++++++++++++ launcher/src/main.rs | 82 +++++++------------ 4 files changed, 239 insertions(+), 55 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 18b676b982..13a6484745 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -257,6 +257,10 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it. played below. Play is the main action (`night::PLAY` tall, cut in one step); the version picker is a raised control of the same height; Mods and Folder are quiet buttons. A Horizon instance gets a plate on the right: a release's notes in one line (not a Nightly's) and Join a friend. + While it gets ready, Play names it ("Getting ready… 34%") beside Cancel, and below sit the + bar, what is happening with the time left, and the steps (game files, Java, sounds, prepare, + play), the current one lit, those done ticked: steps, never the log + (`HORIZON_LAUNCHER_PAGE=getting-ready` shows it for screenshots, nothing runs). ## The mark diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index f482e03bba..94ab460534 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -1598,6 +1598,41 @@ pub enum MinecraftFrom { Mojang, } +/// A preparation's steps, in the order they run, as Home names them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PrepareStep { + /// Minecraft's files: found in an install, or downloaded from Mojang. + Files, + Java, + Sounds, + /// horizon-prepare's own work on those files. + Prepare, +} + +impl PrepareStep { + pub const ALL: [PrepareStep; 4] = [Self::Files, Self::Java, Self::Sounds, Self::Prepare]; + + pub fn name(self) -> &'static str { + match self { + Self::Files => "Game files", + Self::Java => "Java", + Self::Sounds => "Sounds", + Self::Prepare => "Prepare", + } + } +} + +/// The step the preparation under way is at (`PrepareStep::ALL`'s index). +static PREPARE_STEP: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +pub fn prepare_step() -> PrepareStep { + PrepareStep::ALL[PREPARE_STEP.load(std::sync::atomic::Ordering::Relaxed)] +} + +pub fn set_prepare_step(step: PrepareStep) { + PREPARE_STEP.store(step as usize, std::sync::atomic::Ordering::Relaxed); +} + /// Checks what the preparation needs and picks how to run it. PLAY.txt describes the same /// steps: the player's own launcher install, the server jar from that version's own json, and /// Java (and Python, for versions before horizon-prepare). Also returns the Minecraft folder @@ -1609,6 +1644,7 @@ pub fn preparer( from: MinecraftFrom, log: &dyn Fn(String), ) -> Result<(Preparer, PathBuf), String> { + set_prepare_step(PrepareStep::Files); let version = game_version(tag); let dir = version_dir(tag); let reused = horizon_prepare::artifacts::reuse_imported(&dir, &|line| log(line.to_string()))?; @@ -1680,6 +1716,7 @@ pub fn preparer( .into_iter() .map(|(_, dir)| dir), ); + set_prepare_step(PrepareStep::Java); let java_home = java( major, !horizon_prepare::exporters::available(&dir), @@ -1757,6 +1794,7 @@ pub fn sounds(tag: &str, minecraft: &Path, log: &dyn Fn(String)) { .output() .is_ok_and(|o| String::from_utf8_lossy(&o.stdout).contains("horizon-prepare sounds")) }); + set_prepare_step(PrepareStep::Sounds); log("Copying the sounds from your Minecraft install".into()); let result = match binary { Some(binary) => crate::process::command(binary) diff --git a/launcher/src/home.rs b/launcher/src/home.rs index a55896e366..97cf88969b 100644 --- a/launcher/src/home.rs +++ b/launcher/src/home.rs @@ -149,6 +149,36 @@ impl Launcher { pub(super) fn home_actions(&self, ready: bool, java: bool, cx: &mut Context) -> Div { let t = self.theme; let running = self.client.is_some() || self.starting_client.is_some(); + if self.preparing { + // Play waits for the steps below it; a download can stop (the next one goes on from + // there). + let download = download_progress(); + let busy = match download { + Some((done, total)) => format!( + "{} {:.0}%", + tr("Getting ready…"), + done as f32 * 100. / total as f32 + ), + None => tr("Getting ready…").to_string(), + }; + return div() + .flex() + .items_center() + .gap(px(10.)) + .child( + action("play", Action::Main, &t) + .min_w(px(260.)) + .cursor_default() + .child(label(busy)), + ) + .children(download.map(|_| { + action("download-cancel", Action::Quiet, &t) + .child(label(tr("Cancel").to_string())) + .on_click(|_, _, _| { + horizon_launcher::mojang::CANCEL.store(true, Ordering::Relaxed) + }) + })); + } let mut play = action("play", Action::Main, &t) .min_w(px(180.)) .child( @@ -330,3 +360,143 @@ impl Launcher { .child(join) } } + +impl Launcher { + /// How long the download under way has left, from its pace since it was first seen: none + /// for its first seconds, or without one. + pub(super) fn download_left(&mut self) -> Option { + if getting_ready_preview() { + return Some(Duration::from_secs(70)); + } + let Some((done, total)) = horizon_launcher::mojang::progress() else { + self.download_seen = None; + return None; + }; + let (since, from) = match self.download_seen { + // A new download (Java's after the game's) starts counting again. + Some(seen) if seen.1 <= done => seen, + _ => { + self.download_seen = Some((Instant::now(), done)); + return None; + } + }; + let spent = since.elapsed(); + (spent >= Duration::from_secs(5) && done > from) + .then(|| spent.mul_f64((total - done) as f64 / (done - from) as f64)) + } + + /// Under Play while it prepares: a bar, what is happening and the time left, and the steps, + /// the one under way lit, those done ticked. `version`: the Minecraft it downloads. + pub(super) fn steps(&self, version: &str, left: Option) -> Div { + let t = self.theme; + let n = t.night; + let now = backend::prepare_step(); + let download = download_progress(); + let doing = match (download, now) { + (Some((done, total)), backend::PrepareStep::Files) => trf( + "Downloading Minecraft {version} from Mojang · {done} of {total} files", + &[ + ("version", version), + ("done", &thousands(done as u64)), + ("total", &thousands(total as u64)), + ], + ), + (Some((done, total)), _) => trf( + "Downloading from Mojang · {done} of {total} files", + &[ + ("done", &thousands(done as u64)), + ("total", &thousands(total as u64)), + ], + ), + (None, _) => self.prepare_log.back().cloned().unwrap_or_default(), + }; + let left = left.map(|d| match d.as_secs().div_ceil(60) { + 0 | 1 => tr("about a minute left").to_string(), + m => trf("about {n} min left", &[("n", &m.to_string())]), + }); + // Play is the last step when it was pressed, not Prepare. + let play = self.play_when_prepared.is_some(); + let pill = |name: &str, state: std::cmp::Ordering| { + let (mark, color) = match state { + std::cmp::Ordering::Less => ( + Some(blocks::icon("check", 12., c(n.success))), + c(n.ink_soft), + ), + std::cmp::Ordering::Equal => (None, c(n.ink)), + std::cmp::Ordering::Greater => (None, c(n.muted)), + }; + div() + .flex() + .items_center() + .gap(px(6.)) + .child(match mark { + Some(icon) => icon.into_any_element(), + None => div() + .size(px(6.)) + .bg(if state == std::cmp::Ordering::Equal { + c(n.accent_hi) + } else { + alpha(n.muted, 0.5).into() + }) + .into_any_element(), + }) + .child( + shell::text(night::CAPTION, color) + .font_weight(FontWeight::MEDIUM) + .child(tr(name).to_string()), + ) + }; + div() + .flex() + .flex_col() + .gap(px(10.)) + .max_w(px(620.)) + .child(div().flex().child(progress_bar( + download.map(|(done, total)| { + let f = done as f32 / total as f32; + (f, f) + }), + &t, + ))) + .child( + div() + .flex() + .justify_between() + .gap(px(16.)) + .child( + shell::text(night::CAPTION, c(n.ink_soft)) + .min_w_0() + .truncate() + .child(doing), + ) + .children( + left.map(|l| shell::text(night::CAPTION, c(n.muted)).flex_none().child(l)), + ), + ) + .child( + div() + .flex() + .flex_wrap() + .gap(px(16.)) + .children( + backend::PrepareStep::ALL + .iter() + .map(|s| pill(s.name(), s.cmp(&now))), + ) + .children(play.then(|| pill("Play", std::cmp::Ordering::Greater))), + ) + } +} + +/// `HORIZON_LAUNCHER_PAGE=getting-ready` opens Home as if Play had been pressed on a version that +/// downloads from Mojang, a third of the way through: for screenshots, nothing runs. +pub(super) fn getting_ready_preview() -> bool { + static PREVIEW: std::sync::OnceLock = std::sync::OnceLock::new(); + *PREVIEW + .get_or_init(|| std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() == Ok("getting-ready")) +} + +/// The download under way, files done of how many. +fn download_progress() -> Option<(usize, usize)> { + horizon_launcher::mojang::progress().or_else(|| getting_ready_preview().then_some((1754, 5160))) +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 165990c000..5601eff572 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -83,7 +83,8 @@ enum Page { impl Page { /// `HORIZON_LAUNCHER_PAGE=mods` (or `modpacks`, `shaders`, `servers`, `versions`, `notes`, - /// `settings`) opens on that page, for screenshots; Home otherwise. + /// `settings`) opens on that page, for screenshots; Home otherwise (`getting-ready`: Home + /// preparing, `home::getting_ready_preview`). fn from_env() -> Page { match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() { Ok("mods" | "modpacks" | "shaders" | "datapacks") => Page::Mods, @@ -267,6 +268,8 @@ struct Launcher { perf: perf::Perf, install_started: Option, prepare_started: Option, + /// The download under way as first seen (when, files done), for the time left. + download_seen: Option<(Instant, usize)>, play_pressed: Option, /// `horizon-launcher benchmark` started this window to measure it. bench: Option, @@ -469,6 +472,7 @@ impl Launcher { perf: perf::Perf::new("session"), install_started: None, prepare_started: None, + download_seen: None, play_pressed: None, bench: perf::Bench::from_env(), packs: Vec::new(), @@ -520,6 +524,10 @@ impl Launcher { }); this.fetch_startup_releases(); this.fetch_links(); + if home::getting_ready_preview() { + this.preparing = true; + this.play_when_prepared = this.selected().cloned(); + } // What the game and the launcher kept, if the player allows it and there is a server. telemetry::send_in_background(); telemetry::watch_playing(this.tx.clone()); @@ -1141,7 +1149,11 @@ impl Launcher { }; let result = backend::preparer(&tag, &minecraft, from, &log) .inspect(|(_, minecraft)| backend::sounds(&tag, minecraft, &log)) - .and_then(|(p, _)| match p { + .map(|(p, _)| { + backend::set_prepare_step(backend::PrepareStep::Prepare); + p + }) + .and_then(|p| match p { backend::Preparer::Process(cmd) => { backend::spawn(Job::Prepare, cmd, tx.clone()).map(|_running| false) } @@ -1162,6 +1174,7 @@ impl Launcher { }); self.preparing = true; self.prepare_started = Some(Instant::now()); + self.download_seen = None; } fn play(&mut self) { @@ -2392,6 +2405,8 @@ impl Launcher { None => self.hero(&tag, &version, prepared, cx), }; let actions = self.home_actions(prepared || java.is_some(), java.is_some(), cx); + let left = self.download_left(); + let steps = self.preparing.then(|| self.steps(&version, left)); // Not prepared: an owner can have Horizon download Minecraft from Mojang; without an // owner there is nothing to play, so signing in comes first. @@ -2638,56 +2653,15 @@ impl Launcher { .flex_col() .gap(px(28.)) .child(hero) - .child(actions), + .child(actions) + // The preparation's steps right under Play, where the player looks + // after pressing it, not below the fold (#666). + .children(steps), ) // Notes and friends are Horizon's. .children(java.is_none().then(|| self.home_card(&tag, cx))), ); let mut page = div().flex().flex_col().gap(px(28.)).child(banner); - // The preparation's progress and error right under the buttons, where the player - // looks after pressing Prepare, not below the fold (#666). - if self.preparing { - let last = self.prepare_log.back().cloned().unwrap_or_default(); - // A download from Mojang shows how far it is, and can stop (it goes on from there - // next time). - let download = horizon_launcher::mojang::progress(); - let cancel = download.map(|_| { - button("download-cancel", "Cancel", Kind::Ghost, &t).on_click(|_, _, _| { - horizon_launcher::mojang::CANCEL.store(true, Ordering::Relaxed) - }) - }); - page = page.child( - div() - .flex() - .flex_col() - .gap(px(10.)) - .child( - div() - .flex() - .items_center() - .gap(px(12.)) - .child(progress_bar( - download.map(|(done, total)| { - let f = done as f32 / total as f32; - (f, f) - }), - &t, - )) - .children(cancel), - ) - .child( - text(theme::MICRO) - .text_color(t.muted) - .truncate() - .child(last), - ) - .child( - text(theme::CAPTION) - .text_color(t.muted) - .child(tr("The first preparation takes a few minutes.")), - ), - ); - } if let Some(e) = self.prepare_error.clone() { page = page.child(notice(e, &t)); } @@ -3878,22 +3852,20 @@ impl Launcher { } /// A thin bar. A known fraction eases from its previous value; an unknown one shuttles slowly. -/// Progress: a sunk track filling with the accent; work of unknown length shuttles a block. +/// Progress: a plate-coloured track filling with the accent; work of unknown length shuttles. fn progress_bar(fraction: Option<(f32, f32)>, t: &Theme) -> Div { + // Night: a thin accent line on a plate's colour, square like the art. + let n = t.night; let bar = div() .absolute() .top_0() .bottom_0() - .bg(t.accent) - .border_b_2() - .border_color(t.face_lip); + .bg(plate::alpha(n.accent, 1.)); let track = div() .relative() .flex_1() - .h(px(12.)) - .border_2() - .border_color(t.outline) - .bg(t.well) + .h(px(6.)) + .bg(plate::alpha(n.card_hi, n.card_hi_alpha)) .overflow_hidden(); match fraction { Some((from, to)) => { From 5a42da4fe1ffd9f6c8432d9b0b1fee9e9f652ed2 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:26:01 +0100 Subject: [PATCH 385/968] Launcher: test the time left and the steps' order Co-Authored-By: Claude Opus 5.5 --- launcher/src/home.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/launcher/src/home.rs b/launcher/src/home.rs index 97cf88969b..e2d3d99e70 100644 --- a/launcher/src/home.rs +++ b/launcher/src/home.rs @@ -380,9 +380,7 @@ impl Launcher { return None; } }; - let spent = since.elapsed(); - (spent >= Duration::from_secs(5) && done > from) - .then(|| spent.mul_f64((total - done) as f64 / (done - from) as f64)) + time_left(since.elapsed(), from, done, total) } /// Under Play while it prepares: a bar, what is happening and the time left, and the steps, @@ -500,3 +498,33 @@ pub(super) fn getting_ready_preview() -> bool { fn download_progress() -> Option<(usize, usize)> { horizon_launcher::mojang::progress().or_else(|| getting_ready_preview().then_some((1754, 5160))) } + +/// The time `total - done` files take at the pace of `done - from` in `spent`: none for the +/// first 5 s, or before a file is done. +fn time_left(spent: Duration, from: usize, done: usize, total: usize) -> Option { + (spent >= Duration::from_secs(5) && done > from) + .then(|| spent.mul_f64(total.saturating_sub(done) as f64 / (done - from) as f64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_left_follows_the_pace_since_first_seen() { + let s = Duration::from_secs; + assert_eq!(time_left(s(10), 100, 600, 3100), Some(s(50))); + assert_eq!(time_left(s(4), 100, 600, 3100), None, "too early"); + assert_eq!(time_left(s(10), 600, 600, 3100), None, "nothing done yet"); + assert_eq!(time_left(s(10), 0, 50, 50), Some(s(0))); + } + + #[test] + fn steps_run_in_their_order() { + use backend::PrepareStep::*; + assert_eq!(backend::PrepareStep::ALL, [Files, Java, Sounds, Prepare]); + for s in backend::PrepareStep::ALL { + assert_eq!(backend::PrepareStep::ALL[s as usize], s); + } + } +} From 217fb7af0d520e1f875e340706043ce26ccdcb9f Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 30 Oct 2026 21:17:45 +1000 Subject: [PATCH 386/968] Run the Mouse Tweaks wheel and drags in furnaces and crafting tables The gate only allowed the inventory and chest-like menus, so nothing moved in furnaces, crafting tables, hoppers and dispensers. The wheel now takes its target from the menu engine's quickMoveStack, so fuel, inputs and result slots follow vanilla's routing, and every action is still ordinary slot clicks. Fixes #4312 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/tweaks.rs | 172 +++++++++++++++++------ crates/horizon-client/src/menu.rs | 167 ++++++++++++++++++++++ docs/extras.md | 19 ++- tools/lan_e2e.py | 160 ++++++++++++++++++++- 4 files changed, 470 insertions(+), 48 deletions(-) diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index 648073874e..fa9d182f4d 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -2,9 +2,9 @@ //! controls and sorting, both off by default: while an extra is off its //! hooks return false and the screen clicks exactly as vanilla's. Each action is made of ordinary slot clicks //! (the menu engine's, or `ContainerClick`s on a server), so it works -//! wherever vanilla's clicks do. They run in the player inventory and the +//! wherever vanilla's clicks do. They run in the player inventory, the //! chest-like containers (chests, barrels, ender chests, shulker boxes, -//! hoppers, dispensers). +//! hoppers, dispensers), furnaces and crafting tables. //! //! - Right-drag holding a stack: one item into each slot entered, at once. //! - Left-drag holding a stack, before vanilla's drag has spread over a @@ -12,8 +12,10 @@ //! the hand (when it all fits); with Shift, those slots are quick-moved. //! - Shift + left-drag with an empty hand: every slot entered is quick-moved. //! - The wheel over a slot, with an empty hand: down moves one of its items -//! to the other side (container and inventory, or hotbar and inventory), -//! up brings one of the same item back. +//! where a Shift click would send it (`quickMoveStack`: fuel to the fuel +//! slot, a smeltable to the input), up brings one of the same item from the +//! other side. Down over a result slot takes one craft or the whole furnace +//! output to the inventory; nothing ever goes into a result slot. //! //! Sorting draws a small button by the container's title and one by the //! inventory's (an optional key sorts the part under the cursor); the @@ -39,6 +41,23 @@ pub(super) struct Tweaks { /// Wheel steps not yet acted on (a trackpad scrolls in fractions). wheel: f32, } +/// A server's menu the tweaks run in: their clicks are vanilla's in these, +/// and the menu engine knows which slot takes what. Not the menus whose slots +/// cost or trigger something (anvils, enchanting, merchants, beacons, crafters). +pub(super) fn tweak_menu(name: &str) -> bool { + name == "inventory" + || name.starts_with("minecraft:generic_9x") + || matches!( + name, + "minecraft:shulker_box" + | "minecraft:hopper" + | "minecraft:generic_3x3" + | "minecraft:furnace" + | "minecraft:blast_furnace" + | "minecraft:smoker" + | "minecraft:crafting" + ) +} /// A drag the tweaks follow. #[derive(Clone, Debug)] struct Drag { @@ -62,7 +81,7 @@ impl Game<'_> { return false; } if self.remote.is_some() { - self.remote_storage_open() + self.remote_menu_type().is_none_or(tweak_menu) } else { matches!(self.container_kind(), None | Some(BlockEntity::Chest(_))) } @@ -92,7 +111,8 @@ impl Game<'_> { } let held = self.carried.clone(); if right { - if shift || held.is_none() { + // A result slot takes nothing: its press stays vanilla's. + if shift || held.is_none() || self.tweak_result(slot) { return false; } // RMB: the press places at once, then the drag goes on. @@ -123,11 +143,7 @@ impl Game<'_> { let Some(held) = drag.held.clone() else { return; }; - let accepts = match self.slot_view(slot) { - None => true, - Some(s) => s.same_item_same_components(&held) && s.count < self.max_stack(&s), - }; - if accepts && self.tweak_click(slot, 1, Input::Pickup) { + if self.tweak_takes_one(slot, &held) && self.tweak_click(slot, 1, Input::Pickup) { drag.held = (held.count > 1).then(|| ItemStack { count: held.count - 1, ..held @@ -192,7 +208,7 @@ impl Game<'_> { if !self.settings.extras.mouse_tweaks_wheel || !self.tweaks_on() || self.carried.is_some() { return false; } - let Some(slot) = slot.filter(|&s| s != 200) else { + let Some(slot) = slot else { self.tweaks.wheel = 0.0; return false; }; @@ -208,45 +224,78 @@ impl Game<'_> { } true } - /// The slots across from `slot`, in quick-move order: a container's - /// go to the inventory (hotbar first, from the right), the inventory's - /// to the container; without one, hotbar and main inventory trade. + /// `slot` is a result slot of the open menu. + fn tweak_result(&mut self, slot: usize) -> bool { + let mut result = false; + self.menu_session(|menu, _, to_menu| { + result = to_menu(slot).is_some_and(|i| menu.is_result(i)); + Some(false) + }); + result + } + /// `slot` takes one more of `stack`, by the open menu's rules. + fn tweak_takes_one(&mut self, slot: usize, stack: &ItemStack) -> bool { + let mut takes = false; + self.menu_session(|menu, env, to_menu| { + takes = to_menu(slot).is_some_and(|i| menu.takes_one(env, i, stack)); + Some(false) + }); + takes + } + /// Where vanilla's quick move would put `slot`'s items, tried on a copy + /// of the menu that's dropped (`Some(false)` keeps nothing). + fn tweak_landing(&mut self, slot: usize) -> Vec { + let slots = self.slots(); + let mut landing = Vec::new(); + self.menu_session(|menu, env, to_menu| { + let from = to_menu(slot)?; + landing = menu + .quick_move_landing(env, from) + .into_iter() + .filter_map(|i| slots.iter().copied().find(|&g| to_menu(g) == Some(i))) + .collect(); + Some(false) + }); + landing + } + /// The slots across from `slot`: a container's or crafting grid's face + /// the inventory (hotbar first, from the right), the inventory's face + /// them; without one, hotbar and main inventory trade. fn other_side(&self, slot: usize) -> Vec { - let container: Vec = self + let table = self.craft_size == 3; + let own: Vec = self .slots() .into_iter() - .filter(|s| (300..500).contains(s)) + .filter(|s| (300..500).contains(s) || (table && (100..109).contains(s))) .collect(); let player = || (0..9).rev().chain((9..36).rev()); match slot { - 300..500 => player().collect(), - 0..36 if !container.is_empty() => container, + 100..109 | 300..500 => player().collect(), + 0..36 if !own.is_empty() => own, 0..9 => (9..36).collect(), 9..36 => (0..9).collect(), _ => Vec::new(), } } - /// Wheel down: one item of `slot` to the other side, where a stack of - /// it has room, else to the first empty slot (`moveItemStackTo`). + /// Wheel down: one item of `slot` to where a quick move sends it. A + /// result can't be split or put back: one craft (or the furnace's whole + /// output) goes, when it all fits. fn tweak_push(&mut self, slot: usize) { let Some(stack) = self.slot_view(slot) else { return; }; - let max = self.max_stack(&stack); - let side = self.other_side(slot); - let views: Vec<(usize, Option)> = - side.iter().map(|&s| (s, self.slot_view(s))).collect(); - let target = views - .iter() - .find(|(_, v)| { - v.as_ref() - .is_some_and(|v| v.same_item_same_components(&stack) && v.count < max) - }) - .or_else(|| views.iter().find(|(_, v)| v.is_none())) - .map(|(s, _)| *s); - let Some(target) = target else { + let landing = self.tweak_landing(slot); + let Some(&target) = landing.first() else { return; }; + if self.tweak_result(slot) { + if self.tweak_click(slot, 0, Input::Pickup) { + for target in landing { + self.tweak_click(target, 0, Input::Pickup); + } + } + return; + } if self.tweak_click(slot, 0, Input::Pickup) { self.tweak_click(target, 1, Input::Pickup); if stack.count > 1 { @@ -255,19 +304,24 @@ impl Game<'_> { } } /// Wheel up: one item of the same kind from the other side into `slot`. + /// A result slot takes none and gives none. fn tweak_pull(&mut self, slot: usize) { let Some(stack) = self.slot_view(slot) else { return; }; - if stack.count >= self.max_stack(&stack) { + if !self.tweak_takes_one(slot, &stack) { return; } - let side = self.other_side(slot); - let source = side.into_iter().find_map(|s| { - self.slot_view(s) - .filter(|v| v.same_item_same_components(&stack)) - .map(|v| (s, v.count)) - }); + let mut source = None; + for s in self.other_side(slot) { + if let Some(v) = self.slot_view(s) + && v.same_item_same_components(&stack) + && !self.tweak_result(s) + { + source = Some((s, v.count)); + break; + } + } let Some((source, count)) = source else { return; }; @@ -411,3 +465,39 @@ impl Game<'_> { } } } + +#[cfg(test)] +mod tests { + use super::tweak_menu; + + #[test] + fn the_tweaks_run_in_the_menus_with_plain_click_rules() { + for menu in [ + "inventory", + "minecraft:generic_9x1", + "minecraft:generic_9x6", + "minecraft:shulker_box", + "minecraft:hopper", + "minecraft:generic_3x3", + "minecraft:furnace", + "minecraft:blast_furnace", + "minecraft:smoker", + "minecraft:crafting", + ] { + assert!(tweak_menu(menu), "{menu}"); + } + for menu in [ + "minecraft:anvil", + "minecraft:enchantment", + "minecraft:merchant", + "minecraft:beacon", + "minecraft:grindstone", + "minecraft:stonecutter", + "minecraft:loom", + "minecraft:brewing_stand", + "minecraft:crafter_3x3", + ] { + assert!(!tweak_menu(menu), "{menu}"); + } + } +} diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index a8a1852ef8..bdf9e24598 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -1335,6 +1335,61 @@ impl Menu { } } + /// A result slot (`FurnaceResultSlot`, `ResultSlot`): nothing goes in, and + /// what's taken from it can't be put back. + pub fn is_result(&self, s: usize) -> bool { + matches!(self.slots[s].role, Role::Output | Role::CraftResult) + } + + /// Slot `s` takes one more item of `stack` (`Slot.mayPlace`, and room). + pub fn takes_one(&self, env: &Env, s: usize, stack: &ItemStack) -> bool { + self.may_place(env, s, stack) + && self.slot(s).is_none_or(|have| { + have.same_item_same_components(stack) && have.count < self.slot_max(env, s, have) + }) + } + + /// The slots `quickMoveStack` would put slot `index`'s items in: one item + /// of a plain slot, the whole of a result slot, partial stacks first. Empty + /// when they don't all fit. This runs the move, so call it on a menu that + /// is thrown away afterwards. + pub fn quick_move_landing(&mut self, env: &Env, index: usize) -> Vec { + let Some(mut stack) = self.slot(index).cloned() else { + return Vec::new(); + }; + if !self.is_result(index) { + stack.count = 1; + let slot = self.slots[index]; + self.containers[slot.container][slot.index] = Some(stack.clone()); + } + let before: Vec> = (0..self.slots.len()) + .map(|i| self.slot(i).cloned()) + .collect(); + self.quick_move(env, index); + let (mut partial, mut empty) = (Vec::new(), Vec::new()); + let mut moved = 0; + for i in (0..self.slots.len()).filter(|&i| i != index && !self.is_result(i)) { + let had = before[i].as_ref().map_or(0, |b| b.count); + let got = self + .slot(i) + .filter(|now| now.same_item_same_components(&stack)) + .map_or(0, |now| now.count.saturating_sub(had)); + if got > 0 { + moved += got; + if had > 0 { + partial.push(i); + } else { + empty.push(i); + } + } + } + if moved < stack.count { + return Vec::new(); + } + partial.extend(empty); + partial + } + /// `Slot.getMaxStackSize(stack)`. fn slot_max(&self, env: &Env, s: usize, stack: &ItemStack) -> u32 { let own = match self.slots[s].role { @@ -3077,6 +3132,118 @@ pub(crate) mod tests { test(&env, &mut menu); } + /// The Mouse Tweaks wheel's targets: vanilla's routing of one item. + #[test] + fn quick_move_landing_routes_a_furnace_like_vanilla() { + with_menu(Kind::Furnace("smelting"), |env, menu| { + menu.creative = false; + // 0 input, 1 fuel, 2 output, 3..30 inventory, 30..39 hotbar. + let mut landing = |item, count, from| { + menu.set_slot(env, from, stack(item, count)); + let to = menu.quick_move_landing(env, from); + menu.set_slot(env, from, None); + for &t in &to { + menu.set_slot(env, t, None); + } + to + }; + assert_eq!(landing("raw_iron", 5, 30), [0]); + assert_eq!(landing("coal", 5, 30), [1]); + // Neither: between the hotbar and the inventory, as a shift-click. + assert_eq!(landing("dirt", 5, 30), [3]); + assert_eq!(landing("dirt", 5, 3), [30]); + // Out of the fuel slot: moveItemStackTo(3, 39, false). + assert_eq!(landing("coal", 5, 1), [3]); + }); + } + + #[test] + fn quick_move_landing_never_fills_a_result() { + with_menu(Kind::Furnace("smelting"), |env, menu| { + menu.creative = false; + menu.set_slot(env, 2, stack("iron_ingot", 5)); + menu.set_slot(env, 30, stack("iron_ingot", 5)); + // An ingot with room in the output still goes elsewhere. + assert_eq!(menu.quick_move_landing(env, 30), [3]); + assert_eq!(menu.slot(2).map(|s| s.count), Some(5)); + }); + with_menu(Kind::Furnace("smelting"), |env, menu| { + menu.creative = false; + // The whole output moves: into the partial stack, then an empty slot. + menu.set_slot(env, 2, stack("iron_ingot", 5)); + menu.set_slot(env, 20, stack("iron_ingot", 62)); + assert_eq!(menu.quick_move_landing(env, 2), [20, 38]); + }); + with_menu(Kind::Furnace("smelting"), |env, menu| { + menu.creative = false; + // A full inventory: the output stays, nothing to land on. + menu.set_slot(env, 2, stack("iron_ingot", 5)); + for i in 3..39 { + menu.set_slot(env, i, stack("stone", 64)); + } + assert!(menu.quick_move_landing(env, 2).is_empty()); + }); + } + + #[test] + fn quick_move_landing_routes_a_crafting_table_like_vanilla() { + with_menu(Kind::Crafting, |env, menu| { + menu.creative = false; + // 0 result, 1..10 grid, 10..37 inventory, 37..46 hotbar. + menu.set_slot(env, 37, stack("oak_log", 2)); + assert_eq!(menu.quick_move_landing(env, 37), [1]); + }); + with_menu(Kind::Crafting, |env, menu| { + menu.creative = false; + menu.set_slot(env, 1, stack("oak_log", 1)); + menu.settle(env); + assert_eq!(menu.slot(0).map(|s| s.count), Some(4)); + assert_eq!(menu.quick_move_landing(env, 0), [45]); + }); + } + + #[test] + fn quick_move_landing_routes_plain_containers() { + for (kind, size) in [ + (Kind::Hopper, 5), + (Kind::Dispenser, 9), + (Kind::Chest(3), 27), + ] { + with_menu(kind, |env, menu| { + menu.creative = false; + let hotbar = size + 27; + menu.set_slot(env, hotbar, stack("stone", 3)); + assert_eq!(menu.quick_move_landing(env, hotbar), [0]); + }); + with_menu(kind, |env, menu| { + menu.creative = false; + menu.set_slot(env, 0, stack("stone", 3)); + assert_eq!(menu.quick_move_landing(env, 0), [size + 35]); + }); + } + } + + #[test] + fn takes_one_follows_the_slots_rules() { + with_menu(Kind::Furnace("smelting"), |env, menu| { + menu.creative = false; + let (coal, cobble) = (stack("coal", 1).unwrap(), stack("cobblestone", 1).unwrap()); + let bucket = stack("bucket", 1).unwrap(); + assert!(menu.takes_one(env, 0, &cobble), "the input takes anything"); + assert!(menu.takes_one(env, 1, &coal)); + assert!(!menu.takes_one(env, 1, &cobble), "not fuel"); + assert!(!menu.takes_one(env, 2, &coal), "the output"); + menu.set_slot(env, 1, Some(bucket.clone())); + assert!( + !menu.takes_one(env, 1, &bucket), + "one bucket in the fuel slot" + ); + menu.set_slot(env, 1, stack("coal", 1)); + assert!(!menu.takes_one(env, 1, &bucket), "another item"); + assert!(menu.takes_one(env, 1, &coal)); + }); + } + /// `HorseInventoryMenu`: a shift-click wears horse armour, then saddles, /// then fills the chest; the body slot takes only what the mount may wear. #[test] diff --git a/docs/extras.md b/docs/extras.md index c0b92004f6..19bfcd2a23 100644 --- a/docs/extras.md +++ b/docs/extras.md @@ -105,8 +105,11 @@ The game still draws the minimap, for the native mod (`native_minimap` in `nativ written to disk. ### Mouse Tweaks controls (`mouseTweaks`, default off; `mouseTweaksWheel`, default on) -In the player inventory and chest-like containers (chests, barrels, ender -chests, shulker boxes, hoppers, dispensers), in singleplayer and on servers: +In the player inventory, chest-like containers (chests, barrels, ender chests, +shulker boxes, hoppers, dispensers and droppers), furnaces (also blast furnaces +and smokers) and crafting tables, in singleplayer and on servers. Anvils, +enchanting tables, merchants, beacons, brewing stands, crafters and the other +workstations keep vanilla's controls: - Right-drag holding a stack: one item goes into each slot entered, at once (slots holding another item, or full, are skipped). - Left-drag holding a stack: each slot entered that holds the same item is @@ -116,9 +119,15 @@ chests, shulker boxes, hoppers, dispensers), in singleplayer and on servers: - Shift + left-drag: every slot entered is quick-moved (with a stack in hand, only the slots of its item). - The wheel over a slot, with an empty hand (`mouseTweaksWheel`): down moves - one item to the other side (container and inventory, or hotbar and main - inventory: to a stack of it with room, else the first empty slot, as - quick-move picks), up brings one of the same item back. + one item where a Shift click would send it (vanilla's `quickMoveStack`: fuel + to the fuel slot, a smeltable to the input, into the crafting grid, across a + container, between hotbar and main inventory, an armour piece onto the + player), up brings one of the same item from the other side. +- Nothing goes into a result slot. The wheel down over one takes one craft, or a + furnace's whole output, to the inventory when it all fits (a result can't be + split or put back); the wheel up over one does nothing, and it isn't a source + for the wheel up elsewhere. A right-drag skips it, and a right press on it is + vanilla's. Every action is ordinary slot clicks (`ContainerClick` on a server), so a vanilla server sees nothing unusual. With the extra off, the hooks return at diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..5fda650f82 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -102,6 +102,9 @@ address. The script then checks, from both sides, what a guest's game used to ge - clicks: with real mouse presses (not click-slot, which skips the screen's press and drag handling), the guest puts a stack into a chest and a furnace, drags planks across its crafting grid and gathers a stack with a double click; the server has each. +- wheel: with the Mouse Tweaks extra on, real wheel events and a right-drag in a furnace, a + crafting table, a hopper and a dropper: fuel and smeltables go to their slots, nothing + into a result slot, and down over a result takes it. The server has each. - sleep: at night the guest lies down in its own bed (not the host); alone it stays night, it gets up with Escape, and once the host sleeps too the night ends and both wake. - bed-occupied: the bed the host sleeps in is occupied and the guest cannot lie down in it; it is @@ -323,7 +326,7 @@ SERVER_THREAD = True CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'spawned', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'flow-push', 'bubble-column', 'arrows', 'kills', 'leave', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'names', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', - 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'sleep-villager', 'bed-occupied', 'sleep-reload', 'predict', 'push', + 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'wheel', 'sleep', 'sleep-villager', 'bed-occupied', 'sleep-reload', 'predict', 'push', 'piston-push', 'piston-slime', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', @@ -2173,6 +2176,159 @@ def check_clicks(host, guest, out): return {'chest': in_chest, 'furnace': in_furnace, 'grid': grid, 'inventory': counts} +def wheel(game, slot, steps): + """Real wheel events over slot `slot` of the open screen (positive scrolls up).""" + x, y = slot_point(game, slot) + game.raw('scroll-at', f'{x:.1f}', f'{y:.1f}', steps) + game.wait(4) + + +def set_mouse_tweaks(game, on): + """The Extras page's switch, as a player flips it.""" + game('options', page='extras') + switch = next(w for w in game.raw('ui')['widgets'] if w.get('label', '').startswith('Mouse Tweaks controls')) + if switch['label'].endswith('On') != on: + game('click', id=switch['id']) + game('close') + game.wait(5) + + +def check_wheel(host, guest, out): + set_mouse_tweaks(guest, True) + # On the host's cleared floor, with the host behind: nothing stands in the way of the look at the + # one spot each block takes in turn. + hx, hy, hz = pos_of(host) + guest('command', command=f'tp @s {hx:.1f} {hy:.1f} {hz + 4:.1f}') + guest.wait(10) + x, y, z = (int(v // 1) for v in pos_of(guest)) + spot = (x, y, z + 2) + guest('command', command='gamemode survival') + + def place(block): + guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:{block}') + guest.wait(5) + + def held(*stacks): + guest('command', command='clear @s') + for item, count in stacks: + guest('command', command=f'give @s minecraft:{item} {count}') + guest.wait(10) + + def opened(screen='container'): + guest.raw('interact', *spot) + guest.raw('wait-for', 'screen', screen, '--timeout', 10) + guest.wait(10) + + def slots(): + return {s['slot']: (s['item'].removeprefix('minecraft:'), s['count']) + for s in guest('screen').get('slots', []) if s.get('item')} + + def shut(): + guest.raw('key', 'esc', 'tap') + guest.wait(10) + + def server_view(): + """Closing and opening again resends the whole menu: what the server has.""" + shut() + opened() + return slots() + + def where(view, item): + return sorted(s for s, (i, _) in view.items() if i == item) + + try: + # A chest always worked: the extra is on, and the wheel reaches the screen. + held(('cobblestone', 3)) + place('chest') + opened() + wheel(guest, hotbar_slot(guest, 'minecraft:cobblestone'), -1) + view = server_view() + assert view.get(300) == ('cobblestone', 1), f'chest: {view}' + shut() + # The furnace: each item goes to the slot vanilla's quick move picks, never the output. + # Raw copper can't join the ingots in the output, so the furnace stays out and burns nothing. + held(('coal', 3), ('raw_copper', 3), ('dirt', 3)) + place('furnace{Items:[{Slot:2b,id:"minecraft:iron_ingot",count:5}]}') + opened() + wheel(guest, hotbar_slot(guest, 'minecraft:coal'), -1) + wheel(guest, hotbar_slot(guest, 'minecraft:raw_copper'), -1) + wheel(guest, hotbar_slot(guest, 'minecraft:dirt'), -1) + view = server_view() + guest.shot(out, 'wheel-furnace') + assert view.get(300) == ('raw_copper', 1), f'smeltable, input: {view}' + assert view.get(301) == ('coal', 1), f'fuel, fuel slot: {view}' + assert view.get(302) == ('iron_ingot', 5), f'the output changed: {view}' + assert sum(c for i, c in view.values() if i == 'dirt') == 3, f'dirt went astray: {view}' + # Up over the fuel slot: one more coal. Down: one back. + wheel(guest, 301, 1) + pulled = server_view() + assert pulled.get(301) == ('coal', 2), f'pulled: {pulled}' + wheel(guest, 301, -1) + pushed = server_view() + assert pushed.get(301) == ('coal', 1), f'pushed: {pushed}' + # Out of the input again, or taking the output would light the furnace. + wheel(guest, 300, -1) + # Up over the output brings nothing in; down takes it, whole, to the inventory. + wheel(guest, 302, 1) + kept = server_view() + assert kept.get(302) == ('iron_ingot', 5), f'wheel up over the output: {kept}' + wheel(guest, 302, -1) + taken = server_view() + assert 302 not in taken and [taken[s] for s in where(taken, 'iron_ingot')] == [('iron_ingot', 5)], \ + f'wheel down over the output: {taken}' + # An ingot from the inventory has no way back into the output. + wheel(guest, where(taken, 'iron_ingot')[0], -1) + back = server_view() + assert 302 not in back, f'an item went into the output: {back}' + # The right-drag: neither an empty fuel slot (dirt isn't fuel) nor the output takes it. + wheel(guest, 301, -1) + dirt = where(back, 'dirt')[0] + mouse(guest, dirt) + mouse(guest, 301, 302, right=True) + mouse(guest, dirt) + dragged = server_view() + guest.shot(out, 'wheel-furnace-drag') + assert 301 not in dragged and 302 not in dragged, f'right-drag: {dragged}' + assert sum(c for i, c in dragged.values() if i == 'dirt') == 3, f'right-drag lost items: {dragged}' + shut() + + # The crafting table: into the grid; down over the result crafts once, into the inventory. + held(('oak_log', 2)) + place('crafting_table') + opened('inventory') + wheel(guest, hotbar_slot(guest, 'minecraft:oak_log'), -1) + grid = slots() + assert grid.get(100) == ('oak_log', 1) and grid.get(200) == ('oak_planks', 4), f'grid: {grid}' + wheel(guest, 200, 1) + assert slots().get(100) == ('oak_log', 1), f'wheel up over the result: {slots()}' + wheel(guest, 200, -1) + crafted = slots() + guest.shot(out, 'wheel-crafting') + assert 100 not in crafted and 200 not in crafted, f'after the result: {crafted}' + assert [crafted[s] for s in where(crafted, 'oak_planks')] == [('oak_planks', 4)], f'after the result: {crafted}' + shut() + counts = guest('inventory')['counts'] + assert counts.get('minecraft:oak_planks') == 4 and counts.get('minecraft:oak_log') == 1, f'the server has {counts}' + + # Hopper, dropper: plain slots, both ways. + held(('cobblestone', 3)) + for name in ('hopper', 'dropper'): + place(name) + opened() + wheel(guest, hotbar_slot(guest, 'minecraft:cobblestone'), -1) + view = server_view() + assert view.get(300) == ('cobblestone', 1), f'{name}: {view}' + wheel(guest, 300, -1) + view = server_view() + assert 300 not in view, f'{name}, pushed back: {view}' + shut() + finally: + back_to_game(guest) + set_mouse_tweaks(guest, False) + place('air') + return {'furnace': dragged, 'crafting': crafted} + + def check_feedback(host, guest, out): x, y, z = (round(v, 1) for v in pos_of(guest)) guest('command', command='item replace entity @s weapon.mainhand with minecraft:stick') @@ -5383,7 +5539,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'cartography': check_cartography, 'hurt': check_hurt, 'commands': check_commands, 'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls, 'effects': check_effects, 'portals': check_portals, 'distance': check_distance, - 'clicks': check_clicks, 'sleep': check_sleep, 'sleep-villager': check_sleep_villager, + 'clicks': check_clicks, 'wheel': check_wheel, 'sleep': check_sleep, 'sleep-villager': check_sleep_villager, 'bed-occupied': check_bed_occupied, 'sleep-reload': check_sleep_reload, 'push': check_push, 'piston-push': check_piston_push, 'piston-slime': check_piston_slime, From 69ec4ccdd650b34f3b015a7e863beabd58ca63c4 Mon Sep 17 00:00:00 2001 From: simon Date: Fri, 30 Oct 2026 22:15:55 +1000 Subject: [PATCH 387/968] Keep the cursor and the items right under fast menu clicks Rapid clicks, as the Mouse Tweaks wheel sends, could leave a stack in the cursor, and in creative mode add real items. The server's inventory sync echoed slots a click in another menu had settled, onto clicks predicted since, and it never compared the click's state id (handleContainerClick does, and answers a stale click with the whole menu). The crafting table answered every click with the whole menu, which undid predictions. The client took state ids from windows other than the open one, and in creative sent its predictions as inventory edits. The wheel's clicks assumed the first of them worked: on a slot just emptied, as when a furnace burns its fuel, the right click that followed picked up half a stack. It now places with a one-slot drag, which does nothing with an empty hand. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/lan_crafting.rs | 32 +++++++++- .../horizon-client/src/game/lan_stations.rs | 1 + crates/horizon-client/src/game/remote.rs | 24 ++++--- crates/horizon-client/src/game/tweaks.rs | 34 ++++++++-- crates/horizon-client/src/lan/containers.rs | 53 ++++++++++++++++ crates/horizon-client/src/lan/guest.rs | 8 +++ tools/lan_e2e.py | 63 ++++++++++++++++++- 7 files changed, 199 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index d83e2acb5d..15bcf9df78 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -467,17 +467,44 @@ impl Game<'_> { else { return false; }; + // handleContainerClick: broadcastFullState only for a click on an old + // state, else only what the client got wrong. The whole menu after every + // click would undo the clicks it has predicted since. + let mut agreed = false; // A table that is no longer valid takes no clicks (handleContainerClick). if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) && !host.hub.guests[gi].spectator() && self.lan_table_valid(&host.hub.guests[gi], pos) { - self.guest_menu_op(host, gi, &mut menu, |menu, env| { + let changed = self.guest_menu_op(host, gi, &mut menu, |menu, env| { + let before: Vec<_> = (0..menu.slot_count()) + .map(|s| menu.slot(s).cloned()) + .collect(); let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input)); + (0..menu.slot_count()) + .filter(|&s| menu.slot(s) != before[s].as_ref()) + .collect::>() }); + agreed = p.state == state + && host.hub.click_agreed( + gi, + p, + menu.slot_count(), + &|s| menu.slot(s).cloned(), + &changed, + ); + if agreed { + // The inventory menu's sync isn't to send what the click settled. + let g = &mut host.hub.guests[gi]; + for &s in changed.iter().filter(|&&s| s >= 10) { + g.menu_sent[s - 1] = menu.slot(s).cloned(); + } + } } host.play.entry(conn).or_default().crafting.table = Some((window, state, menu, pos)); - self.lan_send_table_content(host, conn); + if !agreed { + self.lan_send_table_content(host, conn); + } true } @@ -549,6 +576,7 @@ impl Game<'_> { carried, }), ); + host.hub.guests[gi].inventory_told(); } /// `AbstractContainerMenu.removed` of a crafting menu: the closed window diff --git a/crates/horizon-client/src/game/lan_stations.rs b/crates/horizon-client/src/game/lan_stations.rs index b056ee52fe..5e208d5fa4 100644 --- a/crates/horizon-client/src/game/lan_stations.rs +++ b/crates/horizon-client/src/game/lan_stations.rs @@ -438,6 +438,7 @@ impl Game<'_> { } } host.hub.send_to(conn, &packet); + host.hub.guests[gi].inventory_told(); for packet in data { host.hub.send_to(conn, &packet); } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 9853e60967..861f3e1ad3 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2947,9 +2947,7 @@ impl Game<'_> { } } C::ContainerSetContent(p) => { - if let Some(r) = self.remote.as_mut() { - r.state_id = p.state; - } + self.remote_menu_state(p.container, p.state); for (slot, item) in p.items.iter().enumerate() { let stack = self.to_stack(item); self.set_menu_slot(p.container, slot as i32, stack); @@ -2958,9 +2956,7 @@ impl Game<'_> { self.remote_synced(); } C::ContainerSetSlot(p) => { - if let Some(r) = self.remote.as_mut() { - r.state_id = p.state; - } + self.remote_menu_state(p.container, p.state); let stack = self.to_stack(&p.item); self.tutorial_get_item(stack.as_ref()); // ClientPacketListener.handleContainerSetSlot: a hotbar slot that @@ -4902,11 +4898,17 @@ impl Game<'_> { // Creative inventory edits are sent slot by slot (SetCreativeModeSlot). let mut creative_slots = Vec::new(); if self.creative { + // A server's open menu takes clicks, which it applies itself: what + // this client predicts for them isn't an edit to send (it would + // overwrite the server's stacks whenever the prediction is wrong). + let in_menu = r.menu.is_some(); let stacks = creative_sync_stacks(&self.inventory, &self.offhand, &self.armor); for (i, stack) in stacks.into_iter().enumerate() { if stack != r.synced[i] { r.synced[i] = stack.clone(); - creative_slots.push((creative_sync_slot(i), stack)); + if !in_menu { + creative_slots.push((creative_sync_slot(i), stack)); + } } } } @@ -6355,6 +6357,14 @@ impl Game<'_> { } self.remote_menu_ops(&[(Some(internal), i32::from(right), input)]); } + /// `AbstractContainerMenu.setItem`/`initializeContents`: the state id a + /// click answers with is the open menu's, not another window's. + fn remote_menu_state(&mut self, container: i32, state: i32) { + let open = self.remote_menu().0; + if let Some(r) = self.remote.as_mut().filter(|_| container == open) { + r.state_id = state; + } + } /// The server's open menu: its window id and menu type (0, "inventory" /// when none is open). pub(super) fn remote_menu(&self) -> (i32, String) { diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index fa9d182f4d..0a65a9a383 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -99,6 +99,28 @@ impl Game<'_> { } true } + /// Puts the hand's stack (`right`: one of it) in `slot`, as a one-slot + /// drag (`QUICK_CRAFT`) and not a click: a server that has the hand empty + /// (the stack it was to come from just burned as fuel) ignores a drag, but + /// a click would pick up what's in `slot`. + fn tweak_put(&mut self, slot: usize, right: bool) -> bool { + let kind = i32::from(right) << 2; + let ops = [ + (None, kind, Input::QuickCraft), + (Some(slot), kind | 1, Input::QuickCraft), + (None, kind | 2, Input::QuickCraft), + ]; + if self.remote.is_some() { + return self.remote_menu_ops(&ops); + } + if !self.menu_ops(&ops) { + return false; + } + if let Some(key) = self.container { + self.container_changed(key); + } + true + } /// A press on `slot`: true when the tweaks took it (a right-drag /// placing), else vanilla's press runs and the tweaks watch the drag. pub(super) fn tweak_press(&mut self, slot: Option, right: bool, shift: bool) -> bool { @@ -143,7 +165,7 @@ impl Game<'_> { let Some(held) = drag.held.clone() else { return; }; - if self.tweak_takes_one(slot, &held) && self.tweak_click(slot, 1, Input::Pickup) { + if self.tweak_takes_one(slot, &held) && self.tweak_put(slot, true) { drag.held = (held.count > 1).then(|| ItemStack { count: held.count - 1, ..held @@ -291,15 +313,15 @@ impl Game<'_> { if self.tweak_result(slot) { if self.tweak_click(slot, 0, Input::Pickup) { for target in landing { - self.tweak_click(target, 0, Input::Pickup); + self.tweak_put(target, false); } } return; } if self.tweak_click(slot, 0, Input::Pickup) { - self.tweak_click(target, 1, Input::Pickup); + self.tweak_put(target, true); if stack.count > 1 { - self.tweak_click(slot, 0, Input::Pickup); + self.tweak_put(slot, false); } } } @@ -326,9 +348,9 @@ impl Game<'_> { return; }; if self.tweak_click(source, 0, Input::Pickup) { - self.tweak_click(slot, 1, Input::Pickup); + self.tweak_put(slot, true); if count > 1 { - self.tweak_click(source, 0, Input::Pickup); + self.tweak_put(source, false); } } } diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 6cb294cc49..ec26c4fdb7 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -309,6 +309,7 @@ impl Hub { }); menu.sent = items; g.send(&packet); + g.inventory_told(); } /// `AbstractContainerMenu.sendAllDataToRemote` for the guest's open @@ -507,6 +508,28 @@ impl Hub { } } + /// What the client predicted for click `p` is what the server has, for a + /// menu of `total` slots read through `view`. + pub fn click_agreed( + &mut self, + i: usize, + p: &play::ContainerClick, + total: usize, + view: &dyn Fn(usize) -> Option, + changed: &[usize], + ) -> bool { + let carried = self.guests[i].carried.clone(); + if !self.hashed_matches(&p.carried, &carried) { + return false; + } + let claimed: HashMap> = + p.changed.iter().map(|(s, h)| (*s as usize, h)).collect(); + (0..total).all(|slot| match claimed.get(&slot) { + Some(c) => self.hashed_matches(c, &view(slot)), + None => !changed.contains(&slot), + }) + } + /// After a click: tell the client about every slot it got wrong, or that /// it did not claim, and the carried stack. fn answer_click( @@ -517,6 +540,29 @@ impl Hub { changed: &[usize], ) { let in_menu = p.container != 0; + // handleContainerClick: a click made without the latest state gets the + // whole menu (`broadcastFullState`). Answering it slot by slot can leave + // the client with a correction of a click it has already gone past. + let g = &self.guests[i]; + let open = g + .menu + .as_ref() + .filter(|_| in_menu) + .map(|m| (m.size, m.state)); + let stale = match open { + Some((_, state)) => p.state != state, + None => !in_menu && p.state != g.menu_state, + }; + if stale { + match open { + Some((size, _)) => { + let items = (0..size).map(|s| self.view_slot(i, world, s)).collect(); + self.send_menu_content(i, items); + } + None => self.send_inventory(i), + } + return; + } let claimed: HashMap> = p .changed .iter() @@ -566,6 +612,13 @@ impl Hub { // What the client now believes equals the server's state. let items: Vec<_> = (0..total).map(|s| self.view_slot(i, world, s)).collect(); if in_menu { + // Nor is the inventory menu's sync to send what this click settled: + // ServerPlayer.tick broadcasts only the open menu. + let own = open.map_or(0, |(size, _)| size); + let settled = claimed.keys().copied().chain(changed.iter().copied()); + for slot in settled.filter(|&s| s >= own && s < own + 36) { + self.guests[i].menu_sent[slot - own + 9] = items[slot].clone(); + } if let Some(m) = self.guests[i].menu.as_mut() { m.sent = items; } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 649e57e8a6..49b7bcb6d5 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -517,6 +517,14 @@ impl Guest { _ => None, } } + /// The open menu's packets told the client every inventory slot: the + /// inventory menu's own sync has nothing to add. Sent again, it could land + /// on clicks the client has predicted since. + pub fn inventory_told(&mut self) { + for slot in 9..45 { + self.menu_sent[slot] = self.menu_slot(slot); + } + } pub fn set_menu_slot(&mut self, slot: usize, item: Option) { match slot { 0 => self.craft_result = item, diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 5fda650f82..95789b81aa 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -2183,6 +2183,12 @@ def wheel(game, slot, steps): game.wait(4) +def wheel_fast(game, slot, steps): + """Wheel events with no pause after them.""" + x, y = slot_point(game, slot) + game.raw('scroll-at', f'{x:.1f}', f'{y:.1f}', steps) + + def set_mouse_tweaks(game, on): """The Extras page's switch, as a player flips it.""" game('options', page='extras') @@ -2206,7 +2212,7 @@ def check_wheel(host, guest, out): def place(block): guest('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:{block}') - guest.wait(5) + guest.wait(20) # until the client has the block, to look at it def held(*stacks): guest('command', command='clear @s') @@ -2310,6 +2316,61 @@ def check_wheel(host, guest, out): counts = guest('inventory')['counts'] assert counts.get('minecraft:oak_planks') == 4 and counts.get('minecraft:oak_log') == 1, f'the server has {counts}' + # Wheel events as fast as they come, with no time for the server to answer: nothing stays in the + # cursor and no item appears or goes. + for mode in ('survival', 'creative'): + guest('command', command=f'gamemode {mode}') + held(('cobblestone', 40), ('coal', 40)) + place('air') # a chest placed on a chest keeps its items + place('chest') + opened() + first, second = hotbar_slot(guest, 'minecraft:cobblestone'), hotbar_slot(guest, 'minecraft:coal') + for round_ in range(12): + wheel_fast(guest, first if round_ % 3 else second, -2) + wheel_fast(guest, 300, 1 if round_ % 2 else -1) + guest.wait(30) + assert guest('screen').get('carried') is None, f'{mode}: a stack stayed in the cursor' + total = {} + for item, count in server_view().values(): + total[item] = total.get(item, 0) + count + assert total == {'cobblestone': 40, 'coal': 40}, f'{mode}: the items became {total}' + shut() + guest('command', command='gamemode survival') + + # A furnace burning the fuel the wheel is taking: the click on the emptied slot takes nothing, and + # what comes next picks up nothing either. + held(('coal', 40), ('raw_copper', 40)) + place('furnace') + opened() + coal, copper = hotbar_slot(guest, 'minecraft:coal'), hotbar_slot(guest, 'minecraft:raw_copper') + for round_ in range(10): + wheel_fast(guest, coal, -1) + wheel_fast(guest, copper, -1) + wheel_fast(guest, 301, -1) + guest.wait(4) + guest.wait(30) + assert guest('screen').get('carried') is None, 'a stack stayed in the cursor over a burning furnace' + shut() + + # The crafting table, as fast: into the grid and out, and over the result. + held(('oak_log', 20), ('dirt', 20)) + place('crafting_table') + opened('inventory') + log, dirt = hotbar_slot(guest, 'minecraft:oak_log'), hotbar_slot(guest, 'minecraft:dirt') + for round_ in range(12): + wheel_fast(guest, log if round_ % 2 else dirt, -2) + wheel_fast(guest, 100 + round_ % 3, -1) + wheel_fast(guest, 200, -1) + guest.wait(30) + assert guest('screen').get('carried') is None, 'a stack stayed in the cursor over a crafting table' + shut() + counts = guest('inventory')['counts'] + assert counts.get('minecraft:dirt') == 20, f'the dirt became {counts}' + guest.raw('key', 'e', 'tap') + guest.wait(5) + guest.raw('key', 'esc', 'tap') + guest.wait(5) + # Hopper, dropper: plain slots, both ways. held(('cobblestone', 3)) for name in ('hopper', 'dropper'): From c81de0f908b62e83af52c7195b9cbe2ec2e66916 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:36:20 +0100 Subject: [PATCH 388/968] Launcher: translate the steps' strings, drop the old preparing line Co-Authored-By: Claude Opus 5.5 --- launcher/lang/de.json | 10 ++++++++-- launcher/lang/es.json | 10 ++++++++-- launcher/lang/fr.json | 10 ++++++++-- launcher/lang/pl.json | 10 ++++++++-- launcher/lang/ru.json | 10 ++++++++-- launcher/lang/uk.json | 10 ++++++++-- launcher/lang/zh.json | 10 ++++++++-- 7 files changed, 56 insertions(+), 14 deletions(-) diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 9d4deaf58a..eee4007ca1 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -107,7 +107,6 @@ "Playing offline": "Offline spielen", "Online servers need a Microsoft account.": "Online-Server benötigen ein Microsoft-Konto.", "Accounts…": "Konten…", - "The first preparation takes a few minutes.": "Die erste Vorbereitung dauert ein paar Minuten.", "Horizon crashed": "Horizon ist abgestürzt", "When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Wenn du es auf Discord meldest, hänge die Absturzbericht-Datei an (oder füge sie mit \"Bericht kopieren\" ein) und beschreibe, was du getan hast.", "The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Das Spiel wurde ohne Absturzbericht beendet. Wenn du es auf Discord meldest, hänge die latest.log aus dem Protokollordner an und beschreibe, was du getan hast.", @@ -192,5 +191,12 @@ "Install and turn on native mods": "Installieren und native Mods einschalten", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.", "Folder": "Ordner", - "Version": "Version" + "Version": "Version", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "Minecraft {version} wird von Mojang heruntergeladen · {done} von {total} Dateien", + "Downloading from Mojang · {done} of {total} files": "Download von Mojang · {done} von {total} Dateien", + "Getting ready…": "Wird vorbereitet…", + "about a minute left": "noch etwa eine Minute", + "about {n} min left": "noch etwa {n} Min.", + "Game files": "Spieldateien", + "Sounds": "Sounds" } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 9bafdb9805..fe981c94e1 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -107,7 +107,6 @@ "Playing offline": "Jugando sin conexión", "Online servers need a Microsoft account.": "Los servidores en línea requieren una cuenta de Microsoft.", "Accounts…": "Cuentas…", - "The first preparation takes a few minutes.": "La primera preparación toma unos minutos.", "Horizon crashed": "Horizon se cerró inesperadamente", "When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Al reportarlo en Discord, adjunta el archivo del informe de fallos (o pégalo con Copiar informe) y describe qué estabas haciendo.", "The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "El juego terminó sin un informe de fallos. Al reportarlo en Discord, adjunta latest.log de la carpeta de registros y describe qué estabas haciendo.", @@ -192,5 +191,12 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.", "Folder": "Carpeta", - "Version": "Versión" + "Version": "Versión", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "Descargando Minecraft {version} de Mojang · {done} de {total} archivos", + "Downloading from Mojang · {done} of {total} files": "Descargando de Mojang · {done} de {total} archivos", + "Getting ready…": "Preparando…", + "about a minute left": "queda un minuto aprox.", + "about {n} min left": "quedan unos {n} min", + "Game files": "Archivos del juego", + "Sounds": "Sonidos" } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 28353362e8..7d2c1dda90 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -107,7 +107,6 @@ "Playing offline": "Jeu hors ligne", "Online servers need a Microsoft account.": "Les serveurs en ligne demandent un compte Microsoft.", "Accounts…": "Comptes…", - "The first preparation takes a few minutes.": "La première préparation prend quelques minutes.", "Horizon crashed": "Horizon a planté", "When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Pour le signaler sur Discord, joignez le rapport de plantage (ou collez-le avec Copier le rapport) et dites ce que vous faisiez.", "The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Le jeu s'est arrêté sans rapport de plantage. Pour le signaler sur Discord, joignez latest.log du dossier des journaux et dites ce que vous faisiez.", @@ -192,5 +191,12 @@ "Move to Trash": "Mettre à la corbeille", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", "Folder": "Dossier", - "Version": "Version" + "Version": "Version", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "Téléchargement de Minecraft {version} depuis Mojang · {done} fichiers sur {total}", + "Downloading from Mojang · {done} of {total} files": "Téléchargement depuis Mojang · {done} fichiers sur {total}", + "Getting ready…": "Préparation…", + "about a minute left": "environ une minute restante", + "about {n} min left": "environ {n} min restantes", + "Game files": "Fichiers du jeu", + "Sounds": "Sons" } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 42a2657a74..d89ab20a7f 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -101,7 +101,6 @@ "Playing offline": "Gra offline", "Online servers need a Microsoft account.": "Serwery online wymagają konta Microsoft.", "Accounts…": "Konta…", - "The first preparation takes a few minutes.": "Pierwsze przygotowanie zajmuje kilka minut.", "Horizon crashed": "Horizon uległ awarii", "Open report": "Otwórz raport", "Copied": "Skopiowano", @@ -192,5 +191,12 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.", "Folder": "Folder", - "Version": "Wersja" + "Version": "Wersja", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "Pobieranie Minecrafta {version} od Mojang · {done} z {total} plików", + "Downloading from Mojang · {done} of {total} files": "Pobieranie od Mojang · {done} z {total} plików", + "Getting ready…": "Przygotowywanie…", + "about a minute left": "zostało około minuty", + "about {n} min left": "zostało około {n} min", + "Game files": "Pliki gry", + "Sounds": "Dźwięki" } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 49fb094700..e4833a8e4a 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -107,7 +107,6 @@ "Playing offline": "Игра офлайн", "Online servers need a Microsoft account.": "Для онлайн-серверов нужен аккаунт Microsoft.", "Accounts…": "Аккаунты…", - "The first preparation takes a few minutes.": "Первая подготовка занимает несколько минут.", "Horizon crashed": "Horizon вылетел", "When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Сообщая об этом в Discord, приложите файл отчёта о сбое (или вставьте его через «Скопировать отчёт») и расскажите, что вы делали.", "The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Игра закрылась без отчёта о сбое. Сообщая об этом в Discord, приложите latest.log из папки журналов и расскажите, что вы делали.", @@ -192,5 +191,12 @@ "Move to Trash": "В корзину", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", "Folder": "Папка", - "Version": "Версия" + "Version": "Версия", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "Загрузка Minecraft {version} с серверов Mojang · {done} из {total} файлов", + "Downloading from Mojang · {done} of {total} files": "Загрузка с серверов Mojang · {done} из {total} файлов", + "Getting ready…": "Подготовка…", + "about a minute left": "осталось около минуты", + "about {n} min left": "осталось около {n} мин", + "Game files": "Файлы игры", + "Sounds": "Звуки" } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index 99feb23662..e2e340c8a2 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -107,7 +107,6 @@ "Playing offline": "Гра офлайн", "Online servers need a Microsoft account.": "Для онлайн-серверів потрібен обліковий запис Microsoft.", "Accounts…": "Облікові записи…", - "The first preparation takes a few minutes.": "Перша підготовка триває кілька хвилин.", "Horizon crashed": "Horizon аварійно завершився", "When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "Коли повідомлятимете про це в Discord, додайте файл звіту про збій (або вставте його через «Копіювати звіт») і розкажіть, що ви робили.", "The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "Гра завершилася без звіту про збій. Коли повідомлятимете про це в Discord, додайте latest.log із теки журналів і розкажіть, що ви робили.", @@ -192,5 +191,12 @@ "Install and turn on native mods": "Встановити й увімкнути нативні моди", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.", "Folder": "Тека", - "Version": "Версія" + "Version": "Версія", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "Завантаження Minecraft {version} із серверів Mojang · {done} з {total} файлів", + "Downloading from Mojang · {done} of {total} files": "Завантаження із серверів Mojang · {done} з {total} файлів", + "Getting ready…": "Підготовка…", + "about a minute left": "залишилося близько хвилини", + "about {n} min left": "залишилося близько {n} хв", + "Game files": "Файли гри", + "Sounds": "Звуки" } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index c06cdfef8c..4d10b3c488 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -106,7 +106,6 @@ "Playing offline": "离线游玩中", "Online servers need a Microsoft account.": "在线服务器需要 Microsoft 账户。", "Accounts…": "账户…", - "The first preparation takes a few minutes.": "第一次准备需要几分钟。", "Horizon crashed": "Horizon 崩溃了", "When you report it on Discord, attach the crash report file (or paste it with Copy report) and say what you were doing.": "在 Discord 上报告时,请附上崩溃报告文件(或用“复制报告”粘贴),并说明你当时在做什么。", "The game ended without a crash report. When you report it on Discord, attach latest.log from the logs folder and say what you were doing.": "游戏结束了,但没有生成崩溃报告。在 Discord 上报告时,请附上日志文件夹里的 latest.log,并说明你当时在做什么。", @@ -192,5 +191,12 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。", "Folder": "文件夹", - "Version": "版本" + "Version": "版本", + "Downloading Minecraft {version} from Mojang · {done} of {total} files": "正在从 Mojang 下载 Minecraft {version} · {done} / {total} 个文件", + "Downloading from Mojang · {done} of {total} files": "正在从 Mojang 下载 · {done} / {total} 个文件", + "Getting ready…": "正在准备…", + "about a minute left": "还剩约 1 分钟", + "about {n} min left": "还剩约 {n} 分钟", + "Game files": "游戏文件", + "Sounds": "声音" } From 8cca9cd942ddc17482fd5c3f533cbb1097be7fe7 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:39:04 +0500 Subject: [PATCH 389/968] Native mods: horizon-mixins with Isloka's two fixes; post-build programs copied out MIXINS_REV moves to horizon-mixins d9c83d7 (#7 and #8 by reservedbytes): - a crate dependency with default features (hashbrown = "0.15") builds: cargo adds the lock links between crates Horizon already locks, any new crate is still refused; - a post-build bin named like another mod's step bin or like horizon-client is refused (E_POST_BUILD). The launcher also copies the game and each step's program out of the shared target folder as soon as it is built, so a bundle staged by an older horizon-mixins can't run another mod's program or replace the game. Co-Authored-By: Claude Opus 5.5 --- docs/native-mods.md | 17 +++++++++------ launcher/src/native_mods.rs | 41 +++++++++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/docs/native-mods.md b/docs/native-mods.md index 5bdf4a7d68..6ade95e699 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -104,10 +104,13 @@ crate, and has no `Cargo.lock`, `[workspace]` or build of its own. **Dependencies come from Horizon's `Cargo.lock`.** A crate names registry crates by version requirement (`crc32fast = "1"`, `{ version, features, default-features, optional }`); each must already be in Horizon's lock file in a version that matches, and builds in exactly that version. The lock file is the allow list: horizon-mixins -refuses a crate the lock lacks (`E_CRATE_DEPENDENCY`) and writes the mod's crates into the staged lock without -changing any other byte, so the game still builds with `--locked`. The launcher checks it again (every downloaded -package of the staged lock as in Horizon's: name, version, source, checksum), and a feature that turns on a -dependency the lock lacks fails at `cargo fetch --locked` with a message saying so. The build stops with: +refuses a crate the lock lacks (`E_CRATE_DEPENDENCY`) and writes the mod's crates into the staged lock. A +dependency's features (its default ones included) may use other crates: `hashbrown = "0.15"` needs +`allocator-api2` and `equivalent`, which Horizon locks for other crates while it uses hashbrown without its defaults. +horizon-mixins lets `cargo update --workspace` add those links between crates already in the lock, and refuses any +crate it would add or change, naming it, so the game still builds with `--locked`. The launcher checks it again +(every downloaded package of the staged lock as in Horizon's: name, version, source, checksum), and a lock that +still misses something fails at `cargo fetch --locked` with a message saying so. The build stops with: ``` horizon-mixins failed (exit status: 1). The end of its output, the whole of it in …/native/build.log: @@ -130,8 +133,10 @@ both (`mixins//data/…`, `mixins///data/…`). ### Post-build steps A `[[post_build]]` names a bin of one of the mod's crates. After the game compiles, the launcher builds it with the -same profile into the same `native/target/`, then runs it (at low priority, in mod order) in the new build's version -folder with: +same profile into the same `native/target/`, copies it out at once, then runs it (at low priority, in mod order) in +the new build's version folder. A bin's file name doesn't include its crate, so horizon-mixins refuses a step bin +named like a workspace bin (`horizon-client`) or another mod's step bin, ignoring case (`E_POST_BUILD`); the copy +keeps an older bundle from running another mod's program or replacing the game. The step runs with: | Variable | What | |---|---| diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index 06cdd8f1e2..cfd43fd3c5 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -29,7 +29,7 @@ mod crates; const PREFIX: &str = "native-"; /// horizon-mixins, for bundles that don't name the revision they were tested with. const MIXINS_GIT: &str = "https://github.com/horizon-rust/horizon-mixins"; -pub const MIXINS_REV: &str = "cb1ad23022392d88f006cf94cbf6552a73f5df12"; +pub const MIXINS_REV: &str = "d9c83d7096b7ea5b83678da2efdfd6161f74997e"; /// The profile builds use: the workspace's `fast` (the release's optimizations without its /// link-time ones, incremental), so a rebuild recompiles and links only what the mods changed. /// A revision without it builds with `release` ([`profile_of`]). @@ -655,17 +655,22 @@ fn lock_crates(rust: &Toolchain, source: &Path, stage: &Path) -> Result<(), Stri } /// Builds the mods' post-build programs with the game's profile in `target`; each step and its -/// program, in mod order. +/// program, in mod order. A bin's file name doesn't include its package, so two mods' bins of the +/// same name (or one named like the game) are the same file in `target`: each program is copied +/// to `programs//` as soon as it is built. horizon-mixins refuses such names too +/// (`E_POST_BUILD`, horizon-mixins#8); this keeps a bundle built by an older one safe. fn post_build_programs( rust: &Toolchain, stage: &Path, target: &Path, + programs: &Path, progress: &impl Fn(Step), ) -> Result, String> { let steps = crates::post_builds(stage)?; let profile = profile_of(stage); + let _ = fs::remove_dir_all(programs); let mut out = Vec::new(); - for step in steps { + for (i, step) in steps.into_iter().enumerate() { progress(Step::PostBuild(step.mod_id.clone())); let mut cmd = cargo(rust, stage, "build"); cmd.env("CARGO_TARGET_DIR", target); @@ -683,10 +688,9 @@ fn post_build_programs( &format!("Building {}'s post-build step", step.mod_id), |_| {}, )?; - let program = - target - .join(profile) - .join(format!("{}{}", step.bin, std::env::consts::EXE_SUFFIX)); + let file = format!("{}{}", step.bin, std::env::consts::EXE_SUFFIX); + let program = programs.join(i.to_string()).join(&file); + copy_out(&target.join(profile).join(&file), &program)?; out.push((step, program)); } Ok(out) @@ -848,7 +852,16 @@ pub fn build( }, ) .map_err(crates::locked)?; - let post = post_build_programs(&rust, &stage, &target, &progress)?; + // Out of `target` before any post-build bin is built there (`post_build_programs`). + let binary = real.join("programs").join(backend::CLIENT); + copy_out(&target.join(profile).join(backend::CLIENT), &binary)?; + let post = post_build_programs( + &rust, + &stage, + &target, + &real.join("programs").join("post-build"), + &progress, + )?; progress(Step::Install); let about = serde_json::json!({ "base": origin, @@ -859,7 +872,6 @@ pub fn build( "profile": profile, "mods": mods.iter().map(|m| serde_json::json!({"id": m.id, "version": m.version})).collect::>(), }); - let binary = target.join(profile).join(backend::CLIENT); install(base, &version, &binary, &about, |dir| { for (step, program) in &post { progress(Step::PostBuild(step.mod_id.clone())); @@ -868,6 +880,7 @@ pub fn build( } Ok(()) })?; + let _ = fs::remove_dir_all(real.join("programs")); } else { // Used again: it counts as the newest of the builds kept (`prune`). let _ = fs::write(backend::version_dir(&version).join(".installed"), ""); @@ -878,6 +891,16 @@ pub fn build( Ok(version) } +/// Copies the program `from` (in the shared target folder) to `to`, keeping it executable. +fn copy_out(from: &Path, to: &Path) -> Result<(), String> { + if let Some(dir) = to.parent() { + fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + fs::copy(from, to) + .map(|_| ()) + .map_err(|e| format!("{}: {e}", from.display())) +} + /// Installs `binary` as version `version`: a copy of installed version `base`'s folder (its /// resources included; cloned where the disk can) with the built game in place of `base`'s. /// `finish` (the mods' post-build steps) runs in its folder before it counts as installed; when From 0cbb5bae35d2dd8137723cee94917815c543424e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:32:18 +0100 Subject: [PATCH 390/968] Launcher: the sign-in sheet in the night look (R5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner gate's screen becomes a sheet: an opaque plate cut in steps over the dimmed launcher. "Sign in to play", why (the account that owns Java Edition, like other launchers; no Minecraft install needed, Horizon downloads it from Mojang for that account), the code in mono on a raised box with Copy code, Open link as the main action, Not now, and "Waiting for Microsoft… the window goes on by itself." The link reads as typed (microsoft.com/link). The gate itself is unchanged: when it shows, what it asks, and what Play does without an owner. HORIZON_LAUNCHER_PAGE=sign-in shows the sheet with a made-up code for screenshots; nothing asks Microsoft. Co-Authored-By: Claude Opus 5.5 --- docs/design-system.md | 5 + launcher/lang/de.json | 5 +- launcher/lang/es.json | 5 +- launcher/lang/fr.json | 5 +- launcher/lang/pl.json | 5 +- launcher/lang/ru.json | 5 +- launcher/lang/uk.json | 5 +- launcher/lang/zh.json | 5 +- launcher/src/main.rs | 3 +- launcher/src/owner_page.rs | 288 +++++++++++++++++++++++++------------ 10 files changed, 233 insertions(+), 98 deletions(-) diff --git a/docs/design-system.md b/docs/design-system.md index 18b676b982..b92c00ce2c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -257,6 +257,11 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it. played below. Play is the main action (`night::PLAY` tall, cut in one step); the version picker is a raised control of the same height; Mods and Folder are quiet buttons. A Horizon instance gets a plate on the right: a release's notes in one line (not a Nightly's) and Join a friend. +- **The sign-in sheet** (`launcher/src/owner_page.rs`, the owner gate): an opaque plate over the + dimmed launcher, never instead of it. It says why (the account that owns Java Edition, no install + needed), the code in mono on a raised box with Copy, and Open link as the main action; the window + goes on by itself once Microsoft answers (`HORIZON_LAUNCHER_PAGE=sign-in` shows it with a made-up + code for screenshots). ## The mark diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 9d4deaf58a..f10b20542f 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -192,5 +192,8 @@ "Install and turn on native mods": "Installieren und native Mods einschalten", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.", "Folder": "Ordner", - "Version": "Version" + "Version": "Version", + "Sign in to play": "Zum Spielen anmelden", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Keine Minecraft-Installation nötig: Horizon lädt es für dein Konto von Mojang herunter.", + "Waiting for Microsoft… the window goes on by itself.": "Warte auf Microsoft… das Fenster geht von selbst weiter." } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 9bafdb9805..5ea41e8704 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -192,5 +192,8 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.", "Folder": "Carpeta", - "Version": "Versión" + "Version": "Versión", + "Sign in to play": "Inicia sesión para jugar", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "No hace falta tener Minecraft instalado: Horizon lo descarga de Mojang para tu cuenta.", + "Waiting for Microsoft… the window goes on by itself.": "Esperando a Microsoft… la ventana seguirá sola." } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 28353362e8..d4dc4cb789 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -192,5 +192,8 @@ "Move to Trash": "Mettre à la corbeille", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", "Folder": "Dossier", - "Version": "Version" + "Version": "Version", + "Sign in to play": "Connectez-vous pour jouer", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Pas besoin d'avoir Minecraft installé : Horizon le télécharge depuis Mojang pour votre compte.", + "Waiting for Microsoft… the window goes on by itself.": "En attente de Microsoft… la fenêtre continue toute seule." } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 42a2657a74..f7b52ca4ef 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -192,5 +192,8 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.", "Folder": "Folder", - "Version": "Wersja" + "Version": "Wersja", + "Sign in to play": "Zaloguj się, aby grać", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Instalacja Minecrafta nie jest potrzebna: Horizon pobierze go od Mojang dla Twojego konta.", + "Waiting for Microsoft… the window goes on by itself.": "Czekanie na Microsoft… okno przejdzie dalej samo." } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 49fb094700..15aac9185a 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -192,5 +192,8 @@ "Move to Trash": "В корзину", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", "Folder": "Папка", - "Version": "Версия" + "Version": "Версия", + "Sign in to play": "Войдите, чтобы играть", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Устанавливать Minecraft не нужно: Horizon скачает его с серверов Mojang для вашей учётной записи.", + "Waiting for Microsoft… the window goes on by itself.": "Ожидание Microsoft… окно продолжит само." } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index 99feb23662..1c3d43427b 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -192,5 +192,8 @@ "Install and turn on native mods": "Встановити й увімкнути нативні моди", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.", "Folder": "Тека", - "Version": "Версія" + "Version": "Версія", + "Sign in to play": "Увійдіть, щоб грати", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "Встановлювати Minecraft не потрібно: Horizon завантажить його із серверів Mojang для вашого облікового запису.", + "Waiting for Microsoft… the window goes on by itself.": "Очікування Microsoft… вікно продовжить саме." } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index c06cdfef8c..1c990270a2 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -192,5 +192,8 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。", "Folder": "文件夹", - "Version": "版本" + "Version": "版本", + "Sign in to play": "登录以开始游戏", + "No Minecraft install needed: Horizon downloads it from Mojang for your account.": "无需安装 Minecraft:Horizon 会为你的账户从 Mojang 下载。", + "Waiting for Microsoft… the window goes on by itself.": "正在等待 Microsoft… 窗口会自动继续。" } diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 165990c000..da3f63c644 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -83,7 +83,8 @@ enum Page { impl Page { /// `HORIZON_LAUNCHER_PAGE=mods` (or `modpacks`, `shaders`, `servers`, `versions`, `notes`, - /// `settings`) opens on that page, for screenshots; Home otherwise. + /// `settings`) opens on that page, for screenshots; Home otherwise (`sign-in`: the owner + /// gate's sheet with a made-up code, `owner_page::sign_in_preview`). fn from_env() -> Page { match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() { Ok("mods" | "modpacks" | "shaders" | "datapacks") => Page::Mods, diff --git a/launcher/src/owner_page.rs b/launcher/src/owner_page.rs index bc455142a2..fabc50c3f6 100644 --- a/launcher/src/owner_page.rs +++ b/launcher/src/owner_page.rs @@ -8,8 +8,8 @@ impl Launcher { /// Whether the screen covers the window: no owner, and either Play was refused or the player /// hasn't read it yet. The data question comes first. pub(crate) fn asking_owner(&self) -> bool { - self.owner_shown() == Some(false) - && (self.owner_screen_open || !self.settings.owner_notice_seen) + (self.owner_shown() == Some(false) || sign_in_preview()) + && (self.owner_screen_open || !self.settings.owner_notice_seen || sign_in_preview()) && !self.asking_consent() && self.opening.is_none() } @@ -44,108 +44,176 @@ impl Launcher { pub(crate) fn owner_screen(&mut self, cx: &mut Context) -> Stateful
{ let t = self.theme; + let n = t.night; let configured = account::client_id().is_some(); + // The screenshot preview shows a code without asking Microsoft for one. + let state = match &self.sign_in_state { + _ if sign_in_preview() => Shown::Code("https://www.microsoft.com/link", "K7Q-4MXR"), + _ if !configured => Shown::Error(tr(account::NOT_CONFIGURED)), + Some(SignInState::Starting) => Shown::Starting, + Some(SignInState::Code(code)) => Shown::Code(&code.verification_uri, &code.user_code), + Some(SignInState::Done(Err(e))) => Shown::Error(e), + Some(SignInState::Done(Ok(_))) | None => Shown::Idle, + }; + let line = |s: &str, color: u32| { + shell::text(theme::night::UI, shell::c(color)) + .relative() + .child(tr(s).to_string()) + }; let mut body = div() + .relative() .flex() .flex_col() - .gap(px(12.)) - .p(px(28.)) - .child(caps(tr("Microsoft account")).text_color(t.muted)) + .gap(px(10.)) .child( - text(theme::HEADING) - .text_color(t.ink) - .child(format!("{}.", tr(account::NEEDS_OWNER))), + shell::text(theme::night::CAPTION, shell::c(n.muted)) + .font_weight(FontWeight::SEMIBOLD) + .child(tr("Microsoft account").to_string()), ) - .child(text(theme::UI).text_color(t.muted).child(tr( - "Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.", - ))) - .child(text(theme::UI).text_color(t.muted).child(tr( - "Your worlds, offline names and settings stay where they are.", - ))); - let status = match &self.sign_in_state { - _ if !configured => Some(div().child(tr(account::NOT_CONFIGURED))), - Some(SignInState::Starting) => Some(div().child(tr("Asking Microsoft…"))), - Some(SignInState::Code(code)) => Some( - div() - .flex() - .flex_col() - .gap(px(6.)) - .child(trf( - "Open {link} in your browser and enter this code:", - &[("link", &code.verification_uri)], - )) - .child( - text(theme::HEADING) - .font_family(theme::MONO) - .text_color(t.ink) - .child(code.user_code.clone()), - ), - ), - Some(SignInState::Done(Err(e))) => Some(div().text_color(t.danger).child(e.clone())), - Some(SignInState::Done(Ok(_))) | None => None, - }; - body = body.children(status.map(|s| { - plate(&t) - .p(px(16.)) - .text_size(px(theme::UI.px)) - .text_color(t.ink_soft) - .child(s) - })); - let mut answers = div() - .flex() - .justify_end() - .gap(px(12.)) - .px(px(28.)) - .pb(px(28.)) .child( - button("owner-later", "Not now", Kind::Secondary, &t).on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.close_owner_screen(); - cx.notify(); - }, - )), - ); - answers = match &self.sign_in_state { - Some(SignInState::Code(code)) => { - let (uri, user_code) = (code.verification_uri.clone(), code.user_code.clone()); - answers - .child( - button("owner-copy", "Copy code", Kind::Secondary, &t).on_click( - move |_, _, cx| { - copy_to_clipboard( - cx, - gpui::ClipboardItem::new_string(user_code.clone()), + shell::text(theme::night::TITLE, shell::c(n.ink)) + .mb(px(4.)) + .child(tr("Sign in to play").to_string()), + ) + .child(line( + "Horizon needs a Microsoft account that owns Minecraft: Java Edition, like other launchers.", + n.ink_soft, + )) + .child(line( + "No Minecraft install needed: Horizon downloads it from Mojang for your account.", + n.ink_soft, + )) + .child(line( + "Your worlds, offline names and settings stay where they are.", + n.muted, + )); + let raised = || { + div() + .relative() + .mt(px(10.)) + .p(px(18.)) + .rounded(px(theme::night::RADIUS_CONTROL)) + .bg(plate::alpha(n.card_hi, n.card_hi_alpha)) + .border_1() + .border_color(plate::alpha(n.stroke, n.stroke_alpha)) + }; + body = match state { + Shown::Code(uri, user_code) => { + let copy = user_code.to_string(); + body.child( + raised() + .flex() + .items_center() + .justify_between() + .gap(px(16.)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(4.)) + .child( + shell::text(theme::night::CAPTION, shell::c(n.ink_soft)).child( + trf( + "Open {link} in your browser and enter this code:", + // The page as the player would type it. + &[("link", short_link(uri))], + ), + ), ) - }, + .child( + div() + .font_family(theme::MONO) + .font_weight(FontWeight::BOLD) + .text_size(px(30.)) + .line_height(px(38.)) + .text_color(shell::c(n.ink)) + .child(user_code.to_string()), + ), + ) + .child( + shell::action("owner-copy", shell::Action::Raised, &t) + .child(shell::label(tr("Copy code").to_string())) + .on_click(move |_, _, cx| { + copy_to_clipboard( + cx, + gpui::ClipboardItem::new_string(copy.clone()), + ) + }), ), - ) - .child( - button("owner-open", "Open link", Kind::Primary, &t) - .on_click(move |_, _, cx| cx.open_url(&uri)), - ) + ) } - Some(SignInState::Starting) => answers, - _ if !configured => answers, - _ => answers.child( - button("owner-sign-in", "Sign in", Kind::Primary, &t).on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { + Shown::Starting => body.child( + raised().child( + shell::text(theme::night::UI, shell::c(n.ink_soft)) + .child(tr("Asking Microsoft…").to_string()), + ), + ), + Shown::Error(e) => body.child( + raised() + .child(shell::text(theme::night::UI, shell::c(n.danger)).child(e.to_string())), + ), + Shown::Idle => body, + }; + let main = match state { + Shown::Code(uri, _) => { + let uri = uri.to_string(); + Some( + shell::action("owner-open", shell::Action::Main, &t) + .child(shell::label(tr("Open link").to_string())) + .on_click(move |_, _, cx| cx.open_url(&uri)), + ) + } + Shown::Starting => None, + Shown::Error(_) if !configured => None, + _ => Some( + shell::action("owner-sign-in", shell::Action::Main, &t) + .child(shell::label(tr("Sign in").to_string())) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.sign_in_to_play(); cx.notify(); - }, - )), + })), ), }; - let card = plate(&t) + let waiting = matches!(state, Shown::Code(..)).then(|| { + shell::text(theme::night::CAPTION, shell::c(n.muted)) + .relative() + .child(tr("Waiting for Microsoft… the window goes on by itself.").to_string()) + }); + let answers = div() + .relative() .flex() - .flex_col() - // A set width: text measured at it wraps where it is drawn. - .w(px(560.)) - .max_w_full() - .bg(t.page) - .border_1() - .border_color(t.rule) - .child(body) - .child(answers); + .items_center() + .gap(px(10.)) + .mt(px(14.)) + .children(main) + .child( + shell::action("owner-later", shell::Action::Quiet, &t) + .child(shell::label(tr("Not now").to_string())) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.close_owner_screen(); + cx.notify(); + })), + ); + // Opaque: it sits over the page's content, not over the bare sky. + let card = plate::cut( + theme::night::PIXEL_CORNER, + 2, + Some(plate::alpha(n.card, 0.98)), + Some((plate::alpha(n.stroke, 2. * n.stroke_alpha), 1.)), + ) + .flex() + .flex_col() + .gap(px(8.)) + // A set width: text measured at it wraps where it is drawn. + .w(px(520.)) + .max_w_full() + .p(px(32.)) + .child(body) + .child(answers) + .children(waiting); + // The sky stays behind, dimmed: the sheet is over the launcher, not instead of it. div() .id("owner-gate") .absolute() @@ -158,9 +226,49 @@ impl Launcher { .justify_center() .px(px(32.)) .py(px(24.)) - .bg(t.page.opacity(0.92)) + .bg(plate::alpha(n.page, 0.8)) .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) .occlude() .child(card) } } + +/// What the sheet shows of the sign-in. +#[derive(Clone, Copy)] +enum Shown<'a> { + Idle, + Starting, + /// The page and the code to enter there. + Code(&'a str, &'a str), + Error(&'a str), +} + +/// `HORIZON_LAUNCHER_PAGE=sign-in` opens the sheet with a made-up code, for screenshots: nothing +/// asks Microsoft, and the gate itself is as it was (no account, no Play). +pub(super) fn sign_in_preview() -> bool { + static PREVIEW: std::sync::OnceLock = std::sync::OnceLock::new(); + *PREVIEW.get_or_init(|| std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() == Ok("sign-in")) +} + +/// `https://www.microsoft.com/link` as `microsoft.com/link`. +fn short_link(uri: &str) -> &str { + let uri = uri + .trim_start_matches("https://") + .trim_start_matches("http://"); + uri.strip_prefix("www.").unwrap_or(uri) +} + +#[cfg(test)] +mod tests { + #[test] + fn links_are_shown_as_typed() { + assert_eq!( + super::short_link("https://www.microsoft.com/link"), + "microsoft.com/link" + ); + assert_eq!( + super::short_link("https://login.live.com/oauth20_remoteconnect.srf"), + "login.live.com/oauth20_remoteconnect.srf" + ); + } +} From 95d06ac8cdd042fa74310e858600364ee36de267 Mon Sep 17 00:00:00 2001 From: ElyTheCat Date: Fri, 30 Oct 2026 16:18:09 +0330 Subject: [PATCH 391/968] perf(light): reuse worker scratch buffers and optimize flood-fill traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reuse states, sky, block, queue, and unpacking buffers via thread-local LightScratch. - Fast-exit wavefront traversal when neighbor light is already >= level - 1. - Hoist from_row occlusion table index and avoid Y-axis column checks in spread loop. - Fast-path open sky ray stepping for empty air-to-air boundaries. - Benchmark: 3267.62 µs/chunk -> 1371.04 µs/chunk (58.0% faster, 2.38x speedup). Co-Authored-By: Antigravity --- crates/horizon-client-render/src/light.rs | 312 ++++++++++++++++------ 1 file changed, 227 insertions(+), 85 deletions(-) diff --git a/crates/horizon-client-render/src/light.rs b/crates/horizon-client-render/src/light.rs index 3b06b4e245..b804ef77e2 100644 --- a/crates/horizon-client-render/src/light.rs +++ b/crates/horizon-client-render/src/light.rs @@ -22,6 +22,7 @@ use crate::{ use horizon_core::geometry::{Direction, ShapeOp, VoxelShape}; use horizon_world::registry::{BlockRegistry, BlockStateId}; use std::{ + cell::RefCell, collections::{HashMap, VecDeque}, path::Path, sync::Arc, @@ -240,7 +241,8 @@ impl LightTables { /// `edge_occluded` for two packed cells. #[inline] fn packed_edge_occluded(&self, top: u16, bottom: u16) -> bool { - bottom & 0xF != 0 || self.packed_blocked(top, bottom, Direction::Down as usize) + bottom & 0xF != 0 + || ((top | bottom) != 0 && self.packed_blocked(top, bottom, Direction::Down as usize)) } pub fn emission(&self, state: u32) -> u8 { self.cells[state as usize].emission @@ -266,12 +268,51 @@ const OFFSETS: [(i32, i32, i32); 6] = [ (1, 0, 0), ]; +struct LightScratch { + states: Vec, + sky: Vec, + block: Vec, + queue: Vec, + sky_queue: Vec, + present: Vec, + lowest: Vec, + ids: Box<[u32; 4096]>, +} + +impl LightScratch { + fn new() -> Self { + Self { + states: Vec::new(), + sky: Vec::new(), + block: Vec::new(), + queue: Vec::new(), + sky_queue: Vec::new(), + present: vec![false; W * W], + lowest: vec![0; W * W], + ids: Box::new([0u32; 4096]), + } + } +} + +thread_local! { + static SCRATCH: RefCell = RefCell::new(LightScratch::new()); +} + /// `region[dz][dx]` holds the chunks around the centre (index 1,1). /// `sky` is false in dimensions without skylight (the Nether). pub fn light_chunk( region: &[[Option<&Chunk>; 3]; 3], tables: &LightTables, sky_light: bool, +) -> Vec { + SCRATCH.with_borrow_mut(|scratch| light_chunk_with_scratch(region, tables, sky_light, scratch)) +} + +fn light_chunk_with_scratch( + region: &[[Option<&Chunk>; 3]; 3], + tables: &LightTables, + sky_light: bool, + scratch: &mut LightScratch, ) -> Vec { let sections = region[1][1].expect("centre chunk").sections.len(); // Highest occupied section in the region bounds the work. @@ -287,142 +328,193 @@ pub fn light_chunk( // One extra section above the highest occupied one: block light rises // up to 14 blocks into it (sky light there is always 15). let h = ((top_section + 1) * 16).min(sections * 16); - let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x; - // Packed cells (`type << 4 | dampening`), not state ids: the flood fill - // then never touches the large `LightTables::cells` table. - let mut states = vec![0u16; W * W * h]; - let mut sky = vec![0u8; W * W * h]; - let mut block = vec![0u8; W * W * h]; - let mut queue = VecDeque::new(); - let mut sky_queue = VecDeque::new(); - let mut ids = Box::new([0u32; 4096]); + let total = W * W * h; + + scratch.states.resize(total, 0); + scratch.states.fill(0); + scratch.sky.resize(total, 0); + scratch.sky.fill(0); + scratch.block.resize(total, 0); + scratch.block.fill(0); + scratch.queue.clear(); + scratch.sky_queue.clear(); + + let states = &mut scratch.states[..total]; + let sky = &mut scratch.sky[..total]; + let block = &mut scratch.block[..total]; + let queue = &mut scratch.queue; + let sky_queue = &mut scratch.sky_queue; + let ids = &mut scratch.ids; + for (dz, row) in region.iter().enumerate() { + let z_base = dz * 16; for (dx, chunk) in row.iter().enumerate() { let Some(chunk) = chunk else { continue }; + let x_base = dx * 16; for (si, section) in chunk.sections.iter().enumerate().take(top_section) { if section.is_uniform(0) { continue; } - section.unpack(&mut ids); - for (i, &id) in ids.iter().enumerate() { - let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8); - let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz); - let info = tables.info[id as usize]; - states[p] = info as u16; - let emission = info >> 16; - if emission > 0 { - block[p] = emission as u8; - queue.push_back(p as u32); + section.unpack(ids); + let y_base = si * 16; + for ly in 0..16 { + let y = y_base + ly; + let y_offset = y * (W * W); + for lz in 0..16 { + let z = z_base + lz; + let p_row = y_offset + z * W + x_base; + let i_row = (ly << 8) | (lz << 4); + for lx in 0..16 { + let id = ids[i_row + lx]; + if id == 0 { + continue; + } + let p = p_row + lx; + let info = tables.info[id as usize]; + states[p] = info as u16; + let emission = info >> 16; + if emission > 0 { + block[p] = emission as u8; + queue.push(p as u32); + } + } } } } } } + // Columns of missing neighbour chunks neither hold nor pass light. - let mut present = vec![false; W * W]; + scratch.present.resize(W * W, false); for z in 0..W { for x in 0..W { - present[z * W + x] = region[z / 16][x / 16].is_some(); + scratch.present[z * W + x] = region[z / 16][x / 16].is_some(); } } - let mut lowest = vec![h as i32; W * W]; + let present = &scratch.present[..W * W]; + + scratch.lowest.resize(W * W, h as i32); + let lowest = &mut scratch.lowest[..W * W]; + for z in 0..W { for x in 0..W { - if !present[z * W + x] || !sky_light { + let col = z * W + x; + if !present[col] || !sky_light { + lowest[col] = h as i32; continue; } // The top layer (above the occupied range) is always open sky. let mut y = h as i32 - 1; - sky[at(x, y as usize, z)] = 15; - while y > 0 - && !tables.packed_edge_occluded( - states[at(x, y as usize, z)], - states[at(x, y as usize - 1, z)], - ) - { + let mut p = (h - 1) * (W * W) + col; + sky[p] = 15; + while y > 0 { + let next_p = p - (W * W); + if tables.packed_edge_occluded(states[p], states[next_p]) { + break; + } y -= 1; - sky[at(x, y as usize, z)] = 15; + p = next_p; + sky[p] = 15; } - lowest[z * W + x] = y; + lowest[col] = y; } } - for z in 0..W { - for x in 0..W { - if !present[z * W + x] || !sky_light { - continue; - } - let own = lowest[z * W + x]; - let mut deepest = own; - for (nx, nz) in [ - (x.wrapping_sub(1), z), - (x + 1, z), - (x, z.wrapping_sub(1)), - (x, z + 1), - ] { - if nx < W && nz < W && present[nz * W + nx] { - deepest = deepest.max(lowest[nz * W + nx]); + + if sky_light { + for z in 0..W { + for x in 0..W { + let col = z * W + x; + if !present[col] { + continue; } - } - // Seed propagation from the source cells that border darker cells. - let from = own.max(0) as usize; - let to = (deepest as usize).min(h - 1); - for y in from..=to { - if sky[at(x, y, z)] == 15 { - sky_queue.push_back(at(x, y, z) as u32); + let own = lowest[col]; + let mut deepest = own; + for (nx, nz) in [ + (x.wrapping_sub(1), z), + (x + 1, z), + (x, z.wrapping_sub(1)), + (x, z + 1), + ] { + if nx < W && nz < W && present[nz * W + nx] { + deepest = deepest.max(lowest[nz * W + nx]); + } + } + // Seed propagation from the source cells that border darker cells. + let from = own.max(0) as usize; + let to = (deepest as usize).min(h.saturating_sub(1)); + if from <= to { + let mut p = from * (W * W) + col; + for _ in from..=to { + if sky[p] == 15 { + sky_queue.push(p as u32); + } + p += W * W; + } } } } } - let spread = |light: &mut [u8], queue: &mut VecDeque| { - while let Some(p) = queue.pop_front() { - let p = p as usize; + + let block_masks = &tables.block_masks; + let types = tables.types; + + let spread = |light: &mut [u8], queue: &mut Vec| { + let mut head = 0; + while head < queue.len() { + let p = queue[head] as usize; + head += 1; let level = light[p]; if level <= 1 { continue; } - let x = p % W; - let z = (p / W) % W; + let min_threshold = level - 1; let y = p / (W * W); + let col = p % (W * W); + let z = col / W; + let x = col % W; let from = states[p]; - let col = z * W + x; + let from_row = (from >> 4) as usize * types; + // Neighbours by linear offset; `present` only depends on the // column, and the axis bounds rule out wrapping. - macro_rules! neighbour { - ($d:expr, $q:expr, $ncol:expr) => {{ - if present[$ncol] { - let q = $q; + macro_rules! step_neighbour { + ($d:expr, $q:expr) => {{ + let q = $q; + if light[q] < min_threshold { let to = states[q]; - let next = level.saturating_sub((to & 0xF).max(1) as u8); - if next > light[q] && !tables.packed_blocked(from, to, $d) { - light[q] = next; - queue.push_back(q as u32); + let damp = (to & 0xF).max(1) as u8; + if level > light[q] + damp { + if block_masks[from_row + (to >> 4) as usize] & (1 << $d) == 0 { + light[q] = level - damp; + queue.push(q as u32); + } } } }}; } if y > 0 { - neighbour!(0, p - W * W, col); + step_neighbour!(0, p - W * W); } if y + 1 < h { - neighbour!(1, p + W * W, col); + step_neighbour!(1, p + W * W); } - if z > 0 { - neighbour!(2, p - W, col - W); + if z > 0 && present[col - W] { + step_neighbour!(2, p - W); } - if z + 1 < W { - neighbour!(3, p + W, col + W); + if z + 1 < W && present[col + W] { + step_neighbour!(3, p + W); } - if x > 0 { - neighbour!(4, p - 1, col - 1); + if x > 0 && present[col - 1] { + step_neighbour!(4, p - 1); } - if x + 1 < W { - neighbour!(5, p + 1, col + 1); + if x + 1 < W && present[col + 1] { + step_neighbour!(5, p + 1); } } }; - spread(&mut sky, &mut sky_queue); - spread(&mut block, &mut queue); - pack_lighting(sections, h, &sky, &block, sky_light) + spread(sky, sky_queue); + spread(block, queue); + pack_lighting(sections, h, sky, block, sky_light) } /// Extract just the centre column's light (`sections` of it); never copy @@ -434,7 +526,6 @@ fn pack_lighting( block: &[u8], sky_light: bool, ) -> Vec { - let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x; let open = if sky_light { 0xF0 } else { 0x00 }; (0..sections) .map(|si| { @@ -447,10 +538,13 @@ fn pack_lighting( if y >= h { continue; } + let y_offset = y * (W * W); for lz in 0..16 { + let p_row = y_offset + (16 + lz) * W + 16; + let out_row = (ly << 8) | (lz << 4); for lx in 0..16 { - let p = at(16 + lx, y, 16 + lz); - data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p]; + let p = p_row + lx; + data[out_row + lx] = sky[p] << 4 | block[p]; } } } @@ -1719,4 +1813,52 @@ mod tests { changed.sections().count() ); } + + #[test] + #[ignore = "benchmark"] + fn benchmark_light_chunk() { + use std::time::Instant; + let t = tables(); + let layout = ColumnLayout::VANILLA; + let mut chunks = Vec::new(); + for cz in -1..=1 { + for cx in -1..=1 { + let mut c = Chunk::with_layout(cx, cz, layout); + for y in layout.min_y()..64 { + for i in 0..256 { + c.set(i & 15, y, i >> 4, STONE); + } + } + for x in 2..14 { + for z in 2..14 { + c.set(x, 64, z, SLAB); + } + } + c.set(8, 65, 8, TORCH); + c.set(4, 30, 4, TORCH); + chunks.push(c); + } + } + let region = [ + [Some(&chunks[0]), Some(&chunks[1]), Some(&chunks[2])], + [Some(&chunks[3]), Some(&chunks[4]), Some(&chunks[5])], + [Some(&chunks[6]), Some(&chunks[7]), Some(&chunks[8])], + ]; + + for _ in 0..5 { + let _ = light_chunk(®ion, &t, true); + } + + let start = Instant::now(); + let iters = 200; + for _ in 0..iters { + let _ = std::hint::black_box(light_chunk(std::hint::black_box(®ion), &t, true)); + } + let elapsed = start.elapsed(); + println!( + "benchmark_light_chunk: {:?} total, {:.2} µs/chunk", + elapsed, + elapsed.as_micros() as f64 / iters as f64 + ); + } } From c274dad88d75af7afc9ed0384b55e4c489ff861f Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 13:59:36 +0100 Subject: [PATCH 392/968] Read pack.mcmeta with a UTF-8 byte order mark (#4388) --- crates/horizon-client-render/src/packs.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client-render/src/packs.rs b/crates/horizon-client-render/src/packs.rs index d7910a824c..5f5b1983cc 100644 --- a/crates/horizon-client-render/src/packs.rs +++ b/crates/horizon-client-render/src/packs.rs @@ -278,7 +278,8 @@ fn read_meta(source: &Source) -> Option { Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?, Source::Zip(z) => Zip::open(z).ok()?.read("pack.mcmeta").ok()?, }; - serde_json::from_slice(&bytes).ok() + // Gson reads past a UTF-8 byte order mark; serde_json rejects it. + serde_json::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).ok() } fn pack(id: String, title: Text, source: Source) -> Option { pack_for(id, title, source, RESOURCE_FORMAT) @@ -938,6 +939,16 @@ impl SelectionModel { mod tests { use super::*; #[test] + fn mcmeta_with_utf8_bom_is_read() { + let dir = std::env::temp_dir().join(format!("horizon-bom-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let mut meta = b"\xEF\xBB\xBF".to_vec(); + meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#); + fs::write(dir.join("pack.mcmeta"), meta).unwrap(); + assert!(read_meta(&Source::Dir(dir.clone())).is_some()); + fs::remove_dir_all(&dir).ok(); + } + #[test] fn compatibility_ranges() { let v = |s: &str| serde_json::from_str::(s).unwrap(); assert!(compatible(&v( From e2479b227244499778df260fdb1369af5ae7d525 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:03:46 +0100 Subject: [PATCH 393/968] Launcher: New instance with Horizon, Vanilla and Modded kinds (R6) The New instance dialog gets the night look (an opaque plate cut in steps, night buttons) and tabs for the kind. Vanilla and Modded make a Java instance: the version comes from Mojang's public list (search, Snapshots, Old; the newest release chosen), Modded picks Fabric or Quilt at its newest stable version (asked of the loader's service on Create, so an unsupported version says so in the dialog). The name follows the choice ("Fabric 1.21.4") until the player types one. Horizon instances are made as before. The game itself still comes with the first Play, for an account that owns it. HORIZON_LAUNCHER_PAGE=new-instance opens it for screenshots. Co-Authored-By: Claude Opus 5.5 --- docs/design-system.md | 5 + launcher/lang/de.json | 19 +- launcher/lang/es.json | 19 +- launcher/lang/fr.json | 19 +- launcher/lang/pl.json | 19 +- launcher/lang/ru.json | 19 +- launcher/lang/uk.json | 19 +- launcher/lang/zh.json | 19 +- launcher/src/instances_page.rs | 128 +++++++++--- launcher/src/main.rs | 9 +- launcher/src/new_instance.rs | 353 +++++++++++++++++++++++++++++++++ launcher/src/servers_page.rs | 6 +- 12 files changed, 592 insertions(+), 42 deletions(-) create mode 100644 launcher/src/new_instance.rs diff --git a/docs/design-system.md b/docs/design-system.md index 18b676b982..0545309b6c 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -257,6 +257,11 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it. played below. Play is the main action (`night::PLAY` tall, cut in one step); the version picker is a raised control of the same height; Mods and Folder are quiet buttons. A Horizon instance gets a plate on the right: a release's notes in one line (not a Nightly's) and Join a friend. +- **New instance** (`launcher/src/new_instance.rs`): an opaque plate over the dimmed launcher, + tabs for the kind (Horizon, Vanilla, Modded). A Java kind lists Mojang's versions (search, + Snapshots, Old; the newest release chosen), Modded adds the loader (Fabric or Quilt, at its newest + stable version), and the name follows the choice ("Fabric 1.21.4") until the player types one. + Create is the main action. ## The mark diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 9d4deaf58a..c0e4c5ea8f 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -192,5 +192,22 @@ "Install and turn on native mods": "Installieren und native Mods einschalten", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.", "Folder": "Ordner", - "Version": "Version" + "Version": "Version", + "Import a modpack…": "Modpack importieren…", + "Vanilla": "Vanilla", + "Modded": "Mit Mods", + "Minecraft version": "Minecraft-Version", + "Search versions": "Versionen suchen", + "Snapshots": "Snapshots", + "Old": "Alt", + "Asking Mojang for its versions…": "Mojang wird nach den Versionen gefragt…", + "No version matches.": "Keine Version passt.", + "Release": "Release", + "Snapshot": "Snapshot", + "Beta": "Beta", + "Alpha": "Alpha", + "Loader": "Loader", + "newest stable version": "neueste stabile Version", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "Spielen lädt das Spiel beim ersten Mal für dein Konto von Mojang herunter, mit dem Java, das es braucht.", + "Mojang's version list isn't here yet.": "Mojangs Versionsliste ist noch nicht da." } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 9bafdb9805..7f01719d96 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -192,5 +192,22 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.", "Folder": "Carpeta", - "Version": "Versión" + "Version": "Versión", + "Import a modpack…": "Importar un modpack…", + "Vanilla": "Vanilla", + "Modded": "Con mods", + "Minecraft version": "Versión de Minecraft", + "Search versions": "Buscar versiones", + "Snapshots": "Snapshots", + "Old": "Antiguas", + "Asking Mojang for its versions…": "Pidiendo a Mojang sus versiones…", + "No version matches.": "Ninguna versión coincide.", + "Release": "Versión", + "Snapshot": "Snapshot", + "Beta": "Beta", + "Alpha": "Alfa", + "Loader": "Cargador", + "newest stable version": "última versión estable", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "Jugar descarga el juego de Mojang para tu cuenta la primera vez, con el Java que necesita.", + "Mojang's version list isn't here yet.": "La lista de versiones de Mojang aún no ha llegado." } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 28353362e8..1a379f3fbc 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -192,5 +192,22 @@ "Move to Trash": "Mettre à la corbeille", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", "Folder": "Dossier", - "Version": "Version" + "Version": "Version", + "Import a modpack…": "Importer un modpack…", + "Vanilla": "Vanilla", + "Modded": "Moddé", + "Minecraft version": "Version de Minecraft", + "Search versions": "Rechercher une version", + "Snapshots": "Snapshots", + "Old": "Anciennes", + "Asking Mojang for its versions…": "Demande des versions à Mojang…", + "No version matches.": "Aucune version ne correspond.", + "Release": "Version", + "Snapshot": "Snapshot", + "Beta": "Bêta", + "Alpha": "Alpha", + "Loader": "Chargeur", + "newest stable version": "dernière version stable", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "Jouer télécharge le jeu depuis Mojang pour votre compte la première fois, avec le Java qu'il lui faut.", + "Mojang's version list isn't here yet.": "La liste des versions de Mojang n'est pas encore arrivée." } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 42a2657a74..a5c60f44f9 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -192,5 +192,22 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.", "Folder": "Folder", - "Version": "Wersja" + "Version": "Wersja", + "Import a modpack…": "Importuj modpack…", + "Vanilla": "Vanilla", + "Modded": "Z modami", + "Minecraft version": "Wersja Minecrafta", + "Search versions": "Szukaj wersji", + "Snapshots": "Snapshoty", + "Old": "Stare", + "Asking Mojang for its versions…": "Pobieranie listy wersji od Mojang…", + "No version matches.": "Żadna wersja nie pasuje.", + "Release": "Wydanie", + "Snapshot": "Snapshot", + "Beta": "Beta", + "Alpha": "Alfa", + "Loader": "Loader", + "newest stable version": "najnowsza stabilna wersja", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "Graj pobiera grę od Mojang dla Twojego konta za pierwszym razem, razem z potrzebną Javą.", + "Mojang's version list isn't here yet.": "Lista wersji od Mojang jeszcze nie dotarła." } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 49fb094700..7474398562 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -192,5 +192,22 @@ "Move to Trash": "В корзину", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", "Folder": "Папка", - "Version": "Версия" + "Version": "Версия", + "Import a modpack…": "Импортировать модпак…", + "Vanilla": "Ванильная", + "Modded": "С модами", + "Minecraft version": "Версия Minecraft", + "Search versions": "Поиск версий", + "Snapshots": "Снапшоты", + "Old": "Старые", + "Asking Mojang for its versions…": "Запрос версий у Mojang…", + "No version matches.": "Нет подходящих версий.", + "Release": "Релиз", + "Snapshot": "Снапшот", + "Beta": "Бета", + "Alpha": "Альфа", + "Loader": "Загрузчик", + "newest stable version": "последняя стабильная версия", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "При первом запуске игра скачивается с серверов Mojang для вашей учётной записи вместе с нужной ей Java.", + "Mojang's version list isn't here yet.": "Список версий Mojang ещё не получен." } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index 99feb23662..5c11af631a 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -192,5 +192,22 @@ "Install and turn on native mods": "Встановити й увімкнути нативні моди", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.", "Folder": "Тека", - "Version": "Версія" + "Version": "Версія", + "Import a modpack…": "Імпортувати модпак…", + "Vanilla": "Ванільна", + "Modded": "З модами", + "Minecraft version": "Версія Minecraft", + "Search versions": "Пошук версій", + "Snapshots": "Снапшоти", + "Old": "Старі", + "Asking Mojang for its versions…": "Запит версій у Mojang…", + "No version matches.": "Немає відповідних версій.", + "Release": "Реліз", + "Snapshot": "Снапшот", + "Beta": "Бета", + "Alpha": "Альфа", + "Loader": "Завантажувач", + "newest stable version": "остання стабільна версія", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "Під час першого запуску гра завантажується із серверів Mojang для вашого облікового запису разом із потрібною їй Java.", + "Mojang's version list isn't here yet.": "Список версій Mojang ще не отримано." } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index c06cdfef8c..f2145da4f2 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -192,5 +192,22 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。", "Folder": "文件夹", - "Version": "版本" + "Version": "版本", + "Import a modpack…": "导入整合包…", + "Vanilla": "原版", + "Modded": "模组版", + "Minecraft version": "Minecraft 版本", + "Search versions": "搜索版本", + "Snapshots": "快照", + "Old": "旧版", + "Asking Mojang for its versions…": "正在向 Mojang 获取版本列表…", + "No version matches.": "没有匹配的版本。", + "Release": "正式版", + "Snapshot": "快照", + "Beta": "Beta", + "Alpha": "Alpha", + "Loader": "加载器", + "newest stable version": "最新稳定版", + "Play downloads the game from Mojang for your account the first time, with the Java it needs.": "首次游玩时会为你的账户从 Mojang 下载游戏及其所需的 Java。", + "Mojang's version list isn't here yet.": "尚未获取到 Mojang 的版本列表。" } diff --git a/launcher/src/instances_page.rs b/launcher/src/instances_page.rs index d76592b854..479b32a87c 100644 --- a/launcher/src/instances_page.rs +++ b/launcher/src/instances_page.rs @@ -51,6 +51,8 @@ pub(super) struct InstanceDialog { icon: Option, error: Option, busy: bool, + /// Horizon, or the Java game it plays. + pub(super) java: new_instance::JavaChoice, } /// The instances and their icons as images, kept between frames. @@ -62,6 +64,8 @@ pub(super) struct Instances { confirm_delete: Option, delete_error: Option, icons: HashMap<(String, u32), Option>>, + /// Mojang's versions, for a new Java instance. + pub(super) versions: new_instance::VersionList, } impl Launcher { @@ -257,7 +261,9 @@ impl Launcher { copy: false, error: None, busy: false, + java: Default::default(), }); + self.servers.text(Field::InstanceVersion).set(String::new()); } /// Escape closes the dialog. Whether it did. @@ -281,12 +287,40 @@ impl Launcher { let icon = d.icon.clone(); let editing = d.editing.clone(); let copy = selected.filter(|_| d.copy); + // A new Java instance: what it plays, and its name when none is typed. + let java = (editing.is_none() && d.java.kind != new_instance::NewKind::Horizon) + .then(|| d.java.clone()); + let minecraft = java.as_ref().map(|j| { + j.minecraft(match &self.instances.versions { + new_instance::VersionList::Ready(v) => Some(v), + _ => None, + }) + }); + let Some(d) = self.instances.dialog.as_mut() else { + return; + }; + let (java, name) = match (java, minecraft) { + (Some(_), Some(None)) => { + d.error = Some(tr("Mojang's version list isn't here yet.").into()); + return; + } + (Some(j), Some(Some(mc))) => { + let name = if name.is_empty() { + j.default_name(&mc) + } else { + name + }; + (Some((j, mc)), name) + } + _ => (None, name), + }; d.busy = true; d.error = None; let task = cx.background_executor().spawn(async move { - let id = match editing { - Some(id) => id, - None => instances::create(&name, copy.as_deref())?.id, + let id = match (editing, java) { + (Some(id), _) => id, + (None, Some((j, mc))) => new_instance::create_java(&j, &name, &mc)?.id, + (None, None) => instances::create(&name, copy.as_deref())?.id, }; instances::edit(&id, |m| { m.name = Some(name); @@ -322,13 +356,16 @@ impl Launcher { pub(super) fn instance_dialog(&mut self, cx: &mut Context) -> Option> { let t = self.theme; let d = self.instances.dialog.as_ref()?; - let (editing, copy, icon, error, busy) = ( + let (editing, copy, icon, error, busy, kind) = ( d.editing.is_some(), d.copy, d.icon.clone(), d.error.clone(), d.busy, + d.java.kind, ); + let java = !editing && kind != new_instance::NewKind::Horizon; + let n = t.night; let from = self .selected_instance() .map_or_else(|| "Horizon".into(), |i| i.name.clone()); @@ -337,29 +374,41 @@ impl Launcher { .flex() .flex_col() .gap(px(8.)) - .child(caps(label).text_color(t.muted)) + .child( + shell::text(theme::night::CAPTION, shell::c(n.muted)) + .font_weight(FontWeight::SEMIBOLD) + .child(tr(label).to_string()), + ) .child(control) }; let mut body = div() + .relative() .flex() .flex_col() .gap(px(20.)) - .p(px(24.)) + .p(px(28.)) .child( - blocks::pixel(22., FontWeight::MEDIUM) - .text_color(t.ink) - .child(if editing { + shell::text(theme::night::TITLE, shell::c(n.ink)).child( + tr(if editing { "Edit instance" } else { "New instance" - }), - ) - .child(row( - "Name", - self.field(Field::InstanceName, "My instance", 400., cx) - .into_any_element(), - )); + }) + .to_string(), + ), + ); if !editing { + body = body.child(self.kind_tabs(cx)); + } + if java { + body = body.child(self.java_choice_rows(cx)); + } + body = body.child(row( + "Name", + self.field(Field::InstanceName, "My instance", 400., cx) + .into_any_element(), + )); + if !editing && !java { let weak = cx.entity().downgrade(); body = body.child(row( "Start from", @@ -456,16 +505,21 @@ impl Launcher { if let Some(e) = error { body = body.child(text(theme::CAPTION).text_color(t.danger).child(e)); } - let cancel = button("instance-cancel", "Cancel", Kind::Secondary, &t); + let cancel = shell::action("instance-cancel", shell::Action::Quiet, &t) + .child(shell::label(tr("Cancel").to_string())); let label = match (editing, busy, copy) { - (_, true, true) => "Copying…", + (_, true, true) if !java => "Copying…", (true, false, _) => "Save", _ => "Create", }; - let save = button("instance-save", label, Kind::Primary, &t); + let save = shell::action("instance-save", shell::Action::Main, &t) + .h(px(theme::CONTROL + 6.)) + .text_size(px(theme::night::UI.px)) + .child(shell::label(tr(label).to_string())); // A modpack becomes an instance by being imported, on the Mods page. let import = (!editing && !busy).then(|| { - button("instance-import", "Import a modpack…", Kind::Ghost, &t) + shell::action("instance-import", shell::Action::Quiet, &t) + .child(shell::label(tr("Import a modpack…").to_string())) .mr_auto() .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.instances.dialog = None; @@ -474,13 +528,15 @@ impl Launcher { })) }); let foot = div() + .relative() .flex() .justify_end() .gap(px(8.)) - .px(px(24.)) + .px(px(28.)) .py(px(16.)) .border_t_1() - .border_color(t.rule) + .border_color(plate::alpha(n.stroke, n.rule_alpha)) + .items_center() .children(import); let foot = if busy { foot.child(disabled(cancel)).child(disabled(save)) @@ -494,16 +550,20 @@ impl Launcher { cx.notify(); }))) }; - let card = plate(&t) - .flex() - .flex_col() - .w_full() - .max_w(px(560.)) - .bg(t.page) - .border_1() - .border_color(t.rule) - .child(body) - .child(foot); + // Opaque, as the sign-in sheet: it sits over the page's content. + let card = plate::cut( + theme::night::PIXEL_CORNER, + 2, + Some(plate::alpha(n.card, 0.98)), + Some((plate::alpha(n.stroke, 2. * n.stroke_alpha), 1.)), + ) + .flex() + .flex_col() + .flex_none() + .w_full() + .max_w(px(600.)) + .child(body) + .child(foot); Some( div() .id("instance-dialog") @@ -516,7 +576,9 @@ impl Launcher { .items_center() .px(px(32.)) .py(px(24.)) - .bg(t.page.opacity(0.92)) + // A Java kind's list can make it taller than the window. + .overflow_y_scroll() + .bg(plate::alpha(n.page, 0.8)) .on_scroll_wheel(|_, _, cx| cx.stop_propagation()) .occlude() .child(card), diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 165990c000..511aeb004d 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -29,6 +29,7 @@ mod modpacks_page; mod mods_page; mod mods_project; mod multiplayer_page; +mod new_instance; mod owner_page; mod packs_page; mod partner; @@ -83,7 +84,8 @@ enum Page { impl Page { /// `HORIZON_LAUNCHER_PAGE=mods` (or `modpacks`, `shaders`, `servers`, `versions`, `notes`, - /// `settings`) opens on that page, for screenshots; Home otherwise. + /// `settings`) opens on that page, for screenshots; Home otherwise (`new-instance`: Home with + /// the New instance dialog open on Modded). fn from_env() -> Page { match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() { Ok("mods" | "modpacks" | "shaders" | "datapacks") => Page::Mods, @@ -520,6 +522,11 @@ impl Launcher { }); this.fetch_startup_releases(); this.fetch_links(); + // `HORIZON_LAUNCHER_PAGE=new-instance`: the dialog open on Modded, for screenshots. + if std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() == Ok("new-instance") { + this.open_instance_dialog(None); + this.pick_kind(new_instance::NewKind::Modded, cx); + } // What the game and the launcher kept, if the player allows it and there is a server. telemetry::send_in_background(); telemetry::watch_playing(this.tx.clone()); diff --git a/launcher/src/new_instance.rs b/launcher/src/new_instance.rs new file mode 100644 index 0000000000..85778f228c --- /dev/null +++ b/launcher/src/new_instance.rs @@ -0,0 +1,353 @@ +//! New instance's kinds (docs/design-system.md, "Night"; plan R0, screen 4): Horizon, Vanilla +//! (Minecraft: Java Edition as Mojang ships it) and Modded (with Fabric or Quilt). A Java kind picks +//! its version from Mojang's public list (`minecraft_versions`), fetched once when the dialog first +//! needs it; the game itself comes with the first Play, for an account that owns it. +use super::*; +use crate::plate::alpha; +use crate::shell::{c, Action}; +use horizon_launcher::loaders::{self, Kind as Loader}; +use horizon_launcher::minecraft_versions::{Kind as VersionKind, Versions}; +use servers_page::Field; +use theme::night; + +/// Rows the version list shows at most: the search finds the others. +const ROWS: usize = 60; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum NewKind { + Horizon, + Vanilla, + Modded, +} + +/// What a new Java instance plays. +#[derive(Clone, Debug)] +pub(super) struct JavaChoice { + pub(super) kind: NewKind, + pub(super) loader: Loader, + /// None: the newest release. + pub(super) version: Option, + pub(super) snapshots: bool, + pub(super) old: bool, +} + +impl Default for JavaChoice { + fn default() -> Self { + JavaChoice { + kind: NewKind::Horizon, + loader: Loader::Fabric, + version: None, + snapshots: false, + old: false, + } + } +} + +impl JavaChoice { + /// The Minecraft version it makes: the chosen one, else the newest release. + pub(super) fn minecraft(&self, versions: Option<&Versions>) -> Option { + self.version + .clone() + .or_else(|| versions.map(|v| v.latest_release.clone())) + } + + /// The name it gets when none is typed: "Vanilla 1.21.4", "Fabric 1.21.4". + pub(super) fn default_name(&self, minecraft: &str) -> String { + match self.kind { + NewKind::Modded => format!("{} {minecraft}", self.loader.name()), + _ => format!("Vanilla {minecraft}"), + } + } +} + +/// Mojang's list as the dialog has it. +#[derive(Default)] +pub(super) enum VersionList { + #[default] + NotAsked, + Loading, + Ready(Versions), + Failed(String), +} + +impl Launcher { + fn java_choice(&mut self) -> Option<&mut JavaChoice> { + self.instances.dialog.as_mut().map(|d| &mut d.java) + } + + /// The name a new Java instance gets for what it plays now. + fn default_name(&self) -> Option { + let j = &self.instances.dialog.as_ref()?.java; + let versions = match &self.instances.versions { + VersionList::Ready(v) => Some(v), + _ => None, + }; + (j.kind != NewKind::Horizon) + .then(|| j.minecraft(versions)) + .flatten() + .map(|mc| j.default_name(&mc)) + } + + /// Changes what the new instance plays. Its name follows while it is the one the dialog gave + /// (or none), never one the player typed. + fn change(&mut self, f: impl FnOnce(&mut JavaChoice)) { + let before = self.default_name(); + if let Some(j) = self.java_choice() { + f(j); + } + let typed = self.servers.value(Field::InstanceName).trim().to_string(); + if typed.is_empty() || Some(&typed) == before.as_ref() { + let after = self.default_name().unwrap_or_default(); + self.servers.text(Field::InstanceName).set(after); + } + } + + /// Picks the dialog's kind; a Java one fetches Mojang's list the first time. + pub(super) fn pick_kind(&mut self, kind: NewKind, cx: &mut Context) { + self.change(|j| j.kind = kind); + if kind == NewKind::Horizon || !matches!(self.instances.versions, VersionList::NotAsked) { + return; + } + self.instances.versions = VersionList::Loading; + let task = cx + .background_executor() + .spawn(async { horizon_launcher::minecraft_versions::load() }); + cx.spawn(async move |this, cx| { + let list = task.await; + let _ = this.update(cx, |this, cx| { + let before = this.servers.value(Field::InstanceName).trim().is_empty(); + this.instances.versions = match list { + Ok(v) => VersionList::Ready(v), + Err(e) => VersionList::Failed(e), + }; + // The newest release is known now: so is the name. + if before { + this.change(|_| {}); + } + cx.notify(); + }); + }) + .detach(); + } + + /// The tabs over a new instance's dialog: Horizon, Vanilla, Modded. + pub(super) fn kind_tabs(&self, cx: &mut Context) -> Div { + let t = self.theme; + let now = self.instances.dialog.as_ref().map(|d| d.java.kind); + div().flex().gap(px(4.)).children( + [ + (NewKind::Horizon, "Horizon"), + (NewKind::Vanilla, "Vanilla"), + (NewKind::Modded, "Modded"), + ] + .map(|(kind, label)| { + shell::tab( + format!("new-kind-{label}"), + tr(label), + now == Some(kind), + &t, + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.pick_kind(kind, cx); + cx.notify(); + })) + }), + ) + } + + /// A Java kind's part of the dialog: the version (search, kinds, list), the loader for + /// Modded, and where the game comes from. + pub(super) fn java_choice_rows(&mut self, cx: &mut Context) -> Div { + let t = self.theme; + let n = t.night; + let Some(choice) = self.instances.dialog.as_ref().map(|d| d.java.clone()) else { + return div(); + }; + let heading = |s: &str| { + shell::text(night::CAPTION, c(n.muted)) + .font_weight(FontWeight::SEMIBOLD) + .child(tr(s).to_string()) + }; + let toggle = |id: &'static str, label: &'static str, on: bool| { + shell::action(id, if on { Action::Raised } else { Action::Quiet }, &t) + .h(px(26.)) + .px(px(10.)) + .child(shell::label(tr(label).to_string())) + }; + let search = self + .servers + .value(Field::InstanceVersion) + .trim() + .to_lowercase(); + let chosen = choice.minecraft(match &self.instances.versions { + VersionList::Ready(v) => Some(v), + _ => None, + }); + let list: AnyElement = match &self.instances.versions { + VersionList::NotAsked | VersionList::Loading => shell::text(night::UI, c(n.muted)) + .child(tr("Asking Mojang for its versions…").to_string()) + .into_any_element(), + VersionList::Failed(e) => shell::text(night::UI, c(n.danger)) + .child(e.clone()) + .into_any_element(), + VersionList::Ready(versions) => { + let rows: Vec<_> = versions + .of(choice.snapshots, choice.old) + .filter(|v| search.is_empty() || v.id.to_lowercase().contains(&search)) + .take(ROWS) + .map(|v| { + let selected = chosen.as_deref() == Some(v.id.as_str()); + let id = v.id.clone(); + let kind = match v.kind { + VersionKind::Release => "Release", + VersionKind::Snapshot => "Snapshot", + VersionKind::OldBeta => "Beta", + VersionKind::OldAlpha => "Alpha", + }; + shell::row(format!("mc-{}", v.id), selected, &t) + .flex_none() + .h(px(32.)) + .px(px(12.)) + .child( + shell::text(night::UI, c(n.ink)) + .relative() + .w(px(120.)) + .font_weight(FontWeight::SEMIBOLD) + .child(v.id.clone()), + ) + .child(shell::text(night::CAPTION, c(n.muted)).relative().child( + format!("{} · {}", tr(kind), v.released.get(..10).unwrap_or("")), + )) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.change(|j| j.version = Some(id.clone())); + cx.notify(); + })) + }) + .collect(); + if rows.is_empty() { + shell::text(night::UI, c(n.muted)) + .child(tr("No version matches.").to_string()) + .into_any_element() + } else { + div() + .id("mc-versions") + .flex() + .flex_col() + .h(px(6. * 32. + 8.)) + .p(px(4.)) + .overflow_y_scroll() + .rounded(px(night::RADIUS_CONTROL)) + .border_1() + .border_color(alpha(n.stroke, n.stroke_alpha)) + .children(rows) + .into_any_element() + } + } + }; + let filters = div() + .flex() + .items_center() + .gap(px(8.)) + .child(self.field(Field::InstanceVersion, "Search versions", 240., cx)) + .child( + toggle("mc-snapshots", "Snapshots", choice.snapshots).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.change(|j| j.snapshots = !j.snapshots); + cx.notify(); + }, + )), + ) + .child(toggle("mc-old", "Old", choice.old).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.change(|j| j.old = !j.old); + cx.notify(); + }, + ))); + let mut rows = div() + .flex() + .flex_col() + .gap(px(10.)) + .child(heading("Minecraft version")) + .child(filters) + .child(list); + if choice.kind == NewKind::Modded { + let card = |loader: Loader| { + let on = choice.loader == loader; + shell::action( + SharedString::from(format!("loader-{}", loader.id())), + Action::Raised, + &t, + ) + .h(px(48.)) + .w(px(180.)) + .justify_start() + .when(on, |d| d.border_2().border_color(c(n.accent_hi))) + .child( + div() + .relative() + .flex() + .flex_col() + .child(loader.name()) + .child( + shell::text(night::CAPTION, c(n.muted)) + .child(tr("newest stable version").to_string()), + ), + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.change(|j| j.loader = loader); + cx.notify(); + })) + }; + rows = rows.child(heading("Loader")).child( + div() + .flex() + .gap(px(10.)) + .child(card(Loader::Fabric)) + .child(card(Loader::Quilt)), + ); + } + rows.child(shell::text(night::CAPTION, c(n.muted)).child( + tr("Play downloads the game from Mojang for your account the first time, with the Java it needs.") + .to_string(), + )) + } +} + +/// Makes a Java instance: `name`, playing `minecraft`, with the newest stable loader for Modded +/// (asked of its service now, so a version it doesn't support says so here). +pub(super) fn create_java( + choice: &JavaChoice, + name: &str, + minecraft: &str, +) -> Result { + let loader = (choice.kind == NewKind::Modded) + .then(|| loaders::choose(choice.loader.id(), minecraft, None)) + .transpose()?; + let made = instances::create_java(name, minecraft)?; + match loader { + Some(loader) => instances::edit(&made.id, |m| { + m.java.get_or_insert_with(Default::default).loader = Some(loader) + }), + None => Ok(made), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_new_java_instance_is_named_after_what_it_plays() { + let mut choice = JavaChoice { + kind: NewKind::Vanilla, + ..Default::default() + }; + assert_eq!(choice.default_name("1.21.4"), "Vanilla 1.21.4"); + choice.kind = NewKind::Modded; + choice.loader = Loader::Quilt; + assert_eq!(choice.default_name("1.21.4"), "Quilt 1.21.4"); + assert_eq!(choice.minecraft(None), None); + choice.version = Some("1.20.1".into()); + assert_eq!(choice.minecraft(None).as_deref(), Some("1.20.1")); + } +} diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index c34322a842..d75e1f7a4f 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -36,6 +36,8 @@ pub(super) enum Field { /// The instance dialog's (`instances_page.rs`). InstanceName, InstanceGroup, + /// Its search in Mojang's versions (`new_instance.rs`). + InstanceVersion, /// Home's Multiplayer section's (`multiplayer_page.rs`). ServerName, ServerAddress, @@ -52,7 +54,7 @@ pub(super) enum Field { } impl Field { - const ALL: [Field; 20] = [ + const ALL: [Field; 21] = [ Field::Name, Field::Seed, Field::Players, @@ -64,6 +66,7 @@ impl Field { Field::Email, Field::InstanceName, Field::InstanceGroup, + Field::InstanceVersion, Field::ServerName, Field::ServerAddress, Field::ConsoleSearch, @@ -995,6 +998,7 @@ impl Launcher { Field::Email => self.invite_friend(), Field::InstanceName | Field::InstanceGroup + | Field::InstanceVersion | Field::ConsoleSearch | Field::DeleteServer => {} Field::PreLaunch | Field::Wrapper | Field::LaunchEnv | Field::PostExit => { From fd21d6ee82d6651c931a02dfe8146dd8d5e5777a Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 14:08:19 +0100 Subject: [PATCH 394/968] Cover no-BOM and partial-BOM pack.mcmeta (#4388) --- crates/horizon-client-render/src/packs.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client-render/src/packs.rs b/crates/horizon-client-render/src/packs.rs index 5f5b1983cc..8c70db3e51 100644 --- a/crates/horizon-client-render/src/packs.rs +++ b/crates/horizon-client-render/src/packs.rs @@ -944,8 +944,17 @@ mod tests { fs::create_dir_all(&dir).unwrap(); let mut meta = b"\xEF\xBB\xBF".to_vec(); meta.extend_from_slice(br#"{"pack":{"description":"d","pack_format":97}}"#); - fs::write(dir.join("pack.mcmeta"), meta).unwrap(); + fs::write(dir.join("pack.mcmeta"), &meta).unwrap(); assert!(read_meta(&Source::Dir(dir.clone())).is_some()); + // Without a BOM the same JSON still reads; other leading bytes still fail. + fs::write(dir.join("pack.mcmeta"), &meta[3..]).unwrap(); + assert!(read_meta(&Source::Dir(dir.clone())).is_some()); + fs::write( + dir.join("pack.mcmeta"), + [b"\xEF\xBB".as_slice(), &meta[3..]].concat(), + ) + .unwrap(); + assert!(read_meta(&Source::Dir(dir.clone())).is_none()); fs::remove_dir_all(&dir).ok(); } #[test] From c7cc6e1256f05bb2a388d056f7a48e63b6a3a4d1 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:09:10 +0100 Subject: [PATCH 395/968] Launcher: Home says Ready to play only when Play would start the game Not while the version needs preparing, prepares, waits for an account that owns the game, or runs (review: it showed beside Needs preparing and over the steps at 34%). Co-Authored-By: Claude Opus 5.5 --- launcher/src/main.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 165990c000..74e3d3f596 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -2498,10 +2498,18 @@ impl Launcher { .into_any_element() }, ); + // "Ready to play" only when Play would start the game now: not while it needs preparing, + // prepares, waits for an account that owns it, or runs. + let running = self.client.is_some() || self.starting_client.is_some(); + let ready = play_press( + prepared || java.is_some(), + self.owner, + self.preparing || running, + ) == PlayPress::Play; let mut rows = div() .flex() .flex_col() - .child(section_title("Ready to play", "check", &t).mb(px(12.))) + .children(ready.then(|| section_title("Ready to play", "check", &t).mb(px(12.)))) .children(self.instance_row(1, cx)) .children(self.export_panel(cx)) .children(self.launch_panel(cx)); From 85dbd835d69239045a9a8d2ae19f0874e1f3dc48 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:25:41 +0500 Subject: [PATCH 396/968] Launcher: build native mods made for different Horizon versions, after "Build anyway" Mods pinning different Horizon commits used to be refused. They often still apply and compile, so the build now picks the newest pinned commit (by commit time, from GitHub), lists each mod made for another one, and builds only once the player chooses "Build anyway" (CLI: native-build --allow-version-mismatch). Those mods are repinned on the build's own copy, like hook-point mods. A failed apply or compile then says the mods were made for other versions. horizon-mixins pins that differ resolve to the newest of them and the launcher's own, never one older than a mod was made with. Co-Authored-By: Claude Opus 5.5 --- docs/launcher-commands.md | 1 + docs/native-mods.md | 9 +- launcher/lang/de.json | 5 +- launcher/lang/es.json | 5 +- launcher/lang/fr.json | 5 +- launcher/lang/pl.json | 5 +- launcher/lang/ru.json | 5 +- launcher/lang/uk.json | 5 +- launcher/lang/zh.json | 5 +- launcher/src/cli.rs | 20 +++- launcher/src/main.rs | 64 +++++++++++-- launcher/src/native_mods.rs | 179 ++++++++++++++++++++++++++++++++++-- 12 files changed, 282 insertions(+), 26 deletions(-) diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 50b83f3f8c..d82e13b13e 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -80,6 +80,7 @@ Native mods (experimental, docs/native-mods.md): compile mixin bundles on the Ho - `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance) - `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one; the game's own home builds into the Native mods instance) - `--trust-native-code`: You read the mods' source and trust it with full access to this computer +- `--allow-version-mismatch`: Build mods made for different Horizon versions together anyway, on the newest of them MCP: none: the player's switch. diff --git a/docs/native-mods.md b/docs/native-mods.md index f0ea4167b8..40bc8e76db 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -16,9 +16,12 @@ A native mod builds on one exact Horizon commit: `[compatibility] horizon_revisi and `horizon` in the `horizon-native.json` of its registry file (`.hnmod`, `horizon_mods::index::native`). Mods pin a **Nightly's commit** (or a release's). Every Nightly ships its source and keeps its commit for good as the tag `nightly-source/-` ([release-flow.md](release-flow.md#nightly-channel)), so a pinned mod still -builds after its Nightly is replaced. All the mods of one build must pin the same commit; when they don't, the -launcher says which mod pins which, and the player installs versions made for the same Nightly. A bundle with no -pin builds on the commit of the installed version the build starts from. +builds after its Nightly is replaced. When the mods of one build pin different commits, the launcher says which +mod pins which and the commit the build would use, the newest of them, and builds only after the player chooses +**Build anyway** (`native-build --allow-version-mismatch`): such mods often still apply and compile. A failed build +then says the mods were made for other versions. Their horizon-mixins pins may differ too: the build uses the +newest of them and the launcher's own, never one older than a mod was made with. A bundle with no pin builds on +the commit of the installed version the build starts from. ### Mods on the hook points diff --git a/launcher/lang/de.json b/launcher/lang/de.json index c31dd48145..b7a78bb4ed 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -192,5 +192,8 @@ "Keep": "Behalten", "Move to Trash": "In den Papierkorb", "Install and turn on native mods": "Installieren und native Mods einschalten", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Native Mods werden in ein eigenes Spiel kompiliert. Wenn du eine installierst, werden native Mods eingeschaltet: Danach baust du das Spiel damit unter Einstellungen > Native Mods. Der erste Build lädt Rust herunter und kann 20 bis 30 Minuten dauern; spätere Builds sind schneller.", + "These mods were made for different Horizon versions": "Diese Mods wurden für verschiedene Horizon-Versionen gemacht", + "They often still work together. If the build fails, install versions made for the same Nightly.": "Oft funktionieren sie trotzdem zusammen. Schlägt der Build fehl, installiere Versionen für dieselbe Nightly.", + "Build anyway": "Trotzdem bauen" } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 4c350b3926..2af88fcd7a 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -192,5 +192,8 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "¿Mover {name} a la papelera, con sus mundos y ajustes? Podrás recuperarlo desde allí.", "Install and turn on native mods": "Instalar y activar los mods nativos", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). La primera vez, Jugar lo descarga de Mojang para tu cuenta, junto con el Java que necesita.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Los mods nativos se compilan en un juego propio. Al instalar uno se activan los mods nativos: después compilas el juego con él en Ajustes > Mods nativos. La primera compilación descarga Rust y puede tardar de 20 a 30 minutos; las siguientes son más rápidas.", + "These mods were made for different Horizon versions": "Estos mods se hicieron para versiones distintas de Horizon", + "They often still work together. If the build fails, install versions made for the same Nightly.": "A menudo funcionan juntos de todos modos. Si la compilación falla, instala versiones hechas para la misma Nightly.", + "Build anyway": "Compilar de todos modos" } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 9b69bd8420..73c1bd2a22 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -192,5 +192,8 @@ "Delete…": "Supprimer…", "Keep": "Garder", "Move to Trash": "Mettre à la corbeille", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Mettre {name} à la corbeille, avec ses mondes et ses réglages ? Vous pourrez l'y récupérer.", + "These mods were made for different Horizon versions": "Ces mods ont été faits pour des versions différentes de Horizon", + "They often still work together. If the build fails, install versions made for the same Nightly.": "Ils fonctionnent souvent ensemble quand même. Si la compilation échoue, installe des versions faites pour la même Nightly.", + "Build anyway": "Compiler quand même" } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 0798c0cf5a..abd41a3fe1 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -192,5 +192,8 @@ "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version} (Java Edition). Za pierwszym razem Graj pobiera go od Mojang dla Twojego konta, razem z potrzebną Javą.", "Move to Trash": "Przenieś do kosza", "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Przenieść {name} do kosza razem z jego światami i ustawieniami? Możesz go stamtąd przywrócić.", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Mody natywne kompilują się we własną wersję gry. Zainstalowanie jednego włącza mody natywne: potem budujesz z nim grę w Ustawienia > Mody natywne. Pierwsza kompilacja pobiera Rust i może potrwać od 20 do 30 minut; kolejne są szybsze.", + "These mods were made for different Horizon versions": "Te mody zrobiono dla różnych wersji Horizon", + "They often still work together. If the build fails, install versions made for the same Nightly.": "Często i tak działają razem. Jeśli kompilacja się nie powiedzie, zainstaluj wersje zrobione dla tej samej Nightly.", + "Build anyway": "Mimo to zbuduj" } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 5b1986d52a..7cbd98fb0a 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -192,5 +192,8 @@ "Delete…": "Удалить…", "Keep": "Оставить", "Move to Trash": "В корзину", - "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть." + "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "Переместить {name} в корзину вместе с мирами и настройками? Оттуда его можно вернуть.", + "These mods were made for different Horizon versions": "Эти моды сделаны для разных версий Horizon", + "They often still work together. If the build fails, install versions made for the same Nightly.": "Часто они всё равно работают вместе. Если сборка не удастся, установите версии, сделанные для одной и той же Nightly.", + "Build anyway": "Всё равно собрать" } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index cf4b3497d4..a4411fbd75 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -192,5 +192,8 @@ "Keep": "Залишити", "Move to Trash": "Перемістити до кошика", "Install and turn on native mods": "Встановити й увімкнути нативні моди", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші." + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "Нативні моди компілюються в окрему гру. Встановлення такого мода вмикає нативні моди: потім ви збираєте з ним гру в «Налаштування > Нативні моди». Перше збирання завантажує Rust і може тривати 20–30 хвилин; наступні швидші.", + "These mods were made for different Horizon versions": "Ці моди зроблено для різних версій Horizon", + "They often still work together. If the build fails, install versions made for the same Nightly.": "Часто вони все одно працюють разом. Якщо збірка не вдасться, встановіть версії, зроблені для однієї й тієї ж Nightly.", + "Build anyway": "Все одно зібрати" } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index d0bc4fbbf7..323249ff7a 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -192,5 +192,8 @@ "Move {name} to the Trash, with its worlds and settings? You can get it back from there.": "要将 {name} 连同其世界和设置一起移到回收站吗?你之后可以从那里恢复。", "Install and turn on native mods": "安装并启用原生模组", "Minecraft {version} (Java Edition). Play downloads it from Mojang for your account the first time, with the Java it needs.": "Minecraft {version}(Java 版)。首次点击“开始游戏”时,会用你的账户从 Mojang 下载游戏及其所需的 Java。", - "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。" + "Native mods compile into a game of their own. Installing one turns Native mods on: you then build the game with it in Settings > Native mods. The first build downloads Rust and can take 20 to 30 minutes; later builds are faster.": "原生模组会编译成一个独立的游戏。安装原生模组会启用“原生模组”:之后你需要在“设置 > 原生模组”中构建包含它的游戏。首次构建会下载 Rust,可能需要 20 到 30 分钟;之后的构建会更快。", + "These mods were made for different Horizon versions": "这些模组是为不同版本的 Horizon 制作的", + "They often still work together. If the build fails, install versions made for the same Nightly.": "它们通常仍能一起使用。如果构建失败,请安装为同一个 Nightly 制作的版本。", + "Build anyway": "仍然构建" } diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index e2efb90ab8..4a3f0b6058 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -724,6 +724,9 @@ enum Cmd { /// You read the mods' source and trust it with full access to this computer #[arg(long)] trust_native_code: bool, + /// Build mods made for different Horizon versions together anyway, on the newest of them + #[arg(long)] + allow_version_mismatch: bool, }, /// Show a release's patch notes (default: the newest) Notes { tag: Option }, @@ -1135,6 +1138,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> { instance, version, trust_native_code, + allow_version_mismatch, } => { if !trust_native_code { return Err("Native mods run with full access to this computer. Read their source, then pass --trust-native-code.".into()); @@ -1152,8 +1156,20 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> { Some(&target), ), }; - let built = horizon_launcher::native_mods::build(&base, &folder, target, |step| { - eprintln!("{}", step.label()) + let built = horizon_launcher::native_mods::build( + &base, + &folder, + target, + allow_version_mismatch, + |step| eprintln!("{}", step.label()), + ) + .map_err(|e| { + match e.starts_with(horizon_launcher::native_mods::MISMATCH) { + true => { + format!("{e} They often still build: pass --allow-version-mismatch to try.") + } + false => e, + } })?; println!( "{built} built and selected for its instance ({:.0} s).", diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 84734f61fa..2ab4902c73 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -184,6 +184,9 @@ struct Launcher { native_step: Option, native_error: Option, native_confirm: Option, String>>, + /// The mods of a build made for other Horizon versions (`native_mods::MISMATCH`): the build + /// waits for "Build anyway". + native_mismatch: Option, confirm_remove: Option, picker_open: bool, /// The top bar's quick switch of who plays (`switch_page.rs`). @@ -413,6 +416,7 @@ impl Launcher { native_step: None, native_error: None, native_confirm: None, + native_mismatch: None, confirm_remove: None, picker_open: false, account_menu: false, @@ -602,12 +606,14 @@ impl Launcher { /// Builds the selected instance's native mods, once the player accepted the warning. The /// build reuses the game files of the version the instance plays, else the newest installed. - fn build_native(&mut self) { + /// `anyway`: the player agreed to build mods made for other Horizon versions together. + fn build_native(&mut self, anyway: bool) { if self.native_step.is_some() { return; } self.native_confirm = None; self.native_error = None; + self.native_mismatch = None; let instance = instances::selected(); let base = instance .version @@ -633,7 +639,7 @@ impl Launcher { self.native_step = Some(tr("Starting the build").into()); let tx = self.tx.clone(); std::thread::spawn(move || { - let result = native_mods::build(&base, &folder, Some(&instance), |step| { + let result = native_mods::build(&base, &folder, Some(&instance), anyway, |step| { let _ = tx.send(Msg::NativeProgress(step.label())); }); let _ = tx.send(Msg::Native(result)); @@ -873,8 +879,13 @@ impl Launcher { self.native_step = None; self.installed = backend::installed(); self.reload_instances(); - if let Err(e) = result { - self.native_error = Some(e); + match result.map_err(|e| match e.strip_prefix(native_mods::MISMATCH) { + Some(list) => Ok(list.to_owned()), + None => Err(e), + }) { + Err(Ok(list)) => self.native_mismatch = Some(list), + Err(Err(e)) => self.native_error = Some(e), + Ok(_) => {} } } Msg::LauncherProgress(p) => { @@ -3300,6 +3311,7 @@ impl Launcher { switch("native-mods", on, &t).on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.settings.native_mods = !this.settings.native_mods; this.native_confirm = None; + this.native_mismatch = None; this.settings.save(); cx.notify(); })); @@ -3318,7 +3330,9 @@ impl Launcher { }), }; detail = detail.children(status); - if self.native_step.is_none() && self.native_confirm.is_none() { + if let (None, Some(list)) = (&self.native_step, &self.native_mismatch) { + detail = detail.child(self.native_mismatch_panel(list, cx)); + } else if self.native_step.is_none() && self.native_confirm.is_none() { detail = detail.child(div().flex().gap(px(8.)).mt(px(4.)).child( button("native-build", "Build", Kind::Primary, &t).on_click(cx.listener( |this, _: &ClickEvent, _, cx| { @@ -3378,7 +3392,7 @@ impl Launcher { &t, ) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.build_native(); + this.build_native(false); cx.notify(); })), ); @@ -3393,6 +3407,44 @@ impl Launcher { )) } + /// Mods made for other Horizon versions: which, and the version the build uses. They often + /// still work, so the player may build anyway. + fn native_mismatch_panel(&self, list: &str, cx: &mut Context) -> Div { + let t = self.theme; + div() + .flex() + .flex_col() + .gap(px(6.)) + .mt(px(8.)) + .p(px(12.)) + .bg(t.well) + .child(div().text_color(t.danger).child(tr("These mods were made for different Horizon versions").to_string())) + .child(list.to_owned()) + .child(tr("They often still work together. If the build fails, install versions made for the same Nightly.").to_string()) + .child( + div() + .flex() + .gap(px(8.)) + .mt(px(4.)) + .child( + button("native-anyway", "Build anyway", Kind::Primary, &t).on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.build_native(true); + cx.notify(); + }), + ), + ) + .child( + button("native-mismatch-cancel", "Cancel", Kind::Secondary, &t).on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.native_mismatch = None; + cx.notify(); + }), + ), + ), + ) + } + fn game_args_row(&self, n: usize, cx: &mut Context) -> Div { let t = self.theme; let focused = self.args_focused; diff --git a/launcher/src/native_mods.rs b/launcher/src/native_mods.rs index c2854e6fac..3dc11d95c8 100644 --- a/launcher/src/native_mods.rs +++ b/launcher/src/native_mods.rs @@ -4,8 +4,9 @@ //! ([`rust_toolchain`]). Unlike `.hmod` mods they are not sandboxed: their code runs with the //! player's rights. //! -//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit), and -//! every mod of one build must pin the same one. A mod that hooks only Horizon's native hook points +//! A native mod pins the Horizon revision it was made for (a Nightly's or a release's commit). +//! Mods pinning different ones build on the newest of them, once the player agreed to try (they +//! often still apply and compile). A mod that hooks only Horizon's native hook points //! (`game/native_hooks.rs`) names the version it needs instead (`hooks`): it builds on any Horizon //! whose hook points have that version or a later one (they are only ever added to), the player's own //! by default, so such mods always build together. The build compiles that revision's source, from @@ -253,6 +254,76 @@ fn agreed( Ok(revision) } +/// The revision `bundles` build on for `which` when their pins differ: the newest of the pins and +/// `also` (the launcher's own horizon-mixins), by its commit's time (`date`), so no mod gets an +/// older one than it was made for. With one pin or none, it is [`agreed`]'s. +fn newest( + bundles: &[Bundle], + which: &str, + get: impl Fn(&Bundle) -> Option<&String>, + fallback: impl FnOnce() -> Result, + also: Option<&str>, + date: impl Fn(&str) -> Result, +) -> Result { + let mut pins: Vec<&str> = bundles + .iter() + .filter_map(|b| get(b).map(String::as_str)) + .collect(); + pins.sort(); + pins.dedup(); + if pins.len() < 2 { + return agreed(bundles, which, get, fallback); + } + if let Some(bad) = pins.iter().find(|p| !is_revision(p)) { + return Err(format!("{bad} is not a {which} commit (40 hex digits).")); + } + pins.extend(also.filter(|a| !pins.contains(a))); + let mut dated = Vec::new(); + for pin in pins { + let when = date(pin).map_err(|e| { + format!("Can't tell which of the mods' {which} revisions is newest: {e}") + })?; + dated.push((when, pin)); + } + Ok(dated + .into_iter() + .max() + .map(|(_, p)| p.to_owned()) + .unwrap_or_default()) +} + +/// When commit `revision` of GitHub's `repo` was made (ISO 8601, so it sorts). +fn committed(repo: &str, revision: &str) -> Result { + let url = format!("https://api.github.com/repos/{repo}/commits/{revision}"); + let commit: serde_json::Value = backend::get(&url)?.into_json().map_err(|e| e.to_string())?; + commit["commit"]["committer"]["date"] + .as_str() + .map(str::to_owned) + .ok_or_else(|| format!("GitHub doesn't know {repo} commit {revision}.")) +} + +/// How [`build`] starts its error when mods pin other Horizon revisions than the one it would +/// use and the player hasn't agreed to try (`anyway`); the rest lists them. +pub const MISMATCH: &str = "Made for other Horizon versions: "; + +/// Each pinned mod whose Horizon revision isn't `revision`, as `id → pin`, and the build's. +fn mismatch(mods: &[Bundle], revision: &str) -> Option { + let list: Vec = mods + .iter() + .filter(|b| b.hooks.is_none()) + .filter_map(|b| b.horizon.as_ref().map(|r| (b, r))) + .filter(|(_, r)| *r != revision) + .map(|(b, r)| format!("{} → {}", b.id, &r[..r.len().min(7)])) + .collect(); + (!list.is_empty()).then(|| { + format!( + "{}; the build uses {}", + list.join(", "), + &revision[..revision.len().min(7)] + ) + }) +} + /// Where Horizon declares its native hook points' version, and the line that does. const HOOKS_FILE: &str = "crates/horizon-client/src/game/native_hooks.rs"; const HOOKS_LINE: &str = "pub(crate) const NATIVE_HOOKS_API: u32 = "; @@ -587,11 +658,14 @@ fn sync(from: &Path, to: &Path) -> Result<(), String> { /// Builds `folder`'s mods on the Horizon revision they pin (else `base`'s, the installed version /// whose game files the build reuses) and points `instance` at the build: the instance the mods /// were installed in, else (None, or the game's own home) the Native mods instance. Returns the -/// build's version; a build already made is used again. +/// build's version; a build already made is used again. Mods pinning different revisions build on +/// the newest one, with the newest horizon-mixins they name, only when the player agreed to try +/// (`anyway`): else the error starts with [`MISMATCH`] and says which. pub fn build( base: &str, folder: &Path, instance: Option<&Instance>, + anyway: bool, progress: impl Fn(Step), ) -> Result { backend::need_prepared(base)?; @@ -615,21 +689,39 @@ pub fn build( // A mod naming a hook points' version doesn't pin: it follows the others, else the game. let pins: Vec = mods.iter().filter(|b| b.hooks.is_none()).cloned().collect(); let free = pins.iter().all(|b| b.horizon.is_none()); - let pinned = agreed( + let pinned = newest( &pins, "Horizon", |b| b.horizon.as_ref(), || revision_of(base), + None, + |r| committed(backend::REPO, r), )?; let revision = with_hooks(&mods, pinned, free, hooks_at)?; - for b in mods.iter().filter(|b| b.hooks.is_some()) { + let other = mismatch(&mods, &revision); + if let (Some(list), false) = (&other, anyway) { + return Err(format!("{MISMATCH}{list}.")); + } + for b in mods + .iter() + .filter(|b| b.hooks.is_some() || b.horizon.as_ref().is_some_and(|r| *r != revision)) + { repin(b, &revision)?; } - let mixins = agreed( + // Said with a failure: a mod made for another revision may not apply or compile on this one. + let blame = |e: String| { + match &other { + Some(list) => format!("{e}\n\nSome mods were made for other Horizon versions ({list}), which may be why. Install versions made for the same Nightly."), + None => e, + } + }; + let mixins = newest( &mods, "horizon-mixins", |b| b.mixins.as_ref(), || Ok(MIXINS_REV.into()), + Some(MIXINS_REV), + |r| committed("horizon-rust/horizon-mixins", r), )?; let version = version_for(&revision, &mixins, &mods)?; if !backend::installed().contains(&version) { @@ -648,7 +740,8 @@ pub fn build( apply(&rust, &tool, &source, &incoming, &fresh), "horizon-mixins", |_| {}, - )?; + ) + .map_err(blame)?; let stage = root().join("stage"); sync(&fresh, &stage)?; let _ = fs::remove_dir_all(&fresh); @@ -667,7 +760,8 @@ pub fn build( let name = name.split_whitespace().next().unwrap_or(name); progress(Step::Compile(crates, name.to_owned())); } - })?; + }) + .map_err(blame)?; progress(Step::Install); let about = serde_json::json!({ "base": base, @@ -1064,6 +1158,75 @@ mod tests { assert!(agreed(&bad, "Horizon", get, never).is_err()); } + #[test] + fn mods_made_for_different_revisions_build_on_the_newest() { + let pinned = |id: &str, horizon: Option<&str>, mixins: Option<&str>| Bundle { + id: id.into(), + version: "1".into(), + dir: PathBuf::new(), + horizon: horizon.map(str::to_owned), + mixins: mixins.map(str::to_owned), + hooks: None, + }; + let c = "c".repeat(40); + // a is the oldest commit, then b, then c. + let date = |r: &str| -> Result { + Ok(match &r[..1] { + "a" => "2026-10-28T00:00:00Z", + "b" => "2026-10-29T00:00:00Z", + _ => "2026-10-30T00:00:00Z", + } + .into()) + }; + let never = || -> Result { panic!("a pin wins") }; + fn horizon(b: &Bundle) -> Option<&String> { + b.horizon.as_ref() + } + fn mixins(b: &Bundle) -> Option<&String> { + b.mixins.as_ref() + } + let split = [ + pinned("veinminer", Some(B), Some(&c)), + pinned("lookat", Some(A), Some(A)), + pinned("zoom", None, None), + ]; + assert_eq!( + newest(&split, "Horizon", horizon, never, None, date).unwrap(), + B + ); + // horizon-mixins: never older than a pin, and the launcher's own when it is newer. + assert_eq!( + newest(&split, "horizon-mixins", mixins, never, Some(B), date).unwrap(), + c + ); + let older = [pinned("a", None, Some(A)), pinned("b", None, Some(B))]; + assert_eq!( + newest(&older, "horizon-mixins", mixins, never, Some(&c), date).unwrap(), + c + ); + // One pin, or the same one everywhere: that pin, and no date is asked for. + let no_date = |_: &str| -> Result { panic!("no date needed") }; + let same = [pinned("a", Some(A), None), pinned("b", Some(A), None)]; + assert_eq!( + newest(&same, "Horizon", horizon, never, None, no_date).unwrap(), + A + ); + let offline = |_: &str| -> Result { Err("offline".into()) }; + let err = newest(&split, "Horizon", horizon, never, None, offline).unwrap_err(); + assert!(err.contains("offline"), "{err}"); + // What the player is shown: each mod made for another revision, and the build's. + assert_eq!( + mismatch(&split, B).unwrap(), + "lookat → aaaaaaa; the build uses bbbbbbb" + ); + assert_eq!(mismatch(&same, A), None); + let hooked = [Bundle { + hooks: Some(1), + ..pinned("minimap", Some(A), None) + }]; + assert_eq!(mismatch(&hooked, B), None, "hook mods follow the build"); + } + #[test] fn mods_on_the_hook_points_follow_the_game_or_the_pins() { assert_eq!( From ea678bc6e6d0dd8c5d7d25fd7d83afd819d9b98d Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:27:47 +0500 Subject: [PATCH 397/968] Docs: proposal to model native hook points on NeoForge 26.3 events Names, firing points, sides and cancel contracts from NeoForge's 26.3.x events (no code copied), a catalog field for the counterpart, how the hooks we have and the PRs in flight map, a priority list, and the capability/menu shape for mod block entities. Co-Authored-By: Claude Opus 5.5 --- docs/README.md | 2 +- docs/native-hooks-neoforge.md | 167 ++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 docs/native-hooks-neoforge.md diff --git a/docs/README.md b/docs/README.md index 55566e31cb..bb2249ad04 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor - [Benchmark](benchmark.md): Horizon against vanilla 26.3 and vanilla with performance mods, on the same machine, world, path and settings: what is measured, how it is kept fair, how to reproduce it. - [Extras](extras.md): optional zoom, minimap, FPS and keystroke overlays and auto-reconnect, all off by default. - [Shader packs](shaders.md): Horizon's own WGSL shader packs, how to make and check one (`horizon-shader`). -- [Mods](mods.md): content mods (data) and native mods (Rust), how to make, install and publish them. [Native mods](native-mods.md): how the launcher builds them. +- [Mods](mods.md): content mods (data) and native mods (Rust), how to make, install and publish them. [Native mods](native-mods.md): how the launcher builds them. [Native hook points modelled on NeoForge](native-hooks-neoforge.md): the proposal for new hooks. - [Upgrading a 26.2 data pack](datapack-upgrade.md): `horizon-port-pack upgrade-datapack` rewrites a 26.2 worldgen pack in 26.3's formats, and how the rewrite is checked against vanilla. - [Modpacks](modpacks.md): porting whole packs (ATM10 and smaller): import, inventory, carry-over, an agent per mod with a harness against the Java original, coverage, and how "10x" is measured. - [Launcher](../launcher/README.md): installs, updates and starts builds; its CLI and MCP server; releasing it. diff --git a/docs/native-hooks-neoforge.md b/docs/native-hooks-neoforge.md new file mode 100644 index 0000000000..5e5586d0ea --- /dev/null +++ b/docs/native-hooks-neoforge.md @@ -0,0 +1,167 @@ +# Native hook points, modelled on NeoForge 26.3 (proposal) + +Status: proposal, for the authors of the hook PRs in flight to comment on. Nothing here is built yet. + +Native mods hook empty functions in `crates/horizon-client/src/game/native_hooks.rs`, listed by name in +`native-hooks.json` ([native-mods.md](native-mods.md)). So far each hook was designed on its own, and the PRs +in flight number their API versions over each other and change signatures between PRs. This proposes one +reference for every new hook point: the NeoForge 26.3 event that does the same thing. + +Why NeoForge: + +- Its events already answer the questions each of our hooks asks again: where exactly it fires, on which side, + what it can cancel, what it is handed. +- Mod authors know them. A Java mod's `@SubscribeEvent` methods map to hook names one to one, which makes + porting by hand, or by [horizon-port-pack](modpacks.md), straightforward. +- It targets the same Minecraft version: NeoForge has a `26.3.x` branch and 26.3.0.x builds. + +What we take is names, firing points and contracts, read from NeoForge's documentation and from where its +patches call each event. No NeoForge code is copied (it is LGPL-2.1); the hooks stay Horizon's own Rust. +Reference: , events under +`src/main/java/net/neoforged/neoforge/event/` and `.../client/event/`, call sites under `patches/`. + +## Rules + +1. **Name.** A hook with a NeoForge counterpart takes the event's name in snake case, its nested class + included: `ServerTickEvent.Pre` → `server_tick_pre`, function `native_server_tick_pre`; + `PlayerInteractEvent.RightClickBlock` → `right_click_block`. A hook with no counterpart (the minimap, the + mods list, the item panel) keeps a Horizon name. +2. **Firing point.** It fires where NeoForge's patch calls the event, in Horizon's port of the same vanilla + method, in the same order relative to vanilla's own steps. The catalog names the vanilla method, so a + reviewer can check it with `tools/rea_vanilla.py`. +3. **Side.** NeoForge fires an event on whichever side runs the method. Horizon has one binary with several + `Game`s (the window, the server thread, a dedicated server), so each hook says its side: + - `server`: the server's `Game` (single player's server thread, LAN host, dedicated) and the in-frame path. + - `client`: the window's `Game`. + - `both`: both, with the `Game`'s role available (`native_window`). Interactions are `both`, as in + NeoForge: the client fires them for its prediction, the server for the real action. A mod that claims a + use on one side only gets the two out of step. +4. **Cancel and results.** Hooks never return; several mods hook the same point. A cancellable event takes + `canceled: &mut bool`; one with a result (an `InteractionResult`) takes `result: &mut Option<...>`. Every + mod is called; a mod checks `*canceled` itself when it should stand back (NeoForge's `receiveCanceled`). +5. **Payload.** The event's getters become parameters, by value or `&`. A hook gets `&mut self` only when the + NeoForge listener may change the world at that point (server-side events); otherwise `&self`, and the + doc does not call a `&mut` hook read-only. +6. **Frozen once merged.** Name, parameters and call site never change after a merge (as today). A changed + contract is a new hook (`..._v2`) or a new catalog `version`. A stack of PRs that refines one hook lands + in one release batch, under one `NATIVE_HOOKS_API` bump. +7. **API numbers are given at merge**, not when a PR is opened: a PR says "API next" and takes `main + 1` + when it merges, so open PRs stop colliding. + +## Catalog fields + +Each `native-hooks.json` entry gains three optional fields: + +```json +{ + "name": "server_tick_pre", + "version": 1, + "target": "horizon_client::game::native_hooks::Game<'_>::native_server_tick_pre", + "neoforge": "net.neoforged.neoforge.event.tick.ServerTickEvent.Pre", + "side": "server", + "cancellable": false +} +``` + +`neoforge` is absent for Horizon-only hooks. horizon-mixins ignores fields it doesn't know, so this needs no +tool change; the launcher and the docs can list them. + +## The hooks we have + +Kept as they are (rule 6); the catalog gains the counterpart for reference. + +| Hook | Side | NeoForge counterpart | Note | +|---|---|---|---| +| `frame` | client | `RenderFrameEvent.Post` (client) | | +| `tick` | both | `ClientTickEvent.Post` / `ServerTickEvent.Post` | one hook for both sides; new code should use the split hooks below | +| `world_lifecycle` | server | `LevelEvent.Load` / `.Unload`, `ServerStartedEvent` / `ServerStoppingEvent` | phases, not one event each | +| `hud` | client | `RenderGuiEvent.Post` | layers would be `RenderGuiLayerEvent` | +| `key` | client | `InputEvent.Key`, `InputEvent.MouseButton.Post` | | +| `wheel` | client | `InputEvent.MouseScrollingEvent` (cancellable) | `claimed` is its cancel | +| `command` | server | `CommandEvent` | | +| `zoom` | client | `ComputeFovModifierEvent` | | +| `minimap`, `mods`, `settings`, `settings_click`, `panel_event` | client | none | Horizon's own | + +## The PRs in flight + +| PR | Its hook | NeoForge counterpart | To change before merge | +|---|---|---|---| +| #3878 (arismoko) | `server_tick` (Start/End) | `ServerTickEvent.Pre` / `.Post` | fire Start at the top of `tick_server`, before the first level's streaming (NeoForge fires `Pre` first thing in `tickServer`); don't call a `&mut` hook read-only | +| #3923 (arismoko) | `native_server_block` (a query) | `Level.getBlockState` | a helper, not an event; fine as is | +| #3941 (arismoko) | `neighbor_changed` | none exact: `BlockEvent.NeighborNotifyEvent` fires on the *notifying* block, cancellable, with the directions | keep it as a Horizon hook (it observes the receiving side); a `neighbor_notify` hook can be added later | +| #3981 (arismoko) | `block_changed` | none exact (`IBlockExtension.onBlockStateChange` is per block) | say "written", not "committed" | +| #4284 (ludovic) | `tooltip` | `ItemTooltipEvent` | name it `item_tooltip` | +| #4302 (ludovic) | `use_item` | `PlayerInteractEvent.RightClickItem` and `.RightClickBlock` | split into `right_click_item` and `right_click_block`, side `both`: this is what removes the double call when a mod item is used on a block | +| #3996 → #4289 (Sylviromi) | block entity tick, slots, menus, screens | see below | reshape before any of it merges | +| #3964 (levzzz5154) | data files (helpers, no hook) | none | takes the next API number | + +## Next hooks, by priority + +Server, gameplay mods first: + +| Hook | NeoForge | Side | Cancellable | Vanilla method it sits in | +|---|---|---|---|---| +| `block_break` | `level.block.BreakBlockEvent` | server | yes | `ServerPlayerGameMode.destroyBlock` | +| `block_place` | `BlockEvent.EntityPlaceEvent` | server | yes | `BlockItem.place` | +| `left_click_block` | `PlayerInteractEvent.LeftClickBlock` | both | yes | `ServerPlayerGameMode.handleBlockBreakAction`, `MultiPlayerGameMode` | +| `right_click_block` | `PlayerInteractEvent.RightClickBlock` | both | yes, with a result | `ServerPlayerGameMode.useItemOn`, `MultiPlayerGameMode.useItemOn` | +| `right_click_item` | `PlayerInteractEvent.RightClickItem` | both | yes, with a result | `ServerPlayerGameMode.useItem` | +| `entity_interact` | `PlayerInteractEvent.EntityInteract` | both | yes | `Player.interactOn` | +| `player_tick_pre` / `_post` | `PlayerTickEvent.Pre` / `.Post` | both | no | `Player.tick` | +| `level_tick_pre` / `_post` | `LevelTickEvent.Pre` / `.Post` | server | no | `ServerLevel.tick` | +| `entity_join_level` | `EntityJoinLevelEvent` | server | yes | `ServerLevel.addEntity` | +| `living_incoming_damage` | `LivingIncomingDamageEvent` | server | yes | `LivingEntity.hurtServer` | +| `living_death` | `LivingDeathEvent` | server | yes | `LivingEntity.die` | +| `living_drops` | `LivingDropsEvent` | server | yes | `LivingEntity.dropAllDeathLoot` | +| `chunk_load` / `chunk_unload` | `ChunkEvent.Load` / `.Unload` | server | no | chunk map load/unload | +| `container_open` / `_close` | `PlayerContainerEvent.Open` / `.Close` | server | no | `ServerPlayer.openMenu`, `doCloseContainer` | +| `server_chat` | `ServerChatEvent` | server | yes | chat handling | + +Client: + +| Hook | NeoForge | Note | +|---|---|---| +| `client_tick_pre` / `_post` | `ClientTickEvent.Pre` / `.Post` | the window's half of `tick` | +| `render_gui_layer` | `RenderGuiLayerEvent.Pre` / `.Post` | per vanilla HUD layer, cancellable before | +| `screen_init`, `screen_render`, `screen_key`, `screen_mouse` | `ScreenEvent.Init.Post`, `.Render.Post`, `.KeyPressed.Pre`, `.MouseButtonPressed.Pre` | for mods that add to vanilla screens | +| `render_level_stage` | `RenderLevelStageEvent` | world-space drawing; needs a small drawing API first | + +## Block entities, menus and screens + +NeoForge 26.3 splits what Sylviromi's stack does into parts that each stay stable: + +- **The block entity and its ticking.** A mod registers a block entity type for its blocks and a ticker; + vanilla calls the ticker with the block entity *still in the world*. A Horizon `block_entity_tick` hook + should do the same, so a machine that breaks its own block still drops its contents (the item loss found + in #4055). +- **What others can reach: capabilities.** Hoppers, comparators, pipes and other mods don't read a fixed + `slots` field; they ask the block for a capability: `Capabilities.Item.BLOCK` gives a + `ResourceHandler` (the `transfer` package), `Capabilities.Energy.BLOCK` an `EnergyHandler`, + each per side. A Horizon `block_capability(pos, kind, side, out)` hook fills an out parameter with a + handler object; hoppers and comparators use it. New kinds (fluids, energy) are new kinds, not new fields + on a struct, so mods built against an older API keep compiling. +- **Menus.** A `MenuType` with extra data sent on open (`IMenuTypeExtension`), server-side slots, and + `quickMoveStack` on the server: a slot the screen hides must not exist in the server menu either (the + shift-click issue in #4106). +- **Screens.** Registered per menu type on the client (`RegisterMenuScreensEvent`); public structs that mods + fill get constructors or builders, so adding a field doesn't break them (the `NativeScreen` defaults in + #4289). + +Proposed order: `block_entity_tick`, then `block_capability` for items, then menus, then screens, landing as +one batch under one API number. + +## Rollout + +1. This document, and the three catalog fields filled in for the hooks we have. +2. Arismoko's #3878–#3981 merge in one batch (API 4–7, or one shared bump), with the #3878 firing point fix + as a follow-up. +3. #4284 as `item_tooltip`; #4302 split into `right_click_item` / `right_click_block`, both sides. +4. Sylviromi's stack reshaped as above. +5. Then the priority list, one small PR per hook or per family, each with a test that it fires where + NeoForge's does. + +## Open questions + +- Is the snake-case NeoForge name the one we want, or a `neoforge_` prefix to mark the family? +- Do `both`-side hooks pass the role as a parameter, or do mods keep calling `native_window`? +- Should horizon-port-pack translate `@SubscribeEvent` methods of ported Java mods to these hooks? From 8a806d23e0aa9032c6b61a52f360e6a7e97bbbba Mon Sep 17 00:00:00 2001 From: Ashura006 Date: Fri, 30 Oct 2026 16:30:01 +0300 Subject: [PATCH 398/968] Cull display entities with the width/height frustum box Matches Display.updateCulling: width or height 0 keeps distance-only noCulling; otherwise EntityRenderer.shouldRender uses the interpolated AABB inflated by 0.5 for text, block and item displays. Co-Authored-By: Cursor Agent --- .../horizon-client/src/game/text_displays.rs | 43 +++++++++++-------- crates/horizon-client/src/text_display.rs | 38 ++++++++++++++++ tools/port_map/game-004.tsv | 8 ++-- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index dea564bfbe..929022c92f 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -223,6 +223,24 @@ impl Game<'_> { ((d.brightness as u32 >> 20 & 15) * 16) | (((d.brightness as u32 >> 4 & 15) * 16) << 8) } } + /// `Display.shouldRenderAtSqrDistance` and, when width and height are set, + /// `EntityRenderer.shouldRender` with `Display.getInterpolatedBoundingBox` + /// inflated by 0.5. Width or height 0 is `noCulling`: distance only. + fn display_in_view( + &self, + camera: glam::DVec3, + origin: glam::DVec3, + data: &TextDisplayData, + view_scale: f64, + ) -> bool { + let range = f64::from(data.view_range) * 64.0 * view_scale; + if text_display::no_culling(data.width, data.height) { + return (origin - camera).length_squared() < range * range; + } + let (lo, hi) = text_display::culling_box(origin, data.width, data.height); + self.entity_in_view(camera, origin, range, lo, hi) + } + /// `DisplayRenderer.BlockDisplayRenderer` and `ItemDisplayRenderer`: the /// block model or the item under the display's transformation, lit like /// any entity and never tinted by a hurt overlay. @@ -246,8 +264,7 @@ impl Game<'_> { display.last_progress = display.progress(partial as f32); let d = display.shown.blend(&display.from, display.last_progress); let position = display.previous.lerp(display.position, partial); - let range = f64::from(d.view_range) * 64.0 * scale; - if position.distance_squared(camera) >= range * range { + if !self.display_in_view(camera, position, &d, scale) { continue; } let rotation = std::array::from_fn(|i| { @@ -295,18 +312,15 @@ impl Game<'_> { ) { self.text_display_vertices = 0; self.mark_translucent_entity(normal.len(), "minecraft:text_display"); - let atlas = &self.gui.atlas; let angles = self.display_camera_angles(); let scale = self.view_scale(); - for display in self.text_displays.values_mut() { - if display.shows != Shows::Text { - continue; - } + let background = self.settings.text_background(0.25); + let mut displays = std::mem::take(&mut self.text_displays); + for display in displays.values_mut().filter(|d| d.shows == Shows::Text) { display.last_progress = display.progress(partial as f32); let d = display.shown.blend(&display.from, display.last_progress); let position = display.previous.lerp(display.position, partial); - let range = f64::from(d.view_range) * 64.0 * scale; - if position.distance_squared(camera) >= range * range || d.text.is_empty() { + if d.text.is_empty() || !self.display_in_view(camera, position, &d, scale) { continue; } let rotation = std::array::from_fn(|i| { @@ -315,7 +329,7 @@ impl Game<'_> { }); let lines = display .lines - .get_or_insert_with(|| Lines::split(&atlas.font, &d.text, d.line_width)); + .get_or_insert_with(|| Lines::split(&self.gui.atlas.font, &d.text, d.line_width)); let matrix = glam::Mat4::from_translation((position - camera).as_vec3()) * d.matrix(rotation, angles) * lines.pose(); @@ -323,13 +337,7 @@ impl Game<'_> { continue; } let mut batch = HudBatch::new([1, 1]); - text_display::draw( - &mut batch, - atlas, - &d, - lines, - self.settings.text_background(0.25), - ); + text_display::draw(&mut batch, &self.gui.atlas, &d, lines, background); let light = Self::display_light(&self.world, &d, position); // Font.DisplayMode: SEE_THROUGH over everything, else POLYGON_OFFSET. let through = d.flags & 2 != 0; @@ -358,6 +366,7 @@ impl Game<'_> { self.text_display_vertices += batch.vertices.len() / 6 * 4; self.map_patches.extend(batch.patches); } + self.text_displays = displays; } } diff --git a/crates/horizon-client/src/text_display.rs b/crates/horizon-client/src/text_display.rs index 204bfcd83c..61281c1018 100644 --- a/crates/horizon-client/src/text_display.rs +++ b/crates/horizon-client/src/text_display.rs @@ -118,6 +118,27 @@ impl TextDisplayData { }); out } +} + +/// `Display.updateCulling`: either edge at 0 sets `noCulling`, so the renderer +/// skips the frustum box (`DisplayRenderer.affectedByCulling`). +pub fn no_culling(width: f32, height: f32) -> bool { + width == 0.0 || height == 0.0 +} + +/// `Display.updateCulling` / `getInterpolatedBoundingBox` at an interpolated +/// origin: the AABB from `(x − width/2, y, z − width/2)` to +/// `(x + width/2, y + height, z + width/2)`. +pub fn culling_box(origin: glam::DVec3, width: f32, height: f32) -> (glam::DVec3, glam::DVec3) { + let half = f64::from(width) * 0.5; + let tall = f64::from(height); + ( + glam::DVec3::new(origin.x - half, origin.y, origin.z - half), + glam::DVec3::new(origin.x + half, origin.y + tall, origin.z + half), + ) +} + +impl TextDisplayData { /// Omitted fields retain their previous values. pub fn apply(&mut self, values: &[(u8, i32, Value)]) { for (id, _, value) in values { @@ -455,4 +476,21 @@ mod tests { assert_eq!(d.align(), Align::Left); assert_eq!(d.translation, [0.0; 3]); } + + /// `Display.updateCulling`: width or height 0 disables the frustum box. + #[test] + fn zero_width_or_height_disables_display_culling() { + assert!(no_culling(0.0, 1.0)); + assert!(no_culling(2.0, 0.0)); + assert!(no_culling(0.0, 0.0)); + assert!(!no_culling(2.0, 1.0)); + } + + /// `Display.updateCulling`: the box is width-centred on XZ and rises by height. + #[test] + fn display_culling_box_is_centred_on_xz_and_stands_on_y() { + let (lo, hi) = culling_box(glam::DVec3::new(10.0, 64.0, -3.0), 2.0, 1.0); + assert_eq!(lo, glam::DVec3::new(9.0, 64.0, -4.0)); + assert_eq!(hi, glam::DVec3::new(11.0, 65.0, -2.0)); + } } diff --git a/tools/port_map/game-004.tsv b/tools/port_map/game-004.tsv index 6ab8dc2681..dc3ce1bdd7 100644 --- a/tools/port_map/game-004.tsv +++ b/tools/port_map/game-004.tsv @@ -65,19 +65,19 @@ ConversionType.convertCommon port crates/horizon-client/src/game/zombie_villager ConversionType.copyComponent inline crates/horizon-client/src/game/zombie_villagers.rs::carry_over gap: minor: the custom name is kept by carry_over and convert_mob, custom data is not Crackiness.byFraction port crates/horizon-client/src/mobs/golem.rs::crackiness iron golem thresholds 0.75, 0.5, 0.25; wolf armor 0.95, 0.69, 0.32 are in mobs/render.rs::layers and mobs.rs::Mob::hurt_wolf_armor Crackiness.byDamage inline crates/horizon-client/src/mobs/render.rs::layers wolf armor: durability left over max picks the crack texture; a golem uses its health instead -Display.onSyncedDataUpdated port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::metadata gap: minor: text displays a remote server sends only; start delay, duration and render state flags are tracked, width and height do not update a culling box +Display.onSyncedDataUpdated port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::metadata gap: minor: text displays a remote server sends only; start delay, duration and render state flags are tracked Display.createTransformation port crates/horizon-client/src/text_display.rs::TextDisplayData::matrix translation, left rotation, scale, right rotation plus the billboard turn Display.tick port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::tick start tick, interpolation duration and render state refresh; text displays only Display.defineSynchedData port crates/horizon-client/src/text_display.rs::TextDisplayData::default the defaults of entries 8 to 22 Display.readAdditionalSaveData missing player: display entities saved in a world (made by commands, datapacks or maps) are not loaded; the local game has no display entity, only a remote server's text displays are drawn Display.setTransformation missing minor: server side setter; there is no local display entity Display.addAdditionalSaveData missing minor: no local display entity to save -Display.getInterpolatedBoundingBox missing minor: no culling box, a text display is drawn whenever it is within its view range, even off screen +Display.getInterpolatedBoundingBox port crates/horizon-client/src/text_display.rs::culling_box width-centred XZ box from y to y+height at the interpolated origin; used by Game::display_in_view Display.setBrightnessOverride missing minor: server side setter; there is no local display entity Display.getBrightnessOverride inline crates/horizon-client/src/game/text_displays.rs::Game::text_display_geometry the packed sky and block light of the brightness entry (-1 uses the world light) Display.calculateInterpolationProgress port crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::progress -Display.updateCulling missing minor: width and height are stored but no culling box is built -Display.shouldRenderAtSqrDistance inline crates/horizon-client/src/game/text_displays.rs::Game::text_display_geometry view_range * 64 * view scale squared +Display.updateCulling port crates/horizon-client/src/text_display.rs::culling_box noCulling when width or height is 0 (`no_culling`); else the width/height AABB +Display.shouldRenderAtSqrDistance inline crates/horizon-client/src/game/text_displays.rs::Game::display_in_view view_range * 64 * view scale squared; frustum when width and height are set Display.getTeamColor missing minor: glow_color_override is read but a glowing display gets no coloured outline Display.createFreshRenderState inline crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::tick the copy of the synced data when no interpolation runs Display.createInterpolatedRenderState inline crates/horizon-client/src/game/text_displays.rs::RemoteTextDisplay::tick the new start state is the old one blended to the last drawn progress (TextDisplayData::blend) From b086d4298b9974e4878a79caf96148e9772ea020 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:35:03 +0100 Subject: [PATCH 399/968] Mod registry: answer refused submissions after the upload, JSON 413, name a slow GitHub A submission refused before its body is read (signed out, CSRF, the hourly limit) or partway through (a file over 64 MiB) answered and closed the connection while the browser was still sending the file. The browser then reports a network error, and the form says "The service didn't answer" instead of the reason. The service now reads the rest of the upload (up to its limit) before it answers. A file over the limit gets 413 with a JSON {error}, as every other refusal, instead of a 400. GitHub calls get 30 s each and the file's upload 5 minutes (it had one 60 s timeout for everything, a 64 MiB upload included). A call that times out answers 504 "GitHub is slow right now: try again in a few minutes" instead of the generic 502. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-mod-registry/Cargo.toml | 3 +- crates/horizon-mod-registry/README.md | 4 + crates/horizon-mod-registry/src/github.rs | 38 ++++++- crates/horizon-mod-registry/src/lib.rs | 111 +++++++++++++++----- crates/horizon-mod-registry/tests/submit.rs | 88 ++++++++++++++-- 5 files changed, 207 insertions(+), 37 deletions(-) diff --git a/crates/horizon-mod-registry/Cargo.toml b/crates/horizon-mod-registry/Cargo.toml index 959e84f91d..3010874340 100644 --- a/crates/horizon-mod-registry/Cargo.toml +++ b/crates/horizon-mod-registry/Cargo.toml @@ -25,10 +25,11 @@ base64 = "0.22" getrandom = "0.3" # Ratings: one SQLite file on a small volume, as the telemetry server keeps its data. rusqlite = { version = "0.37", features = ["bundled"] } +# Draining a refused upload (already one of axum's). +http-body-util = "0.1" [dev-dependencies] tower = { version = "0.5", features = ["util"] } -http-body-util = "0.1" [profile.release] strip = true diff --git a/crates/horizon-mod-registry/README.md b/crates/horizon-mod-registry/README.md index 763678a787..c4e6b9232b 100644 --- a/crates/horizon-mod-registry/README.md +++ b/crates/horizon-mod-registry/README.md @@ -29,6 +29,10 @@ submissions per account and sixty in all per hour; thirty ratings per account an rate it, and only projects on the registry's `main` can be rated. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else is a pull request by hand. +A refused submission is always `{error}` JSON, sent once the upload is over (a browser still sending when the answer +comes reports a network error instead): 413 for a file over 64 MiB, 504 when GitHub doesn't answer in time (30 s a +call, 5 minutes for the file's upload), 502 for GitHub's other failures. + ## Running it ```sh diff --git a/crates/horizon-mod-registry/src/github.rs b/crates/horizon-mod-registry/src/github.rs index 01a1b1bdbf..21ec2bae10 100644 --- a/crates/horizon-mod-registry/src/github.rs +++ b/crates/horizon-mod-registry/src/github.rs @@ -37,6 +37,10 @@ pub struct GitHub { pub client_secret: String, /// Fine-grained, this repository only: contents and pull requests. pub bot_token: String, + /// How long one call may take, and the file's upload: past it, the + /// submitter is told GitHub is slow rather than left waiting. + pub call_timeout: Duration, + pub upload_timeout: Duration, agent: ureq::Agent, } @@ -45,6 +49,25 @@ pub struct GitHub { pub struct Failure { pub status: u16, pub message: String, + /// GitHub didn't answer in time. + pub timed_out: bool, +} + +/// Whether `e`, or what caused it, is a timeout. +fn timed_out(e: &(dyn std::error::Error + 'static)) -> bool { + let mut cause = Some(e); + while let Some(e) = cause { + if e.downcast_ref::().is_some_and(|e| { + matches!( + e.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) + }) { + return true; + } + cause = e.source(); + } + false } type Result = std::result::Result; @@ -59,9 +82,11 @@ fn failure(e: ureq::Error) -> Failure { .chars() .take(300) .collect(), + timed_out: false, }, other => Failure { status: 0, + timed_out: timed_out(&other), message: other.to_string(), }, } @@ -70,6 +95,7 @@ fn failure(e: ureq::Error) -> Failure { fn read(response: ureq::Response) -> Result { response.into_json().map_err(|e| Failure { status: 0, + timed_out: timed_out(&e), message: e.to_string(), }) } @@ -83,7 +109,7 @@ impl GitHub { bot_token: String, ) -> GitHub { let agent = ureq::AgentBuilder::new() - .timeout(Duration::from_secs(60)) + .timeout_connect(Duration::from_secs(10)) .user_agent("horizon-mod-registry") .build(); GitHub { @@ -92,6 +118,8 @@ impl GitHub { client_id, client_secret, bot_token, + call_timeout: Duration::from_secs(30), + upload_timeout: Duration::from_secs(300), agent, } } @@ -112,6 +140,7 @@ impl GitHub { let token = self .agent .post(&format!("{}/login/oauth/access_token", self.at.web)) + .timeout(self.call_timeout) .set("Accept", "application/json") .send_json(json!({ "client_id": self.client_id, @@ -125,11 +154,13 @@ impl GitHub { return Err(Failure { status: 401, message: "GitHub refused the sign-in".into(), + timed_out: false, }); }; let user = self .agent .get(&format!("{}/user", self.at.api)) + .timeout(self.call_timeout) .set("Authorization", &format!("Bearer {token}")) .set("Accept", "application/vnd.github+json") .call() @@ -140,6 +171,7 @@ impl GitHub { _ => Err(Failure { status: 0, message: "GitHub sent no login".into(), + timed_out: false, }), } } @@ -150,6 +182,7 @@ impl GitHub { method, &format!("{}/repos/{}{path}", self.at.api, self.repo), ) + .timeout(self.call_timeout) .set("Authorization", &format!("Bearer {}", self.bot_token)) .set("Accept", "application/vnd.github+json") .set("X-GitHub-Api-Version", "2022-11-28") @@ -174,6 +207,7 @@ impl GitHub { let entry = toml::from_str(&String::from_utf8_lossy(&bytes)).map_err(|e| Failure { status: 0, message: format!("mods/{id}.toml on main: {e}"), + timed_out: false, })?; Ok(Some((entry, file["sha"].as_str().unwrap_or("").to_owned()))) } @@ -196,6 +230,7 @@ impl GitHub { let bytes = std::fs::read(path).map_err(|e| Failure { status: 0, message: e.to_string(), + timed_out: false, })?; self.agent .post(&format!( @@ -203,6 +238,7 @@ impl GitHub { self.at.uploads, self.repo )) .query("name", name) + .timeout(self.upload_timeout) .set("Authorization", &format!("Bearer {}", self.bot_token)) .set("Accept", "application/vnd.github+json") .set("Content-Type", "application/octet-stream") diff --git a/crates/horizon-mod-registry/src/lib.rs b/crates/horizon-mod-registry/src/lib.rs index 05e5a7e490..1da7f94c9d 100644 --- a/crates/horizon-mod-registry/src/lib.rs +++ b/crates/horizon-mod-registry/src/lib.rs @@ -10,7 +10,11 @@ pub mod ratings; use axum::{ Json, Router, - extract::{DefaultBodyLimit, Multipart, Path as UrlPath, Query, State}, + body::Body, + extract::{ + DefaultBodyLimit, FromRequest, Multipart, Path as UrlPath, Query, Request, State, + multipart::MultipartError, + }, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Redirect, Response}, routing::{get, post}, @@ -40,6 +44,8 @@ const PER_ACCOUNT: usize = 6; const PER_HOUR: usize = 60; /// The page's JSON. const MAX_FORM: usize = 64 << 10; +/// A submission's whole body: the file, the page and the form's framing. +const MAX_BODY: usize = index::MAX_FILE as usize + MAX_FORM * 2; /// Ratings set per account per hour, and by everyone. const RATINGS_PER_ACCOUNT: usize = 30; const RATINGS_PER_HOUR: usize = 600; @@ -134,9 +140,7 @@ pub fn router(app: App) -> Router { .route("/api/mods/{id}/rating", get(my_rating).post(rate)) .route( "/api/mods/submit", - post(submit).layer(DefaultBodyLimit::max( - index::MAX_FILE as usize + MAX_FORM * 2, - )), + post(submit).layer(DefaultBodyLimit::max(MAX_BODY)), ) .with_state(app) } @@ -163,6 +167,12 @@ fn bad(message: impl Into) -> Refusal { fn github_down(e: github::Failure) -> Refusal { eprintln!("horizon-mod-registry: GitHub {}: {}", e.status, e.message); + if e.timed_out { + return Refusal::new( + StatusCode::GATEWAY_TIMEOUT, + "GitHub is slow right now: try again in a few minutes", + ); + } Refusal::new( StatusCode::BAD_GATEWAY, "GitHub didn't answer as expected: try again in a minute", @@ -539,23 +549,83 @@ fn allow(windows: &Windows, login: &str, per_account: usize, per_hour: usize) -> ok } +/// The answer to a file that is too big. +fn too_big() -> Refusal { + Refusal::new( + StatusCode::PAYLOAD_TOO_LARGE, + format!("the file is over {} MiB", index::MAX_FILE >> 20), + ) +} + +/// A form that broke off: over the body's limit, or not a form. +fn torn(e: MultipartError) -> Refusal { + if e.status() == StatusCode::PAYLOAD_TOO_LARGE { + too_big() + } else { + bad(e.body_text()) + } +} + +/// Reads and drops the rest of a refused submission, up to its limit. A +/// browser still sending the file when the answer comes reports a network +/// error instead of the answer, so the answer waits for the upload's end. +async fn drain(body: Body) { + use http_body_util::BodyExt; + let mut body = http_body_util::Limited::new(body, MAX_BODY); + while let Some(Ok(_)) = body.frame().await {} +} + +/// The rest of a form, past a part that was refused. +async fn drain_form(multipart: &mut Multipart) { + while let Ok(Some(mut field)) = multipart.next_field().await { + while let Ok(Some(_)) = field.chunk().await {} + } +} + async fn submit( State(app): State, - headers: HeaderMap, - mut multipart: Multipart, + request: Request, ) -> Result, Refusal> { let inner = app.0.clone(); - let (login, user_id) = signed_in_here(&inner, &headers)?; - if !allow(&inner.recent, &login, PER_ACCOUNT, PER_HOUR) { + let (parts, body) = request.into_parts(); + let checked = signed_in_here(&inner, &parts.headers).and_then(|account| { + let length = parts.headers.get(header::CONTENT_LENGTH); + if length.and_then(|l| l.to_str().ok()?.parse::().ok()) > Some(MAX_BODY) { + return Err(too_big()); + } + if !allow(&inner.recent, &account.0, PER_ACCOUNT, PER_HOUR) { + return Err(Refusal::new( + StatusCode::TOO_MANY_REQUESTS, + "too many submissions this hour: try again later", + )); + } + Ok(account) + }); + let (login, user_id) = match checked { + Ok(account) => account, + Err(refusal) => { + // Too big: what's past the limit would be cut anyway. + if refusal.0 != StatusCode::PAYLOAD_TOO_LARGE { + drain(body).await; + } + return Err(refusal); + } + }; + let mut multipart = Multipart::from_request(Request::from_parts(parts, body), &()) + .await + .map_err(|e| bad(e.body_text()))?; + let dir = inner.config.scratch.join(random_hex()); + if let Err(e) = std::fs::create_dir_all(&dir) { + drain_form(&mut multipart).await; return Err(Refusal::new( - StatusCode::TOO_MANY_REQUESTS, - "too many submissions this hour: try again later", + StatusCode::INTERNAL_SERVER_ERROR, + e.to_string(), )); } - let dir = inner.config.scratch.join(random_hex()); - std::fs::create_dir_all(&dir) - .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let result = receive(&mut multipart, &dir).await; + if result.is_err() { + drain_form(&mut multipart).await; + } let result = match result { Ok((form, file)) => { let inner = inner.clone(); @@ -580,14 +650,10 @@ async fn submit( async fn receive(multipart: &mut Multipart, dir: &Path) -> Result<(Form, PathBuf), Refusal> { use tokio::io::AsyncWriteExt; let (mut form, mut file) = (None, None); - while let Some(mut field) = multipart - .next_field() - .await - .map_err(|e| bad(e.body_text()))? - { + while let Some(mut field) = multipart.next_field().await.map_err(torn)? { match field.name() { Some("entry") => { - let text = field.text().await.map_err(|e| bad(e.body_text()))?; + let text = field.text().await.map_err(torn)?; if text.len() > MAX_FORM { return Err(bad("the page's text is too long")); } @@ -599,13 +665,10 @@ async fn receive(multipart: &mut Multipart, dir: &Path) -> Result<(Form, PathBuf .await .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut size = 0u64; - while let Some(chunk) = field.chunk().await.map_err(|e| bad(e.body_text()))? { + while let Some(chunk) = field.chunk().await.map_err(torn)? { size += chunk.len() as u64; if size > index::MAX_FILE { - return Err(bad(format!( - "the file is over {} MiB", - index::MAX_FILE >> 20 - ))); + return Err(too_big()); } out.write_all(&chunk).await.map_err(|e| { Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) diff --git a/crates/horizon-mod-registry/tests/submit.rs b/crates/horizon-mod-registry/tests/submit.rs index d5591d4fca..e54bc498e0 100644 --- a/crates/horizon-mod-registry/tests/submit.rs +++ b/crates/horizon-mod-registry/tests/submit.rs @@ -34,6 +34,8 @@ struct Mock { pulls: Vec, /// The account the next sign-in gets. login: String, + /// GitHub takes this long to find the staging release. + slow: Option, } type Shared = Arc>; @@ -84,6 +86,10 @@ async fn mock_github() -> (String, Shared) { .route( "/repos/o/r/releases/tags/staging", get(|State(s): State| async move { + let slow = s.lock().unwrap().slow; + if let Some(slow) = slow { + tokio::time::sleep(slow).await; + } let names: Vec = s.lock().unwrap().assets.iter().map(|(n, _)| json!({"name": n})).collect(); Json(json!({"id": 42, "assets": names})) }), @@ -259,23 +265,29 @@ fn form() -> Value { } async fn setup() -> (Client, Shared) { + setup_with(|_| {}).await +} + +async fn setup_with(change: impl FnOnce(&mut github::GitHub)) -> (Client, Shared) { let (base, mock) = mock_github().await; let scratch = std::env::temp_dir().join(format!("horizon-mod-registry-test-{}", std::process::id())); + let mut github = github::GitHub::new( + github::Endpoints { + web: base.clone(), + api: base.clone(), + uploads: base, + }, + "o/r".into(), + "client".into(), + "secret".into(), + "bot".into(), + ); + change(&mut github); let config = Config { public_url: PUBLIC.into(), session_key: b"a test key of at least thirty-two bytes".to_vec(), - github: github::GitHub::new( - github::Endpoints { - web: base.clone(), - api: base.clone(), - uploads: base, - }, - "o/r".into(), - "client".into(), - "secret".into(), - "bot".into(), - ), + github, scratch, database: ":memory:".into(), }; @@ -383,6 +395,13 @@ async fn what_the_form_refuses() { ); let (status, ..) = client.send(request).await; assert_eq!(status, StatusCode::FORBIDDEN); + // Too big: past the file's own limit, and past the whole body's. + let max = horizon_mods::index::MAX_FILE as usize; + for size in [max + 1, max + (200 << 10)] { + let (status, json) = client.submit(&csrf, &form(), &vec![0; size]).await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE, "{size}: {json}"); + refused(status, &json, "the file is over 64 MiB"); + } let m = mock.lock().unwrap(); assert!( m.assets.is_empty() && m.pulls.is_empty(), @@ -481,3 +500,50 @@ async fn ratings() { } assert_eq!(last, StatusCode::TOO_MANY_REQUESTS); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_slow_github_is_named() { + let (mut client, mock) = + setup_with(|gh| gh.call_timeout = std::time::Duration::from_millis(300)).await; + mock.lock().unwrap().slow = Some(std::time::Duration::from_secs(3)); + let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap(); + let csrf = client.sign_in().await; + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::GATEWAY_TIMEOUT, "{json}"); + assert_eq!( + json["error"], + "GitHub is slow right now: try again in a few minutes" + ); +} + +/// Over a real connection: a submission refused before its body is read +/// still gets its answer once the browser has sent the whole file, instead +/// of a connection closed mid-upload (the form's "the service didn't answer"). +#[tokio::test(flavor = "multi_thread")] +async fn a_refusal_waits_for_the_upload() { + use std::io::{Read, Write}; + let (client, _mock) = setup().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let at = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, client.app).await.unwrap() }); + let answer = tokio::task::spawn_blocking(move || { + let size = 16 << 20; + let mut stream = std::net::TcpStream::connect(at).unwrap(); + write!( + stream, + "POST /api/mods/submit HTTP/1.1\r\nHost: x\r\nConnection: close\r\nOrigin: {PUBLIC}\r\nContent-Type: multipart/form-data; boundary=x\r\nContent-Length: {size}\r\n\r\n" + ) + .unwrap(); + let chunk = vec![0u8; 64 << 10]; + for _ in 0..size / chunk.len() { + stream.write_all(&chunk).expect("the whole file is read"); + } + let mut answer = String::new(); + stream.read_to_string(&mut answer).unwrap(); + answer + }) + .await + .unwrap(); + assert!(answer.starts_with("HTTP/1.1 401"), "{answer}"); + assert!(answer.contains("{\"error\":"), "{answer}"); +} From 726d9e86ef1f7044b83b5b1c6690231ab9ea1edc Mon Sep 17 00:00:00 2001 From: chi4a Date: Fri, 30 Oct 2026 15:41:13 +0200 Subject: [PATCH 400/968] Thrown tridents: clip blocks with vanilla's centre ray, not the 0.5 box The server tick tested blocks with an AABB sweep of the trident's 0.5 x 0.5 box and a per-axis moved != velocity test, so a trident passing up to 0.25 blocks to the side of a block stuck in mid-air beside it. Use AbstractArrow.tick's zero-width level().clip(...) and AbstractArrow.onHitBlock's 0.05 pull-back along Math.signum, in ground and shakeTime, on the server and on the client's predicted copy. Fixes #4365 Co-Authored-By: DeepSeek 4.1 Flash (DeepSeek Harness) --- crates/horizon-client/examples/probe/main.rs | 2 + .../examples/probe/trident_probe.rs | 130 +++++++++++++++ crates/horizon-client/src/arrows.rs | 55 +++++++ crates/horizon-client/src/game/trident.rs | 75 ++++----- tools/test_trident_parity.py | 153 ++++++++++++++++++ 5 files changed, 379 insertions(+), 36 deletions(-) create mode 100644 crates/horizon-client/examples/probe/trident_probe.rs create mode 100644 tools/test_trident_parity.py diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index f208fcbe8f..272e9ccaa6 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -99,6 +99,7 @@ mod toggle_keys_probe; mod tooltip_display_fixture; mod trading_fixture; mod translucency_fixture; +mod trident_probe; mod weather_fixture; mod world_border_render_fixture; mod world_options_fixture; @@ -191,6 +192,7 @@ probes! { text_display_fixture, text_display_render_fixture, trading_fixture, + trident_probe, translucency_fixture, weather_fixture, world_border_render_fixture, diff --git a/crates/horizon-client/examples/probe/trident_probe.rs b/crates/horizon-client/examples/probe/trident_probe.rs new file mode 100644 index 0000000000..9974756880 --- /dev/null +++ b/crates/horizon-client/examples/probe/trident_probe.rs @@ -0,0 +1,130 @@ +//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`), +//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and +//! `AbstractArrow.onHitBlock`, one line of state per fixture. +#![forbid(unsafe_code)] +use horizon_client::{ + Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World, +}; +use horizon_core::geometry::Vec3; +use serde_json::Value; +use std::{ + fs, + io::{BufRead, Write}, + path::PathBuf, +}; + +fn number(v: &Value) -> f64 { + f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap()) +} +fn vector(v: &Value) -> Vec3 { + Vec3::new(number(&v[0]), number(&v[1]), number(&v[2])) +} +fn bits(v: Vec3) -> String { + format!( + "{:016x},{:016x},{:016x}", + v.x.to_bits(), + v.y.to_bits(), + v.z.to_bits() + ) +} + +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().skip(1).collect(); + if args.len() != 3 { + return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into()); + } + let root = PathBuf::from(&args[0]); + let dir = PathBuf::from(&args[1]); + let assets = Assets::load(&root)?; + let render = assets.reload(&root)?; + let mut scene = World::test_scene(&assets, &render, 1)?; + fs::write( + dir.join("scene.json"), + serde_json::to_vec( + &serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}), + )?, + )?; + if args[2] == "--describe" { + return Ok(()); + } + let binding = collisions::load(&root, assets.registry)?; + let facts = BlockFacts::load(&root, assets.registry)?; + let side = &args[2]; + let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?); + for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() { + let f: Value = serde_json::from_str(&line?)?; + let mut position = vector(&f["position"]); + let mut velocity = vector(&f["velocity"]); + let mut in_ground = false; + let mut shake = 0u32; + for op in f["ops"].as_array().unwrap() { + match op["mode"].as_str().unwrap() { + "block" => { + let p = &op["position"]; + let mut state = assets + .registry + .default_state(op["name"].as_str().unwrap()) + .ok_or("missing fixture block")?; + if let Some(properties) = op["properties"].as_object() { + let mut definition = assets.registry.state(state).unwrap().clone(); + for (name, value) in properties { + definition + .properties + .insert(name.clone(), value.as_str().unwrap().into()); + } + state = assets + .registry + .id(&definition) + .ok_or("missing fixture state")?; + } + scene.set( + p[0].as_i64().unwrap() as i32, + p[1].as_i64().unwrap() as i32, + p[2].as_i64().unwrap() as i32, + state, + )?; + } + // Ops only the vanilla oracle needs. + "level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {} + "tick" => { + // AbstractArrow.tick: shakeTime down, then the block step. + shake = shake.saturating_sub(1); + if in_ground { + continue; + } + if let Some((stop, _)) = + arrows::clip_block_stop(&scene, &facts, &binding, position, velocity) + { + // onHitBlock: pulled back, stopped, stuck, shaking 7 ticks. + position = stop; + velocity = Vec3::ZERO; + in_ground = true; + shake = 7; + } else { + // A MISS moves the whole movement, then air drag and gravity. + position = Vec3::new( + position.x + velocity.x, + position.y + velocity.y, + position.z + velocity.z, + ); + velocity = Vec3::new( + velocity.x * f64::from(0.99f32), + velocity.y * f64::from(0.99f32) - 0.05, + velocity.z * f64::from(0.99f32), + ); + } + } + other => return Err(format!("unknown operation {other}").into()), + } + } + out.write_all( + format!( + "trident {} {} {in_ground} {shake}\n", + bits(position), + bits(velocity) + ) + .as_bytes(), + )?; + } + Ok(()) +} diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 1f20ff3e7c..65fc469a01 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -375,6 +375,61 @@ impl ArrowScene<'_, '_> { true } } +/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width +/// `level().clip(new ClipContext(position(), position().add(movement), +/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then +/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns +/// the position to stop at (pull-back included) and the block that was hit. +pub fn clip_block_stop( + world: &World, + facts: &BlockFacts, + collision: &BlockCollisionRegistry<'_>, + from: Vec3, + movement: Vec3, +) -> Option<(Vec3, BlockPos)> { + let to = Vec3::new( + from.x + movement.x, + from.y + movement.y, + from.z + movement.z, + ); + let mut scene = SceneCollisions { + world, + binding: collision, + subject: None, + }; + let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| { + let state = world.block(p.x, p.y, p.z); + if facts.is_air(state) { + return None; + } + let shape = collision + .collision_shape(&mut scene, state, [p.x, p.y, p.z]) + .ok()?; + shape.clip(from, to, p) + }) + .ok() + .flatten()?; + Some((back_off(hit.location, movement), hit.block)) +} +/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by +/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0). +pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 { + fn signum(v: f64) -> f64 { + if v > 0.0 { + 1.0 + } else if v < 0.0 { + -1.0 + } else { + 0.0 + } + } + let offset = f64::from(0.05f32); + Vec3::new( + location.x - signum(movement.x) * offset, + location.y - signum(movement.y) * offset, + location.z - signum(movement.z) * offset, + ) +} fn bounds(at: Vec3) -> Aabb { // EntityDimensions.makeBoundingBox: half the float width. let w = f64::from(WIDTH / 2.0); diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index 312beb0fe4..2e328020a3 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -5,6 +5,7 @@ //! lightning on a hit (`channeling.json`). use super::{Game, view_vector}; use horizon_client::{ + arrows::{back_off, clip_block_stop}, collisions::SceneCollisions, enchant::EffectContext, items::ItemStack, @@ -29,6 +30,8 @@ pub(super) struct ThrownTrident { pub(super) pitch: f32, pub(super) in_ground: bool, in_ground_time: u32, + /// `AbstractArrow.shakeTime`: seven ticks after sticking, no pickup. + shake: u32, dealt_damage: bool, pub(super) loyalty: u32, pub(super) no_physics: bool, @@ -64,6 +67,7 @@ impl ThrownTrident { pitch, in_ground: false, in_ground_time: 0, + shake: 0, dealt_damage: false, loyalty: 0, no_physics: false, @@ -96,6 +100,7 @@ impl ThrownTrident { /// server decides what it hits. pub(super) fn remote_tick(&mut self, w: &impl ProjectileWorld, owner_eye: Option) { self.previous = self.position; + self.shake = self.shake.saturating_sub(1); if self.in_ground_time > 4 { self.dealt_damage = true; } @@ -124,9 +129,11 @@ impl ThrownTrident { if !self.no_physics && let Some((at, _, _)) = w.clip(self.position, to) { - self.position = at; + // AbstractArrow.onHitBlock's back-off, as the server's tick does it. + self.position = back_off(at, m); self.velocity = Vec3::ZERO; self.in_ground = true; + self.shake = 7; return; } self.position = to; @@ -138,12 +145,6 @@ impl ThrownTrident { } } const HALF: f64 = 0.25; -fn bounds(p: Vec3) -> Aabb { - Aabb::new( - Vec3::new(p.x - HALF, p.y, p.z - HALF), - Vec3::new(p.x + HALF, p.y + 2.0 * HALF, p.z + HALF), - ) -} /// The entry distance of a segment into a box, if it crosses it. fn segment_box(from: Vec3, d: Vec3, b: Aabb) -> Option { let (lo, hi) = (b.min_position(), b.max_position()); @@ -409,6 +410,7 @@ impl Game<'_> { pitch: projectile.pitch, in_ground: false, in_ground_time: 0, + shake: 0, dealt_damage: false, no_physics: false, returning: 0, @@ -521,6 +523,8 @@ impl Game<'_> { while i < self.tridents.len() { let mut t = self.tridents.remove(i); t.previous = t.position; + // AbstractArrow.tick: `if (shakeTime > 0) --shakeTime;`. + t.shake = t.shake.saturating_sub(1); if t.in_ground_time > 4 { t.dealt_damage = true; } @@ -634,34 +638,32 @@ impl Game<'_> { -t.velocity.z * 0.02, ); } else { - let mut scene = SceneCollisions { - world: &self.world, - binding: self.collision, - subject: None, - }; - if let Ok(moved) = scene.resolve(bounds(t.position), t.velocity, 0.0, false) { - let (from, flight) = (t.position, t.velocity); - let blocked = (moved.x - t.velocity.x).abs() > 1e-7 - || (moved.y - t.velocity.y).abs() > 1e-7 - || (moved.z - t.velocity.z).abs() > 1e-7; - t.position = Vec3::new( - t.position.x + moved.x, - t.position.y + moved.y, - t.position.z + moved.z, - ); - if blocked { - if t.stack.enchantment_level("minecraft:channeling") > 0 { - self.channel_rod_hit(from, flight, t.guest); - } - t.in_ground = true; - t.velocity = Vec3::ZERO; - self.audio.play( - "minecraft:item.trident.hit_ground", - Some([t.position.x, t.position.y, t.position.z]), - 1.0, - 1.0, - ); + // AbstractArrow.tick: a zero-width ray clip of the movement + // (`ClipContext.Block.COLLIDER`, no fluids), then + // `AbstractArrow.onHitBlock` on a hit. + let from = t.position; + let flight = t.velocity; + if let Some((stop, _)) = + clip_block_stop(&self.world, self.res.facts(), self.collision, from, flight) + { + if t.stack.enchantment_level("minecraft:channeling") > 0 { + self.channel_rod_hit(from, flight, t.guest); } + // 0.05 back along signum(movement), stopped, stuck, shaking. + t.position = stop; + t.velocity = Vec3::ZERO; + t.in_ground = true; + t.shake = 7; + self.audio.play( + "minecraft:item.trident.hit_ground", + Some([stop.x, stop.y, stop.z]), + 1.0, + 1.0, + ); + } else { + // A MISS moves the whole segment. + t.position = + Vec3::new(from.x + flight.x, from.y + flight.y, from.z + flight.z); } } if !t.in_ground { @@ -679,8 +681,9 @@ impl Game<'_> { } // playerTouch: stuck or returning tridents go back to a player. // ThrownTrident.playerTouch: only its owner takes it, or anyone - // once the owner is not in this level (`getOwner() == null`). - if t.in_ground || t.no_physics { + // once the owner is not in this level (`getOwner() == null`), and + // not while `AbstractArrow.shakeTime` still runs. + if (t.in_ground || t.no_physics) && t.shake == 0 { let near = |c: &&Catcher| { c.touches && (owner_eye.is_none() || c.guest == t.guest) diff --git a/tools/test_trident_parity.py b/tools/test_trident_parity.py new file mode 100644 index 0000000000..64715a5295 --- /dev/null +++ b/tools/test_trident_parity.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Compare thrown tridents' block step with vanilla. + +`ThrownTrident.tick` delegates to `AbstractArrow.tick`, whose block phase is a +zero-width `level().clip(new ClipContext(position(), position().add(movement), +ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, followed by +`AbstractArrow.onHitBlock` (0.05 back along `Math.signum` per axis, velocity +zero, in ground, `shakeTime = 7`). A trident whose centre ray passes next to a +block must fly on; a sweep of its 0.5 x 0.5 box instead stops it in the air +beside the block. + +The vanilla side is `VanillaArrowOracle`: the block phase it ticks is +`AbstractArrow`'s, shared verbatim with `ThrownTrident` (the trident only adds +the `inGroundTime > 4`/Loyalty pre-step before `super.tick()`), so we do not +duplicate the oracle for the trident entity. Horizon's side is +`examples/probe/trident_probe.rs`, which calls `arrows::clip_block_stop`, the +same function the game's trident tick calls. + +Fixtures: a stone block at x 12..13, y 20..21, z 10..11 in the probe's test +scene, and a projectile starting at (10.5, 20.52, z) with a 2.5-block first +movement, so the centre ray is inside the block's z span for z in [10, 11). +The z offsets are 10.55 and 10.85 (hit), 11.05, 11.15 and 11.30 (0.05, 0.15 +and 0.30 outside: miss), two diagonal passes near the block's corner and one +three-tick flight that has to keep falling after a miss. +""" +import json +import os +import platform +import struct +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'tools')) + +import lab # noqa: E402 +import oracle_harness # noqa: E402 +import test_model_baking_parity as baking # noqa: E402 +import test_rust_parity as base # noqa: E402 + + +def bits(value): + return struct.pack('>d', value).hex() + + +def vec(values): + return [bits(v) for v in values] + + +def row(position, velocity, ops, seed): + return {'seed': str(seed), 'position': vec(position), 'velocity': vec(velocity), + 'crit': False, 'no_physics': False, 'intangible': False, 'ops': ops} + + +def fixtures(): + wall = {'mode': 'block', 'name': 'minecraft:stone', 'position': [12, 20, 10]} + + def ticks(n): + return [{'mode': 'tick'} for _ in range(n)] + + cases = [] + for i, z in enumerate((10.55, 10.85, 11.05, 11.15, 11.30)): + cases.append((f'z={z:5.2f}', row((10.5, 20.52, z), (2.5, 0.0, 0.0), + [{'mode': 'level_seed', 'value': str(1000 + i)}, wall] + ticks(1), 1000 + i))) + # Diagonal passes near the block's corner: the ray reaches x 12 after 0.6 of + # its 2.5-block move, so it is inside z [10, 11) only for z < 10.4. + for i, z in enumerate((10.35, 10.55)): + cases.append((f'corner z={z:5.2f}', row((10.5, 20.52, z), (2.5, 0.0, 1.0), + [{'mode': 'level_seed', 'value': str(2000 + i)}, wall] + ticks(1), 2000 + i))) + # A miss keeps flying, with the air drag and gravity after each move. + cases.append(('miss, 3 ticks', row((10.5, 20.52, 11.05), (2.5, 0.0, 0.0), + [{'mode': 'level_seed', 'value': '3000'}, wall] + ticks(3), 3000))) + return cases + + +def oracle_lines(build): + """One final `arrow` line per fixture from VanillaArrowOracle's output.""" + cases, current = [], [] + for line in (build / 'client.txt').read_text().splitlines(): + if line == 'end': + cases.append(current) + current = [] + elif line.startswith('arrow '): + current.append(line.split()) + return cases + + +def probe_lines(build): + out = [] + for line in (build / 'rust-client.txt').read_text().splitlines(): + fields = line.split() + if len(fields) != 5 or fields[0] != 'trident': + raise AssertionError(f'probe line: {line!r}') + out.append(fields) + return out + + +def main(): + lock, jars, _ = base.verify_artifacts() + libraries = baking.client_libraries() + build = lab.LOCAL / 'trident-parity' + build.mkdir(parents=True, exist_ok=True) + resources = lab.LOCAL / 'horizon-client' / lock['minecraft']['version'] + oracle = ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaArrowOracle.java' + sources = [Path(__file__), ROOT / 'tools/oracle_harness.py', ROOT / 'tools/lab.py', + ROOT / 'tools/test_rust_parity.py', ROOT / 'tools/test_model_baking_parity.py', oracle, + ROOT / 'Cargo.toml', ROOT / 'Cargo.lock', + ROOT / 'crates/horizon-client/examples/probe/trident_probe.rs', + ROOT / 'crates/horizon-client/examples/probe/main.rs', + ROOT / 'crates/horizon-client/src/arrows.rs', + ROOT / 'crates/horizon-client/src/game/trident.rs'] + tested = base.source_hashes(sources) + cases = fixtures() + (build / 'inputs.txt').write_text(''.join(json.dumps(row, separators=(',', ':')) + '\n' + for _, row in cases)) + input_hash = lab.digest(build / 'inputs.txt', 'sha256') + classes, dependencies, _ = oracle_harness.compile_oracle( + build, 'VanillaArrowOracle', jars, libraries, lock['minecraft']['java_major']) + probe = oracle_harness.build_probe(build, 'trident_probe') + oracle_harness.run_logged([*probe, resources, build, '--describe'], build, 'describe') + oracle_harness.run_logged([*probe, resources, build, 'client'], build, 'rust') + oracle_harness.java_run(build, classes, dependencies, 'VanillaArrowOracle', + ['client', build, resources], 'client') + vanilla, rust = oracle_lines(build), probe_lines(build) + if not (len(vanilla) == len(rust) == len(cases)): + raise AssertionError(f'case count: {len(cases)} fixtures, {len(vanilla)} vanilla, {len(rust)} rust') + failures = [] + for (name, _), ref, got in zip(cases, vanilla, rust): + state = ref[-1] # the arrow line printed after the fixture's last op + expected = (state[1], state[2], state[5], state[8]) + actual = (got[1], got[2], got[3], got[4]) + if expected != actual: + failures.append((name, expected, actual)) + if failures: + report = '\n'.join(f' {name}: vanilla {exp} != rust {act}' for name, exp, act in failures) + raise AssertionError(f'{len(failures)} of {len(cases)} trident block steps differ:\n{report}') + base.ensure_sources_unchanged(sources, tested) + if input_hash != lab.digest(build / 'inputs.txt', 'sha256'): + raise AssertionError('Trident fixtures changed during verification') + report = {'schema': 1, 'minecraft': lock['minecraft']['version'], + 'manifest_sha1': lock['minecraft']['manifest_sha1'], 'platform': platform.platform(), + 'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(), + 'java': subprocess.check_output(['java', '--version'], text=True).splitlines()[0], + 'fixtures': len(cases), 'matched_output_lines': len(cases), + 'sources_sha256': tested, 'input_sha256': input_hash, + 'client_jar_sha256': lab.digest(jars['client'], 'sha256'), 'scope': __doc__} + lab.write_json(build / 'report.json', report) + print(f'{len(cases)} trident block steps match the pinned client: {build / "report.json"}', flush=True) + + +if __name__ == '__main__': + main() From 4e9ddb17d9a8bad9b339922e8d4405eacb67b61c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:44:09 +0100 Subject: [PATCH 401/968] Telemetry server: drop the hub's suggestion endpoints The website's suggest form is gone (horizon-site#21) and Kira no longer relays it (kira#85): roadmap suggestions come only from #horizon-ideas posts. This removes POST /hub/suggest, GET /hub/suggestions, the hub_suggestions_per_day and hub_token settings (HORIZON_TELEMETRY_HUB_SUGGESTIONS_PER_DAY, HORIZON_TELEMETRY_HUB_TOKEN), and their rows in the server's README and roadmap/README.md. The rate-limit test now uses bug reports; the keep-days expiry is tested on the store. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-telemetry-server/README.md | 10 +- crates/horizon-telemetry-server/src/intake.rs | 107 ++----------- crates/horizon-telemetry-server/src/lib.rs | 29 +--- crates/horizon-telemetry-server/tests/hub.rs | 140 +++++------------- roadmap/README.md | 12 +- 5 files changed, 61 insertions(+), 237 deletions(-) diff --git a/crates/horizon-telemetry-server/README.md b/crates/horizon-telemetry-server/README.md index f4875310b9..c551b00b60 100644 --- a/crates/horizon-telemetry-server/README.md +++ b/crates/horizon-telemetry-server/README.md @@ -1,6 +1,6 @@ # horizon-telemetry-server -This server receives what players choose to send (performance summaries, crash and bug reports, benchmark uploads: [docs/telemetry.md](../../docs/telemetry.md)) and stores it in one SQLite file. It serves anonymous totals to the website. It also keeps the live player count from the games' anonymous "I'm playing" pings, in memory only, and takes what players send from the hub (playhorizon.dev/hub): votes, bug reports and suggestions. It is one small Rust process, about 7 MB of memory when idle. +This server receives what players choose to send (performance summaries, crash and bug reports, benchmark uploads: [docs/telemetry.md](../../docs/telemetry.md)) and stores it in one SQLite file. It serves anonymous totals to the website. It also keeps the live player count from the games' anonymous "I'm playing" pings, in memory only, and takes what players send from the hub (playhorizon.dev/hub): votes and bug reports. It is one small Rust process, about 7 MB of memory when idle. cargo run --release # in this folder: its own workspace, like the launcher's PORT=8080 HORIZON_TELEMETRY_DB=/data/telemetry.sqlite3 horizon-telemetry-server @@ -20,8 +20,6 @@ It runs at **https://telemetry.playhorizon.dev** (`DEFAULT_ENDPOINT` in `crates/ | `POST /hub/vote` | `{"item": "", "voter": "", "up": true}`: a 👍 on a roadmap item, one per voter; `"up": false` takes it back. Returns `{"item", "votes"}`. 400 for a bad id, 429 over the address's limit. | | `GET /hub/votes` | `{"votes": {"": n}}`. Made again at most every minute. | | `POST /hub/report` | A bug report from the hub's form: `{"title", "did", "happened", "expected", "version"?, "system", "name"?}`. Opens a GitHub issue at once (labels `bug` and `from-website`, the `#horizon-bugs` template, "Reported on playhorizon.dev") and returns 201 `{"id", "issue": ""}`. If GitHub fails: 202 `{"id", "issue": null}`, and the upkeep tries again for a week. The same report twice in a day returns the first one's issue. 400 for an empty or too long field, 429 over the address's limit, 503 past the day's cap or without `GITHUB_TOKEN`. | -| `POST /hub/suggest` | `{"text", "item"?, "name"?}` into the suggestion queue. Returns 202 `{"id"}`. Never published: Kira relays it to the maintainers' private channel. | -| `GET /hub/suggestions?after=&limit=100` | The queue, oldest first, for Kira: `{"suggestions": [{"id", "text", "item"?, "name"?, "created_at"}]}`. Needs `Authorization: Bearer `: 401 without it, 404 when the server has none. | | `GET /healthz` | `ok` | The hub's posts take JSON from the website (`OPTIONS` preflights answer with its origin). A form's hidden `website` field filled in (a bot) gets 202 and is dropped. @@ -63,7 +61,7 @@ The live count is a table in memory: a session ID, when it last pinged, its vers The day stored is the day the server received an item. The sender's IP address is never written down. The per-address rate limit keeps addresses in memory for an hour at most. -The hub keeps votes as an item ID and the browser's random ID, and reports and suggestions as their text, for `HORIZON_TELEMETRY_HUB_KEEP_DAYS` (90). Its rate limits key their buckets by a hash of the address with a key drawn at each start, in memory. +The hub keeps votes as an item ID and the browser's random ID, and bug reports as their text, for `HORIZON_TELEMETRY_HUB_KEEP_DAYS` (90). Its rate limits key their buckets by a hash of the address with a key drawn at each start, in memory. Raw rows are deleted after `HORIZON_TELEMETRY_RETENTION_DAYS` (180). The daily totals stay; they hold no install ID. @@ -83,11 +81,9 @@ All are environment variables: - `HORIZON_TELEMETRY_LIVE_WINDOW_SECONDS` (120) - `HORIZON_TELEMETRY_LIVE_MAX_SESSIONS` (200 000) - `HORIZON_TELEMETRY_HUB_PER_HOUR` votes and vote reads per address (600, bursts of 60: a page load is one read, and several players can share an address) -- `HORIZON_TELEMETRY_HUB_POSTS_PER_HOUR` reports and suggestions per address (20, bursts of 2) +- `HORIZON_TELEMETRY_HUB_POSTS_PER_HOUR` bug reports per address (20, bursts of 2) - `HORIZON_TELEMETRY_HUB_REPORTS_PER_DAY` from everyone (30): each opens an issue -- `HORIZON_TELEMETRY_HUB_SUGGESTIONS_PER_DAY` from everyone (200) - `HORIZON_TELEMETRY_HUB_KEEP_DAYS` (90) -- `HORIZON_TELEMETRY_HUB_TOKEN`: the token Kira reads the suggestions with (unset: `GET /hub/suggestions` is off) - `HORIZON_TELEMETRY_GITHUB_TOKEN`: a fine-grained token with Issues read and write on the repository only (unset: `POST /hub/report` answers 503 and the form points to GitHub and Discord) - `HORIZON_TELEMETRY_GITHUB_REPO` (`horizon-rust/horizon`) diff --git a/crates/horizon-telemetry-server/src/intake.rs b/crates/horizon-telemetry-server/src/intake.rs index 59ce571416..3e3e3ed19b 100644 --- a/crates/horizon-telemetry-server/src/intake.rs +++ b/crates/horizon-telemetry-server/src/intake.rs @@ -1,23 +1,21 @@ -//! What players send from the hub (playhorizon.dev/hub) for the maintainers: bug reports and suggestions. +//! What players send from the hub (playhorizon.dev/hub) for the maintainers: bug reports. //! roadmap/README.md documents it for Kira and the website. //! //! - `POST /hub/report`: a bug report, opened at once as a GitHub issue (labels `bug`, `from-website`) in //! the template Kira's `/report` uses (`github`). Kept and tried again by the upkeep if GitHub fails. -//! - `POST /hub/suggest`: a suggestion, into the queue. Never published: Kira relays it to the -//! maintainers' private channel. -//! - `GET /hub/suggestions?after=`: the queue for Kira, with the `HORIZON_TELEMETRY_HUB_TOKEN` bearer. //! -//! Stored: reports and suggestions as their text, for `hub_keep_days`. Never an IP address. +//! Roadmap suggestions come only from #horizon-ideas posts on Discord (Kira), not from here. +//! +//! Stored: reports as their text, for `hub_keep_days`. Never an IP address. use std::sync::Arc; -use axum::extract::{Query, Request, State}; -use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; -use axum::response::{IntoResponse, Response}; +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::response::Response; use rusqlite::params; -use serde::Deserialize; use serde_json::{Map, Value, json}; -use crate::hub::{answer, item_id, json_body, refuse}; +use crate::hub::{answer, json_body, refuse}; use crate::store::Store; use crate::{App, github, take, utc_now}; @@ -45,11 +43,6 @@ const REPORT: &[Field] = &[ Field("system", true, 3..=200, false), Field("name", false, 0..=64, false), ]; -const SUGGESTION: &[Field] = &[ - Field("text", true, 10..=1000, true), - Field("item", false, 0..=48, false), - Field("name", false, 0..=64, false), -]; /// Text as stored: no control characters but line breaks (where allowed), trimmed. fn clean(text: &str, lines: bool) -> String { @@ -127,21 +120,6 @@ impl Store { Ok((self.db.last_insert_rowid(), None)) } - /// Suggestions after `after`, oldest first: `{id, text, item?, name?, created_at}`. - pub fn suggestions(&self, after: i64, limit: u32) -> rusqlite::Result> { - let mut statement = self.db.prepare( - "SELECT id, created, body FROM hub_queue WHERE kind = 'suggestion' AND id > ?1 ORDER BY id LIMIT ?2", - )?; - let rows = statement.query_map(params![after, limit], |r| { - let mut entry: Map = - serde_json::from_str(&r.get::<_, String>(2)?).unwrap_or_default(); - entry.insert("id".into(), r.get::<_, i64>(0)?.into()); - entry.insert("created_at".into(), r.get::<_, String>(1)?.into()); - Ok(Value::Object(entry)) - })?; - rows.collect() - } - /// Reports whose issue isn't open yet, from `since` on: id and fields. pub fn unfiled(&self, since: u32) -> rusqlite::Result)>> { let mut statement = self.db.prepare( @@ -178,7 +156,7 @@ impl Store { } impl App { - /// The same for reports and suggestions, a much smaller bucket. + /// The same for bug reports, a much smaller bucket. fn allow_post(&self, req: &Request) -> bool { take( &self.hub_post_buckets, @@ -225,7 +203,7 @@ impl App { /// What `receive` keeps: the entry's id, its issue if already opened, its fields. type Received = (i64, Option, Map); -/// Checks a report or a suggestion and queues it. +/// Checks a report and queues it. async fn receive( app: &App, req: Request, @@ -246,11 +224,6 @@ async fn receive( return Err(Box::new(thanks)); } let entry = fields(&body, rules).map_err(|e| no(StatusCode::BAD_REQUEST, &e))?; - if let Some(item) = entry.get("item").and_then(Value::as_str) - && !item_id(item) - { - return Err(no(StatusCode::BAD_REQUEST, "item: a roadmap item id")); - } let today = (app.today)(); let mut store = app.store(); if store.queued_on(kind, today).unwrap_or(u32::MAX) >= per_day { @@ -308,63 +281,3 @@ pub async fn report(State(app): State>, req: Request) -> Response { } } } - -/// `POST /hub/suggest` `{"text", "item"?, "name"?}`: 202 `{"id"}`. -pub async fn suggest(State(app): State>, req: Request) -> Response { - let per_day = app.config.hub_suggestions_per_day; - match receive(&app, req, "suggestion", SUGGESTION, per_day).await { - Ok((id, ..)) => answer(&app, StatusCode::ACCEPTED, json!({ "id": id })), - Err(response) => *response, - } -} - -#[derive(Deserialize)] -pub struct After { - after: Option, - limit: Option, -} - -/// `GET /hub/suggestions?after=&limit=100` with `Authorization: Bearer `: -/// `{"suggestions": [{"id", "text", "item"?, "name"?, "created_at"}]}`, oldest first. 404 when no token is -/// set, 401 for a wrong one. -pub async fn suggestions( - State(app): State>, - headers: HeaderMap, - Query(after): Query, -) -> Response { - let Some(token) = app.config.hub_token.as_deref() else { - return StatusCode::NOT_FOUND.into_response(); - }; - let sent = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .unwrap_or(""); - if !same(sent.as_bytes(), token.as_bytes()) { - return StatusCode::UNAUTHORIZED.into_response(); - } - let limit = after.limit.unwrap_or(100).clamp(1, 500); - let listed = app.store().suggestions(after.after.unwrap_or(0), limit); - match listed { - Ok(list) => ( - [ - ( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ), - (header::CACHE_CONTROL, HeaderValue::from_static("no-store")), - ], - json!({ "suggestions": list }).to_string(), - ) - .into_response(), - Err(e) => { - eprintln!("horizon-telemetry-server: could not read the suggestions: {e}"); - StatusCode::SERVICE_UNAVAILABLE.into_response() - } - } -} - -/// Equal, in a time that doesn't depend on where they differ. -fn same(a: &[u8], b: &[u8]) -> bool { - a.len() == b.len() && a.iter().zip(b).fold(0, |diff, (x, y)| diff | (x ^ y)) == 0 -} diff --git a/crates/horizon-telemetry-server/src/lib.rs b/crates/horizon-telemetry-server/src/lib.rs index d00d9c5b6e..f861530a18 100644 --- a/crates/horizon-telemetry-server/src/lib.rs +++ b/crates/horizon-telemetry-server/src/lib.rs @@ -7,7 +7,7 @@ //! least 3 installs only (`summary`), cached; the website's Benchmarks page reads it. //! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only). //! - `GET /v1/live`: how many play now, rounded, cached briefly. -//! - `/hub/…`: the hub's votes (`hub`), bug reports and suggestions (`intake`). +//! - `/hub/…`: the hub's votes (`hub`) and bug reports (`intake`). //! - `GET /healthz`. //! //! Abuse limits: a token bucket per IP address (kept in memory for an hour, never stored), a @@ -64,16 +64,12 @@ pub struct Config { pub live_max_sessions: usize, /// The hub's votes and reads of their counts an IP address may make an hour. pub hub_per_hour: f64, - /// Bug reports and suggestions together an IP address may send an hour. + /// Bug reports an IP address may send an hour. pub hub_posts_per_hour: f64, /// Bug reports taken a day, from everyone: each one opens a GitHub issue. pub hub_reports_per_day: u32, - /// Suggestions taken a day, from everyone. - pub hub_suggestions_per_day: u32, - /// Days reports and suggestions are kept (Kira has relayed them long before). + /// Days reports are kept (opened as issues long before). pub hub_keep_days: u32, - /// The bearer token `GET /hub/suggestions` wants (Kira's); unset, it answers 404. - pub hub_token: Option, /// Opens the website's bug reports as issues; unset, `POST /hub/report` answers 503. pub github_token: Option, /// Where those issues go, `owner/name`. @@ -95,9 +91,7 @@ impl Default for Config { hub_per_hour: 600.0, hub_posts_per_hour: 20.0, hub_reports_per_day: 30, - hub_suggestions_per_day: 200, hub_keep_days: 90, - hub_token: None, github_token: None, github_repo: "horizon-rust/horizon".into(), } @@ -108,8 +102,8 @@ impl Config { /// The defaults, with `HORIZON_TELEMETRY_RETENTION_DAYS`, `…_MIN_INSTALL_DAYS`, /// `…_REQUESTS_PER_HOUR`, `…_ITEMS_PER_DAY`, `…_TRUST_PROXY` (1), `…_ALLOW_ORIGIN`, /// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS`, `…_LIVE_MAX_SESSIONS`, `…_HUB_PER_HOUR`, - /// `…_HUB_POSTS_PER_HOUR`, `…_HUB_REPORTS_PER_DAY`, `…_HUB_SUGGESTIONS_PER_DAY`, `…_HUB_KEEP_DAYS`, - /// `…_HUB_TOKEN`, `…_GITHUB_TOKEN` and `…_GITHUB_REPO`. Empty tokens count as unset. + /// `…_HUB_POSTS_PER_HOUR`, `…_HUB_REPORTS_PER_DAY`, `…_HUB_KEEP_DAYS`, `…_GITHUB_TOKEN` and + /// `…_GITHUB_REPO`. An empty token counts as unset. pub fn from_env() -> Self { let var = |k: &str| std::env::var(format!("HORIZON_TELEMETRY_{k}")).ok(); let secret = |k: &str| { @@ -151,13 +145,9 @@ impl Config { hub_reports_per_day: var("HUB_REPORTS_PER_DAY") .and_then(|v| v.parse().ok()) .unwrap_or(d.hub_reports_per_day), - hub_suggestions_per_day: var("HUB_SUGGESTIONS_PER_DAY") - .and_then(|v| v.parse().ok()) - .unwrap_or(d.hub_suggestions_per_day), hub_keep_days: var("HUB_KEEP_DAYS") .and_then(|v| v.parse().ok()) .unwrap_or(d.hub_keep_days), - hub_token: secret("HUB_TOKEN"), github_token: secret("GITHUB_TOKEN"), github_repo: var("GITHUB_REPO").unwrap_or(d.github_repo), } @@ -185,7 +175,7 @@ pub struct App { hub_key: RandomState, /// The hub's votes and vote counts, per hashed address. hub_buckets: Mutex, - /// The hub's reports and suggestions, per hashed address. + /// The hub's bug reports, per hashed address. hub_post_buckets: Mutex, /// `GET /hub/votes`'s answer and when it was made. votes_cache: Mutex>, @@ -303,7 +293,7 @@ impl App { } /// Daily upkeep: totals rebuilt, expired rows removed, idle rate-limit entries and live - /// sessions forgotten, the hub's old reports and suggestions removed. Reports still waiting for + /// sessions forgotten, the hub's old reports removed. Reports still waiting for /// their issue are [`Self::file_pending`]'s. pub fn maintain(&self) -> rusqlite::Result { let oldest = (self.today)().saturating_sub(self.config.retention_days); @@ -590,11 +580,6 @@ pub fn router(app: Arc) -> Router { .route("/hub/vote", post(hub::vote).options(hub::preflight)) .route("/hub/votes", get(hub::votes)) .route("/hub/report", post(intake::report).options(hub::preflight)) - .route( - "/hub/suggest", - post(intake::suggest).options(hub::preflight), - ) - .route("/hub/suggestions", get(intake::suggestions)) .route("/healthz", get(|| async { "ok" })) .with_state(app) } diff --git a/crates/horizon-telemetry-server/tests/hub.rs b/crates/horizon-telemetry-server/tests/hub.rs index a72a8551f1..9f2b534560 100644 --- a/crates/horizon-telemetry-server/tests/hub.rs +++ b/crates/horizon-telemetry-server/tests/hub.rs @@ -1,5 +1,4 @@ -//! The hub's intake through its HTTP interface: votes, bug reports (opened as issues by a fake GitHub), -//! suggestions and the queue Kira reads. +//! The hub's intake through its HTTP interface: votes and bug reports (opened as issues by a fake GitHub). use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Mutex}; @@ -59,7 +58,6 @@ fn hub(config: Config) -> Hub { fn config() -> Config { Config { - hub_token: Some("kira-secret".into()), hub_posts_per_hour: 1000.0, ..Config::default() } @@ -83,12 +81,9 @@ impl Hub { .await } - async fn get(&self, path: &str, token: Option<&str>) -> (StatusCode, Value) { - let mut req = Request::get(path); - if let Some(token) = token { - req = req.header("authorization", format!("Bearer {token}")); - } - self.send(req.body(Body::empty()).unwrap()).await + async fn get(&self, path: &str) -> (StatusCode, Value) { + self.send(Request::get(path).body(Body::empty()).unwrap()) + .await } } @@ -126,7 +121,7 @@ async fn votes_count_once_per_browser_and_can_be_taken_back() { 2 ); assert_eq!( - hub.get("/hub/votes", None).await, + hub.get("/hub/votes").await, (StatusCode::OK, json!({ "votes": { "smooth-frames": 2 } })) ); assert_eq!( @@ -134,7 +129,7 @@ async fn votes_count_once_per_browser_and_can_be_taken_back() { 1 ); assert_eq!( - hub.get("/hub/votes", None).await.1["votes"]["smooth-frames"], + hub.get("/hub/votes").await.1["votes"]["smooth-frames"], 1, "a vote drops the cached counts" ); @@ -238,114 +233,40 @@ async fn reports_are_closed_without_a_github_token() { } #[tokio::test] -async fn suggestions_wait_in_the_queue_for_kira() { - let hub = hub(config()); - let first = json!({ "text": " Add a waypoint mod\r\nto the\u{7} browser ", "item": "mod-registry", "name": "alex", "extra": 1 }); - let (code, body) = hub.post("/hub/suggest", first).await; - assert_eq!(code, StatusCode::ACCEPTED); - let first_id = body["id"].as_i64().unwrap(); - hub.post( - "/hub/suggest", - json!({ "text": "Bigger render distance on the Steam Deck" }), - ) - .await; - assert_eq!( - hub.post("/hub/suggest", json!({ "text": "too short" })) - .await - .0, - StatusCode::BAD_REQUEST - ); - assert_eq!( - hub.post( - "/hub/suggest", - json!({ "text": "A long enough idea", "item": "Bad Id" }) - ) - .await - .0, - StatusCode::BAD_REQUEST - ); - - assert_eq!( - hub.get("/hub/suggestions", None).await.0, - StatusCode::UNAUTHORIZED - ); - assert_eq!( - hub.get("/hub/suggestions", Some("wrong-secret")).await.0, - StatusCode::UNAUTHORIZED - ); - let (code, all) = hub.get("/hub/suggestions", Some("kira-secret")).await; - assert_eq!(code, StatusCode::OK); - let list = all["suggestions"].as_array().unwrap(); - assert_eq!(list.len(), 2); - assert_eq!( - list[0]["text"], "Add a waypoint mod\nto the browser", - "trimmed, no control characters" - ); - assert!(list[0].get("extra").is_none()); - assert_eq!(list[0]["item"], "mod-registry"); - assert_eq!(list[0]["name"], "alex"); - assert!(list[0]["created_at"].as_str().unwrap().ends_with('Z')); - assert!(list[1].get("name").is_none()); - let (_, after) = hub - .get( - &format!("/hub/suggestions?after={first_id}"), - Some("kira-secret"), - ) - .await; - assert_eq!(after["suggestions"].as_array().unwrap().len(), 1); - - let closed = hub_without_token(); - assert_eq!( - closed.get("/hub/suggestions", Some("anything")).await.0, - StatusCode::NOT_FOUND - ); -} - -fn hub_without_token() -> Hub { - hub(Config::default()) -} - -#[tokio::test] -async fn posts_are_rate_limited_per_address_and_capped_per_day() { +async fn reports_are_rate_limited_per_address_and_capped_per_day() { let hub = hub(Config { hub_posts_per_hour: 20.0, - hub_suggestions_per_day: 3, + hub_reports_per_day: 3, ..config() }); // A burst of a tenth of the hourly rate: two, then refused. - let idea = |n: u32| json!({ "text": format!("Idea number {n}, long enough") }); + let bug = |n: u32| report(&format!("Bug number {n}")); + assert_eq!(hub.post("/hub/report", bug(1)).await.0, StatusCode::CREATED); + assert_eq!(hub.post("/hub/report", bug(2)).await.0, StatusCode::CREATED); assert_eq!( - hub.post("/hub/suggest", idea(1)).await.0, - StatusCode::ACCEPTED - ); - assert_eq!( - hub.post("/hub/suggest", idea(2)).await.0, - StatusCode::ACCEPTED - ); - assert_eq!( - hub.post("/hub/suggest", idea(3)).await.0, + hub.post("/hub/report", bug(3)).await.0, StatusCode::TOO_MANY_REQUESTS ); let busy = self::hub(Config { hub_posts_per_hour: 1000.0, - hub_suggestions_per_day: 3, + hub_reports_per_day: 3, ..config() }); for n in 0..3 { assert_eq!( - busy.post("/hub/suggest", idea(n)).await.0, - StatusCode::ACCEPTED + busy.post("/hub/report", bug(n)).await.0, + StatusCode::CREATED ); } assert_eq!( - busy.post("/hub/suggest", idea(9)).await.0, + busy.post("/hub/report", bug(9)).await.0, StatusCode::SERVICE_UNAVAILABLE ); busy.day.store(DAY + 1, Ordering::SeqCst); assert_eq!( - busy.post("/hub/suggest", idea(9)).await.0, - StatusCode::ACCEPTED, + busy.post("/hub/report", bug(9)).await.0, + StatusCode::CREATED, "a new day" ); } @@ -358,8 +279,6 @@ async fn old_entries_go_and_reports_never_opened_are_given_up() { .post("/hub/report", report("Crash when opening a chest")) .await; assert!(body["id"].as_i64().unwrap() > 0); - hub.post("/hub/suggest", json!({ "text": "An idea from long ago" })) - .await; hub.day.store(DAY + 8, Ordering::SeqCst); hub.app.maintain().unwrap(); *hub.github.down.lock().unwrap() = false; @@ -368,10 +287,25 @@ async fn old_entries_go_and_reports_never_opened_are_given_up() { hub.github.opened.lock().unwrap().is_empty(), "past a week a report is given up" ); - hub.day.store(DAY + 91, Ordering::SeqCst); - hub.app.maintain().unwrap(); - let (_, left) = hub.get("/hub/suggestions", Some("kira-secret")).await; - assert!(left["suggestions"].as_array().unwrap().is_empty()); +} + +#[test] +fn entries_past_the_keep_days_are_removed() { + let mut store = Store::open(":memory:").unwrap(); + let fields = report("Crash when opening a chest") + .as_object() + .unwrap() + .clone(); + store.enqueue("report", DAY, DAY - 1, &fields).unwrap(); + // Looked for duplicates from DAY + 1 only: a second entry, not the first one again. + store.enqueue("report", DAY + 1, DAY + 1, &fields).unwrap(); + assert_eq!( + store.expire_queue(DAY + 1, DAY).unwrap(), + 1, + "the older day goes" + ); + assert_eq!(store.queued_on("report", DAY).unwrap(), 0); + assert_eq!(store.queued_on("report", DAY + 1).unwrap(), 1); } #[tokio::test] diff --git a/roadmap/README.md b/roadmap/README.md index 9e3db1601c..0d9e99fd40 100644 --- a/roadmap/README.md +++ b/roadmap/README.md @@ -61,8 +61,8 @@ Whoever moves the work (the orchestrator on each heartbeat, or anyone in their p shows it among the releases, and `/data/journal.json` carries it to Kira's #updates and to X. In the same pass, `python3 tools/parity_gaps.py readme --write` refreshes the parity headline: the player-visible gaps still open in the port ledger's ten lists (#3512 to #3521), on the README and the website. -6. Suggestions from the website and Discord reach the maintainers' private channel through Kira (below). A good - one becomes an item in a normal pull request; none is published automatically. +6. Suggestions come only from #horizon-ideas posts on Discord: Kira sends each one to the maintainers' private + channel. A good one becomes an item in a normal pull request; none is published automatically. CI runs `tests/test_roadmap.py` (in the Python tests), which fails when ROADMAP.md is not what `build` writes or when the file breaks a rule; `python3 tools/roadmap.py check --offline` runs the same rules without GitHub. @@ -97,25 +97,21 @@ merged pull request, release, shipped item and weekly summary, newest first, the - `https://playhorizon.dev/data/roadmap.json` is `roadmap.toml` as JSON (`areas`, `items` with every key above, `overview`, the weekly summaries); an item's page is `https://playhorizon.dev/hub#`. -## The intake: votes, bug reports, suggestions +## The intake: votes and bug reports The hub's forms and vote buttons talk to the telemetry server (`https://telemetry.playhorizon.dev`, `crates/horizon-telemetry-server`, `src/hub.rs` and `src/intake.rs`; its README lists the limits and settings). -Kira uses the same endpoints, so a bug or an idea goes the same way from the site and from Discord. +Kira uses the same endpoints, so a bug goes the same way from the site and from Discord. | Endpoint | Body | Answer | | --- | --- | --- | | `POST /hub/vote` | `{"item": "", "voter": "", "up": true}` | `{"item", "votes"}`; `"up": false` takes the vote back | | `GET /hub/votes` | | `{"votes": {"": n}}`, a minute old at most | | `POST /hub/report` | `{"title", "did", "happened", "expected", "version"?, "system", "name"?}` | 201 `{"id", "issue": ""}`; 202 `{"id", "issue": null}` when GitHub was down (opened later) | -| `POST /hub/suggest` | `{"text", "item"?, "name"?}` | 202 `{"id"}` | -| `GET /hub/suggestions?after=` | `Authorization: Bearer ` | `{"suggestions": [{"id", "text", "item"?, "name"?, "created_at"}]}`, oldest first | - A report becomes an issue at once, labels `bug` and `from-website`, in the `#horizon-bugs` template (What I did, What happened, What I expected, Horizon version, OS, CPU, GPU) with "Reported on playhorizon.dev". Every field but the version and the name is required; mentions, `#123` references and HTML in people's text are made inert. -- Suggestions are never published. Kira polls `/hub/suggestions` with the last id it relayed and posts each one - to `#roadmap-suggestions`. - Errors are `{"error": "…"}`: 400 names the field (`"did: 3 to 2000 characters"`), 429 means too many from that address, 503 a full day or reports turned off (no GitHub token on the server). - The page shows the forms and the buttons only once `GET /hub/votes` answers. Until then it points to GitHub and From ef4f8d3edcb00def5763bce59764a07d761c8393 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 14:48:44 +0100 Subject: [PATCH 402/968] Run the weapon hit on an armor stand so a Wind Burst launches the attacker Fixes #4404 --- .../horizon-client/src/game/armor_stands.rs | 6 +++ crates/horizon-client/src/game/mace.rs | 52 +++++++++++++++---- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 7019dc8245..69467ec8c4 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -546,6 +546,10 @@ impl Game<'_> { } // The stand's box and id, before the hit may remove it. let (area, id) = (self.armor_stands[i].bounds(), self.armor_stands[i].id); + let (center, on_ground) = ( + self.armor_stands[i].position, + self.armor_stands[i].on_ground, + ); let (_, _, _, magic) = self.melee_damage("minecraft:armor_stand", true); let hurt = self.punch_armor_stand(i); if hurt { @@ -555,6 +559,8 @@ impl Game<'_> { super::mace::Spared::Stand(id), magic.then_some(id as i32), ); + // Player.itemAttackInteraction: the stand is a living target. + self.weapon_hit_stand(id, center, on_ground); self.swing_arm(); } hurt diff --git a/crates/horizon-client/src/game/mace.rs b/crates/horizon-client/src/game/mace.rs index 918239ae06..4593148518 100644 --- a/crates/horizon-client/src/game/mace.rs +++ b/crates/horizon-client/src/game/mace.rs @@ -238,7 +238,8 @@ impl Game<'_> { fn mace_hurt_enemy(&mut self, index: usize) { let target_on_ground = self.mobs[index].on_ground; if let Some(fall) = self.mace_smash_self(target_on_ground) { - self.smash_knockback(index, fall); + let target = &self.mobs[index]; + self.smash_knockback(target.position, Spared::Mob(target.id), fall); } } /// The attacker's side of `MaceItem.hurtEnemy` for a smash: the fall @@ -299,6 +300,37 @@ impl Game<'_> { } self.weapon_wear(&stack); } + /// `Player.itemAttackInteraction` after the player's hit hurt the armor + /// stand `id` (a living target) at `center`: as `weapon_hit` does for a + /// mob, a smash stops the fall and knocks the creatures around the stand + /// away, and the Wind Burst (a `post_attack` explosion on the attacker) + /// launches the attacker. + pub(super) fn weapon_hit_stand(&mut self, id: u32, center: Vec3, on_ground: bool) { + let Some(stack) = self.held().cloned() else { + return; + }; + if self.smash_attack() + && let Some(fall) = self.mace_smash_self(on_ground) + { + self.smash_knockback(center, Spared::Stand(id), fall); + } + if self + .res + .items() + .get(&stack.item) + .is_some_and(|i| i.weapon_damage.is_some()) + { + self.stats.add("used", &stack.item, 1); + } + self.post_attack_bursts(&stack, "minecraft:armor_stand"); + // MaceItem.postHurtEnemy + if self.smash_attack() + && let Some(p) = self.player.as_mut() + { + p.fall_distance = 0.0; + } + self.weapon_wear(&stack); + } /// `MaceItem.knockback`: the dust pillar (level event 2013) under the /// target, then every living entity within 3.5 blocks of it but the /// player and the target pushed away and up, harder after a fall of more @@ -306,9 +338,7 @@ impl Game<'_> { /// pets and the allays that like it), marker armor stands and creative /// flyers are spared; the predicate's own tamed-animal test asks whether /// the target owns the animal, which a mob never does. - fn smash_knockback(&mut self, index: usize, fall: f64) { - let target = &self.mobs[index]; - let (id, center) = (target.id, target.position); + fn smash_knockback(&mut self, center: Vec3, target: Spared, fall: f64) { // Entity.getOnPos without a supporting block. let on_pos = [ center.x.floor() as i32, @@ -318,11 +348,9 @@ impl Game<'_> { self.level_event_at(2013, on_pos, 750); let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 }; let player = self.level.player_uuid.or(Some([0, 0, 0, 1])); - for m in self - .mobs - .iter_mut() - .filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player)) - { + for m in self.mobs.iter_mut().filter(|m| { + target != Spared::Mob(m.id) && m.alive() && !m.removed && !ally_of_player(m, player) + }) { smash_push( center, heavy, @@ -331,7 +359,11 @@ impl Game<'_> { m.attrs.knockback_resistance, ); } - for s in self.armor_stands.iter_mut().filter(|s| !s.marker) { + for s in self + .armor_stands + .iter_mut() + .filter(|s| !s.marker && target != Spared::Stand(s.id)) + { smash_push(center, heavy, s.position, &mut s.velocity, 0.0); } // The host and the LAN guests are living entities too. A guest's turn From 94a93ab7aaa3e13d99bcc81b6246da13bab30475 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:49:47 +0500 Subject: [PATCH 403/968] Native mods: server tick Start fires before any level; the tick clock ticks once per server tick `ServerTickStart` now fires at the top of `tick_server`, before the first level's streaming and world update, where NeoForge fires `ServerTickEvent.Pre`. Before, it fired inside the first level's `tick`. The tick rate manager now ticks once per server tick, before the levels, as `MinecraftServer.tickServer` does; each level reuses its answer, like `ServerLevel.tick` reading `runsNormally`. Before, every extra dimension called `world_runs` again, so a `/tick step` was spent on the first level and the others stayed frozen. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 8 +++---- crates/horizon-client/src/game/levels.rs | 22 ++++++++++++++++--- .../horizon-client/src/game/native_hooks.rs | 7 ++++-- docs/native-mods.md | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index ebe3f7d1b8..402f9c3d3c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -5565,10 +5565,10 @@ impl Game<'_> { self.ticks += 1; self.track_experience_display(); // `/tick freeze` stops the world but not the player. - let running = self.clock.world_runs(); - if self.serving.is_some() || self.options.dedicated.is_some() { - self.native_lifecycle_server_tick_start(running); - } + let running = match self.level_pass.running { + Some(running) => running, + None => self.clock.world_runs(), + }; // GameRenderer.tick: a wither's bar darkens the world, then fades; // the activated item counts down. self.boss_overlay.tick_darken(running); diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index cb01d64a1f..673995d402 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -89,11 +89,14 @@ pub(super) struct Parked { /// Which passes of the server tick the level in place runs: the first /// reads the network (`Hub::poll`), the last ends the tick for every player -/// (`Hub::end_tick`). A tick with one level is both. +/// (`Hub::end_tick`). A tick with one level is both. `running` is the +/// tick rate manager's answer for this server tick, asked once before the +/// levels (`MinecraftServer.tickServer`); `None` outside `tick_server`. #[derive(Clone, Copy)] pub(super) struct Pass { pub first: bool, pub last: bool, + pub running: Option, } impl Default for Pass { @@ -101,6 +104,7 @@ impl Default for Pass { Self { first: true, last: true, + running: None, } } } @@ -338,8 +342,19 @@ impl Game<'_> { /// and loaded and its world ticked. The first pass reads the network and runs the /// tick's shared parts (the clock, the weather, functions, the autosave); /// the next ones only their level. While `paused`, only the connections - /// run. + /// run. The native server tick hook brackets it (`ServerTickStart` before + /// the first level, `ServerTickEnd` after the last). pub(super) fn tick_server(&mut self, paused: bool) -> Result<()> { + // `tickRateManager.tick()` once per server tick, before any level + // (each `ServerLevel.tick` only reads `runsNormally`), and the native + // `ServerTickStart` before that level work, where NeoForge fires + // `ServerTickEvent.Pre`. A paused pass and a held server tick nothing. + let running = (!paused && !self.agent.hold_server).then(|| self.clock.world_runs()); + if let Some(running) = running + && (self.serving.is_some() || self.options.dedicated.is_some()) + { + self.native_lifecycle_server_tick_start(running); + } let dims = self.levels_to_tick(); let n = dims.len(); let mut result = Ok(()); @@ -351,6 +366,7 @@ impl Game<'_> { self.level_pass = Pass { first: k == 0, last: k + 1 == n, + running, }; self.update_dedicated_streaming(); self.update_world(); @@ -360,7 +376,7 @@ impl Game<'_> { } else if k == 0 { self.tick() } else { - let running = self.clock.world_runs(); + let running = running.unwrap_or_else(|| self.clock.world_runs()); if running { self.tick_weather_effects(); } diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 7d196702a0..7adc7d4ac3 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -326,8 +326,11 @@ impl Game<'_> { ) { let _ = (context, phase, error_text); } - /// The authoritative server tick's phases, read-only. A paused pass - /// fires nothing; a frozen world carries `world_running` false except + /// The authoritative server tick's phases: `ServerTickStart` before any + /// level is ticked (NeoForge's `ServerTickEvent.Pre`), `ServerTickEnd` + /// after the last (`.Post`). Read blocks with `native_server_block`, by + /// dimension: which level `self.world` holds here is not defined. A paused + /// pass fires nothing; a frozen world carries `world_running` false except /// during a requested step. pub(super) fn native_server_tick( &mut self, diff --git a/docs/native-mods.md b/docs/native-mods.md index be15d1af00..b7d96917ef 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -42,7 +42,7 @@ mod shows one. API 3 adds authoritative world lifecycle events through `native_world_lifecycle`: open ready, open failed, and final close. Bundles that use these events need `"hooks": 3`. -API 4 adds authoritative server tick events through `native_server_tick`: start and end of each non-paused +API 4 adds authoritative server tick events through `native_server_tick`: start (before any level ticks, like NeoForge's `ServerTickEvent.Pre`) and end of each non-paused server tick, with `world_running`. A frozen world reports false except during a requested step. Bundles that use these events need `"hooks": 4`. From b46c30af80cd93d16aec4fde8a7ce3bfaef0ad3a Mon Sep 17 00:00:00 2001 From: SlaVKsVolks <96493050+SlaVKsVolks@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:53:44 -0300 Subject: [PATCH 404/968] fix(port-ledger): support jar-only citation checks Resolve declared and inherited fields from bytecode when source files are absent, while retaining source-based citation checks. Co-Authored-By: OpenAI Codex GPT-6.1 Sol --- tests/test_port_ledger.py | 77 +++++++++++++++++++++++++++++++++++++++ tools/port_ledger.py | 11 ++++-- 2 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 tests/test_port_ledger.py diff --git a/tests/test_port_ledger.py b/tests/test_port_ledger.py new file mode 100644 index 0000000000..10e62b7b5b --- /dev/null +++ b/tests/test_port_ledger.py @@ -0,0 +1,77 @@ +import json +import struct +import tempfile +import unittest +import zipfile +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from unittest import mock + +from tests.test_class_index import Pool +from tools import port_ledger + + +def class_bytes(name: str, parent: str, fields: tuple[str, ...] = (), methods: tuple[str, ...] = ()) -> bytes: + """A small class file with declared int fields and abstract methods.""" + pool = Pool() + this, super_ = pool.cls(name), pool.cls(parent) + encoded_fields = [struct.pack(">HHHH", 1, pool.utf8(field), pool.utf8("I"), 0) for field in fields] + encoded_methods = [struct.pack(">HHHH", 0x0401, pool.utf8(method), pool.utf8("()V"), 0) for method in methods] + return (b"\xca\xfe\xba\xbe" + struct.pack(">HH", 0, 69) + pool.bytes() + + struct.pack(">HHHH", 0x0421, this, super_, 0) + + struct.pack(">H", len(encoded_fields)) + b"".join(encoded_fields) + + struct.pack(">H", len(encoded_methods)) + b"".join(encoded_methods) + struct.pack(">H", 0)) + + +class PortLedgerJarOnlyTests(unittest.TestCase): + def test_jar_only_and_partial_sources_preserve_field_and_stale_citations(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rust = root / "crates/example/src/lib.rs" + rust.parent.mkdir(parents=True) + rust.write_text("/// Child.present\nfn a() {}\n" + "/// Child.inherited\nfn b() {}\n" + "/// Child.missing\nfn c() {}\n" + "/// Child.tick\nfn d() {}\n" + "/// Child.sourceOnly\nfn e() {}\n", encoding="utf-8") + jar = root / "game.jar" + with zipfile.ZipFile(jar, "w") as archive: + archive.writestr("net/minecraft/test/Parent.class", class_bytes( + "net/minecraft/test/Parent", "java/lang/Object", fields=("inherited",), methods=("tick",))) + archive.writestr("net/minecraft/test/Child.class", class_bytes( + "net/minecraft/test/Child", "net/minecraft/test/Parent", fields=("present",))) + sources = root / "sources" + parent = sources / "net/minecraft/test/Parent.java" + parent.parent.mkdir(parents=True) + parent.write_text("package net.minecraft.test;\npublic class Parent {\n int inherited;\n void tick() {}\n}\n", + encoding="utf-8") + out = root / "output" + with mock.patch.multiple(port_ledger, ROOT=root, OUT=out, LEDGER=out / "ledger.json", + MAP_DIR=root / "maps", ORACLE_DIR=root / "oracles", + ProcessPoolExecutor=ThreadPoolExecutor), \ + mock.patch.object(port_ledger, "rust_files", return_value=["crates/example/src/lib.rs"]): + for source_roots in ([], [str(sources)]): + with self.subTest(source_roots=source_roots): + result = port_ledger.build(source_roots, [str(jar)], 1) + stale = {g["cite"] for g in result["gaps"] if g["kind"] == "stale"} + self.assertIn("Child.missing", stale) + self.assertNotIn("Child.present", stale) + self.assertNotIn("Child.inherited", stale) + self.assertNotIn("Child.tick", stale) + self.assertIn("Child.sourceOnly", stale) + self.assertEqual(json.loads((out / "ledger.json").read_text())["sources"], source_roots) + child = sources / "net/minecraft/test/Child.java" + child.write_text("package net.minecraft.test;\npublic class Child extends Parent {\n" + " int present;\n int sourceOnly;\n void tick() {}\n}\n", encoding="utf-8") + for jars in ([str(jar)], []): + with self.subTest(jars=jars): + result = port_ledger.build([str(sources)], jars, 1) + stale = {g["cite"] for g in result["gaps"] if g["kind"] == "stale"} + self.assertNotIn("Child.present", stale) + self.assertNotIn("Child.sourceOnly", stale) + self.assertNotIn("Child.tick", stale) + self.assertIn("Child.missing", stale) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/port_ledger.py b/tools/port_ledger.py index 4a0af97740..8027e6ba42 100644 --- a/tools/port_ledger.py +++ b/tools/port_ledger.py @@ -407,7 +407,7 @@ def parse_java(path_and_root): path, root = path_and_root rel = os.path.relpath(path, root).replace(os.sep, "/") try: - text = open(path, encoding="utf-8", errors="replace").read() + text = Path(path).read_text(encoding="utf-8", errors="replace") except OSError: return [] code, strings = blank_java(text) @@ -599,6 +599,7 @@ def parse_class_bytes(item): "extends": [x for x in [cf.super] + cf.interfaces if x and not x.startswith("java/")], "methods": {}, "imports": {}, "package": name.rsplit("/", 1)[0], "outer": name.rsplit("$", 1)[0] if "$" in name else None, "fields": {}, "bytecode": True, + "declared_fields": {field.split(" ", 1)[0] for field in cf.fields}, } if any(p[:1].isdigit() for p in name.rsplit("/", 1)[-1].split("$")[1:]): cls["anonymous"] = True @@ -1243,8 +1244,12 @@ def build(sources, jars, jobs): decl, meth = van.find_method(cp, member) if not decl: cls = van.classes[cp] - fields = re.search(r"\b" + re.escape(member) + r"\b", open(os.path.join(next(s for s in sources if os.path.exists(os.path.join(s, cls["file"]))), cls["file"]), errors="replace").read()) if member[0].islower() else None - if not fields: + field = any(member in van.classes[p].get("declared_fields", ()) for p in van.lineage(cp)) + source = next((Path(s) / cls["file"] for s in sources if (Path(s) / cls["file"]).is_file()), None) + if not field and source is not None and member[0].islower(): + field = bool(re.search(r"\b" + re.escape(member) + r"\b", + source.read_text(encoding="utf-8", errors="replace"))) + if not field: stale.append({"what": "method", "cite": f"{short(cp)}.{member}", "file": f["file"], "line": line}) continue k = key(decl, member) From 736d22009e5ee8882d95642eb6226d0f55829f9a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:58:47 +0100 Subject: [PATCH 405/968] native_world_read: a block's bounds are its world's main's read_block took the build range from Dim::min_y and max_y, which this branch replaces with each level's shape: it now reads the bounds of the world it reads from (the level's own, data packs included). Co-Authored-By: Claude Opus 5.5 --- .../src/game/native_world_read.rs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client/src/game/native_world_read.rs b/crates/horizon-client/src/game/native_world_read.rs index ef43663ba3..dac28a8c07 100644 --- a/crates/horizon-client/src/game/native_world_read.rs +++ b/crates/horizon-client/src/game/native_world_read.rs @@ -41,20 +41,19 @@ impl Game<'_> { self.levels.parked_world(dim) } .ok_or(NativeBlockReadError::UnavailableDimension)?; - read_block(world, dim, self.pack.assets.registry, pos) + read_block(world, self.pack.assets.registry, pos) } } /// Resolve an existing world state, preserving unloaded and invalid states. fn read_block( world: &World, - dimension: Dim, registry: &BlockRegistry, [x, y, z]: [i32; 3], ) -> Result { if !(-30_000_000..30_000_000).contains(&x) || !(-30_000_000..30_000_000).contains(&z) - || !(dimension.min_y()..=dimension.max_y()).contains(&y) + || !(world.shape.min_y..world.shape.max_y()).contains(&y) { return Err(NativeBlockReadError::OutOfBounds); } @@ -84,30 +83,34 @@ mod tests { let mut world = World::new(BlockStateId(0), 0); world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0))); world.set(-1, 64, 1, BlockStateId(1)).unwrap(); - let block = read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]).unwrap(); + // The Nether's build range starts at y 0 (its level's shape). + let mut nether = World::new(BlockStateId(0), 0); + nether.shape = horizon_client::chunk::LevelShape::NETHER; + nether.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0))); + let block = read_block(&world, ®istry, [-1, 64, 1]).unwrap(); assert_eq!(block.name, "test:pillar"); assert_eq!(block.properties["axis"], "x"); assert_eq!( - read_block(&world, Dim::Overworld, ®istry, [-1, 65, 1]) + read_block(&world, ®istry, [-1, 65, 1]) .unwrap() .name, "minecraft:air" ); assert_eq!( - read_block(&world, Dim::Overworld, ®istry, [0, 64, 1]), + read_block(&world, ®istry, [0, 64, 1]), Err(NativeBlockReadError::Unloaded) ); assert_eq!( - read_block(&world, Dim::Nether, ®istry, [-1, -1, 1]), + read_block(&nether, ®istry, [-1, -1, 1]), Err(NativeBlockReadError::OutOfBounds) ); assert_eq!( - read_block(&world, Dim::Overworld, ®istry, [30_000_000, 64, 1]), + read_block(&world, ®istry, [30_000_000, 64, 1]), Err(NativeBlockReadError::OutOfBounds) ); world.set(-1, 64, 1, BlockStateId(99)).unwrap(); assert_eq!( - read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]), + read_block(&world, ®istry, [-1, 64, 1]), Err(NativeBlockReadError::InvalidState) ); assert_eq!(world.chunks.len(), 1, "reads must not load chunks"); From 320778a3d244de677252172f9ec3d442cc4e40f7 Mon Sep 17 00:00:00 2001 From: SlaVKsVolks <96493050+SlaVKsVolks@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:12:46 -0300 Subject: [PATCH 406/968] fix(bench): reject incomparable benchmark reports Check the documented comparison conditions before printing tables, and report missing or conflicting settings without producing a misleading comparison. Co-Authored-By: OpenAI Codex GPT-6.1 Sol --- docs/benchmark-report.md | 5 +-- tests/test_perf_bench.py | 74 ++++++++++++++++++++++++++++++++++++++++ tools/perf_bench.py | 23 +++++++++++-- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/docs/benchmark-report.md b/docs/benchmark-report.md index 95815da1dd..e4492e6260 100644 --- a/docs/benchmark-report.md +++ b/docs/benchmark-report.md @@ -69,8 +69,9 @@ known. Any field can be missing. On mains or battery is `conditions.power`. `render_distance`, `simulation_distance`, `window` (`[width, height]` actually rendered), `vsync`, `fps_cap` (absent: unlimited), `offscreen` (rendered without presenting: not a player number), `repeats`, `warmup_s`, `power` (`ac`, `battery`), `load_avg_1m` (before the run; above about 1 the machine was busy), -`backend` (when forced). Readers compare reports only with equal `scenario`, distances, `window`, `vsync`, -`fps_cap` and `offscreen`. +`backend` (when forced). Readers compare reports only with equal `scenario`, `seed`, distances, `window`, `vsync`, +`fps_cap` and `offscreen`. `tools/perf_bench.py --table` refuses a comparison when these values differ or a required +value is unknown; an absent `fps_cap` means unlimited. A single report can still be shown without comparison. **`metrics`.** Scalar measurements, by name; each is a summary over the run's repeats: `n`, `mean`, `stdev` (sample standard deviation, null when `n` is 1), `min`, `max`, `ci95` (half-width of the 95% diff --git a/tests/test_perf_bench.py b/tests/test_perf_bench.py index 16f61c01d7..d0a6e3ffb0 100644 --- a/tests/test_perf_bench.py +++ b/tests/test_perf_bench.py @@ -4,6 +4,78 @@ import unittest from tools.perf_bench import HISTOGRAM_EDGES_MS, OPTIONS, frames, rnd, summary +class ComparisonConditionsTests(unittest.TestCase): + def test_rejects_incompatible_or_unknown_conditions_before_printing(self): + import contextlib + import io + import tempfile + from pathlib import Path + from unittest import mock + import tools.perf_bench as pb + + conditions = {"scenario": "flight", "seed": "1234", "render_distance": 8, "simulation_distance": 12, + "window": [1920, 1080], "vsync": False, "offscreen": False} + differences = {"scenario": "other", "seed": "5678", "simulation_distance": 8, "window": [1280, 720], + "vsync": True, "offscreen": True, "fps_cap": 60} + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + reports = root / "benchmarks/perf" + reports.mkdir(parents=True) + def write(label, values): + (reports / f"{label}-rd8.json").write_text( + json.dumps({"conditions": values, "metrics": {}}), encoding="utf-8") + write("first", conditions) + with mock.patch.object(pb, "ROOT", root): + for key, value in differences.items(): + with self.subTest(key=key): + write("second", dict(conditions, **{key: value})) + out = io.StringIO() + with contextlib.redirect_stdout(out), self.assertRaisesRegex(ValueError, key): + pb.table(["first", "second"]) + self.assertEqual(out.getvalue(), "") + incomplete = dict(conditions) + del incomplete["window"] + write("second", incomplete) + with contextlib.redirect_stdout(io.StringIO()), self.assertRaisesRegex(ValueError, "window"): + pb.table(["first", "second"]) + stderr, stdout = io.StringIO(), io.StringIO() + with mock.patch("sys.argv", ["perf_bench.py", "--table", "first", "second"]), \ + contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), \ + self.assertRaises(SystemExit) as exit_error: + pb.main() + self.assertEqual(exit_error.exception.code, 2) + self.assertEqual(stdout.getvalue(), "") + self.assertIn("window missing in second", stderr.getvalue()) + self.assertNotIn("Traceback", stderr.getvalue()) + write("second", dict(conditions, fps_cap=None)) + with contextlib.redirect_stdout(io.StringIO()), self.assertRaisesRegex(ValueError, "fps_cap"): + pb.table(["first", "second"]) + + def test_matching_reports_and_single_incomplete_report_remain_readable(self): + import contextlib + import io + import tempfile + from pathlib import Path + from unittest import mock + import tools.perf_bench as pb + + conditions = {"scenario": "flight", "seed": "1234", "render_distance": 8, "simulation_distance": 12, + "window": [1920, 1080], "vsync": False, "offscreen": False} + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + reports = root / "benchmarks/perf" + reports.mkdir(parents=True) + for label, values in (("first", conditions), ("second", conditions), + ("single", {"render_distance": 8})): + (reports / f"{label}-rd8.json").write_text( + json.dumps({"conditions": values, "metrics": {}}), encoding="utf-8") + with mock.patch.object(pb, "ROOT", root), contextlib.redirect_stdout(io.StringIO()) as out: + pb.table(["first", "second"]) + pb.table(["single"]) + self.assertIn("| 8 |", out.getvalue()) + + + class PerfBenchStatsTests(unittest.TestCase): def test_lows_average_the_slowest_frames(self): times = [10.0] * 990 + [100.0] * 9 + [1000.0] @@ -51,6 +123,8 @@ class PerfBenchStatsTests(unittest.TestCase): self.assertEqual(OPTIONS["graphicsPreset"], '"custom"') + + if __name__ == "__main__": unittest.main() diff --git a/tools/perf_bench.py b/tools/perf_bench.py index 32e43521a0..a7d8a9ed24 100644 --- a/tools/perf_bench.py +++ b/tools/perf_bench.py @@ -891,6 +891,22 @@ def table(labels): r = json.loads(path.read_text()) runs.setdefault(label, {})[r["conditions"]["render_distance"]] = r distances = sorted({d for by in runs.values() for d in by}) + # The report contract requires these conditions to match before comparing runs. + for distance in distances: + present = [(label, runs[label][distance]["conditions"]) for label in labels + if label in runs and distance in runs[label]] + if len(present) < 2: + continue + for key in ("scenario", "seed", "simulation_distance", "window", "vsync", "fps_cap", "offscreen"): + # Only an absent fps_cap has a defined meaning (unlimited); null is unknown. + missing = [label for label, c in present + if c.get(key) is None and (key != "fps_cap" or key in c)] + if missing: + raise ValueError(f"cannot compare RD {distance}: {key} missing in {', '.join(missing)}") + baseline = present[0][1].get(key) + for label, c in present[1:]: + if c.get(key) != baseline: + raise ValueError(f"cannot compare RD {distance}: {key} differs between {present[0][0]} and {label}") cell = lambda m, digits=0: "–" if not m else f"{m['mean']:.{digits}f}" + (f" ±{m['ci95']:.{digits}f}" if m["ci95"] else "") print("| RD | scene | " + " | ".join(f"{l}: avg / 1% / 0.1% low FPS" for l in labels) + " |") print("| --- | --- |" + " --- |" * len(labels)) @@ -946,10 +962,13 @@ def main(): help="horizon: measure a later start, after one start to the title screen built the caches") p.add_argument("--mod-jars", help="neoforge: the mod jars to load (the user's own; never committed)") p.add_argument("--deps", help="neoforge: their required libraries") - p.add_argument("--table", nargs="+", metavar="LABEL", help="print markdown tables of saved runs, and exit") + p.add_argument("--table", nargs="+", metavar="LABEL", help="compare saved runs with matching recorded conditions, and exit") args = p.parse_args() if args.table: - table(args.table) + try: + table(args.table) + except ValueError as error: + p.error(str(error)) return args.label = args.label or f"{args.game}-{platform.node().split('.')[0]}" WORK.mkdir(parents=True, exist_ok=True) From 6b5428915b1deca86e66ce72d3a9584c32b93bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB=20=D0=A0=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D1=86=D0=B5=D0=B2?= <110433238+quintyo@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:20:10 +0300 Subject: [PATCH 407/968] Keep the swap-scale PR narrowly scoped Co-Authored-By: OpenAI Codex --- crates/horizon-client/src/first_person.rs | 38 ----------------------- 1 file changed, 38 deletions(-) diff --git a/crates/horizon-client/src/first_person.rs b/crates/horizon-client/src/first_person.rs index adb71ba8c8..ddadb45639 100644 --- a/crates/horizon-client/src/first_person.rs +++ b/crates/horizon-client/src/first_person.rs @@ -933,44 +933,6 @@ mod tests { ); } - #[test] - fn switching_after_a_spear_jab_keeps_the_swing_but_resets_charge() { - use crate::attack_strength::{AttackStrength, HeldStack}; - - let held = |name: &str| { - Some(HeldStack { - item: name.into(), - rest: "1".into(), - }) - }; - let spear = held("minecraft:netherite_spear"); - let sword = held("minecraft:wooden_sword"); - let mut charge = AttackStrength::default(); - for _ in 0..80 { - charge.tick(spear.clone()); - } - let mut swing = SwingState::default(); - assert!(swing.start_if_able( - Hand::Main, - SwingAnimation { - kind: 2, - duration: 23 - }, - 23 - )); - charge.reset_attack(); - swing.tick(); - charge.tick(spear); - assert_eq!((charge.ticker, charge.swap_ticker), (1, 80)); - - // Player.tick resets both counters on a different item, while the - // active swing keeps its original duration (vanilla 26.3 oracle). - swing.tick(); - charge.tick(sword); - assert_eq!((charge.ticker, charge.swap_ticker), (0, 0)); - assert_eq!(swing.current.unwrap().duration_ticks, 23); - assert_eq!(swing.animation.to_bits(), (1.0_f32 / 23.0).to_bits()); - } #[test] fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() { let (bow, apple) = ( From 2e2c11005ae236d6a0cbb00295f4fef25eb223bb Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:34:44 +0500 Subject: [PATCH 408/968] Replace the both-players console teleport step with single-target steps The multi-target cross-dimension tracking gap is a separate issue. Co-Authored-By: Claude Sonnet 5.5 --- tools/test_server_dimension_players_parity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/test_server_dimension_players_parity.py b/tools/test_server_dimension_players_parity.py index 17e6d8d337..2f543d119b 100644 --- a/tools/test_server_dimension_players_parity.py +++ b/tools/test_server_dimension_players_parity.py @@ -45,7 +45,8 @@ SCRIPT = [ # more than one player in the same command. ('console-alice-end', [f'console execute in {END} run tp Alice 0.5 100 0.5']), ('console-bob-to-alice', ['console tp Bob Alice']), - ('console-both-back', [f'console execute in {OVERWORLD} run tp @a 2.5 100 0.5']), + ('console-alice-back', [f'console execute in {OVERWORLD} run tp Alice 0.5 100 0.5']), + ('console-bob-back2', [f'console execute in {OVERWORLD} run tp Bob 2.5 100 0.5']), # Through an End portal (Entity.handlePortal), and back by command. ('bob-portal', ['cmd Bob setblock 2 101 3 minecraft:end_portal', 'cmd Bob tp @s 2.5 101 3.5']), ('bob-portal-back', [f'cmd Bob execute in {OVERWORLD} run tp @s 2.5 100 0.5']), From 3e2bcb00bc3aab2fb421c3acf6af7957dba4e940 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:36:53 +0100 Subject: [PATCH 409/968] Fix main's test build: one tests module per file, Items::load and in_water signatures in tests Merging #4222/#4082 and #4259 left two `mod tests` in game/creatures.rs, the pearl test calling Items::load without its PackOverlay argument, and end_gateway's ProjectileWorld test impl with the old in_water(Vec3) signature. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/end_gateway.rs | 2 +- crates/horizon-client/src/game/creatures.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/end_gateway.rs b/crates/horizon-client/src/end_gateway.rs index c4089bda74..245665d0e1 100644 --- a/crates/horizon-client/src/end_gateway.rs +++ b/crates/horizon-client/src/end_gateway.rs @@ -187,7 +187,7 @@ mod tests { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool { false } fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] { diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 28dd5167b9..dbe1d45d6a 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -5628,7 +5628,7 @@ mod breeding_tests { } #[cfg(test)] -mod tests { +mod pearl_tests { use super::*; /// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the @@ -5651,7 +5651,13 @@ mod tests { let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap(); let data = horizon_client::mobs::MobData::load(&root, &facts); let biomes = horizon_client::biome::Biomes::load(&root).unwrap(); - let items = horizon_client::items::Items::load(&root, registry, &facts).unwrap(); + let items = horizon_client::items::Items::load( + &root, + registry, + &facts, + &horizon_world::loot::PackOverlay::NONE, + ) + .unwrap(); let collision = horizon_client::collisions::load(&root, registry).unwrap(); let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); // A pool of still water one block deep at y = 4, from x = 20 on (clear of the From 6ca4b32d06bbec002bf85b932c0af3ef70ecd6e2 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:41:45 +0500 Subject: [PATCH 410/968] Fix main's test build: Aabb in_water stub in end_gateway, duplicate tests module and Items::load args in creatures Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/creatures.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 8f8f2beaaf..6fe5a98acf 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -5634,7 +5634,7 @@ mod breeding_tests { } #[cfg(test)] -mod tests { +mod pearl_water_tests { use super::*; /// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the @@ -5657,7 +5657,13 @@ mod tests { let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap(); let data = horizon_client::mobs::MobData::load(&root, &facts); let biomes = horizon_client::biome::Biomes::load(&root).unwrap(); - let items = horizon_client::items::Items::load(&root, registry, &facts).unwrap(); + let items = horizon_client::items::Items::load( + &root, + registry, + &facts, + &horizon_world::loot::PackOverlay::NONE, + ) + .unwrap(); let collision = horizon_client::collisions::load(&root, registry).unwrap(); let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); // A pool of still water one block deep at y = 4, from x = 20 on (clear of the From b5ad463d2d5e3af796535657c0ca3cf580333ef2 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:41:45 +0500 Subject: [PATCH 411/968] Fix main's test build: Aabb in_water stub in end_gateway, duplicate tests module and Items::load args in creatures Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/creatures.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 8f8f2beaaf..6fe5a98acf 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -5634,7 +5634,7 @@ mod breeding_tests { } #[cfg(test)] -mod tests { +mod pearl_water_tests { use super::*; /// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the @@ -5657,7 +5657,13 @@ mod tests { let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap(); let data = horizon_client::mobs::MobData::load(&root, &facts); let biomes = horizon_client::biome::Biomes::load(&root).unwrap(); - let items = horizon_client::items::Items::load(&root, registry, &facts).unwrap(); + let items = horizon_client::items::Items::load( + &root, + registry, + &facts, + &horizon_world::loot::PackOverlay::NONE, + ) + .unwrap(); let collision = horizon_client::collisions::load(&root, registry).unwrap(); let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); // A pool of still water one block deep at y = 4, from x = 20 on (clear of the From 6e5d821eebbdaf915037871c9c3e52974c8a9956 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:45:08 +0100 Subject: [PATCH 412/968] native_world_read: rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/native_world_read.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/native_world_read.rs b/crates/horizon-client/src/game/native_world_read.rs index dac28a8c07..1e078d6960 100644 --- a/crates/horizon-client/src/game/native_world_read.rs +++ b/crates/horizon-client/src/game/native_world_read.rs @@ -91,9 +91,7 @@ mod tests { assert_eq!(block.name, "test:pillar"); assert_eq!(block.properties["axis"], "x"); assert_eq!( - read_block(&world, ®istry, [-1, 65, 1]) - .unwrap() - .name, + read_block(&world, ®istry, [-1, 65, 1]).unwrap().name, "minecraft:air" ); assert_eq!( From c30f519c169bbf1a0da8a87541349420d12f3b1a Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:45:15 +0500 Subject: [PATCH 413/968] Skip chunk copies only of vehicles restored from the player's file The early skips in spawn_loaded_entities and load_mob matched any remembered UUID, so stale id to UUID entries dropped chunk minecarts after a reopen. Track the UUIDs restore_saved_vehicle brought in for this load instead. Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/persist.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index f15c193c15..dac4c007cd 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -35,6 +35,10 @@ pub struct EntityMemory { /// the dragon): the UUID and compound they were loaded with, found again by /// type and position (an entity that has moved since loading is saved afresh). anon: HashMap<(String, [u64; 3]), (Vec3, Option<[i32; 4]>, Tag)>, + /// UUIDs of the vehicles `restore_saved_vehicle` brought in from the + /// player's file: their chunk copies must not spawn a second one. Cleared + /// with the rest when a world closes. + restored: HashSet<[i32; 4]>, } fn anon_key(kind: &str, p: Vec3) -> (String, [u64; 3]) { @@ -249,7 +253,7 @@ impl Game<'_> { for tag in tags { let kind = entity_nbt::kind(&tag).unwrap_or_default(); let uuid = entity_nbt::uuid(&tag); - if uuid.is_some_and(|u| self.vehicle_by_uuid(u).is_some()) { + if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) { continue; } if matches!( @@ -475,7 +479,7 @@ impl Game<'_> { /// A saved mob and, under `Passengers`, the mobs riding it. pub(super) fn load_mob(&mut self, tag: Tag, mount: Option) -> Option { let uuid = entity_nbt::uuid(&tag); - if let Some(id) = uuid.and_then(|u| self.vehicle_by_uuid(u)) { + if let Some(id) = uuid.and_then(|u| self.restored_vehicle(u)) { return Some(id); } self.next_entity += 1; @@ -667,6 +671,14 @@ impl Game<'_> { || self.carts.iter().any(|c| c.id == id); there.then_some(id) } + /// A vehicle already in the world that was restored from the player's file + /// during this load (not any entity whose UUID is remembered). + fn restored_vehicle(&self, uuid: [i32; 4]) -> Option { + if !self.persist.restored.contains(&uuid) { + return None; + } + self.vehicle_by_uuid(uuid) + } /// Vanilla stores the root entity in the player's file, outside chunk entities. /// Keep the raw wrapper until attachment succeeds, including unsupported trees. pub(super) fn restore_saved_vehicle( @@ -692,9 +704,11 @@ impl Game<'_> { (at.x.floor() as i32).div_euclid(16), (at.z.floor() as i32).div_euclid(16), ); + self.persist.restored.insert(root_uuid); self.world.loaded_entities.push((key, vec![tag])); self.spawn_loaded_entities(); } + self.persist.restored.insert(uuid); self.vehicle_by_uuid(uuid) } /// `ServerPlayer.loadAndSpawnParentVehicle`: the ride the player's file From f47cec589b6f73337e229b27324e87be23e8499f Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:46:02 +0500 Subject: [PATCH 414/968] Draw the mooshroom variant before the child is borrowed Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/game/creatures.rs | 36 ++++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2dbd5d3a2e..99b31e0f62 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2248,6 +2248,24 @@ impl Game<'_> { } else { None }; + // MushroomCow.getOffspringVariant, from the mother's random: the other + // colour 1 time in 1024 when both parents share one, otherwise either + // parent's colour by a coin flip. + let mooshroom = if p.species() == Species::Mooshroom { + let mate = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant); + let own = p.extra.variant; + self.mobs.iter_mut().find(|m| m.id == parent).map(|m| { + if own == mate && m.random.next_int_bound(1024).unwrap_or(1) == 0 { + if own == 1 { 0 } else { 1 } + } else if m.random.next_boolean() { + own + } else { + mate + } + }) + } else { + None + }; // A horse and a donkey have a mule. let kind = match (p.species(), q.as_ref().map(|q| q.species())) { (Species::Horse, Some(Species::Donkey)) | (Species::Donkey, Some(Species::Horse)) => { @@ -2395,22 +2413,8 @@ impl Game<'_> { { child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant); } - // MushroomCow.getOffspringVariant, from the mother's random: the other - // colour 1 time in 1024 when both parents share one, otherwise either - // parent's colour by a coin flip. - if p.species() == Species::Mooshroom { - let mate = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant); - let own = p.extra.variant; - let variant = self.mobs.iter_mut().find(|m| m.id == parent).map(|m| { - if own == mate && m.random.next_int_bound(1024).unwrap_or(1) == 0 { - if own == 1 { 0 } else { 1 } - } else if m.random.next_boolean() { - own - } else { - mate - } - }); - child.extra.variant = variant.unwrap_or(own); + if let Some(variant) = mooshroom { + child.extra.variant = variant; } // Sheep.getBreedOffspring: the lamb's wool mixes both parents' dyes. if p.species() == Species::Sheep { From 7cee64a389ebeed28b57e052957610ff8d9184fb Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:48:35 +0500 Subject: [PATCH 415/968] Fix main's test build: Aabb in_water stub in end_gateway Co-Authored-By: Claude Sonnet 5.5 --- crates/horizon-client/src/end_gateway.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-client/src/end_gateway.rs b/crates/horizon-client/src/end_gateway.rs index c4089bda74..245665d0e1 100644 --- a/crates/horizon-client/src/end_gateway.rs +++ b/crates/horizon-client/src/end_gateway.rs @@ -187,7 +187,7 @@ mod tests { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool { false } fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] { From 6a066d9076d85ffae5d55144283ad9d253522bc4 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:51:25 +0200 Subject: [PATCH 416/968] Save and restore the player's attribute bases (#4425) LivingEntity.addAdditionalSaveData writes AttributeMap.pack as the player's `attributes` list and readAdditionalSaveData applies it before the health. Horizon kept the list it loaded and never wrote the live map, so /attribute base changes (scale, max_health, ...) were lost on reopen. The host writes the live attributes in sync_level and applies the saved list when its first body loads; a LAN guest does the same through playerdata::store and its first body after joining. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 2 + crates/horizon-client/src/game/lan_actions.rs | 8 +- crates/horizon-client/src/lan/playerdata.rs | 10 ++ crates/horizon-client/src/motion.rs | 152 ++++++++++++++++++ 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a3f230028e..a43194d681 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -12302,6 +12302,8 @@ impl Game<'_> { ), ); map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16)); + // LivingEntity.addAdditionalSaveData: AttributeMap.pack. + map.insert("attributes".into(), p.saved_attributes()); } level.fall_distance = p.fall_distance; diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 1e580dca97..20e49e0bc0 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -300,10 +300,14 @@ impl Game<'_> { ); host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new); // The first body after joining burns, breathes and falls on as saved. - if let Some(saved) = host.hub.guests[i].loaded_body.take() - && let Some(m) = host.hub.guests[i].motion.as_mut() + let g = &mut host.hub.guests[i]; + if let Some(saved) = g.loaded_body.take() + && let Some(m) = g.motion.as_mut() { saved.apply(m); + // LivingEntity.readAdditionalSaveData: the saved attributes. + let _ = + m.apply_saved_attributes(g.saved.as_ref().and_then(|t| t.get("attributes"))); } // The saved golden hearts go on the first body of a player who // has been here before. diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index af8f15bb4b..6de1d6fb01 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -189,6 +189,16 @@ pub fn store(guest: &Guest, level: &mut Level) { map.insert("Air".into(), short(body.air)); } } + // LivingEntity.addAdditionalSaveData: the live body's AttributeMap.pack, + // or the list the file had while the player has no body yet. + if let Some(m) = guest.motion.as_deref() { + let extra = level + .player_extra + .get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default())); + if let horizon_world::nbt::Tag::Compound(map) = extra { + map.insert("attributes".into(), m.saved_attributes()); + } + } level.recipe_book = guest.recipe_book.clone(); level.ender_items = guest.ender_items.clone(); level.inventory.clear(); diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 9f18b105b3..31d5b23f9d 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -432,6 +432,9 @@ impl PlayerMotion { .and_then(|t| crate::save::number(t.get("Air"))) .unwrap_or(300) as i32; self.fall_distance = level.fall_distance; + // LivingEntity.readAdditionalSaveData applies the saved attributes + // before it reads the health (a raised max_health keeps its hearts). + self.apply_saved_attributes(extra.and_then(|t| t.get("attributes")))?; self.set_health(level.health, false)?; // LivingEntity.readAdditionalSaveData: internalSetAbsorptionAmount. self.internal_set_absorption(level.absorption)?; @@ -681,6 +684,94 @@ impl PlayerMotion { .map_err(attribute_error)?; Ok(()) } + /// `AttributeMap.pack` in `AttributeInstance.Packed.LIST_CODEC`'s form: the + /// player's `attributes` list (LivingEntity.addAdditionalSaveData), each + /// instance's id, base and permanent modifiers. + pub fn saved_attributes(&self) -> horizon_world::nbt::Tag { + use horizon_client_core::nbt_value::string; + use horizon_world::nbt::Tag; + let list = self + .attributes + .instances() + .map(|(_, key, instance)| { + let packed = instance.pack(); + let mut entry = vec![ + ("id", string(key.name())), + ("base", Tag::Double(packed.base.to_bits())), + ]; + // `modifiers` is an optional field whose default is the empty list. + if !packed.modifiers.is_empty() { + let modifiers = packed + .modifiers + .iter() + .map(|m| { + let operation = match m.operation() { + Operation::AddValue => "add_value", + Operation::AddMultipliedBase => "add_multiplied_base", + Operation::AddMultipliedTotal => "add_multiplied_total", + }; + Tag::compound([ + ("id", string(m.id())), + ("amount", Tag::Double(m.amount().to_bits())), + ("operation", string(operation)), + ]) + }) + .collect(); + entry.push(("modifiers", Tag::List(modifiers))); + } + Tag::compound(entry) + }) + .collect(); + Tag::List(list) + } + /// `AttributeMap.apply` of a saved `attributes` list + /// (LivingEntity.readAdditionalSaveData): each known attribute takes its + /// saved base and permanent modifiers; unknown ones are skipped. + pub fn apply_saved_attributes( + &mut self, + saved: Option<&horizon_world::nbt::Tag>, + ) -> Result<()> { + use horizon_client_core::nbt_value::{float, text}; + use horizon_world::{attributes::Packed, nbt::Tag}; + let Some(Tag::List(list)) = saved else { + return Ok(()); + }; + for entry in list { + let Some(key) = text(entry.get("id")).and_then(|id| self.attributes.key(&id)) else { + continue; + }; + let modifiers = match entry.get("modifiers") { + Some(Tag::List(l)) => l + .iter() + .filter_map(|m| { + let operation = match text(m.get("operation"))?.as_str() { + "add_value" => Operation::AddValue, + "add_multiplied_base" => Operation::AddMultipliedBase, + "add_multiplied_total" => Operation::AddMultipliedTotal, + _ => return None, + }; + Modifier::new(&text(m.get("id"))?, float(m.get("amount"))?, operation).ok() + }) + .collect(), + _ => Vec::new(), + }; + let packed = Packed { + base: float(entry.get("base")).unwrap_or(0.0), + modifiers, + }; + let Some(handle) = self + .attributes + .get_instance(Some(key)) + .map_err(attribute_error)? + else { + continue; + }; + self.attributes + .edit(&handle, |value, observer| value.apply(&packed, observer)) + .map_err(attribute_error)?; + } + Ok(()) + } pub fn set_sprinting(&mut self, sprinting: bool) -> Result<()> { self.sprinting = sprinting; let key = self @@ -2640,6 +2731,67 @@ mod tests { assert!(!p.is_sweep_attack(1.0, false, true)); } + #[test] + fn attribute_bases_survive_a_save_and_reload() { + // #4425: LivingEntity.addAdditionalSaveData writes AttributeMap.pack + // and readAdditionalSaveData applies it (before the health). + let root = std::env::var_os("HORIZON_TEST_RESOURCES") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION) + }); + if !root.join("movement-properties.nbt").exists() { + eprintln!("skipped attribute save: prepared resources unavailable"); + return; + } + let assets = Assets::load(&root).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut p = PlayerMotion::new(&resources, Vec3::ZERO).unwrap(); + let set_base = |p: &mut PlayerMotion, name: &str, v: f64| { + let key = p.attributes.key(name).unwrap(); + let h = p.attributes.get_instance(Some(key)).unwrap().unwrap(); + p.attributes + .edit(&h, |a, o| a.set_base_value(v, o)) + .unwrap(); + }; + set_base(&mut p, "minecraft:scale", 2.0); + set_base(&mut p, "minecraft:max_health", 30.0); + let key = p.attributes.key("minecraft:max_health").unwrap(); + let h = p.attributes.get_instance(Some(key)).unwrap().unwrap(); + let bonus = Modifier::new("test:bonus", 0.5, Operation::AddMultipliedBase).unwrap(); + p.attributes + .edit(&h, |a, o| a.add_permanent(bonus, o)) + .unwrap(); + let mut level = crate::save::Level { + health: 40.0, + food: 20, + saturation: 5.0, + player_extra: Some(horizon_world::nbt::Tag::compound([( + "attributes", + p.saved_attributes(), + )])), + ..Default::default() + }; + // Through the player compound as the file holds it. + let tag = level.player_tag(); + level = crate::save::Level::default(); + level.read_player(&tag); + level.player_extra = Some(tag); + let mut back = PlayerMotion::new(&resources, Vec3::ZERO).unwrap(); + let mut pending = true; + back.load_saved_state_once(&mut pending, &level).unwrap(); + let base = |p: &mut PlayerMotion, name: &str| { + let key = p.attributes.key(name).unwrap(); + p.attributes.value(Some(key), ValueKind::Base).unwrap() + }; + assert_eq!(base(&mut back, "minecraft:scale"), 2.0); + assert_eq!(base(&mut back, "minecraft:max_health"), 30.0); + assert_eq!(back.attribute("minecraft:max_health").unwrap(), 45.0); + assert_eq!(back.health.health(), 40.0); + } + #[test] fn player_stands_walks_sneaks_and_jumps_on_ghast() { let root = std::env::var_os("HORIZON_TEST_RESOURCES") From 0f8f59ec2282028e6c59073b0625e90f63891f71 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:54:56 +0100 Subject: [PATCH 417/968] Nightly notes open with the way to the launcher A player ran horizon.exe from the release's game zip, thinking it was the launcher. Game releases already open with the "Want to play? Download the Horizon launcher" banner (release_train.PLAYERS, #1647); the Nightly, the only release since 10-28, did not. Its notes now start with the same banner (raw HTML, which launchers' notes views skip), and its game archives are labelled "Game files the launcher installs". Co-Authored-By: Claude Opus 5.5 --- .github/workflows/nightly-build.yml | 6 +++--- tests/test_nightly.py | 4 ++++ tools/ci/nightly.py | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly-build.yml b/.github/workflows/nightly-build.yml index 469a147f9d..c159d05135 100644 --- a/.github/workflows/nightly-build.yml +++ b/.github/workflows/nightly-build.yml @@ -233,9 +233,9 @@ jobs: # 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-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \ + "dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \ + "dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): 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 diff --git a/tests/test_nightly.py b/tests/test_nightly.py index 8adb68f273..dc529e8406 100644 --- a/tests/test_nightly.py +++ b/tests/test_nightly.py @@ -63,6 +63,10 @@ class NightlyTests(unittest.TestCase): self.assertNotIn("### Not merged yet", text) self.assertIn("Nothing new for players on main", notes()) + def test_notes_open_with_the_way_to_the_launcher(self): + # A raw HTML line that launchers skip, as on a game release. + self.assertTrue(notes().startswith(nightly.train.PLAYERS + "\n\nNightly 2026-10-28 · bbbbbbb: ")) + def test_notes_name_the_commit_and_its_permanent_source_tag(self): sha = "0c0206714abc" + "0" * 28 text = notes(chosen=nightly.Ref("main", sha, None)) diff --git a/tools/ci/nightly.py b/tools/ci/nightly.py index ab5d3fe382..290a097568 100644 --- a/tools/ci/nightly.py +++ b/tools/ci/nightly.py @@ -95,7 +95,8 @@ def notes(today: str, chosen: Ref, main: Ref, picked: list[dict], left_out: list new = [pr for pr in shown if "feature" in pr["labels"]] fixed = [pr for pr in shown if "feature" not in pr["labels"]] built = f"main ({main.sha[:7]})" + (f" with {len(picked)} pull requests merged in" if picked else "") - parts = [f"Nightly {today} · {chosen.sha[:7]}: {built}, built from `{chosen.name}` ({chosen.sha}).", + # The banner first: people who land on the Nightly ran its game zip for the launcher (a player, 2026-10-30). + parts = [train.PLAYERS, f"Nightly {today} · {chosen.sha[:7]}: {built}, built from `{chosen.name}` ({chosen.sha}).", source_line(today, chosen.sha), PLAYERS] if not shown and not picked: parts.append("Nothing new for players on main since the previous Nightly.") From 36c489208be9cd55ed0b93775edd3805c423df4e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:49:44 +0100 Subject: [PATCH 418/968] Launcher: the instance's tabs under Home's hero (redesign R7a) Home's lower part was one long page: the setup rows, the console, worlds, servers, packs and screenshots one after the other. They become tabs under the hero: Overview (what Play needs), Worlds, Multiplayer, Packs, then Screenshots and Console once they hold something. Each tab names how many it holds; its own buttons sit at the strip's end. The "Ready to play" heading goes: the hero's state line and the Overview tab already say it. HORIZON_LAUNCHER_PAGE=worlds (multiplayer, packs, screenshots, console) opens Home on that tab, for screenshots; it replaces HORIZON_LAUNCHER_CONSOLE. Co-Authored-By: Claude Opus 5.5 --- docs/design-system.md | 6 ++ launcher/lang/de.json | 9 ++- launcher/lang/es.json | 9 ++- launcher/lang/fr.json | 9 ++- launcher/lang/pl.json | 9 ++- launcher/lang/ru.json | 9 ++- launcher/lang/uk.json | 9 ++- launcher/lang/zh.json | 9 ++- launcher/src/console_page.rs | 53 ++++---------- launcher/src/home_tabs.rs | 119 +++++++++++++++++++++++++++++++ launcher/src/main.rs | 32 ++------- launcher/src/multiplayer_page.rs | 48 +++++++------ launcher/src/packs_page.rs | 65 +++++++++-------- launcher/src/screenshots_page.rs | 25 ++++--- launcher/src/worlds_page.rs | 15 ++-- 15 files changed, 273 insertions(+), 153 deletions(-) create mode 100644 launcher/src/home_tabs.rs diff --git a/docs/design-system.md b/docs/design-system.md index 794096af51..7bf46a6075 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -261,6 +261,12 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it. bar, what is happening with the time left, and the steps (game files, Java, sounds, prepare, play), the current one lit, those done ticked: steps, never the log (`HORIZON_LAUNCHER_PAGE=getting-ready` shows it for screenshots, nothing runs). +- **The instance's tabs** (`launcher/src/home_tabs.rs`): under Home's hero, one tab at a time + instead of one long page: Overview (what Play needs: the instance, the game or its resources, + the account), Worlds, Multiplayer, Packs, then Screenshots and Console once they have something. + A tab names how many it holds ("Worlds 3"); its own buttons (Add server, Open folder) sit at the + strip's end, where a list's header would put them (`HORIZON_LAUNCHER_PAGE=worlds`, `packs`... + opens one for screenshots). - **The sign-in sheet** (`launcher/src/owner_page.rs`, the owner gate): an opaque plate over the dimmed launcher, never instead of it. It says why (the account that owns Java Edition, no install needed), the code in mono on a raised box with Copy, and Open link as the main action; the window diff --git a/launcher/lang/de.json b/launcher/lang/de.json index 8c1bbd7947..3d6d827f6b 100644 --- a/launcher/lang/de.json +++ b/launcher/lang/de.json @@ -100,7 +100,6 @@ "Prepare again": "Erneut vorbereiten", "None needed: Horizon downloads Minecraft from Mojang for your account.": "Nicht erforderlich: Horizon lädt Minecraft für dein Konto von Mojang herunter.", "Not found. Choose the folder where your launcher keeps Minecraft.": "Nicht gefunden. Wähle den Ordner aus, in dem dein Launcher Minecraft speichert.", - "Ready to play": "Bereit zum Spielen", "Resources": "Ressourcen", "Minecraft folder": "Minecraft-Ordner", "Choose…": "Auswählen…", @@ -218,5 +217,11 @@ "about a minute left": "noch etwa eine Minute", "about {n} min left": "noch etwa {n} Min.", "Game files": "Spieldateien", - "Sounds": "Sounds" + "Sounds": "Sounds", + "Overview": "Übersicht", + "Worlds": "Welten", + "Multiplayer": "Mehrspieler", + "Packs": "Pakete", + "Screenshots": "Screenshots", + "Console": "Konsole" } diff --git a/launcher/lang/es.json b/launcher/lang/es.json index 9cb176cc4c..6c9eec81e5 100644 --- a/launcher/lang/es.json +++ b/launcher/lang/es.json @@ -100,7 +100,6 @@ "Prepare again": "Preparar de nuevo", "None needed: Horizon downloads Minecraft from Mojang for your account.": "No hace falta: Horizon descarga Minecraft de Mojang para tu cuenta.", "Not found. Choose the folder where your launcher keeps Minecraft.": "No encontrado. Elige la carpeta donde tu lanzador guarda Minecraft.", - "Ready to play": "Listo para jugar", "Resources": "Recursos", "Minecraft folder": "Carpeta de Minecraft", "Choose…": "Elegir…", @@ -218,5 +217,11 @@ "about a minute left": "queda un minuto aprox.", "about {n} min left": "quedan unos {n} min", "Game files": "Archivos del juego", - "Sounds": "Sonidos" + "Sounds": "Sonidos", + "Overview": "Resumen", + "Worlds": "Mundos", + "Multiplayer": "Multijugador", + "Packs": "Paquetes", + "Screenshots": "Capturas", + "Console": "Consola" } diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index a1764cdb52..ba844d74c9 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -100,7 +100,6 @@ "Prepare again": "Préparer à nouveau", "None needed: Horizon downloads Minecraft from Mojang for your account.": "Aucun dossier nécessaire : Horizon télécharge Minecraft depuis Mojang pour votre compte.", "Not found. Choose the folder where your launcher keeps Minecraft.": "Introuvable. Choisissez le dossier où votre launcher garde Minecraft.", - "Ready to play": "Prêt à jouer", "Resources": "Ressources", "Minecraft folder": "Dossier Minecraft", "Choose…": "Choisir…", @@ -218,5 +217,11 @@ "about a minute left": "environ une minute restante", "about {n} min left": "environ {n} min restantes", "Game files": "Fichiers du jeu", - "Sounds": "Sons" + "Sounds": "Sons", + "Overview": "Aperçu", + "Worlds": "Mondes", + "Multiplayer": "Multijoueur", + "Packs": "Packs", + "Screenshots": "Captures d'écran", + "Console": "Console" } diff --git a/launcher/lang/pl.json b/launcher/lang/pl.json index 13ee28a806..93f20a4072 100644 --- a/launcher/lang/pl.json +++ b/launcher/lang/pl.json @@ -94,7 +94,6 @@ "Preparing…": "Przygotowywanie…", "Prepare": "Przygotuj", "Prepare again": "Przygotuj ponownie", - "Ready to play": "Gotowe do gry", "Resources": "Zasoby", "Minecraft folder": "Folder Minecrafta", "Choose…": "Wybierz…", @@ -218,5 +217,11 @@ "about a minute left": "zostało około minuty", "about {n} min left": "zostało około {n} min", "Game files": "Pliki gry", - "Sounds": "Dźwięki" + "Sounds": "Dźwięki", + "Overview": "Przegląd", + "Worlds": "Światy", + "Multiplayer": "Gra wieloosobowa", + "Packs": "Paczki", + "Screenshots": "Zrzuty ekranu", + "Console": "Konsola" } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 51e8ffc901..c258f0a82b 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -100,7 +100,6 @@ "Prepare again": "Подготовить заново", "None needed: Horizon downloads Minecraft from Mojang for your account.": "Папка не нужна: Horizon скачает Minecraft у Mojang для вашего аккаунта.", "Not found. Choose the folder where your launcher keeps Minecraft.": "Не найдено. Выберите папку, где ваш лаунчер хранит Minecraft.", - "Ready to play": "Готово к игре", "Resources": "Ресурсы", "Minecraft folder": "Папка Minecraft", "Choose…": "Выбрать…", @@ -218,5 +217,11 @@ "about a minute left": "осталось около минуты", "about {n} min left": "осталось около {n} мин", "Game files": "Файлы игры", - "Sounds": "Звуки" + "Sounds": "Звуки", + "Overview": "Обзор", + "Worlds": "Миры", + "Multiplayer": "Сетевая игра", + "Packs": "Наборы", + "Screenshots": "Скриншоты", + "Console": "Консоль" } diff --git a/launcher/lang/uk.json b/launcher/lang/uk.json index 62e26c972e..4265aab6f1 100644 --- a/launcher/lang/uk.json +++ b/launcher/lang/uk.json @@ -100,7 +100,6 @@ "Prepare again": "Підготувати знову", "None needed: Horizon downloads Minecraft from Mojang for your account.": "Не потрібна: Horizon завантажує Minecraft від Mojang для вашого облікового запису.", "Not found. Choose the folder where your launcher keeps Minecraft.": "Не знайдено. Виберіть теку, де ваш лаунчер зберігає Minecraft.", - "Ready to play": "Готово до гри", "Resources": "Ресурси", "Minecraft folder": "Тека Minecraft", "Choose…": "Вибрати…", @@ -218,5 +217,11 @@ "about a minute left": "залишилося близько хвилини", "about {n} min left": "залишилося близько {n} хв", "Game files": "Файли гри", - "Sounds": "Звуки" + "Sounds": "Звуки", + "Overview": "Огляд", + "Worlds": "Світи", + "Multiplayer": "Мережева гра", + "Packs": "Набори", + "Screenshots": "Знімки екрана", + "Console": "Консоль" } diff --git a/launcher/lang/zh.json b/launcher/lang/zh.json index 6a9e9b9426..5724200ba7 100644 --- a/launcher/lang/zh.json +++ b/launcher/lang/zh.json @@ -99,7 +99,6 @@ "Prepare": "准备", "Prepare again": "重新准备", "Not found. Choose the folder where your launcher keeps Minecraft.": "未找到。请选择你的启动器存放 Minecraft 的文件夹。", - "Ready to play": "可以开始游戏了", "Resources": "资源", "Minecraft folder": "Minecraft 文件夹", "Choose…": "选择…", @@ -218,5 +217,11 @@ "about a minute left": "还剩约 1 分钟", "about {n} min left": "还剩约 {n} 分钟", "Game files": "游戏文件", - "Sounds": "声音" + "Sounds": "声音", + "Overview": "概览", + "Worlds": "世界", + "Multiplayer": "多人游戏", + "Packs": "资源包", + "Screenshots": "截图", + "Console": "控制台" } diff --git a/launcher/src/console_page.rs b/launcher/src/console_page.rs index 5e26a624ea..0a736bfae5 100644 --- a/launcher/src/console_page.rs +++ b/launcher/src/console_page.rs @@ -1,4 +1,4 @@ -//! Home's Console: the game's output of this run as it comes, with a search, Follow (keep the +//! Home's Console tab: the game's output of this run as it comes, with a search, Follow (keep the //! newest line in view), Copy, and Share log (`horizon_launcher::game_log`): an upload to mclo.gs //! that happens only after the player confirms, without their home folder or names. use super::servers_page::Field; @@ -7,7 +7,6 @@ use gpui::{ClipboardItem, ScrollHandle}; use horizon_launcher::game_log::{self, Shared}; pub(super) struct Console { - open: bool, follow: bool, scroll: ScrollHandle, /// Lines and search at the last draw: a change means scroll down when following. @@ -20,7 +19,6 @@ pub(super) struct Console { impl Default for Console { fn default() -> Self { Console { - open: std::env::var("HORIZON_LAUNCHER_CONSOLE").is_ok(), follow: true, scroll: ScrollHandle::new(), seen: (0, String::new()), @@ -109,48 +107,21 @@ impl Launcher { .detach(); } - /// Home's Console section, once the game has said something (or is running). - pub(super) fn console_section(&mut self, cx: &mut Context) -> Option
{ + /// Home's Console tab, once the game has said something (or is running): how many lines + /// (beside the tabs) and the log. + pub(super) fn console_section(&mut self, cx: &mut Context) -> Option<(Div, Div)> { let t = self.theme; if self.prepare_log.is_empty() && self.client.is_none() { return None; } - let open = self.console.open; - let toggle = button( - "console-toggle", - if open { "Hide" } else { "Show" }, - Kind::Ghost, - &t, - ) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.console.open = !this.console.open; - cx.notify(); - })); let count = self.prepare_log.len(); - let header = div() - .flex() - .items_center() - .justify_between() - .child(section_title("Console", "terminal", &t)) - .child( - div() - .flex() - .items_center() - .gap(px(10.)) - .child( - text(theme::CAPTION) - .text_color(t.muted) - .child(match self.client { - Some(_) => format!("{count} lines, the game is running"), - None => format!("{count} lines from the last run"), - }), - ) - .child(toggle), - ); - let mut section = div().flex().flex_col().gap(px(10.)).child(header); - if !open { - return Some(section); - } + let header = text(theme::CAPTION) + .text_color(t.muted) + .child(match self.client { + Some(_) => format!("{count} lines, the game is running"), + None => format!("{count} lines from the last run"), + }); + let mut section = div().flex().flex_col().gap(px(10.)); let search = self.servers.value(Field::ConsoleSearch).trim().to_string(); let lines: Vec = self .prepare_log @@ -315,6 +286,6 @@ impl Launcher { }; div().text_color(color).child(line) })); - Some(section.child(body)) + Some((header, section.child(body))) } } diff --git a/launcher/src/home_tabs.rs b/launcher/src/home_tabs.rs new file mode 100644 index 0000000000..454bc0fab8 --- /dev/null +++ b/launcher/src/home_tabs.rs @@ -0,0 +1,119 @@ +//! The selected instance's tabs under Home's hero (docs/design-system.md, "Night"; plan R0, screen +//! 5): Overview (what Play needs), then its worlds, servers, packs, screenshots and, once the game +//! has said something, its console. One at a time, instead of one long page under the hero. +use super::*; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) enum HomeTab { + #[default] + Overview, + Worlds, + Multiplayer, + Packs, + Screenshots, + Console, +} + +impl HomeTab { + /// `HORIZON_LAUNCHER_PAGE=worlds` (or `multiplayer`, `packs`, `screenshots`, `console`) opens + /// Home on that tab, for screenshots. + pub(super) fn from_env() -> Self { + match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() { + Ok("worlds") => HomeTab::Worlds, + Ok("multiplayer") => HomeTab::Multiplayer, + Ok("packs") => HomeTab::Packs, + Ok("screenshots") => HomeTab::Screenshots, + Ok("console") => HomeTab::Console, + _ => HomeTab::Overview, + } + } +} + +impl Launcher { + /// The tabs the selected instance has now, with how many things each holds (None: not known + /// yet, or nothing to count). Screenshots and Console only once there is something to show. + fn home_tab_list(&mut self, cx: &mut Context) -> Vec<(HomeTab, Option)> { + let mut tabs = vec![(HomeTab::Overview, None)]; + if self.selected_instance().is_none() { + return tabs; + } + tabs.push((HomeTab::Worlds, self.worlds_count(cx))); + tabs.push((HomeTab::Multiplayer, Some(self.servers_count()))); + tabs.push((HomeTab::Packs, Some(self.packs_count()))); + let shots = self.screenshots_count(); + if shots > 0 { + tabs.push((HomeTab::Screenshots, Some(shots))); + } + if !self.prepare_log.is_empty() || self.client.is_some() { + tabs.push((HomeTab::Console, None)); + } + tabs + } + + /// The tab strip and the open tab's content; `overview` is what Play needs (Home's rows). + pub(super) fn home_tabs(&mut self, overview: Div, cx: &mut Context) -> Div { + let t = self.theme; + let tabs = self.home_tab_list(cx); + // A tab that went away (no screenshots left, another instance) falls back to Overview. + let open = tabs + .iter() + .map(|(tab, _)| *tab) + .find(|tab| *tab == self.home_tab) + .unwrap_or(HomeTab::Overview); + let strip = div() + .flex() + .flex_wrap() + .gap(px(4.)) + .children(tabs.into_iter().map(|(tab, count)| { + let name = match tab { + HomeTab::Overview => tr("Overview"), + HomeTab::Worlds => tr("Worlds"), + HomeTab::Multiplayer => tr("Multiplayer"), + HomeTab::Packs => tr("Packs"), + HomeTab::Screenshots => tr("Screenshots"), + HomeTab::Console => tr("Console"), + }; + let label = match count { + Some(n) if n > 0 => format!("{name} {n}"), + _ => name.to_string(), + }; + shell::tab(format!("home-tab-{tab:?}"), &label, tab == open, &t).on_click( + cx.listener(move |this, _: &ClickEvent, _, cx| { + this.home_tab = tab; + cx.notify(); + }), + ) + })); + // The open tab's own buttons sit at the strip's end, as a list's header would. + let (actions, body) = match open { + HomeTab::Overview => (None, Some(overview.into_any_element())), + HomeTab::Worlds => ( + None, + self.worlds_section(cx).map(IntoElement::into_any_element), + ), + HomeTab::Multiplayer => self.multiplayer_section(cx).map(split).unzip(), + HomeTab::Packs => self.packs_section(cx).map(split).unzip(), + HomeTab::Screenshots => self.screenshots_section(cx).map(split).unzip(), + HomeTab::Console => self.console_section(cx).map(split).unzip(), + }; + div() + .flex() + .flex_col() + .gap(px(20.)) + .child( + div() + .flex() + .items_center() + .justify_between() + .gap(px(16.)) + .child(strip) + .children(actions.flatten()), + ) + .children(body) + } +} + +/// A tab's buttons and content, as the strip and the page take them. +fn split(tab: (Div, impl IntoElement)) -> (Option
, AnyElement) { + (Some(tab.0), tab.1.into_any_element()) +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 9c88fea007..e454ea86ab 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -17,6 +17,7 @@ mod dev_options; mod export_page; mod game_tools; mod home; +mod home_tabs; mod import_page; mod instances_page; mod internet; @@ -87,7 +88,7 @@ impl Page { /// `settings`) opens on that page, for screenshots; Home otherwise (`getting-ready`: Home /// preparing, `home::getting_ready_preview`; `sign-in`: the owner gate's sheet with a /// made-up code, `owner_page::sign_in_preview`; `new-instance`: Home with the New instance - /// dialog open on Modded). + /// dialog open on Modded; `worlds`, `packs`...: Home on that tab, `HomeTab::from_env`). fn from_env() -> Page { match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() { Ok("mods" | "modpacks" | "shaders" | "datapacks") => Page::Mods, @@ -273,6 +274,8 @@ struct Launcher { prepare_started: Option, /// The download under way as first seen (when, files done), for the time left. download_seen: Option<(Instant, usize)>, + /// The tab open under Home's hero (`home_tabs.rs`). + home_tab: home_tabs::HomeTab, play_pressed: Option, /// `horizon-launcher benchmark` started this window to measure it. bench: Option, @@ -476,6 +479,7 @@ impl Launcher { install_started: None, prepare_started: None, download_seen: None, + home_tab: home_tabs::HomeTab::from_env(), play_pressed: None, bench: perf::Bench::from_env(), packs: Vec::new(), @@ -2521,18 +2525,9 @@ impl Launcher { .into_any_element() }, ); - // "Ready to play" only when Play would start the game now: not while it needs preparing, - // prepares, waits for an account that owns it, or runs. - let running = self.client.is_some() || self.starting_client.is_some(); - let ready = play_press( - prepared || java.is_some(), - self.owner, - self.preparing || running, - ) == PlayPress::Play; let mut rows = div() .flex() .flex_col() - .children(ready.then(|| section_title("Ready to play", "check", &t).mb(px(12.)))) .children(self.instance_row(1, cx)) .children(self.export_panel(cx)) .children(self.launch_panel(cx)); @@ -2681,22 +2676,7 @@ impl Launcher { if let Some(e) = self.prepare_error.clone() { page = page.child(notice(e, &t)); } - page = page.child(rows); - if let Some(console) = self.console_section(cx) { - page = page.child(console); - } - if let Some(worlds) = self.worlds_section(cx) { - page = page.child(worlds); - } - if let Some(servers) = self.multiplayer_section(cx) { - page = page.child(servers); - } - if let Some(packs) = self.packs_section(cx) { - page = page.child(packs); - } - if let Some(shots) = self.screenshots_section(cx) { - page = page.child(shots); - } + page = page.child(self.home_tabs(rows, cx)); if let Some(e) = self.client_error.clone() { page = page.child(notice(e, &t)); } diff --git a/launcher/src/multiplayer_page.rs b/launcher/src/multiplayer_page.rs index d46edea032..58dae51b9b 100644 --- a/launcher/src/multiplayer_page.rs +++ b/launcher/src/multiplayer_page.rs @@ -77,30 +77,34 @@ impl Launcher { } } - /// Home's Multiplayer section, for the selected instance. - pub(super) fn multiplayer_section(&mut self, cx: &mut Context) -> Option
{ + /// How many servers the selected instance's list has. + pub(super) fn servers_count(&mut self) -> usize { + let Some(home) = self.selected_instance().map(|i| i.home.clone()) else { + return 0; + }; + self.load_servers(home); + self.multiplayer.list.len() + } + + /// Home's Multiplayer tab, for the selected instance: its button (beside the tabs) and its + /// list. + pub(super) fn multiplayer_section(&mut self, cx: &mut Context) -> Option<(Div, Div)> { let t = self.theme; let home = self.selected_instance()?.home.clone(); self.load_servers(home); - let header = div() - .flex() - .items_center() - .justify_between() - .mb(px(4.)) - .child(section_title("Multiplayer", "server", &t)) - .child( - button("server-add", "Add server", Kind::Ghost, &t).on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.multiplayer.adding = !this.multiplayer.adding; - this.multiplayer.error = None; - if this.multiplayer.adding { - this.servers.focus_soon = Some(Field::ServerAddress); - } - cx.notify(); - }, - )), - ); - let mut section = div().flex().flex_col().gap(px(8.)).child(header); + let actions = div().child( + button("server-add", "Add server", Kind::Ghost, &t).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.multiplayer.adding = !this.multiplayer.adding; + this.multiplayer.error = None; + if this.multiplayer.adding { + this.servers.focus_soon = Some(Field::ServerAddress); + } + cx.notify(); + }, + )), + ); + let mut section = div().flex().flex_col().gap(px(8.)); if let Some(e) = self.multiplayer.error.clone() { section = section.child(notice(e, &t)); } @@ -133,7 +137,7 @@ impl Launcher { for (i, (server, icon)) in list.into_iter().enumerate() { section = section.child(self.server_row(i, server, icon, cx)); } - Some(section) + Some((actions, section)) } fn server_row( diff --git a/launcher/src/packs_page.rs b/launcher/src/packs_page.rs index d33b257dd6..4bee5b6d75 100644 --- a/launcher/src/packs_page.rs +++ b/launcher/src/packs_page.rs @@ -89,39 +89,39 @@ impl Launcher { .detach(); } - /// Home's Packs section, for the selected instance. - pub(super) fn packs_section(&mut self, cx: &mut Context) -> Option> { + /// How many resource and shader packs the selected instance has. + pub(super) fn packs_count(&mut self) -> usize { + let Some(home) = self.selected_instance().map(|i| i.home.clone()) else { + return 0; + }; + self.load_packs_list(home); + self.game_packs.resource.len() + self.game_packs.shader.len() + } + + /// Home's Packs tab, for the selected instance: its buttons (beside the tabs) and its list. + pub(super) fn packs_section(&mut self, cx: &mut Context) -> Option<(Div, Stateful
)> { let t = self.theme; let home = self.selected_instance()?.home.clone(); self.load_packs_list(home.clone()); - let header = - div() - .flex() - .items_center() - .justify_between() - .mb(px(4.)) - .child(section_title("Packs", "package", &t)) - .child( - div() - .flex() - .items_center() - .gap(px(6.)) - .child(button("packs-add", "Add pack…", Kind::Ghost, &t).on_click( - cx.listener(|this, _: &ClickEvent, _, cx| { - this.choose_packs(cx); - cx.notify(); - }), - )) - .child( - button("packs-folder", "Open folder", Kind::Ghost, &t).on_click( - move |_, _, cx| { - let dir = home.join(PackKind::Resource.folder()); - let _ = std::fs::create_dir_all(&dir); - cx.open_with_system(&dir); - }, - ), - ), - ); + let actions = div() + .flex() + .items_center() + .gap(px(6.)) + .child( + button("packs-add", "Add pack…", Kind::Ghost, &t).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.choose_packs(cx); + cx.notify(); + }, + )), + ) + .child( + button("packs-folder", "Open folder", Kind::Ghost, &t).on_click(move |_, _, cx| { + let dir = home.join(PackKind::Resource.folder()); + let _ = std::fs::create_dir_all(&dir); + cx.open_with_system(&dir); + }), + ); let mut section = div() .id("packs") .flex() @@ -138,8 +138,7 @@ impl Launcher { // Not the window's import as well. cx.stop_propagation(); cx.notify(); - })) - .child(header); + })); match &self.game_packs.note { Some(Ok(m)) => { section = section.child(text(theme::CAPTION).text_color(t.muted).child(m.clone())) @@ -176,7 +175,7 @@ impl Launcher { for (i, pack) in shader.into_iter().enumerate() { section = section.child(self.pack_row(i, pack, None, cx)); } - Some(section) + Some((actions, section)) } fn pack_row( diff --git a/launcher/src/screenshots_page.rs b/launcher/src/screenshots_page.rs index d4017ade16..4764e85b4a 100644 --- a/launcher/src/screenshots_page.rs +++ b/launcher/src/screenshots_page.rs @@ -99,8 +99,18 @@ impl Launcher { self.screenshots.stale(); } - /// Home's Screenshots section, for the selected instance; None while it has none. - pub(super) fn screenshots_section(&mut self, cx: &mut Context) -> Option
{ + /// How many screenshots the selected instance has. + pub(super) fn screenshots_count(&mut self) -> usize { + let Some(home) = self.selected_instance().map(|i| i.home.clone()) else { + return 0; + }; + self.load_screenshots(home); + self.screenshots.list.len() + } + + /// Home's Screenshots tab, for the selected instance: its buttons (beside the tabs) and its + /// pictures; None while it has none. + pub(super) fn screenshots_section(&mut self, cx: &mut Context) -> Option<(Div, Div)> { let t = self.theme; let home = self.selected_instance()?.home.clone(); self.load_screenshots(home.clone()); @@ -131,13 +141,6 @@ impl Launcher { button("screenshots-folder", "Open folder", Kind::Ghost, &t) .on_click(move |_, _, cx| cx.open_with_system(&folder)), ); - let header = div() - .flex() - .items_center() - .justify_between() - .mb(px(4.)) - .child(section_title("Screenshots", "image", &t)) - .child(buttons); let grid = div().flex().flex_wrap().gap(px(10.)).children( list.into_iter().take(shown).enumerate().map(|(i, shot)| { div() @@ -163,13 +166,13 @@ impl Launcher { })) }), ); - let mut section = div().flex().flex_col().gap(px(8.)).child(header); + let mut section = div().flex().flex_col().gap(px(8.)); if self.screenshots.viewing.is_none() { if let Some(Err(e)) = &self.screenshots.note { section = section.child(notice(e.clone(), &t)); } } - Some(section.child(grid)) + Some((buttons, section.child(grid))) } /// The viewer over the whole window, while a picture is open. diff --git a/launcher/src/worlds_page.rs b/launcher/src/worlds_page.rs index 93fb7ae7a1..63958adabb 100644 --- a/launcher/src/worlds_page.rs +++ b/launcher/src/worlds_page.rs @@ -111,17 +111,20 @@ impl Launcher { .detach(); } - /// Home's Worlds section, for the selected instance. + /// How many worlds the selected instance has; None while they are read. + pub(super) fn worlds_count(&mut self, cx: &mut Context) -> Option { + let home = self.selected_instance()?.home.clone(); + self.load_worlds(home, cx); + self.worlds.fresh.then_some(self.worlds.list.len()) + } + + /// Home's Worlds tab, for the selected instance. pub(super) fn worlds_section(&mut self, cx: &mut Context) -> Option
{ let t = self.theme; let home = self.selected_instance()?.home.clone(); self.load_worlds(home.clone(), cx); let list = self.worlds.list.clone(); - let mut section = div() - .flex() - .flex_col() - .gap(px(8.)) - .child(section_title("Worlds", "globe", &t).mb(px(4.))); + let mut section = div().flex().flex_col().gap(px(8.)); match &self.worlds.done { Some(Ok((message, zip))) => { let mut line = div().flex().items_center().gap(px(12.)).child( From e6f5246fdf9980c8d3aa3633106798ea37af3a51 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:54:07 +0100 Subject: [PATCH 419/968] Game started without its files: a launcher screen, not a crash A player ran horizon.exe from the release's game zip, thinking it was the launcher. v0.2.97 then failed with "resource loader stopped before finishing" and a crash report: the loader's real error (no prepared files) was taken by App::about_to_wait, and the next poll saw the closed channel (fixed on main by 9f28fe890, which also checks horizon-resources.json before any window). That check still ended in an error and a crash report. - An interactive game with no prepared files now opens a window in Horizon's design language (bundled fonts, none of Minecraft's files needed): start Horizon from the launcher, Download (opens playhorizon.dev), Quit. The console says the same. Closing it quits with code 0 and no crash report. Offscreen, scripted and server runs keep the error. - The heartbeat and servers.dat wait until the files are there. - A loader channel that closes without a result always names why. - PLAY.txt sends players to playhorizon.dev for the launcher. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 35 ++- .../horizon-client/src/game/no_resources.rs | 167 +++++++++++ .../horizon-client/src/game/startup_menu.rs | 9 +- crates/horizon-client/src/lib.rs | 1 + crates/horizon-client/src/no_resources.rs | 278 ++++++++++++++++++ packaging/PLAY.txt | 5 +- 6 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 crates/horizon-client/src/game/no_resources.rs create mode 100644 crates/horizon-client/src/no_resources.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a3f230028e..658f6871fe 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -168,6 +168,7 @@ mod natural_spawning; mod nautili; mod net_harness; mod nether_portal; +mod no_resources; mod ominous_item_spawner; mod options; mod packs; @@ -16510,9 +16511,11 @@ fn missing_resources(root: &std::path::Path) -> Option { let shown = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf()); Some(format!( "Horizon's prepared game files are missing: no horizon-resources.json in {}\n\ - Start Horizon from the Horizon launcher, which downloads and prepares them, \ - or prepare them with: horizon-prepare --prepare (or pass --resources )", - shown.display() + Horizon is started from its launcher: download it at {} (Download). It needs your \ + own Minecraft: Java Edition; the launcher signs you in and prepares the game files.\n\ + Developers: horizon-prepare --prepare, or pass --resources ", + shown.display(), + horizon_client::no_resources::DOWNLOAD_URL )) } @@ -16595,6 +16598,23 @@ pub fn run() -> Result<()> { // 10 to 200 ms to get one back. horizon_threads::favour_current(); } + if options.execution_role.is_client() { + horizon_threads::catch_stop_signals(); + } + let started = Instant::now(); + horizon_client::spans::enable(options.spikes.is_some()); + let vanilla_root = options.resources.clone(); + if let Some(error) = missing_resources(&vanilla_root) { + // The game build started by hand (the release's zip), not by the + // launcher: a window says where to get it. Scripts and servers get + // the error. + if !options.offscreen && !synchronous_startup(&options) { + log::error!("Horizon: {error}"); + let settings = Settings::load(&std::env::current_dir()?.join("options.txt")); + return no_resources::show(&vanilla_root, &settings); + } + return Err(error.into()); + } // Someone is playing: the anonymous ping behind the launcher's "N playing now", and the // official server in their list (servers.dat in the game folder, the working directory). Not // for a server, a benchmark, a run that quits by itself, a harness's fixture or a run that @@ -16606,15 +16626,6 @@ pub fn run() -> Result<()> { log::warn!("could not add the official server to servers.dat: {e}"); } } - if options.execution_role.is_client() { - horizon_threads::catch_stop_signals(); - } - let started = Instant::now(); - horizon_client::spans::enable(options.spikes.is_some()); - let vanilla_root = options.resources.clone(); - if let Some(error) = missing_resources(&vanilla_root) { - return Err(error.into()); - } let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt")); // "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord. if options.dedicated.is_none() { diff --git a/crates/horizon-client/src/game/no_resources.rs b/crates/horizon-client/src/game/no_resources.rs new file mode 100644 index 0000000000..0b80957ae2 --- /dev/null +++ b/crates/horizon-client/src/game/no_resources.rs @@ -0,0 +1,167 @@ +//! A game started without its prepared files (the game build run by hand, not +//! through the launcher): a window that says where to get the launcher +//! (`horizon_client::no_resources`) and quits when it is closed. No loader, no +//! crash report: nothing went wrong in the game. +use super::*; +use horizon_client::no_resources::{Action, DOWNLOAD_URL, NoResources}; +use winit::event::MouseButton; + +struct Missing { + window: Option>, + renderer: Option, + gui: UiResources, + screen: NoResources, + /// The pointer in physical pixels. + pointer: (f64, f64), + api: horizon_client::graphics::GraphicsApi, + error: Option>, +} + +/// Shows the screen until the player closes it. +pub(super) fn show(looked_in: &std::path::Path, settings: &Settings) -> Result<()> { + let mut app = Missing { + window: None, + renderer: None, + gui: UiResources { + atlas: HudAtlas::build(looked_in)?, + panorama: Arc::new(horizon_client::gfx::panorama::Faces { + size: 1, + pixels: [0u8; 4].repeat(6), + }), + }, + screen: NoResources::new(looked_in), + pointer: (0.0, 0.0), + api: settings.horizon.api, + error: None, + }; + horizon_client::hud::GUI_SCALE.store(settings.gui_scale, Ordering::Relaxed); + EventLoop::new()?.run_app(&mut app)?; + app.error.map_or(Ok(()), Err) +} + +impl Missing { + fn size(&self) -> [u32; 2] { + self.renderer + .as_ref() + .map_or([1280, 720], |r| [r.config.width, r.config.height]) + } + + /// `f` over a Ui of the window's size, the cursor where the pointer is. + fn with_ui( + &self, + batch: &mut HudBatch, + f: impl FnOnce(&mut horizon_client::ui_theme::Ui) -> R, + ) -> R { + let cursor = ( + self.pointer.0 as f32 / batch.scale, + self.pointer.1 as f32 / batch.scale, + ); + let mut ui = horizon_client::ui_theme::Ui::new(&self.gui.atlas, batch, cursor); + let result = f(&mut ui); + ui.finish(); + result + } + + fn draw(&mut self) { + let mut batch = HudBatch::new(self.size()); + self.with_ui(&mut batch, |ui| self.screen.draw(ui)); + let Some(renderer) = &mut self.renderer else { + return; + }; + match renderer.render(&loading_frame(&batch)) { + Ok(true) => return, + Ok(false) => {} + Err(e) => log::warn!("Horizon: frame failed: {e}"), + } + // The frame was not shown (the first one often isn't): the glyphs it + // rasterized never reached the atlas, so draw them again next frame. + horizon_client::ui_theme::reset_atlas(); + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + fn click(&mut self, event_loop: &ActiveEventLoop) { + let mut batch = HudBatch::new(self.size()); + match self.with_ui(&mut batch, |ui| self.screen.click(ui)) { + Some(Action::Download) => packs::open_uri(DOWNLOAD_URL), + Some(Action::Quit) => event_loop.exit(), + None => {} + } + } +} + +impl ApplicationHandler for Missing { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attributes = Window::default_attributes() + .with_title("Horizon") + .with_window_icon(horizon_client::window::icon()) + .with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0)); + let made = event_loop + .create_window(attributes) + .map_err(Into::into) + .and_then(|window| { + let window = Arc::new(window); + let renderer = + loading_renderer(window.clone(), false, Some(&self.gui), None, self.api)?; + Ok((window, renderer)) + }); + match made { + Ok((window, renderer)) => { + window.request_redraw(); + self.window = Some(window); + self.renderer = Some(renderer); + } + Err(e) => { + self.error = Some(e); + event_loop.exit(); + } + } + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { + let Some(window) = self.window.clone().filter(|w| w.id() == id) else { + return; + }; + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => { + if let Some(r) = &mut self.renderer { + r.resize(size.width, size.height); + } + window.request_redraw(); + } + WindowEvent::RedrawRequested => self.draw(), + WindowEvent::CursorMoved { position, .. } => { + self.pointer = (position.x, position.y); + window.request_redraw(); + } + WindowEvent::MouseInput { + state: ElementState::Released, + button: MouseButton::Left, + .. + } => self.click(event_loop), + WindowEvent::KeyboardInput { event, .. } + if event.state == ElementState::Pressed + && event.physical_key == PhysicalKey::Code(KeyCode::Escape) => + { + event_loop.exit() + } + _ => {} + } + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 { + event_loop.exit(); + return; + } + // Wake now and then for Ctrl+C in the console; redraws come with events. + event_loop.set_control_flow(ControlFlow::WaitUntil( + Instant::now() + Duration::from_millis(250), + )); + } +} diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index a377377bb0..8d075e810a 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -284,11 +284,12 @@ impl Loading { Err(TryRecvError::Disconnected) => { // The loader sends `Done` before it returns: it panicked. let reason = match self.handle.take().map(JoinHandle::join) { - Some(Err(payload)) => format!(": {}", panic_text(payload.as_ref())), - _ => String::new(), + Some(Err(payload)) => panic_text(payload.as_ref()).to_owned(), + Some(Ok(())) => "it returned without a result".into(), + None => "no loader thread was running".into(), }; return Err( - format!("resource loader stopped before finishing{reason}").into() + format!("resource loader stopped before finishing: {reason}").into(), ); } } @@ -887,7 +888,7 @@ mod tests { let mut loading = loading(receiver); assert_eq!( loading.poll_menu_resources().unwrap_err().to_string(), - "resource loader stopped before finishing" + "resource loader stopped before finishing: no loader thread was running" ); } } diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 428d29e827..af34740e38 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -82,6 +82,7 @@ pub mod multiplayer; pub mod music; pub mod net; pub mod nether_portal; +pub mod no_resources; pub mod offline_name; pub mod osk; pub mod patrol; diff --git a/crates/horizon-client/src/no_resources.rs b/crates/horizon-client/src/no_resources.rs new file mode 100644 index 0000000000..7b639201d6 --- /dev/null +++ b/crates/horizon-client/src/no_resources.rs @@ -0,0 +1,278 @@ +//! The screen of a game started without its prepared files: the game build +//! run by hand (the release's game zip) instead of through the launcher. It +//! says where to get the launcher, and quits. Drawn in Horizon's design +//! language with the bundled fonts: none of Minecraft's files are there. +use crate::ui_theme::{self, Face, Ui, color}; + +/// The launcher's download page. +pub const DOWNLOAD_URL: &str = "https://playhorizon.dev"; + +const TITLE: &str = "Start Horizon from the launcher"; +const PARAGRAPHS: [&str; 3] = [ + "This is the game the Horizon launcher installs and starts. On its own it has none of \ + Minecraft's files, so it can't start.", + "Horizon needs your own copy of Minecraft: Java Edition. The launcher signs you in and \ + prepares the game files from it.", + "Download the launcher at playhorizon.dev, then press Play there.", +]; +const COLUMN: f32 = 560.0; +const BUTTON_GAP: f32 = 12.0; + +/// What a click on the screen does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// Open the launcher's download page. + Download, + Quit, +} + +pub struct NoResources { + /// Where the prepared files were looked for, for whoever starts the game by hand on purpose. + looked_in: String, +} + +impl NoResources { + pub fn new(looked_in: &std::path::Path) -> Self { + let shown = std::path::absolute(looked_in).unwrap_or_else(|_| looked_in.to_path_buf()); + Self { + looked_in: format!("No prepared game files in {}", shown.display()), + } + } + + /// The buttons' labels, actions and rectangles in dp, and the paragraphs' wrapped + /// lines: one layout for drawing and clicks. + fn layout(&self, ui: &Ui) -> Layout { + let width = COLUMN.min(ui.w - 64.0).max(160.0); + let x = ((ui.w - width) / 2.0).round(); + let lines: Vec> = PARAGRAPHS + .iter() + .map(|p| wrap(ui, Face::Regular, BODY, p, width)) + .collect(); + let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::() + + (lines.len() - 1) as f32 * PARAGRAPH_GAP; + let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE; + let y = ((ui.h - height) / 2.0).max(24.0).round(); + let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0; + let mut buttons = Vec::new(); + let mut bx = x; + for (label, action) in [ + ("Download the launcher", Action::Download), + ("Quit", Action::Quit), + ] { + let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round(); + buttons.push(Button { + label, + action, + x: bx, + y: buttons_y, + w, + }); + bx += w + BUTTON_GAP; + } + Layout { + x, + y, + width, + lines, + buttons, + } + } + + pub fn draw(&self, ui: &mut Ui) { + let layout = self.layout(ui); + ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG); + let mut y = layout.y; + ui.text_line( + Face::Medium, + TITLE_SIZE, + TITLE_LINE, + layout.x, + y, + color::TEXT, + TITLE, + ); + y += TITLE_LINE + 16.0; + for paragraph in &layout.lines { + for line in paragraph { + ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line); + y += LINE; + } + y += PARAGRAPH_GAP; + } + for button in &layout.buttons { + let hovered = button.contains(ui.cursor); + let (plate, ink) = match (button.action, hovered) { + (Action::Download, false) => (color::ACCENT, color::ON_ACCENT), + (Action::Download, true) => { + (color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT) + } + (Action::Quit, false) => (color::RAISED, color::TEXT), + (Action::Quit, true) => (color::HOVER, color::TEXT), + }; + let h = CONTROL; + ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate); + let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label); + let lx = button.x + ((button.w - lw) / 2.0).round(); + ui.text_line( + Face::Medium, + BUTTON_TEXT, + h, + lx, + button.y, + ink, + button.label, + ); + } + let small_y = layout.buttons[0].y + CONTROL + 20.0; + for (i, line) in wrap( + ui, + Face::Regular, + ui_theme::SMALL, + &self.looked_in, + layout.width, + ) + .iter() + .enumerate() + { + let line_y = small_y + i as f32 * SMALL_LINE; + ui.text_line( + Face::Regular, + ui_theme::SMALL, + SMALL_LINE, + layout.x, + line_y, + color::MUTED, + line, + ); + } + } + + /// The button under the cursor (`ui.cursor`, in dp), if any. + pub fn click(&self, ui: &Ui) -> Option { + self.layout(ui) + .buttons + .iter() + .find(|b| b.contains(ui.cursor)) + .map(|b| b.action) + } +} + +const TITLE_SIZE: f32 = horizon_theme::TITLE.px; +const TITLE_LINE: f32 = 40.0; +const BODY: f32 = horizon_theme::BODY.px; +const LINE: f32 = 24.0; +const PARAGRAPH_GAP: f32 = 10.0; +const BUTTON_TEXT: f32 = horizon_theme::UI.px; +const SMALL_LINE: f32 = 18.0; +const CONTROL: f32 = horizon_theme::CONTROL_LARGE; + +struct Layout { + x: f32, + y: f32, + width: f32, + lines: Vec>, + buttons: Vec

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 DnR;@t!+~r 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 k5SgloMBW@ Date: Fri, 30 Oct 2026 02:36:05 +0100 Subject: [PATCH 224/968] Start remote end crystal spin from a random phase A crystal spawned from an add-entity packet kept time 0, so every crystal placed in multiplayer or LAN looked identically rotated. Vanilla's EndCrystal constructor draws time from nextInt(100000). --- .../src/game/remote_projectiles.rs | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 12c2358ae1..2586111526 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -286,14 +286,19 @@ impl Game<'_> { } // FishingHook.recreateFromPacket: the owner's id is the data. "minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, velocity, data), - "minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal { - id, - position: at, - show_bottom: true, - time: 0, - beam: None, - invulnerable: false, - }), + "minecraft:end_crystal" => { + // EndCrystal's constructor starts `time` random, so each + // placed crystal spins from its own phase. + let time = self.world.random.next_int_bound(100_000).unwrap_or(0); + self.crystals.push(super::dragon::Crystal { + id, + position: at, + show_bottom: true, + time, + beam: None, + invulnerable: false, + }); + } _ => { let Some(k) = projectile_kind(kind) else { return false; From 77738c63560a0aa868c83ceca76f3118b288b498 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:38:18 +0100 Subject: [PATCH 225/968] Keep latched sneak and sprint across a short focus loss Focus loss cleared the key set, which also held the keys synthesized from the sneak/sprint toggles. A quick alt-tab opens no pause screen, so nothing restored them. Re-sync both toggles after the clear. Fixes #4133. --- crates/horizon-client/src/game.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c2b7b4ad97..b2dc1402e9 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -13408,6 +13408,11 @@ impl ApplicationHandler for Game<'_> { self.focus_lost = (!focused).then(Instant::now); if !focused && self.control.is_none() { self.keys.clear(); + // Latched sneak/sprint live in `toggles`, not in the + // physical keys: put them back so a short alt-tab + // (no pause screen) does not drop them. + self.sync_toggle(0); + self.sync_toggle(1); self.left = false; self.right = false; self.capture(false); From 63325c9928006cef86b0340af9942378e06c7c6f Mon Sep 17 00:00:00 2001 From: martipops Date: Thu, 29 Oct 2026 21:39:33 -0400 Subject: [PATCH 226/968] Audio: a shared output keeps the device name upstream added Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/audio.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs index a02e678915..f322f17c9e 100644 --- a/crates/horizon-client-render/src/audio.rs +++ b/crates/horizon-client-render/src/audio.rs @@ -889,6 +889,7 @@ impl Audio { engine.loaded = self.backend.live; Self { _stream: None, + device_name: self.device_name.clone(), engine, backend: MixerBackend { mixer: self.backend.mixer.clone(), From 3acfebb2fe48c3849a62aaef1108eb3f731ed992 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:41:31 +0100 Subject: [PATCH 227/968] Document the armor stand seat offset --- crates/horizon-client/src/game/passengers.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/passengers.rs b/crates/horizon-client/src/game/passengers.rs index 7cde83a271..6d4dde2ae8 100644 --- a/crates/horizon-client/src/game/passengers.rs +++ b/crates/horizon-client/src/game/passengers.rs @@ -148,7 +148,8 @@ impl Game<'_> { } // Armor stands riding a mob or the local player (a server's name tag // hologram): seated like the displays above, so the name follows its - // vehicle instead of staying where the stand spawned. + // vehicle instead of staying where the stand spawned. The stand's own + // vehicle attachment is the default 0, so its feet sit on the seat. for i in 0..self.armor_stands.len() { let Some(vehicle) = self.armor_stands[i].vehicle else { continue; From a97afdc93fb152411e97c2fe25d0f814966b44ea Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:46:03 +0100 Subject: [PATCH 228/968] Hover the nearest slot when the pointer is in the gap between slots Vanilla's AbstractContainerScreen.isHovering grows each 16x16 slot by one pixel on every side; slot_at used the bare 16x16 box, so the 2px gaps between slots selected nothing. Fixes #4134. --- crates/horizon-client/src/game.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c2b7b4ad97..12357f4725 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6743,7 +6743,9 @@ impl Game<'_> { let (ox, oy) = self.inventory_origin(batch); self.slots().into_iter().find(|&i| { let (sx, sy) = self.slot_position(i); - x >= ox + sx && x < ox + sx + 16.0 && y >= oy + sy && y < oy + sy + 16.0 + // AbstractContainerScreen.isHovering: the 16x16 slot grows by one + // pixel on every side, so the gaps between slots still hover one. + x >= ox + sx - 1.0 && x < ox + sx + 17.0 && y >= oy + sy - 1.0 && y < oy + sy + 17.0 }) } fn slot_mut(&mut self, i: usize) -> &mut Option { From 8ec6e3d9f41818a773242ae6586474392fd652c2 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:53:03 +0100 Subject: [PATCH 229/968] Update port ledger note for slot_at hover margin --- tools/port_map/gui-inventory-1.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/port_map/gui-inventory-1.tsv b/tools/port_map/gui-inventory-1.tsv index 0298232c26..10cf129b9b 100644 --- a/tools/port_map/gui-inventory-1.tsv +++ b/tools/port_map/gui-inventory-1.tsv @@ -21,7 +21,7 @@ AbstractContainerScreen.getHoveredSlot port crates/horizon-client/src/game.rs::G AbstractContainerScreen.slotClicked port crates/horizon-client/src/game.rs::Game::menu_ops includes the bundle unselect of onMouseClickAction; sent to the server on a remote game AbstractContainerScreen.mouseClicked port crates/horizon-client/src/game.rs::Game::inventory_press press logic is container_screen::Clicks::press; double click gathers, Shift double click quick-moves all AbstractContainerScreen.mouseDragged port crates/horizon-client/src/game.rs::Game::inventory_drag_move -AbstractContainerScreen.isHovering port crates/horizon-client/src/game.rs::Game::slot_at hit box is the 16x16 slot; vanilla uses 18x18 (one pixel more each side), so the border between slots hits nothing +AbstractContainerScreen.isHovering port crates/horizon-client/src/game.rs::Game::slot_at hit box is the 16x16 slot grown by one pixel on each side, like vanilla AbstractContainerScreen.onMouseClickAction inline crates/horizon-client/src/game.rs::Game::menu_ops Shift click or swap clears a bundle's selection AbstractContainerScreen.keyPressed port crates/horizon-client/src/game.rs::Game::inventory_hotkey number keys, F and Q over a slot; keyPickItem bound to a keyboard key does not clone the hovered slot AbstractContainerScreen.checkHotbarKeyPressed port crates/horizon-client/src/game.rs::Game::inventory_hotkey hotbar and off hand swaps while the hand is empty From 73632dae7edacd4b077109c47c180c04d541b140 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 02:59:29 +0100 Subject: [PATCH 230/968] lan_e2e: use vanilla ExactTeleport key for the End gateway in guest_end Fixes #4145 --- tools/lan_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 3d279c48f8..685de55325 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -1001,7 +1001,7 @@ def level_guest_end(host, guest, out): level_floor(guest, 700, 80, 0) guest('command', command='tp @s 700.5 80 0.5') guest.wait(20) - guest('command', command='setblock 702 80 0 minecraft:end_gateway{exit_portal:[I;100,50,0],exact_teleport:1b}') + guest('command', command='setblock 702 80 0 minecraft:end_gateway{exit_portal:[I;100,50,0],ExactTeleport:1b}') guest('command', command='tp @s 702.5 80 0.5') gateway = None for _ in range(20): From 7037d586cab041729acf4bbb1eca94c087837d79 Mon Sep 17 00:00:00 2001 From: asowneryt Date: Fri, 30 Oct 2026 15:05:11 +1300 Subject: [PATCH 231/968] Tamed pets no longer turn on the owner who hits them Vanilla's TamableAnimal.canAttack refuses the pet's owner (isOwnedBy), so HurtByTargetGoal never starts when the owner hits their wolf, cat or parrot, and no target or anger follows. Horizon's player check (Mob::spares_players) only knew the player-built iron golem, so a tamed wolf hunted its owner after one hit (#4119). It now spares a TamableAnimal's owner too, in every place that check is made. NonTameRandomTargetGoal also checks isTame before its random interval roll: the wolf's and cat's untamed-only target goals now gate before the draw, so a tamed pet's random sequence follows vanilla. mob_ai harness: a new `owned` flag (tamed by the scenario's player) and the wolf_owner_hurt scenario. Co-Authored-By: Claude Opus 5.5 --- .../examples/probe/mob_ai_probe.rs | 4 ++ crates/horizon-client/src/mobs/goals.rs | 45 ++++++++++++------- crates/horizon-client/src/mobs/golem.rs | 4 +- crates/horizon-client/src/mobs/tame.rs | 8 ++-- .../horizonlab/parity/VanillaMobAiOracle.java | 6 +++ tools/test_mob_ai_parity.py | 5 +++ 6 files changed, 51 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 2ad40a2f7a..a8fac88bc2 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1016,6 +1016,10 @@ pub fn main() -> horizon_client::Result<()> { "inlove" => m.extra.in_love = 600, "nectar" => horizon_client::mobs::bee::set_has_nectar(&mut m, true), "tame" => m.extra.tame = true, + "owned" => { + horizon_client::mobs::tame::set_tame(&mut m, true); + m.extra.owner = Some(PLAYER_UUID); + } x if x.starts_with("age=") => m.extra.age = x[4..].parse().unwrap(), x if x.starts_with("strength=") => m.extra.variant2 = x[9..].parse().unwrap(), x if x.starts_with("hand=") => { diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index f208236de4..085c9e8db6 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -591,15 +591,34 @@ impl Mob { let b = self.bounds().inflate(Vec3::new(reach, 0.0, reach)); b.intersects(t.bounds) } - /// `IronGolem.canAttack`: a golem a player built never attacks players. - pub fn spares_players(&self) -> bool { - self.species() == Species::IronGolem && self.extra.player_created + /// Whether the species is a `TamableAnimal` (not the horses and llamas, + /// which are tamed another way). + pub fn is_tamable_animal(&self) -> bool { + matches!( + self.species(), + Species::Wolf + | Species::Cat + | Species::Parrot + | Species::Nautilus + | Species::ZombieNautilus + ) + } + /// `canAttack` for the player: a golem a player built never attacks + /// players (`IronGolem.canAttack`), and a pet never attacks its owner + /// (`TamableAnimal.canAttack`: `isOwnedBy`). + pub fn spares_players(&self, w: &MobWorld) -> bool { + (self.species() == Species::IronGolem && self.extra.player_created) + || (self.is_tamable_animal() + && self.extra.tame + && w.player + .as_ref() + .is_some_and(|p| super::tame::owned_by(self.extra.owner, p.uuid))) } /// TargetingConditions.forCombat().test for the player: alive, not /// creative, within range scaled by visibility, optionally visible. pub fn can_target_player(&mut self, w: &MobWorld, range: f64, check_sight: bool) -> bool { let Some(p) = &w.player else { return false }; - if self.spares_players() { + if self.spares_players(w) { return false; } // TargetingConditions.isCombat: never an ally. @@ -612,7 +631,7 @@ impl Mob { /// a target the mob already has, which ignores the visibility scaling. pub fn can_keep_target_player(&mut self, w: &MobWorld, range: f64) -> bool { let Some(p) = &w.player else { return false }; - if self.spares_players() { + if self.spares_players(w) { return false; } if p.creative || p.allies.contains(&self.id) { @@ -1428,7 +1447,8 @@ pub fn target_continue(st: &mut TargetState, must_see: bool, m: &mut Mob, cx: &m let Some(t) = cx.w.target_info(who) else { return false; }; - if !t.alive || !t.can_be_seen_as_enemy || t.is_player && (t.creative || m.spares_players()) { + if !t.alive || !t.can_be_seen_as_enemy || t.is_player && (t.creative || m.spares_players(cx.w)) + { return false; } let follow = m.attrs.follow_range * st.follow_scale; @@ -1619,7 +1639,9 @@ impl Goal for HurtByTarget { let Some(t) = cx.w.target_info(by) else { return false; }; - t.alive && t.can_be_seen_as_enemy && !(t.is_player && (t.creative || m.spares_players())) + t.alive + && t.can_be_seen_as_enemy + && !(t.is_player && (t.creative || m.spares_players(cx.w))) } fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { if self.bee && !super::bee::angry(m, cx.w) { @@ -1643,14 +1665,7 @@ impl Goal for HurtByTarget { let pos = m.position; // TamableAnimal: its owner (`getOwner`, none for a wild one) and // whether it is a pet at all. - let tamable = matches!( - species, - Species::Wolf - | Species::Cat - | Species::Parrot - | Species::Nautilus - | Species::ZombieNautilus - ); + let tamable = m.is_tamable_animal(); let owner = m.extra.tame.then_some(m.extra.owner).flatten(); let player = cx.w.player.as_ref().map(|p| p.uuid); let ids: Vec = diff --git a/crates/horizon-client/src/mobs/golem.rs b/crates/horizon-client/src/mobs/golem.rs index 44bfe3dff9..bf0a5825fd 100644 --- a/crates/horizon-client/src/mobs/golem.rs +++ b/crates/horizon-client/src/mobs/golem.rs @@ -60,7 +60,7 @@ impl Goal for DefendVillage { } fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { let Some(p) = &cx.w.player else { return false }; - if !p.alive || p.creative || p.spectator || m.spares_players() { + if !p.alive || p.creative || p.spectator || m.spares_players(cx.w) { return false; } // The box around the golem: 10 across, 8 up and down. @@ -81,7 +81,7 @@ impl Goal for DefendVillage { bad && m.sees(cx.w, Who::Player) } fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { - let spares = m.spares_players(); + let spares = m.spares_players(cx.w); m.target_info(cx.w).is_some_and(|t| { t.alive && !(t.is_player && (t.creative || spares)) diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index fb8297c8ac..c9f5ec83e2 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -81,11 +81,11 @@ pub fn register(m: &mut Mob) { .add(4, NearestTarget::player(true).when(angry_at_player)); m.ai.targets.add( 5, - NearestTarget::new(Prey::Filter(wolf_prey), false).when(|m, _| !m.extra.tame), + NearestTarget::new(Prey::Filter(wolf_prey), false).gate(|m, _| !m.extra.tame), ); m.ai.targets.add( 6, - NearestTarget::new(Prey::Filter(baby_turtle_on_land), false).when(|m, _| !m.extra.tame), + NearestTarget::new(Prey::Filter(baby_turtle_on_land), false).gate(|m, _| !m.extra.tame), ); m.ai.targets.add( 7, @@ -860,11 +860,11 @@ pub fn register_cat(m: &mut Mob) { g.add(12, LookAtPlayer::new(10.0)); m.ai.targets.add( 1, - NearestTarget::species(&[Species::Rabbit], false).when(|m, _| !m.extra.tame), + NearestTarget::species(&[Species::Rabbit], false).gate(|m, _| !m.extra.tame), ); m.ai.targets.add( 1, - NearestTarget::new(Prey::Filter(baby_turtle_on_land), false).when(|m, _| !m.extra.tame), + NearestTarget::new(Prey::Filter(baby_turtle_on_land), false).gate(|m, _| !m.extra.tame), ); } /// `Ocelot.registerGoals`, then `reassessTrustingGoals` adds the player diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java index d179f7388d..658f28e88a 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java @@ -612,6 +612,12 @@ public final class VanillaMobAiOracle { case "inlove" -> ((net.minecraft.world.entity.animal.Animal) mob).setInLoveTime(600); case "nectar" -> method(net.minecraft.world.entity.animal.bee.Bee.class, "setHasNectar", boolean.class).invoke(mob, true); case "tame" -> { if (mob instanceof net.minecraft.world.entity.animal.equine.AbstractHorse horse) horse.setTamed(true); } + // TamableAnimal.tame without the player entity: tamed (with its side effects) by the scenario's player. + case "owned" -> { + net.minecraft.world.entity.TamableAnimal pet = (net.minecraft.world.entity.TamableAnimal) mob; + pet.setTame(true, true); + pet.setOwnerReference(net.minecraft.world.entity.EntityReference.of(PLAYER_UUID)); + } case "strength" -> method(net.minecraft.world.entity.animal.equine.Llama.class, "setStrength", int.class) .invoke(mob, Integer.parseInt(v)); case "dead" -> mob.setHealth(0.0F); diff --git a/tools/test_mob_ai_parity.py b/tools/test_mob_ai_parity.py index 492fa78b4d..7a9d75927e 100644 --- a/tools/test_mob_ai_parity.py +++ b/tools/test_mob_ai_parity.py @@ -313,6 +313,11 @@ def scenarios(): # NeutralMob.updatePersistentAnger in Wolf.aiStep restarts the 20-39 s timer, one random draw, # every tick it has one, not once when hurt. add('wolf_hurt', 200, ['player 5.5 -60 0.5', 'mob minecraft:wolf 0.5 -60 0.5 0 181 persistent', 'hurt 20 0']) + # A tamed wolf its owner hits (#4119): TamableAnimal.canAttack never lets it target its owner, + # so HurtByTargetGoal doesn't start and the wolf stays calm; a second wolf of the same owner + # isn't called either. + add('wolf_owner_hurt', 120, ['player 5.5 -60 0.5', 'mob minecraft:wolf 0.5 -60 0.5 0 185 persistent owned', + 'mob minecraft:wolf 0.5 -60 3.5 0 186 persistent owned', 'hurt 20 0']) # Mobs dropped into deep water (#542): FloatGoal users float at the surface (their navigation # can float, so Mob.jumpInLiquid adds 0.04 a tick), zombies sink. for species, seed in [('pig', 171), ('cow', 172), ('wolf', 173), ('cat', 174), ('horse', 175), From 56402b95f325d579cf9f9195ef16f2cd6f60cc86 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Fri, 30 Oct 2026 05:06:21 +0300 Subject: [PATCH 232/968] Launcher: the language row is a dropdown, not a segment per language Nine segments are wider than the row, which left its sentence a character wide and wrapping one letter per line. The row shows the choice and a menu now, and the sentence stays only while the language follows the system's. Co-Authored-By: Hermes Agent (Nous Research) --- launcher/src/language_page.rs | 138 ++++++++++++++++++++++++++++------ launcher/src/main.rs | 4 + 2 files changed, 117 insertions(+), 25 deletions(-) diff --git a/launcher/src/language_page.rs b/launcher/src/language_page.rs index 5dd940311f..12b5de9a6d 100644 --- a/launcher/src/language_page.rs +++ b/launcher/src/language_page.rs @@ -1,35 +1,123 @@ -//! Settings' Language row (`horizon_launcher::i18n`): the system's language, or one the player -//! picks, each named in itself. +//! Settings' Language row (`horizon_launcher::i18n`): a dropdown of the system's language and the +//! ones the launcher has, each named in itself. A list rather than a segment per language: nine +//! segments took the row's whole width and left the row's own sentence a character wide. use super::*; use horizon_launcher::i18n::Language; +/// What the dropdown shows for a choice: the system's, in the launcher's language, or a +/// language's name in itself (never through the table: a table entry for "English" would rename +/// the very entry a player looks for). +fn choice_name(choice: Option) -> &'static str { + match choice { + None => tr("System"), + Some(language) => language.name(), + } +} + impl Launcher { pub(super) fn language_row(&self, cx: &mut Context) -> Div { let t = self.theme; - let mut options = vec![(None, "System")]; - options.extend(Language::ALL.map(|l| (Some(l), l.name()))); - let picker = segments("language", &options, self.settings.language, &t, { - let this = cx.entity().downgrade(); - move |choice, _, cx| { - let _ = this.update(cx, |this, cx| { - this.settings.language = choice; - this.settings.save(); - i18n::set(this.settings.language()); + let mut wrap = div().relative().child( + keyboard(div().id("language-picker"), &t) + .relative() + .flex() + .items_center() + .gap(px(10.)) + .h(px(theme::CONTROL)) + .px(px(14.)) + .border_2() + .border_color(t.outline) + .bg(t.button) + .children(blocks::edges(t.button_bevel, t.lip, theme::LIP)) + .cursor_pointer() + .hover(move |s| s.bg(t.raised)) + .child( + div() + .flex_1() + .font_family(theme::PIXEL) + .text_size(px(15.)) + .font_weight(FontWeight::MEDIUM) + .text_color(t.ink) + .child(choice_name(self.settings.language)), + ) + .child(blocks::icon("chevron-down", 16., t.muted)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.language_menu = !this.language_menu; cx.notify(); - }); - } - }); - let said = trf( - "System follows your computer's language: {language} here. A string not translated \ - yet shows in English.", - &[("language", Language::system().name())], + })), ); - index_row( - 0, - tr("Language"), - Some(div().child(said).into_any_element()), - Some(picker.into_any_element()), - &t, - ) + if self.language_menu { + let mut menu = div() + .id("language-menu") + .absolute() + .top(px(theme::CONTROL + 4.)) + .right_0() + .w(px(220.)) + .p(px(6.)) + .border_2() + .border_color(t.outline) + .bg(t.surface) + .flex() + .flex_col() + .occlude(); + for choice in std::iter::once(None).chain(Language::ALL.map(Some)) { + let chosen = choice == self.settings.language; + let id = choice.map_or("system", Language::code); + menu = menu.child( + div() + .id(SharedString::from(format!("language-pick-{id}"))) + .flex() + .items_center() + .justify_between() + .gap(px(8.)) + .h(px(30.)) + .px(px(10.)) + .rounded(px(theme::RADIUS)) + .cursor_pointer() + .hover(move |s| s.bg(t.plate_soft)) + .child( + text(theme::LABEL) + .text_color(t.ink) + .child(choice_name(choice)), + ) + .when(chosen, |d| { + d.child(div().size(px(6.)).rounded_full().bg(t.accent)) + }) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.settings.language = choice; + this.settings.save(); + i18n::set(this.settings.language()); + this.language_menu = false; + cx.notify(); + })), + ); + } + wrap = wrap.child(gpui::deferred(menu).with_priority(1)); + } + // The sentence is about following the computer's language, so it says nothing true once + // the player has picked one: the row keeps it only while the choice is the system's. + let said = self.settings.language.is_none().then(|| { + div() + .child(trf( + "System follows your computer's language: {language} here. A string not \ + translated yet shows in English.", + &[("language", Language::system().name())], + )) + .into_any_element() + }); + index_row(0, tr("Language"), said, Some(wrap.into_any_element()), &t) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_launcher::i18n::{current, tr_in}; + + #[test] + fn a_language_is_named_in_itself_and_the_system_choice_in_the_launchers_language() { + assert_eq!(choice_name(Some(Language::Russian)), "Русский"); + assert_eq!(choice_name(Some(Language::English)), "English"); + assert_eq!(choice_name(None), tr_in(current(), "System")); } } diff --git a/launcher/src/main.rs b/launcher/src/main.rs index b90d0fd8af..3f6382b516 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -185,6 +185,8 @@ struct Launcher { native_confirm: Option, String>>, confirm_remove: Option, picker_open: bool, + /// Settings > Language's dropdown (`language_page.rs`). + language_menu: bool, /// The top bar's quick switch of who plays (`switch_page.rs`). account_menu: bool, /// Settings > Account's skin panel for an offline name (`skin_page.rs`). @@ -414,6 +416,7 @@ impl Launcher { native_confirm: None, confirm_remove: None, picker_open: false, + language_menu: false, account_menu: false, skin: None, background_error: None, @@ -539,6 +542,7 @@ impl Launcher { } } self.picker_open = false; + self.language_menu = false; self.account_menu = false; } From d87ead957e5ece5b3f2bc658ec66d94d095d7f1a Mon Sep 17 00:00:00 2001 From: spider <51022611+spidercraft@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:12:23 +0100 Subject: [PATCH 233/968] Far terrain: the dimension the player is in, not the server's level in place The window built its far terrain from whichever level the server thread had in place when it asked. Dying in the Nether and respawning in the Overworld could catch the Nether there, and Nether far terrain stood around the Overworld until the world was reopened. Levels::lod_source_in gives the player's dimension's, in place or parked. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 3 ++- crates/horizon-client/src/game/levels.rs | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c2b7b4ad97..ab3cabb8b1 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -13009,8 +13009,9 @@ impl Game<'_> { return; } self.lod_source_attempted = true; + let dim = self.dim; let source = match &self.integrated { - Some(server) => server.call(|game| game.streaming.lod_source()), + Some(server) => server.call(move |game| game.lod_source_in(dim)).flatten(), None => Some(self.streaming.lod_source()), }; if let Some(source) = source { diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 6a7b88e134..68ba52a850 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -309,6 +309,19 @@ impl Game<'_> { } } + /// Far terrain for a client in `dim`: the level in place may be another + /// one, the server ticking its levels in turn. + pub(super) fn lod_source_in( + &self, + dim: Dim, + ) -> Option> { + if dim == self.dim { + return Some(self.streaming.lod_source()); + } + let parked = self.levels.parked.get(&dim)?; + Some(parked.streaming.lod_source()) + } + /// Put `dim`'s level in place for a command whose source is there /// (`execute in`, `execute at` a player in another level), opening it if /// nobody is in it; returns the level to put back afterwards. Only a From e8c32f6610d46d11f0a2c33e7330538a308b63af Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 03:13:48 +0100 Subject: [PATCH 234/968] Run single-player use clicks after attack clicks within a tick A place and a break in one click now replace the targeted block as in vanilla's handleKeybinds: attack first, then use against the hit result picked before the break. Fixes #4147. --- crates/horizon-client/src/game.rs | 9 ++++++--- crates/horizon-client/src/game/hand.rs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c2b7b4ad97..a0a292ae09 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1733,6 +1733,8 @@ struct Game<'a> { /// the attack strength tickers the hand reads. clicks: horizon_client::first_person::Clicks, attack_clicks: u32, + /// Use clicks since the last tick; `handleKeybinds` consumes them after the attack clicks. + use_clicks: u32, hand_strength: horizon_client::attack_strength::AttackStrength, settings: Settings, tutorial: tutorial::Tutorial, @@ -12921,10 +12923,10 @@ impl Game<'_> { .button(3, pressed, false, self.settings.toggle_use); self.right = self.toggles.is_down(3); // handleKeybinds consumes use clicks without acting while an - // item is in use. + // item is in use. Minecraft.tick runs them after the attack + // clicks, so a place and a break in one tick replace the block. if pressed && self.using.is_none() { - self.use_with_hand()?; - self.use_delay = 4; + self.use_clicks += 1; } } MouseButton::Middle if pressed => { @@ -14552,6 +14554,7 @@ impl<'a> Game<'a> { visible_hands: [(0, None), (0, None)], clicks: Default::default(), attack_clicks: 0, + use_clicks: 0, hand_strength: Default::default(), settings, tutorial, diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index c241208948..e2d90678c4 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -73,6 +73,7 @@ impl Game<'_> { }); self.clicks.destroying = destroying; self.attack_clicks = 0; + self.use_clicks = 0; self.request_remote_mining_stop(); self.mining = None; } @@ -101,12 +102,18 @@ impl Game<'_> { /// attack clicks since the last tick and the held attack button. pub(super) fn tick_local_hand(&mut self) -> Result<()> { let attack_clicks = std::mem::take(&mut self.attack_clicks); + let use_clicks = std::mem::take(&mut self.use_clicks); if self.screen == Screen::Playing && self.title.is_none() && self.sleeping.is_none() { let mut clicks = std::mem::take(&mut self.clicks); // keyAttack.isDown(); playing implies the grabbed mouse here // (scripts under --control hold buttons without grabbing it). let down = self.left; let ticks = self.clicks.destroying.ticks; + // Minecraft.hitResult is picked once per frame: a creative break + // does not move the use that follows it past the broken block. + if use_clicks > 0 && attack_clicks > 0 { + self.tick_hit = Some(self.pick()); + } let mut world = GameHands { game: self, error: None, @@ -115,8 +122,24 @@ impl Game<'_> { let error = world.error.take(); self.clicks = clicks; if let Some(e) = error { + self.tick_hit = None; return Err(e); } + // Use clicks follow the attack clicks within the tick, against + // the block the attack may just have broken. + let mut used = Ok(()); + for _ in 0..use_clicks { + if self.using.is_some() { + break; + } + used = self.use_with_hand(); + self.use_delay = 4; + if used.is_err() { + break; + } + } + self.tick_hit = None; + used?; // A tick of breaking: the hit sound every fourth tick and the // cracking particles. let d = &self.clicks.destroying; From 800e7cef3371762ec67184fa1c4da4910c15295b Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Thu, 29 Oct 2026 21:18:30 +0100 Subject: [PATCH 235/968] Game: mimalloc as the global allocator World generation, meshing and the tick allocate and free small blocks on every worker at once, and the system heap serialised them: on Windows RtlAllocateHeap/RtlFreeHeap were 16% of chunkgen_bench's CPU. mimalloc keeps a heap per thread. release_free_memory also gives mimalloc's free pages back. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 27 +++++++++++++++++++++++++++ crates/horizon-client/src/main.rs | 3 +++ crates/horizon-threads/Cargo.toml | 6 +++++- crates/horizon-threads/src/lib.rs | 20 +++++++++++++++----- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50f9808fae..9d8302ca89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,6 +561,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1412,6 +1418,8 @@ name = "horizon-threads" version = "0.1.0" dependencies = [ "libc", + "libmimalloc-sys", + "mimalloc", "windows-sys 0.61.2", ] @@ -1787,6 +1795,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", + "cty", +] + [[package]] name = "libredox" version = "0.1.25" @@ -1878,6 +1896,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minisign-verify" version = "0.3.0" diff --git a/crates/horizon-client/src/main.rs b/crates/horizon-client/src/main.rs index 8d03455af1..c82f55cd80 100644 --- a/crates/horizon-client/src/main.rs +++ b/crates/horizon-client/src/main.rs @@ -4,6 +4,9 @@ mod game; use horizon_client::{crash, logging}; +#[global_allocator] +static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator; + /// The mod API level this game serves, kept in the program for the launcher /// to read without running it (`horizon_mods::api_level::MARK`). `main` /// reads it, so the linker keeps it. diff --git a/crates/horizon-threads/Cargo.toml b/crates/horizon-threads/Cargo.toml index 67b3c20dae..72ae58c214 100644 --- a/crates/horizon-threads/Cargo.toml +++ b/crates/horizon-threads/Cargo.toml @@ -4,7 +4,11 @@ version = "0.1.0" edition.workspace = true license.workspace = true rust-version = "1.85" -description = "Scheduling hints for the game's threads: the OS calls the crates that forbid unsafe code can't make" +description = "Scheduling hints for the game's threads and its allocator: the OS calls the crates that forbid unsafe code can't make" + +[dependencies] +mimalloc = { version = "0.1.52", default-features = false } +libmimalloc-sys = { version = "0.1.49", default-features = false, features = ["extended"] } [target.'cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))'.dependencies] libc = "0.2" diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index 01e815e8b3..d4d8047b19 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -1,5 +1,5 @@ -//! Scheduling hints for the game's threads: the few OS calls that the other -//! crates, which forbid unsafe code, can't make themselves. +//! Scheduling hints for the game's threads and its allocator: the few OS calls +//! that the other crates, which forbid unsafe code, can't make themselves. use std::sync::atomic::{AtomicBool, Ordering}; @@ -195,11 +195,21 @@ pub fn catch_stop_signals() { #[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))] pub fn catch_stop_signals() {} +/// The game's global allocator (`#[global_allocator]` in its `main.rs`). +/// World generation, meshing and the tick allocate and free small blocks on +/// every worker at once; the system heaps serialise them (Windows' heap was +/// 16% of a chunk's generation time), mimalloc keeps a heap per thread. +pub use mimalloc::MiMalloc as Allocator; + /// Give back to the OS the memory the allocator holds free, after something -/// large was dropped: glibc keeps freed pages in the arena of the thread that -/// allocated them, and macOS in its zones until memory runs short. Nothing -/// elsewhere. +/// large was dropped: mimalloc's free pages, then the system heap's, which +/// native libraries still use: glibc keeps freed pages in the arena of the +/// thread that allocated them, and macOS in its zones until memory runs short. pub fn release_free_memory() { + // SAFETY: frees only pages no allocation uses, in every thread's heap. + unsafe { + libmimalloc_sys::mi_collect(true); + } release(); } From 5b695d5a2674c94f669a8a147048fe0fa0b84fa4 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 01:07:04 +0100 Subject: [PATCH 236/968] Allocator: give free pages back at once With mimalloc's default 1 s purge delay the server held 26% and the client 37% more memory than with the system heap. Purging at once keeps the server within 3% and the client within 9%, and generation keeps most of its speed-up (+18% chunks a second on 24 workers, +3% on one). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/main.rs | 1 + crates/horizon-threads/src/lib.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/horizon-client/src/main.rs b/crates/horizon-client/src/main.rs index c82f55cd80..8361da23c6 100644 --- a/crates/horizon-client/src/main.rs +++ b/crates/horizon-client/src/main.rs @@ -13,6 +13,7 @@ static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator; static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK; fn main() { + horizon_threads::configure_allocator(); std::hint::black_box(&MOD_API_LEVEL); horizon_client::telemetry::mark_start(); let args: Vec = std::env::args().skip(1).collect(); diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index d4d8047b19..6a899a3e14 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -201,6 +201,21 @@ pub fn catch_stop_signals() {} /// 16% of a chunk's generation time), mimalloc keeps a heap per thread. pub use mimalloc::MiMalloc as Allocator; +/// `mi_option_purge_delay` in mimalloc.h (index 15 in v2 and v3); the bindings +/// don't name it. +const PURGE_DELAY: libmimalloc_sys::mi_option_t = 15; + +/// Set the allocator up, first thing in `main`: free pages go back to the OS +/// at once instead of a second later. With mimalloc's delay the game held a +/// quarter more memory than with the system heap; without it a few percent, +/// and generation keeps most of its speed-up. +pub fn configure_allocator() { + // SAFETY: sets one integer option, which mimalloc reads at each purge. + unsafe { + libmimalloc_sys::mi_option_set(PURGE_DELAY, 0); + } +} + /// Give back to the OS the memory the allocator holds free, after something /// large was dropped: mimalloc's free pages, then the system heap's, which /// native libraries still use: glibc keeps freed pages in the arena of the @@ -310,4 +325,17 @@ mod tests { .join() .unwrap(); } + + /// The option set is the purge delay: mimalloc v3's default for it is + /// 1000 ms, and it is 0 once configured. + #[test] + fn configuring_the_allocator_purges_at_once() { + // SAFETY: reads and sets one integer option. + let get = || unsafe { libmimalloc_sys::mi_option_get(super::PURGE_DELAY) }; + if std::env::var_os("MIMALLOC_PURGE_DELAY").is_none() { + assert_eq!(get(), 1000); + } + super::configure_allocator(); + assert_eq!(get(), 0); + } } From 85ff723581b8448947117ddb2b83c068d06ab102 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Fri, 30 Oct 2026 02:34:01 +0100 Subject: [PATCH 237/968] Allocator: say which heaps mi_collect frees Co-Authored-By: Claude Opus 5.5 --- crates/horizon-threads/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horizon-threads/src/lib.rs b/crates/horizon-threads/src/lib.rs index 6a899a3e14..6c45e873df 100644 --- a/crates/horizon-threads/src/lib.rs +++ b/crates/horizon-threads/src/lib.rs @@ -221,7 +221,7 @@ pub fn configure_allocator() { /// native libraries still use: glibc keeps freed pages in the arena of the /// thread that allocated them, and macOS in its zones until memory runs short. pub fn release_free_memory() { - // SAFETY: frees only pages no allocation uses, in every thread's heap. + // SAFETY: frees only pages no allocation uses (this thread's heap, shared arenas). unsafe { libmimalloc_sys::mi_collect(true); } From effec4887d3240b032001767e01fb444b229fadc Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 04:20:36 +0100 Subject: [PATCH 238/968] Time out stalled server resource pack downloads The download queue's HTTP agent had no connect or read timeout, so a server that accepted the connection and went silent blocked the single queue thread and the pack screen forever. Fail the request after 30s instead. Fixes #4168 --- .../src/server_packs/download.rs | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/server_packs/download.rs b/crates/horizon-client/src/server_packs/download.rs index 4b075962fa..0ae237c02d 100644 --- a/crates/horizon-client/src/server_packs/download.rs +++ b/crates/horizon-client/src/server_packs/download.rs @@ -17,6 +17,10 @@ use std::{ pub const MAX_SIZE: u64 = 262_144_000; /// `DownloadQueue.MAX_KEPT_PACKS`. const MAX_KEPT: usize = 20; +/// Longest wait for a connection, and for any one read from the server. The +/// queue runs batches on one thread, so a server that accepts the connection +/// and then goes silent would otherwise hold the pack screen forever. +const NET_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); /// `createDownloadHeaders`: who is downloading, with which game. pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> { @@ -195,6 +199,17 @@ pub fn download_file( headers: &[(&'static str, String)], max_size: u64, progress: &Sender, +) -> Result { + download_file_within(dir, request, headers, max_size, progress, NET_TIMEOUT) +} +/// `download_file` with the connect and read timeout given. +fn download_file_within( + dir: &Path, + request: &Request, + headers: &[(&'static str, String)], + max_size: u64, + progress: &Sender, + timeout: std::time::Duration, ) -> Result { let target = request.sha1.as_ref().map(|sha1| dir.join(sha1)); if let (Some(target), Some(sha1)) = (&target, &request.sha1) { @@ -219,7 +234,11 @@ pub fn download_file( }) .map_err(|e| format!("Failed to remove existing file {}: {e}", target.display()))?; } - let mut call = ureq::AgentBuilder::new().build().get(&request.url); + let mut call = ureq::AgentBuilder::new() + .timeout_connect(timeout) + .timeout_read(timeout) + .build() + .get(&request.url); for (name, value) in headers { call = call.set(name, value); } @@ -471,6 +490,38 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn a_server_that_goes_silent_fails_the_download() { + let dir = scratch(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/pack.zip", listener.local_addr().unwrap()); + let (release, hold) = channel::<()>(); + let server = std::thread::spawn(move || { + // Accept, then answer nothing until the test is done. + let (_stream, _) = listener.accept().unwrap(); + let _ = hold.recv(); + }); + let request = Request { + id: 9, + url, + sha1: None, + }; + let started = std::time::Instant::now(); + let result = download_file_within( + &dir, + &request, + &[], + MAX_SIZE, + &channel().0, + std::time::Duration::from_millis(300), + ); + assert!(result.is_err()); + assert!(started.elapsed() < std::time::Duration::from_secs(10)); + drop(release); + server.join().unwrap(); + fs::remove_dir_all(dir).unwrap(); + } + #[test] fn the_queue_logs_each_download() { let dir = scratch(); From 10cc390c21e863f747499ce5857b569dc244865a Mon Sep 17 00:00:00 2001 From: martipops Date: Thu, 29 Oct 2026 23:42:35 -0400 Subject: [PATCH 239/968] Audio: the mixer callback neither allocates nor copies world names Review on #3777: worlds are small ids now (Mixer::world_id), so the callback compares and copies integers; its camera list is a buffer the mixer keeps; a sound only looks for another player's copy (and takes the lock for it) while another player shares the output. Measured with 32 playing sounds and one player, 512-frame buffers (release, M-series Mac): upstream main 21.9-22.5 us per buffer, this part before 21.3-21.8 us, after 21.3-21.6 us: the same within noise; the time goes to mixing the samples. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/audio.rs | 136 +++++++++++++++------- 1 file changed, 91 insertions(+), 45 deletions(-) diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs index f322f17c9e..2e8f5369a0 100644 --- a/crates/horizon-client-render/src/audio.rs +++ b/crates/horizon-client-render/src/audio.rs @@ -41,7 +41,8 @@ struct Channel { relative: bool, /// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`). owner: u8, - world: String, + /// Its world (`Mixer::world_id`; 0 none). + world: u32, /// The sound file it started and when, to tell another player's copy of it. started: Option<(String, std::time::Instant)>, } @@ -54,9 +55,29 @@ struct Mixer { /// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is /// heard from the nearest camera, and panned half as wide while several share the speakers. listeners: HashMap, - worlds: HashMap, + /// Each camera's world by owner, as ids: the callback compares and copies integers. + worlds: HashMap, + /// World names by id - 1 (`world_id`). + world_names: Vec, + /// The callback's cameras (position, basis, world), kept to be refilled without allocating. + cameras: Vec<([f64; 3], Basis, u32)>, limiter_peak: f32, } +/// A camera's forward, up and right vectors. +type Basis = ([f64; 3], [f64; 3], [f64; 3]); +impl Mixer { + /// The id of world `name` (1 and up), given once per name. + fn world_id(&mut self, name: &str) -> u32 { + let i = match self.world_names.iter().position(|w| w == name) { + Some(i) => i, + None => { + self.world_names.push(name.to_owned()); + self.world_names.len() - 1 + } + }; + i as u32 + 1 + } +} /// A sound the player could have heard (`SoundEventListener.onPlaySound`): /// what the subtitle overlay needs. #[derive(Clone, Debug, PartialEq)] @@ -80,14 +101,19 @@ pub struct MixerBackend { live: bool, /// Whose channels these are (`Channel::owner`). owner: u8, - world: String, + world: u32, + /// How many other players share this output: alone, a sound never looks for its copy. + shares: Arc, } /// A splitscreen player's backend leaving: its camera and sounds go with it. impl Drop for MixerBackend { fn drop(&mut self) { - if self.owner != 0 - && let Ok(mut m) = self.mixer.lock() - { + if self.owner == 0 { + return; + } + self.shares + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + if let Ok(mut m) = self.mixer.lock() { m.listeners.remove(&self.owner); m.worlds.remove(&self.owner); let owner = self.owner; @@ -250,6 +276,10 @@ impl MixerBackend { /// Splitscreen: another player's game started this same sound here just now (both clients /// heard it); this copy stays stopped, the first is heard from the nearest camera. fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool { + // Alone: nobody else's copy, and no lock taken for it. + if self.shares.load(std::sync::atomic::Ordering::Relaxed) == 0 { + return false; + } let Ok(mut m) = self.mixer.lock() else { return false; }; @@ -263,7 +293,7 @@ impl MixerBackend { let key = format!("{namespace}:{path}"); let key = variantless(&key); let at = c.at; - let world = c.world.clone(); + let world = c.world; let twin = m.channels.values().any(|o| { o.owner != self.owner && o.world == world @@ -307,7 +337,7 @@ impl Backend for MixerBackend { at: [0.0; 3], relative: false, owner: self.owner, - world: self.world.clone(), + world: self.world, started: None, }, ); @@ -479,25 +509,28 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { ]; (forward, up, right) }; - // Every camera hearing (splitscreen: the other players' too). - let cameras: Vec<_> = std::iter::once(( - 0, - (mixer.listener, mixer.listener_yaw, mixer.listener_pitch), - )) - .chain( - mixer - .listeners - .iter() - .map(|(&owner, &camera)| (owner, camera)), - ) - .map(|(owner, (at, yaw, pitch))| { - ( - at, - basis(yaw, pitch), - mixer.worlds.get(&owner).cloned().unwrap_or_default(), + // Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer. + let mut cameras = std::mem::take(&mut mixer.cameras); + cameras.clear(); + cameras.extend( + std::iter::once(( + 0, + (mixer.listener, mixer.listener_yaw, mixer.listener_pitch), + )) + .chain( + mixer + .listeners + .iter() + .map(|(&owner, &camera)| (owner, camera)), ) - }) - .collect(); + .map(|(owner, (at, yaw, pitch))| { + ( + at, + basis(yaw, pitch), + mixer.worlds.get(&owner).copied().unwrap_or_default(), + ) + }), + ); let shared = cameras.len() > 1; let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32; // Gains and pans per source for this buffer. @@ -550,6 +583,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) { let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0)); voices.push((*id, [left, right, gain], step)); } + mixer.cameras = cameras; for (id, [left, right, gain], step) in voices { let Some(c) = mixer.channels.get_mut(&id) else { continue; @@ -744,7 +778,8 @@ impl Audio { next: 0, live, owner: 0, - world: String::new(), + world: 0, + shares: Arc::default(), }, record_heard: false, listener: [0.0; 3], @@ -857,11 +892,10 @@ impl Audio { } /// World identity for this player's sources and camera. Existing channels keep their world. pub fn set_world(&mut self, world: &str) { - if self.backend.world != world { - self.backend.world = world.to_owned(); - } if let Ok(mut mixer) = self.backend.mixer.lock() { - mixer.worlds.insert(self.backend.owner, world.to_owned()); + let id = mixer.world_id(world); + self.backend.world = id; + mixer.worlds.insert(self.backend.owner, id); } } @@ -885,6 +919,9 @@ impl Audio { /// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go. pub fn share(&self, owner: u8) -> Self { assert_ne!(owner, 0, "shared audio owners must be nonzero"); + self.backend + .shares + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mut engine = SoundEngine::new(self.engine.registry.clone()); engine.loaded = self.backend.live; Self { @@ -900,7 +937,8 @@ impl Audio { << 40, live: self.backend.live, owner, - world: self.backend.world.clone(), + world: self.backend.world, + shares: self.backend.shares.clone(), }, record_heard: false, listener: [0.0; 3], @@ -1172,7 +1210,7 @@ mod tests { at, relative: false, owner: 0, - world: String::new(), + world: 0, started: None, } } @@ -1363,11 +1401,13 @@ mod tests { guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0); let id = guest.backend.create(false).unwrap(); let mixer = host.backend.mixer.lock().unwrap(); - assert_eq!( - mixer.worlds.get(&1).map(String::as_str), - Some("minecraft:overworld") - ); - assert_eq!(mixer.channels[&id].world, "minecraft:overworld"); + let overworld = mixer + .world_names + .iter() + .position(|w| w == "minecraft:overworld"); + let overworld = overworld.map(|i| i as u32 + 1); + assert_eq!(mixer.worlds.get(&1).copied(), overworld); + assert_eq!(Some(mixer.channels[&id].world), overworld); } #[test] @@ -1377,7 +1417,8 @@ mod tests { ..Default::default() }; m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0)); - m.worlds.insert(1, "nether".into()); + let nether = m.world_id("nether"); + m.worlds.insert(1, nether); m.channels .insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true)); let mut data = [0.0; 2]; @@ -1386,10 +1427,11 @@ mod tests { data, [0.0; 2], "nearby Nether camera must not hear Overworld source" ); - m.channels.get_mut(&1).unwrap().world = "nether".into(); + m.channels.get_mut(&1).unwrap().world = nether; mix(&mut m, &mut data, 2, 48_000); assert!(data[0] > 0.5, "same-world camera must hear source"); - m.worlds.insert(1, "end".into()); + let end = m.world_id("end"); + m.worlds.insert(1, end); data.fill(0.0); mix(&mut m, &mut data, 2, 48_000); assert_eq!(data, [0.0; 2], "no listener remains in source's world"); @@ -1399,7 +1441,8 @@ mod tests { fn sounds_in_a_world_without_listeners_finish_while_inaudible() { let mut mixer = Mixer::default(); let mut source = channel(vec![1.0; 4], None, [0.0; 3], false); - source.world = "nether".into(); + let nether = mixer.world_id("nether"); + source.world = nether; mixer.channels.insert(1, source); let mut data = [0.0; 4]; mix(&mut mixer, &mut data, 2, 48_000); @@ -1407,7 +1450,7 @@ mod tests { assert!(mixer.channels[&1].position > 0.0); mix(&mut mixer, &mut data, 2, 48_000); assert_eq!(mixer.channels[&1].state, State::Stopped); - mixer.worlds.insert(0, "nether".into()); + mixer.worlds.insert(0, nether); mix(&mut mixer, &mut data, 2, 48_000); assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot"); } @@ -1431,6 +1474,8 @@ mod tests { #[test] fn another_players_copy_of_a_sound_is_dropped() { let mixer = Arc::new(Mutex::new(Mixer::default())); + // One other player shares the output. + let shares = Arc::new(std::sync::atomic::AtomicUsize::new(1)); let backend = |owner| MixerBackend { mixer: mixer.clone(), root: PathBuf::new(), @@ -1439,7 +1484,8 @@ mod tests { next: (u64::from(owner)) << 40, live: true, owner, - world: String::new(), + world: 0, + shares: shares.clone(), }; let (mut p1, mut p2) = (backend(0), backend(1)); // P2 is listening (its camera is in the mixer), so two players share the speakers. @@ -1460,7 +1506,7 @@ mod tests { assert!(started(&mut p2, [40.0, 64.0, 5.0])); assert!(started(&mut p1, [5.0, 64.0, 5.0])); // Equal coordinates in another dimension describe a different sound. - p2.world = "nether".into(); + p2.world = mixer.lock().unwrap().world_id("nether"); assert!(started(&mut p2, [5.0, 64.0, 5.0])); // P2 leaving takes its sounds. drop(p2); From b636b37e9ad07430c43f412bf147c0a8c71ca3dd Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 04:45:34 +0100 Subject: [PATCH 240/968] Launcher: new servers check Minecraft accounts by default The create screen no longer offers the offline choice; the server's settings page still turns account checks off, and the CLI takes --offline. --- docs/launcher-commands.md | 2 +- launcher/README.md | 2 +- launcher/src/cli.rs | 8 ++++---- launcher/src/servers.rs | 6 ++++-- launcher/src/servers/tests.rs | 2 +- launcher/src/servers_page.rs | 36 ++--------------------------------- 6 files changed, 13 insertions(+), 43 deletions(-) diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index 50b83f3f8c..ef83856b8b 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -914,7 +914,7 @@ Create a server; with no options it is a new world, survival, normal, 10 players - `--difficulty` (peaceful, easy, normal, hard) - `--max-players` - `--open`: Let anyone in (no whitelist) -- `--check-accounts`: Check players' Minecraft accounts (they must sign in with Microsoft) +- `--offline`: Do not check players' Minecraft accounts (offline mode: anyone can use any name) - `--player`: Who may join (repeatable); the first becomes an operator - `--accept-eula`: Agree to the Minecraft EULA (https://aka.ms/MinecraftEULA) diff --git a/launcher/README.md b/launcher/README.md index 7466e7b262..f455a30c42 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -341,7 +341,7 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code horizon-launcher mods remove horizon-launcher servers [list] the servers hosted from the launcher, and whether they run horizon-launcher servers create [--name N] [--seed S | --copy-world DIR] [--mode M] [--difficulty D] - [--max-players N] [--open] [--check-accounts] [--player NAME]... --accept-eula + [--max-players N] [--open] [--offline] [--player NAME]... --accept-eula horizon-launcher servers set-up [--version ] copy a prepared version's programs and game files for servers horizon-launcher servers run -- for example: servers run "My server" -- start horizon-launcher servers forget take it off the list (its folder stays) diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index e2efb90ab8..4d0b126596 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -68,9 +68,9 @@ enum ServersCmd { /// Let anyone in (no whitelist) #[arg(long)] open: bool, - /// Check players' Minecraft accounts (they must sign in with Microsoft) + /// Do not check players' Minecraft accounts (offline mode: anyone can use any name) #[arg(long)] - check_accounts: bool, + offline: bool, /// Who may join (repeatable); the first becomes an operator #[arg(long = "player")] players: Vec, @@ -1897,7 +1897,7 @@ fn servers(action: ServersCmd, as_json: bool) -> Result<(), String> { difficulty, max_players, open, - check_accounts, + offline, players, version, accept_eula, @@ -1914,7 +1914,7 @@ fn servers(action: ServersCmd, as_json: bool) -> Result<(), String> { difficulty, max_players, friends_only: !open, - check_accounts, + check_accounts: !offline, players, eula: accept_eula, }; diff --git a/launcher/src/servers.rs b/launcher/src/servers.rs index e48c755a34..0762b2f5a5 100644 --- a/launcher/src/servers.rs +++ b/launcher/src/servers.rs @@ -511,7 +511,9 @@ pub struct NewServer { pub max_players: u32, /// Only listed players (and operators) may join. pub friends_only: bool, - /// Check players' Minecraft accounts: they must sign in with Microsoft. + /// Check players' Minecraft accounts: they must sign in with Microsoft. On by default; an offline + /// server is a security risk, so it is turned off only per server (`--offline`, or the server's + /// settings page). pub check_accounts: bool, /// Who may join first (whitelisted), and the first of them becomes an operator. pub players: Vec, @@ -529,7 +531,7 @@ impl Default for NewServer { difficulty: "normal".into(), max_players: 10, friends_only: true, - check_accounts: false, + check_accounts: true, players: Vec::new(), eula: false, } diff --git a/launcher/src/servers/tests.rs b/launcher/src/servers/tests.rs index fce4520e78..cc4e4be08b 100644 --- a/launcher/src/servers/tests.rs +++ b/launcher/src/servers/tests.rs @@ -25,6 +25,7 @@ fn create_asks_init_and_config_set() { }, players: vec!["Alex".into(), "Steve".into()], eula: true, + check_accounts: false, ..NewServer::default() }; assert_eq!( @@ -34,7 +35,6 @@ fn create_asks_init_and_config_set() { ); let open = NewServer { friends_only: false, - check_accounts: true, world: World::Copy("/saves/Old".into()), ..NewServer::default() }; diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index c7b08185f9..050cdd59ee 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -797,7 +797,7 @@ impl Launcher { difficulty: "normal", max_players: 10, friends_only: true, - check_accounts: false, + check_accounts: true, eula: false, eula_missing: false, }); @@ -2685,8 +2685,7 @@ impl Launcher { let c = self.servers.create.as_ref()?; let (new_world, copy, worlds) = (c.new_world, c.copy.clone(), c.worlds.clone()); let (mode, difficulty, max_players) = (c.mode, c.difficulty, c.max_players); - let (friends_only, check_accounts, eula, eula_missing) = - (c.friends_only, c.check_accounts, c.eula, c.eula_missing); + let (friends_only, eula, eula_missing) = (c.friends_only, c.eula, c.eula_missing); let creating = self.servers.creating; let row = |label: &'static str, control: AnyElement| { div() @@ -2833,36 +2832,6 @@ impl Launcher { .cursor_pointer() .when(on, |d| d.child("✓")) }; - let accounts = div() - .flex() - .items_start() - .gap(px(10.)) - .child( - checkbox("create-accounts", check_accounts).on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - if let Some(c) = &mut this.servers.create { - c.check_accounts = !c.check_accounts; - } - cx.notify(); - }, - )), - ) - .child( - div() - .flex_1() - .min_w_0() - .flex() - .flex_col() - .child( - text(theme::UI) - .text_color(t.ink) - .child("Check Minecraft accounts"), - ) - .child(text(theme::CAPTION).text_color(t.muted).child( - "Only if every player signs in with Microsoft. Leave it off for Horizon \ - players without an account: Friends only keeps strangers out.", - )), - ); let eula_line = div() .flex() .items_center() @@ -2953,7 +2922,6 @@ impl Launcher { "The first one becomes an operator. Add more later on the Players tab.", )) }) - .child(accounts) .into_any_element(), )) .child(eula_line); From 6f654f9180068124d5fd7655243e643b6bf9ecd1 Mon Sep 17 00:00:00 2001 From: DUzzL Date: Fri, 30 Oct 2026 04:54:15 +0100 Subject: [PATCH 241/968] Launcher: keep --check-accounts as a hidden no-op for old scripts --- launcher/src/cli.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index 4d0b126596..4f15405c47 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -71,6 +71,9 @@ enum ServersCmd { /// Do not check players' Minecraft accounts (offline mode: anyone can use any name) #[arg(long)] offline: bool, + /// Kept for old scripts: account checking is now the default + #[arg(long, hide = true, conflicts_with = "offline")] + check_accounts: bool, /// Who may join (repeatable); the first becomes an operator #[arg(long = "player")] players: Vec, @@ -1898,6 +1901,7 @@ fn servers(action: ServersCmd, as_json: bool) -> Result<(), String> { max_players, open, offline, + check_accounts: _, players, version, accept_eula, From 58cf8aa10cc5bf79a73ebeba753b7b266e7766b3 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Fri, 30 Oct 2026 06:33:15 +0300 Subject: [PATCH 242/968] Graphics screens: their text through the language table The graphics screens' captions, values, headers, titles, cost tags, blocked reasons and descriptions were English display text handed to the widgets, so no key ever reached i18n. The text is the key now: labelled() translates a caption and its value, the description is translated before it wraps, and the headers, titles and buttons go through lang::get where they are drawn. Numbers in values became templates the table can hold. Co-Authored-By: Hermes Agent (Nous Research) --- .../src/graphics_style.rs | 6 +- .../horizon-client-render/src/graphics_ui.rs | 53 ++++--- .../assets/horizon/lang/en_us.json | 134 +++++++++++++++++- .../assets/horizon/lang/ru_ru.json | 134 +++++++++++++++++- 4 files changed, 304 insertions(+), 23 deletions(-) diff --git a/crates/horizon-client-render/src/graphics_style.rs b/crates/horizon-client-render/src/graphics_style.rs index bf0ffbc0cd..c26e5a7c46 100644 --- a/crates/horizon-client-render/src/graphics_style.rs +++ b/crates/horizon-client-render/src/graphics_style.rs @@ -3,6 +3,7 @@ //! lay out rows and handle input; a menu theme replaces this module. use crate::{ hud::{HudAtlas, HudBatch}, + lang, text::Text, ui, }; @@ -144,10 +145,11 @@ impl Style { cost: Cost, text: &str, ) { - let tag = Text::literal(cost.label()); + let tag = Text::literal(lang::get(cost.label())); batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true); let indent = atlas.font.width_text(&tag) + 4.0; - let lines = wrap(atlas, text, self.text_width(batch.width), indent); + let text = lang::get(text); + let lines = wrap(atlas, &text, self.text_width(batch.width), indent); for (i, line) in lines.iter().take(2).enumerate() { let lx = if i == 0 { x + indent } else { x }; batch.text_styled( diff --git a/crates/horizon-client-render/src/graphics_ui.rs b/crates/horizon-client-render/src/graphics_ui.rs index 3bb558ab1d..e45a86bdfb 100644 --- a/crates/horizon-client-render/src/graphics_ui.rs +++ b/crates/horizon-client-render/src/graphics_ui.rs @@ -5,6 +5,7 @@ use crate::{ graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling}, graphics_style::{Cost, STYLE}, hud::{HudAtlas, HudBatch}, + lang, settings::{Opt, Settings}, text::Text, ui, @@ -354,11 +355,14 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec { rows } -fn on_off(on: bool) -> &'static str { - if on { "On" } else { "Off" } +fn on_off(on: bool) -> String { + lang::get(if on { "On" } else { "Off" }) } +/// A control's caption and its value, both through the language table: Horizon's captions are +/// English sentences, and some of the values ("Off", "12 chunks") are words too. fn labelled(caption: &str, value: impl std::fmt::Display) -> Text { - Text::literal(format!("{caption}: {value}")) + let value = value.to_string(); + Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value))) } impl H { @@ -396,19 +400,21 @@ impl H { H::Preset => labelled("Preset", g.preset.label()), H::LodDistance => match g.lod_distance { 0 => labelled("LOD Distance", "Off"), - n => labelled("LOD Distance", format!("{n} chunks")), + n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])), }, H::FarDetail => labelled("Far Detail", g.far_detail.label()), H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)), H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)), H::ChunkUpdates => match g.chunk_updates { 0 => labelled("Chunk Updates", "Unlimited"), - n => labelled("Chunk Updates", format!("{n} per frame")), + n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])), }, H::UploadBudget => match g.upload_budget_kib { 0 => labelled("Upload Budget", "Unlimited"), - k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)), - k => labelled("Upload Budget", format!("{k} KiB/frame")), + k if k >= 1024 => { + labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)])) + } + k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])), }, H::Sky => labelled("Sky", on_off(g.sky)), H::Fog => labelled("Fog", on_off(g.fog)), @@ -423,7 +429,10 @@ impl H { }, ), H::Workers => match g.workers { - 0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())), + 0 => labelled( + "Worker Threads", + lang::tr("Auto (%s)", &[&g.worker_threads()]), + ), n => labelled("Worker Threads", n), }, H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)), @@ -437,21 +446,27 @@ impl H { ), H::Upscaling => labelled("Upscaling", g.upscaling.label()), H::Hdr => labelled("HDR", on_off(g.hdr)), - H::HdrSettings => Text::literal("HDR Settings..."), + H::HdrSettings => Text::literal(lang::get("HDR Settings...")), H::HdrPaperWhite => match g.hdr_paper_white { 0 => labelled( "Paper White", - format!("Auto ({} nits)", g.paper_white_nits()), + lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]), ), - n => labelled("Paper White", format!("{n} nits")), + n => labelled("Paper White", lang::tr("%s nits", &[&n])), }, H::HdrPeak => match g.hdr_peak { - 0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())), - n => labelled("Peak Brightness", format!("{n} nits")), + 0 => labelled( + "Peak Brightness", + lang::tr("Auto (%s nits)", &[&g.peak_nits()]), + ), + n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])), }, H::HdrBlackLevel => labelled( "Black Level", - format!("{:.2} nits", g.hdr_black_level as f32 / 100.0), + lang::tr( + "%s nits", + &[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)], + ), ), H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)), H::BatteryMode => labelled("Battery Mode", on_off(g.battery)), @@ -618,7 +633,7 @@ impl Item { match self { Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled( "Full Detail Distance", - format!("{} chunks", s.render_distance), + lang::tr("%s chunks", &[&s.render_distance]), ), Item::Vanilla(o) => o.label(s), Item::Horizon(h) => h.label(s), @@ -818,7 +833,7 @@ impl GraphicsUi { let x = STYLE.row_x(w); for (y, name) in headers { if shown(y, STYLE.header_height) { - STYLE.header(batch, atlas, &Text::literal(name), x, y); + STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y); } } let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s) @@ -844,7 +859,7 @@ impl GraphicsUi { let blocked = item.blocked(s); let live = blocked.is_none(); if hover && let Some(why) = blocked { - tooltip = Some(atlas.font.split(&Text::literal(why), 170.0)); + tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0)); } match item.slider(s) { Some(value) => STYLE.slider( @@ -884,7 +899,7 @@ impl GraphicsUi { batch, atlas, (wd.x, wd.y, wd.w), - &Text::literal(label), + &Text::literal(lang::get(label)), hover, true, ); @@ -906,7 +921,7 @@ impl GraphicsUi { Level::Advanced => "Graphics: Advanced", Level::Hdr => "Graphics: HDR", }; - STYLE.title(batch, atlas, &Text::literal(title)); + STYLE.title(batch, atlas, &Text::literal(lang::get(title))); if let Some(lines) = tooltip { ui::tooltip(batch, atlas, &lines, cursor); } diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index 75a87a1a03..0c0192e686 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -63,5 +63,137 @@ "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.multiplayer.relay": "Relay (optional)", + "Preset": "Preset", + "LOD Distance": "LOD Distance", + "Far Detail": "Far Detail", + "Cave Culling": "Cave Culling", + "Entity Culling": "Entity Culling", + "Chunk Updates": "Chunk Updates", + "Upload Budget": "Upload Budget", + "Sky": "Sky", + "Fog": "Fog", + "Weather": "Weather", + "Animated Textures": "Animated Textures", + "Present Mode": "Present Mode", + "Frame Pacing": "Frame Pacing", + "Worker Threads": "Worker Threads", + "Resolution Scale": "Resolution Scale", + "Anti-aliasing": "Anti-aliasing", + "Upscaling": "Upscaling", + "HDR Settings...": "HDR Settings...", + "Paper White": "Paper White", + "Peak Brightness": "Peak Brightness", + "Black Level": "Black Level", + "UI Brightness": "UI Brightness", + "Battery Mode": "Battery Mode", + "Graphics API": "Graphics API", + "Performance Overlay": "Performance Overlay", + "Full Detail Distance": "Full Detail Distance", + "On": "On", + "Off": "Off", + "Unlimited": "Unlimited", + "Auto": "Auto", + "Custom": "Custom", + "Performance": "Performance", + "Balanced": "Balanced", + "Quality": "Quality", + "Ultra": "Ultra", + "Ultra Performance": "Ultra Performance", + "Native": "Native", + "Full": "Full", + "Reduced": "Reduced", + "Efficient": "Efficient", + "Precise": "Precise", + "Auto (VSync)": "Auto (VSync)", + "FIFO": "FIFO", + "Mailbox": "Mailbox", + "Immediate": "Immediate", + "Vulkan": "Vulkan", + "DirectX 12": "DirectX 12", + "OpenGL": "OpenGL", + "FPS and Graph": "FPS and Graph", + "FPS": "FPS", + "TAA": "TAA", + "FXAA": "FXAA", + "No cost": "No cost", + "Low cost": "Low cost", + "Medium cost": "Medium cost", + "High cost": "High cost", + "Saves time": "Saves time", + "Rendering": "Rendering", + "Display": "Display", + "Preferences": "Preferences", + "Overlay": "Overlay", + "HDR": "HDR", + "Graphics": "Graphics", + "Graphics: Advanced": "Graphics: Advanced", + "Graphics: HDR": "Graphics: HDR", + "Advanced...": "Advanced...", + "Simple...": "Simple...", + "Done": "Done", + "This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.", + "Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.", + "Turn HDR on to use it.": "Turn HDR on to use it.", + "%s chunks": "%s chunks", + "%s per frame": "%s per frame", + "%s MiB/frame": "%s MiB/frame", + "%s KiB/frame": "%s KiB/frame", + "%s nits": "%s nits", + "Auto (%s)": "Auto (%s)", + "Auto (%s nits)": "Auto (%s nits)", + "%.2f nits": "%.2f nits", + "Full-detail terrain range. LOD Distance can extend local worlds farther.": "Full-detail terrain range. LOD Distance can extend local worlds farther.", + "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.", + "Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.", + "Reduced draws far terrain coarser, for GPUs it slows down.": "Reduced draws far terrain coarser, for GPUs it slows down.", + "Field of view. Wider shows more terrain at once.": "Field of view. Wider shows more terrain at once.", + "Caps frames per second; a cap saves power and heat.": "Caps frames per second; a cap saves power and heat.", + "Waits for the display's refresh: no tearing, a little latency.": "Waits for the display's refresh: no tearing, a little latency.", + "Size of menus and the HUD.": "Size of menus and the HUD.", + "Sets the options below together. Changing one makes it Custom.": "Sets the options below together. Changing one makes it Custom.", + "Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, fire and sunlight shine brighter than white. Needs an HDR display.", + "Your display's darkest black. 0 for OLED; LCDs glow a little.": "Your display's darkest black. 0 for OLED; LCDs glow a little.", + "How bright highlights get. Auto: the peak your system reports.": "How bright highlights get. Auto: the peak your system reports.", + "How bright white paper and menus look. Auto: your desktop's white.": "How bright white paper and menus look. Auto: your desktop's white.", + "How bright the HUD and menus are, against paper white.": "How bright the HUD and menus are, against paper white.", + "How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.", + "How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.", + "Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.", + "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.", + "Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.", + "Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.", + "Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.", + "Blends grass and water colours across biomes when building chunks.": "Blends grass and water colours across biomes when building chunks.", + "Cloud layer: off, flat (fast) or 3D (fancy).": "Cloud layer: off, flat (fast) or 3D (fancy).", + "How far clouds reach, in chunks.": "How far clouds reach, in chunks.", + "Draws the sky, sun, moon and stars.": "Draws the sky, sun, moon and stars.", + "Distance fog. Off shows the edge of the loaded world.": "Distance fog. Off shows the edge of the loaded world.", + "Draws rain and snow.": "Draws rain and snow.", + "How far rain and snow fall around you.": "How far rain and snow fall around you.", + "How many particles effects spawn.": "How many particles effects spawn.", + "Round shadows under mobs and items.": "Round shadows under mobs and items.", + "Leaves with see-through gaps. Off draws them solid.": "Leaves with see-through gaps. Off draws them solid.", + "Sorts water and glass more exactly behind each other.": "Sorts water and glass more exactly behind each other.", + "Which chunk rebuilds wait for the frame: none, nearby or yours.": "Which chunk rebuilds wait for the frame: none, nearby or yours.", + "Animates water, lava, fire and other moving textures.": "Animates water, lava, fire and other moving textures.", + "World pixels per screen pixel. Below 100% is faster on the GPU.": "World pixels per screen pixel. Below 100% is faster on the GPU.", + "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.", + "Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renders the world smaller and rebuilds it with TAA. Faster, softer.", + "HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR output on an HDR display: lava, fire and sunlight shine brighter.", + "How frames reach the screen while VSync is off.": "How frames reach the screen while VSync is off.", + "Efficient sleeps until the next frame; Precise spins, like vanilla.": "Efficient sleeps until the next frame; Precise spins, like vanilla.", + "Fills the screen (borderless).": "Fills the screen (borderless).", + "Brightens dark places.": "Brightens dark places.", + "Lowers the frame rate when minimised, or idle too.": "Lowers the frame rate when minimised, or idle too.", + "Sways the view while walking.": "Sways the view while walking.", + "Fades newly loaded chunks in.": "Fades newly loaded chunks in.", + "Darkens the screen's edges.": "Darkens the screen's edges.", + "Shows when the world saves.": "Shows when the world saves.", + "Shows attack cooldown.": "Shows attack cooldown.", + "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS cap (30 in menus or unfocused), half the workers: less power.", + "Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.", + "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.", + "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.", + "Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph." } diff --git a/crates/horizon-client/assets/horizon/lang/ru_ru.json b/crates/horizon-client/assets/horizon/lang/ru_ru.json index a10032e24e..2effba9c5f 100644 --- a/crates/horizon-client/assets/horizon/lang/ru_ru.json +++ b/crates/horizon-client/assets/horizon/lang/ru_ru.json @@ -63,5 +63,137 @@ "horizon.multiplayer.cancel_reconnecting": "Отменить переподключение", "horizon.multiplayer.join_friend": "Играть с другом", "horizon.multiplayer.invite_code": "Код приглашения", - "horizon.multiplayer.relay": "Ретранслятор (необязательно)" + "horizon.multiplayer.relay": "Ретранслятор (необязательно)", + "Preset": "Пресет", + "LOD Distance": "Дистанция LOD", + "Far Detail": "Детализация вдали", + "Cave Culling": "Отсечение пещер", + "Entity Culling": "Отсечение сущностей", + "Chunk Updates": "Обновления чанков", + "Upload Budget": "Бюджет выгрузки", + "Sky": "Небо", + "Fog": "Туман", + "Weather": "Погода", + "Animated Textures": "Анимированные текстуры", + "Present Mode": "Режим вывода", + "Frame Pacing": "Ритм кадров", + "Worker Threads": "Рабочие потоки", + "Resolution Scale": "Масштаб разрешения", + "Anti-aliasing": "Сглаживание", + "Upscaling": "Апскейл", + "HDR Settings...": "Настройки HDR…", + "Paper White": "Белая точка", + "Peak Brightness": "Пиковая яркость", + "Black Level": "Уровень чёрного", + "UI Brightness": "Яркость интерфейса", + "Battery Mode": "Режим экономии", + "Graphics API": "Графический API", + "Performance Overlay": "Оверлей производительности", + "Full Detail Distance": "Дальность полной детализации", + "On": "Вкл.", + "Off": "Выкл.", + "Unlimited": "Без ограничения", + "Auto": "Авто", + "Custom": "Свои", + "Performance": "Производительность", + "Balanced": "Сбалансированный", + "Quality": "Качество", + "Ultra": "Ультра", + "Ultra Performance": "Максимальная производительность", + "Native": "Нативный", + "Full": "Полная", + "Reduced": "Снижена", + "Efficient": "Экономный", + "Precise": "Точный", + "Auto (VSync)": "Авто (верт. синхр.)", + "FIFO": "FIFO", + "Mailbox": "Mailbox", + "Immediate": "Immediate", + "Vulkan": "Vulkan", + "DirectX 12": "DirectX 12", + "OpenGL": "OpenGL", + "FPS and Graph": "FPS и график", + "FPS": "FPS", + "TAA": "TAA", + "FXAA": "FXAA", + "No cost": "Без затрат", + "Low cost": "Мало затрат", + "Medium cost": "Средние затраты", + "High cost": "Много затрат", + "Saves time": "Экономит время", + "Rendering": "Рендеринг", + "Display": "Изображение", + "Preferences": "Предпочтения", + "Overlay": "Оверлей", + "HDR": "HDR", + "Graphics": "Графика", + "Graphics: Advanced": "Графика: подробно", + "Graphics: HDR": "Графика: HDR", + "Advanced...": "Подробно…", + "Simple...": "Просто…", + "Done": "Готово", + "This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.", + "Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.", + "Turn HDR on to use it.": "Включите HDR, чтобы пользоваться этим.", + "%s chunks": "%s чанков", + "%s per frame": "%s за кадр", + "%s MiB/frame": "%s МиБ/кадр", + "%s KiB/frame": "%s КиБ/кадр", + "%s nits": "%s нит", + "Auto (%s)": "Авто (%s)", + "Auto (%s nits)": "Авто (%s нит)", + "%.2f nits": "%.2f нит", + "Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальность прорисованного рельефа в деталях. Дистанция LOD может продлить её для локальных миров.", + "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубый дальний рельеф в локальных мирах. На встроенной графике — не дальше 128 чанков.", + "Reduced draws far terrain coarser: faster on this integrated GPU.": "«Снижено» рисует дальний рельеф грубее: на этой встроенной видеокарте быстрее.", + "Reduced draws far terrain coarser, for GPUs it slows down.": "«Снижено» рисует дальний рельеф грубее — для карт, которым он мешает.", + "Field of view. Wider shows more terrain at once.": "Угол обзора. Шире — больше рельефа видно сразу.", + "Caps frames per second; a cap saves power and heat.": "Ограничивает кадры в секунду: предел экономит энергию и снижает нагрев.", + "Waits for the display's refresh: no tearing, a little latency.": "Ждёт обновления экрана: без разрывов, с небольшой задержкой.", + "Size of menus and the HUD.": "Размер меню и интерфейса.", + "Sets the options below together. Changing one makes it Custom.": "Задаёт настройки ниже разом. Изменишь одну — станет «Свои».", + "Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, огонь и солнце светятся ярче белого. Нужен HDR-экран.", + "Your display's darkest black. 0 for OLED; LCDs glow a little.": "Самый тёмный чёрный вашего экрана. 0 для OLED; ЖК-панели светятся немного.", + "How bright highlights get. Auto: the peak your system reports.": "Насколько яркими будут блики. Авто: пик, который сообщает система.", + "How bright white paper and menus look. Auto: your desktop's white.": "Насколько ярким выглядит белый и меню. Авто: белый цвет рабочего стола.", + "How bright the HUD and menus are, against paper white.": "Яркость интерфейса и меню относительно белой точки.", + "How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.", + "How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.", + "Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.", + "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.", + "Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.", + "Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.", + "Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.", + "Blends grass and water colours across biomes when building chunks.": "Смешивает цвета травы и воды на границах биомов при построении чанков.", + "Cloud layer: off, flat (fast) or 3D (fancy).": "Слой облаков: выключен, плоский (быстро) или 3D (красиво).", + "How far clouds reach, in chunks.": "Как далеко простираются облака, в чанках.", + "Draws the sky, sun, moon and stars.": "Рисует небо, солнце, луну и звёзды.", + "Distance fog. Off shows the edge of the loaded world.": "Туман по дальности. Выключенный виден край прогруженного мира.", + "Draws rain and snow.": "Рисует дождь и снег.", + "How far rain and snow fall around you.": "Как далеко вокруг вас идёт дождь и снег.", + "How many particles effects spawn.": "Сколько частиц создают эффекты.", + "Round shadows under mobs and items.": "Круглые тени под мобами и предметами.", + "Leaves with see-through gaps. Off draws them solid.": "Листва с просветами. Выключено — рисуется сплошной.", + "Sorts water and glass more exactly behind each other.": "Точнее сортирует воду и стекло друг за другом.", + "Which chunk rebuilds wait for the frame: none, nearby or yours.": "Какие перестройки чанков ждут кадр: никакие, ближние или ваши.", + "Animates water, lava, fire and other moving textures.": "Анимирует воду, лаву, огонь и другие движущиеся текстуры.", + "World pixels per screen pixel. Below 100% is faster on the GPU.": "Пикселей мира на пиксель экрана. Ниже 100% — быстрее для видеокарты.", + "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Сглаживает рваные края. FXAA: мягче. TAA: глаже всего, но движущееся может шлейфить.", + "Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Рисует мир мельче и восстанавливает его через TAA. Быстрее, но мягче.", + "HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-вывод на HDR-экране: лава, огонь и солнце светятся ярче.", + "How frames reach the screen while VSync is off.": "Как кадры попадают на экран, когда вертикальная синхронизация выключена.", + "Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Экономный» спит до следующего кадра; «Точный» крутится, как в ванили.", + "Fills the screen (borderless).": "Занимает весь экран (без рамки).", + "Brightens dark places.": "Осветляет тёмные места.", + "Lowers the frame rate when minimised, or idle too.": "Снижает частоту кадров в свёрнутом окне, а также в простое.", + "Sways the view while walking.": "Покачивает вид при ходьбе.", + "Fades newly loaded chunks in.": "Плавно проявляет только что загруженные чанки.", + "Darkens the screen's edges.": "Затемняет края экрана.", + "Shows when the world saves.": "Показывает, когда мир сохраняется.", + "Shows attack cooldown.": "Показывает перезарядку удара.", + "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Предел 60 FPS (30 в меню и без фокуса) и половина потоков: меньше энергии.", + "Threads that generate, light and mesh chunks. Next start.": "Потоки, которые генерируют, освещают и строят чанки. Со следующего запуска.", + "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, при сбое — DirectX 12 (Windows) или OpenGL (Linux). Со следующего запуска.", + "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.", + "Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком." } From dbce6420c66602dce13cfaa571648527d8a4e7b0 Mon Sep 17 00:00:00 2001 From: My Name Date: Fri, 30 Oct 2026 07:44:42 +0300 Subject: [PATCH 243/968] Command blocks: one data path; the command reaches only the player who opens it #3582 and #3748 fixed the same bug (#3614) twice. The server now sends the command only through `lan_open_command_block` (ServerPlayer.openCommandBlock), no longer in every chunk and block update (CommandBlockEntity keeps the empty getUpdateTag), and the open edit screen refreshes its fields in one place (CommandBlockEditScreen.updateGui). docs/rust-parity.md: the toolbar keys and the Saved Hotbars tab are wired since #3693. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 4 --- .../horizon-client/src/game/command_blocks.rs | 33 +++++++------------ crates/horizon-client/src/game/remote.rs | 9 ----- crates/horizon-client/src/lan/chunks.rs | 3 -- docs/rust-parity.md | 5 +-- 5 files changed, 14 insertions(+), 40 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 550348890c..e6b7da3f90 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3966,10 +3966,6 @@ impl Game<'_> { return; } if horizon_client::command_block::Mode::of_block(&name).is_some() { - // `ServerPlayer.openCommandBlock` sends the block entity's data - // (`ClientboundBlockEntityDataPacket`) so the opening client shows - // the stored command, not its stale copy. - self.mark_block_entity_updated(key); self.open_command_block(key); return; } diff --git a/crates/horizon-client/src/game/command_blocks.rs b/crates/horizon-client/src/game/command_blocks.rs index 3ca65c40bc..c3adf0b3be 100644 --- a/crates/horizon-client/src/game/command_blocks.rs +++ b/crates/horizon-client/src/game/command_blocks.rs @@ -30,7 +30,7 @@ type Pos = (i32, i32, i32); /// The open `CommandBlockEditScreen`. pub(super) struct CommandBlockScreen { - pub(super) pos: Pos, + pos: Pos, /// The command box with its `CommandSuggestions`. command: ChatScreen, /// When the command box was focused, for its cursor's blink. @@ -112,6 +112,7 @@ impl CommandBlockScreen { self.previous.set_value(&shown); } } + fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> { registry.state(state) } @@ -240,21 +241,21 @@ impl Game<'_> { let at = BlockPos::new(pos.0, pos.1, pos.2); self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at)); } - /// `CommandBlockEditScreen.updateGui`: the server's block-entity data - /// arrived while the screen is open; refresh the fields from the entity. - /// The mode and conditional live in the block state here (vanilla's - /// `CommandBlockEntity.Mode`). - pub(super) fn update_command_block_screen(&mut self) { - let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else { + /// `ClientPacketListener.handleBlockEntityData` then + /// `CommandBlockEditScreen.updateGui`: new data for the command block the + /// edit screen shows refreshes its fields. The mode and conditional live in + /// the block state here (vanilla's `CommandBlockEntity.Mode`). + pub(super) fn command_block_data_arrived(&mut self, pos: Pos) { + if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) { return; - }; - let state = self.block_state_at(screen_pos).map(|s| { + } + let state = self.block_state_at(pos).map(|s| { ( Mode::of_block(&s.name).unwrap_or(Mode::Redstone), conditional(s), ) }); - let block = self.command_block(screen_pos).cloned(); + let block = self.command_block(pos).cloned(); let m = Measure(&self.gui.atlas.font); let c = Commands { dispatcher: super::chat::chat_tree(&self.remote), @@ -825,18 +826,6 @@ impl Game<'_> { }), ); } - /// `ClientPacketListener.handleBlockEntityData`: new data for the command - /// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`). - pub(super) fn command_block_data_arrived(&mut self, pos: Pos) { - let open = self.screen == Screen::CommandBlock - && self - .command_block_screen - .as_ref() - .is_some_and(|s| s.pos == pos); - if open { - self.open_command_block(pos); - } - } /// What `handleSetCommandBlock` does once the player may edit the block. fn apply_command_block(&mut self, s: &CommandBlockEdit) { let pos = s.pos; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 99d38950bc..64a63efd3b 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1389,15 +1389,6 @@ impl Game<'_> { self.command_block_data_arrived(key); } } - // `handleBlockEntityData`: a command block's data arriving while its - // edit screen is open refreshes the screen. - if self - .command_block_screen - .as_ref() - .is_some_and(|s| s.pos == key) - { - self.update_command_block_screen(); - } } /// `handleAnimate`: a player wakes up, or an entity gets the critical /// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter. diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index 0e473dbeaf..c290cc0c9d 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -979,9 +979,6 @@ pub fn entity_tag(entity: &BlockEntity) -> Option { .next_spawn_data .as_ref() .map(|d| Tag::compound([("spawn_data", d.encode())])), - // CommandBlockEntity.getUpdateTag: saveWithoutMetadata, the command - // and its output settings (the edit screen reads them). - BlockEntity::CommandBlock(c) => Some(Tag::compound(c.save())), // SkullBlockEntity: the profile is the whole reason a head differs. BlockEntity::Skull(s) => { let mut entries = Vec::new(); diff --git a/docs/rust-parity.md b/docs/rust-parity.md index 12dd076f5a..28b7d9085d 100644 --- a/docs/rust-parity.md +++ b/docs/rust-parity.md @@ -1451,8 +1451,9 @@ do not show attribute modifiers yet). `horizon_client::hotbar` reads the creative toolbars of an older `hotbar.nbt` through the `HOTBAR` data fixers (`EmptyItemInHotbarFix`, every item stack fixer): `tools/test_hotbar_parity.py` loads generated files of 34 versions with -the real `HotbarManager`. The toolbar screen (save and load keys) is not wired -to it yet. +the real `HotbarManager`. The creative screen's Saved Hotbars tab shows its +rows, and the save and load keys write and read it +(`game/creative_hotbar.rs`). ## Player list and world border rendering From 7e6da998faa617a2b24a315a2aa1b53280ae58e9 Mon Sep 17 00:00:00 2001 From: My Name Date: Fri, 30 Oct 2026 07:52:38 +0300 Subject: [PATCH 244/968] README: the creative toolbar keys are wired The known issues still said C/X aren't wired to the hotbar loader; they are since 85f34393f (game/creative_hotbar.rs), and #3693 added the Saved Hotbars tab. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8bbe6eba6a..596f5a95b1 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,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. +Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines. ## Licence From df86a8dd4f5f924ea74a5e23aec44a4fe7ac22f9 Mon Sep 17 00:00:00 2001 From: Vaspyyy <197029450+Vaspyyy@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:02:18 +0000 Subject: [PATCH 245/968] Shader API: sprite tags and the weather at the camera for the material stage Packs declare [[tags]] of block sprites (glass, say) and read them as MaterialInput.tags; the frame gains precipitation, temperature, downfall and wetness (rain smoothed as the shaders/ format's). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb --- crates/horizon-client-render/src/gfx.rs | 9 +- .../src/gfx/native_material.rs | 42 ++++++--- .../src/gfx/native_post.rs | 27 +++++- .../horizon-client/src/game/native_shaders.rs | 6 +- crates/horizon-shader-api/src/inputs.rs | 13 ++- crates/horizon-shader-api/src/lib.rs | 2 +- crates/horizon-shader-api/src/manifest.rs | 87 +++++++++++++++++++ crates/horizon-shader-api/src/stages.rs | 3 +- .../samples/schema-1/horizon-shaders.toml | 4 + crates/horizon-shader-api/tests/schema.rs | 34 ++++++++ docs/api/shaders-reference.md | 5 ++ docs/api/shaders.md | 6 ++ docs/shaders.md | 14 +++ 13 files changed, 234 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 8dc5051d84..cb67cc1d88 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -3474,9 +3474,13 @@ impl Renderer { /// stages' labels; on an error the pack that ran keeps running. pub fn set_native_shader_pack( &mut self, - pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>, + pack: Option<( + horizon_shader_api::Report, + Vec<(String, f64)>, + Vec<(u32, u32)>, + )>, ) -> std::result::Result, String> { - let Some((report, constants)) = pack else { + let Some((report, constants, sprite_tags)) = pack else { self.native = None; return Ok(Vec::new()); }; @@ -3485,6 +3489,7 @@ impl Renderer { flat: self.flat.as_ref().map(|f| &f.draw_layout), sprites: self.terrain_sprites, format: self.config.format.remove_srgb_suffix(), + sprite_tags: &sprite_tags, }; let (pack, labels) = native_pack::NativePack::new(&self.device, &terrain, report, &constants)?; diff --git a/crates/horizon-client-render/src/gfx/native_material.rs b/crates/horizon-client-render/src/gfx/native_material.rs index e7f8f7bf25..8710e3c067 100644 --- a/crates/horizon-client-render/src/gfx/native_material.rs +++ b/crates/horizon-client-render/src/gfx/native_material.rs @@ -66,7 +66,7 @@ fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4 { let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu); return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv, - translucent)).color; + translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color; } @fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4 { let color = horizon_material_color(in, false); @@ -91,6 +91,9 @@ pub struct TerrainShape<'a> { pub sprites: [f64; 2], /// The world target's colour format. pub format: wgpu::TextureFormat, + /// The pack's tags of each tagged sprite layer, as `(layer, bits)` + /// (`Manifest::sprite_tags`). + pub sprite_tags: &'a [(u32, u32)], } /// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the @@ -110,17 +113,34 @@ fn vanilla_terrain() -> String { ) } -/// The terrain shader for the pack's material stage `source`. -fn shader_source(source: &str) -> String { +/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer. +fn sprite_tags(tags: &[(u32, u32)]) -> String { + let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n"); + let mut tags = tags.to_vec(); + tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer)); + for group in tags.chunk_by(|a, b| a.1 == b.1) { + let layers: Vec = group.iter().map(|(layer, _)| format!("{layer}u")).collect(); + out += &format!( + " case {}: {{ return {}u; }}\n", + layers.join(", "), + group[0].1 + ); + } + out + " default: { return 0u; }\n }\n}\n" +} + +/// The terrain shader for the pack's material stage `source`, with its sprite `tags`. +fn shader_source(source: &str, tags: &[(u32, u32)]) -> String { format!( - "{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}", - vanilla_terrain() + "{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}", + vanilla_terrain(), + sprite_tags(tags) ) } /// The material's naga module, its errors in the stage's file. -fn module(stage: &CheckedStage) -> Result { - let text = shader_source(&stage.source); +fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result { + let text = shader_source(&stage.source, tags); naga::front::wgsl::parse_str(&text) .map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text))) } @@ -134,7 +154,7 @@ impl NativeMaterial { stage: &CheckedStage, constants: &[(String, f64)], ) -> Result { - let module = module(stage)?; + let module = module(stage, shape.sprite_tags)?; let mut constants = present_constants(&module, constants); let sprite_names = [ "horizon_terrain_SPRITE_ARRAYS", @@ -299,13 +319,15 @@ mod tests { let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \ return Surface(apply_fog(sprite(lit)));\n}\n"; let stage = stage(own); - let module = module(&stage).unwrap(); + let tags = [(7, 1), (3, 1), (9, 2)]; + assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }")); + let module = module(&stage, &tags).unwrap(); let info = naga::valid::Validator::new( naga::valid::ValidationFlags::all(), naga::valid::Capabilities::all(), ) .validate(&module) - .unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source)))); + .unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags)))); let entries: Vec<&str> = module .entry_points .iter() diff --git a/crates/horizon-client-render/src/gfx/native_post.rs b/crates/horizon-client-render/src/gfx/native_post.rs index bf42931572..0afd5cb3ee 100644 --- a/crates/horizon-client-render/src/gfx/native_post.rs +++ b/crates/horizon-client-render/src/gfx/native_post.rs @@ -38,6 +38,8 @@ pub struct NativeFrame { pub group: wgpu::BindGroup, start: std::time::Instant, frames: u32, + /// `frame.wetness`, and when it was last written. + wetness: (f32, Option), } /// The uniform `frame` at binding 0, read by `visibility`. @@ -80,13 +82,18 @@ impl NativeFrame { group, start: std::time::Instant::now(), frames: 0, + wetness: (0.0, None), } } /// Writes this frame's inputs, for a world of `size`. pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) { let seconds = self.start.elapsed().as_secs_f32() % 3600.0; - let inputs = frame_inputs(frame, size, seconds, self.frames); + let now = std::time::Instant::now(); + let dt = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32()); + self.wetness = (wetness(self.wetness.0, frame.pack.rain, dt), Some(now)); + let mut inputs = frame_inputs(frame, size, seconds, self.frames); + inputs.wetness = self.wetness.0; self.frames = self.frames.wrapping_add(1); queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs)); } @@ -357,6 +364,13 @@ impl NativePost { } } +/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains +/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`). +fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 { + let half_life = if rain > wetness { 30.0 } else { 10.0 }; + rain + (wetness - rain) * 0.5f32.powf(dt / half_life) +} + /// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`. pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs { let aspect = size[0] as f32 / size[1].max(1) as f32; @@ -416,12 +430,23 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> }, frame_index: index, hide_gui: u32::from(pack.hide_gui), + precipitation: pack.biome_precipitation.clamp(0, 2) as u32, + temperature: pack.temperature, + downfall: pack.rainfall, + wetness: pack.rain, } } #[cfg(test)] mod tests { use super::*; + + #[test] + fn wetness_rises_slower_than_it_dries() { + assert_eq!(wetness(0.0, 1.0, 30.0), 0.5); + assert_eq!(wetness(1.0, 0.0, 10.0), 0.5); + assert_eq!(wetness(0.25, 0.25, 5.0), 0.25); + } use horizon_shader_api::{MANIFEST, Pack, check}; fn device() -> Option<(wgpu::Device, wgpu::Queue)> { diff --git a/crates/horizon-client/src/game/native_shaders.rs b/crates/horizon-client/src/game/native_shaders.rs index e72892b0ed..e78c6e5ef5 100644 --- a/crates/horizon-client/src/game/native_shaders.rs +++ b/crates/horizon-client/src/game/native_shaders.rs @@ -63,8 +63,12 @@ impl Game<'_> { .iter() .map(|o| (o.id.clone(), o.kind.default_constant())) .collect(); + let sprites = self.pack.render.sprite_layers.iter(); + let tags = pack + .manifest + .sprite_tags(sprites.map(|(name, layer)| (name.as_str(), *layer))); let labels = renderer - .set_native_shader_pack(Some((report, constants))) + .set_native_shader_pack(Some((report, constants, tags))) .map_err(|e| vec![e])?; Ok((pack.manifest, labels)) }); diff --git a/crates/horizon-shader-api/src/inputs.rs b/crates/horizon-shader-api/src/inputs.rs index f25fe28473..9c011b657a 100644 --- a/crates/horizon-shader-api/src/inputs.rs +++ b/crates/horizon-shader-api/src/inputs.rs @@ -143,6 +143,14 @@ uniform! { frame_index: u32, /// 1 while F1 hides the HUD, else 0. hide_gui: u32, + /// What falls in the biome at the camera while it rains: 0 nothing, 1 rain, 2 snow. + precipitation: u32, + /// The biome at the camera's temperature (vanilla's, -0.7..2.0; it snows below 0.15). + temperature: f32, + /// The biome at the camera's downfall, 0..1. + downfall: f32, + /// `rain`, smoothed: rises over about 30 seconds and dries over about 10, 0..1. + wetness: f32, } } @@ -176,6 +184,7 @@ pub const MATERIAL_INPUT: Struct = Struct { position: "vec3" = "The point, camera-relative, in blocks.", uv: "vec2" = "The texture coordinate inside the block atlas.", translucent: "bool" = "Drawn in the translucent pass (water, stained glass, ice).", + tags: "u32" = "The pack's sprite tags this surface's sprite has: bit i for the manifest's i-th `[[tags]]`.", }, }; @@ -207,9 +216,9 @@ mod tests { #[test] fn frame_fields_carry_their_docs() { let wgsl = Frame::WGSL; - assert_eq!(wgsl.fields.len(), 28); + assert_eq!(wgsl.fields.len(), 32); assert!(wgsl.fields.iter().all(|f| !f.doc.trim().is_empty())); - assert_eq!(std::mem::size_of::(), 4 * 64 + 9 * 16); + assert_eq!(std::mem::size_of::(), 4 * 64 + 10 * 16); assert!( wgsl.wgsl() .contains(" camera_position: vec3,\n time: f32,\n") diff --git a/crates/horizon-shader-api/src/lib.rs b/crates/horizon-shader-api/src/lib.rs index ff676f64fb..4a16dc8c18 100644 --- a/crates/horizon-shader-api/src/lib.rs +++ b/crates/horizon-shader-api/src/lib.rs @@ -12,6 +12,6 @@ pub mod stages; pub use check::{Problem, Report, check}; pub use inputs::Frame; -pub use manifest::{API, MANIFEST, Manifest, OptionKind, PackOption, SCHEMA, Stage}; +pub use manifest::{API, MANIFEST, Manifest, OptionKind, PackOption, SCHEMA, SpriteTag, Stage}; pub use package::{EXTENSION, Pack}; pub use stages::{Composed, StageKind, compose}; diff --git a/crates/horizon-shader-api/src/manifest.rs b/crates/horizon-shader-api/src/manifest.rs index d03a4f213c..ac71752d43 100644 --- a/crates/horizon-shader-api/src/manifest.rs +++ b/crates/horizon-shader-api/src/manifest.rs @@ -14,6 +14,8 @@ pub const MAX_POST_PASSES: usize = 8; pub const MAX_OPTIONS: usize = 64; /// The most GPU time a pack may declare for itself, in milliseconds. pub const MAX_GPU_MS: f32 = 16.0; +/// Sprite tags a pack may declare: the bits of `MaterialInput.tags`. +pub const MAX_TAGS: usize = 32; /// A checked manifest. #[derive(Clone, Debug, PartialEq)] @@ -34,9 +36,43 @@ pub struct Manifest { /// Full-screen passes, in the order they run. pub post: Vec, pub options: Vec, + /// Sprite tags the material stage reads, in bit order. + pub tags: Vec, pub budget: Budget, } +/// A set of block sprites the material stage can tell apart: its bit in +/// `MaterialInput.tags` is its index in the manifest. +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SpriteTag { + /// A WGSL identifier, for the pack's own reading and its errors. + pub id: String, + /// Sprite names such as `minecraft:block/glass` (the namespace defaults to + /// `minecraft`), each with at most one `*` matching any run of characters. + pub sprites: Vec, +} + +impl SpriteTag { + /// Whether the sprite `name` (`namespace:path`) is one of the tag's. + pub fn matches(&self, name: &str) -> bool { + self.sprites.iter().any(|pattern| { + let pattern = match pattern.contains(':') { + true => std::borrow::Cow::Borrowed(pattern.as_str()), + false => std::borrow::Cow::Owned(format!("minecraft:{pattern}")), + }; + match pattern.split_once('*') { + None => pattern == name, + Some((head, tail)) => { + name.len() >= head.len() + tail.len() + && name.starts_with(head) + && name.ends_with(tail) + } + } + }) + } +} + /// One stage: the WGSL file that defines its function. #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] @@ -104,6 +140,8 @@ struct Raw { #[serde(default)] options: Vec, #[serde(default)] + tags: Vec, + #[serde(default)] budget: RawBudget, } @@ -188,6 +226,32 @@ impl Manifest { } options.push(option); } + if !raw.tags.is_empty() && raw.material.is_none() { + return Err(err( + "tags are read by the material stage: give it a [material]".into(), + )); + } + if raw.tags.len() > MAX_TAGS { + return Err(err(format!( + "{} tags; a pack may declare {MAX_TAGS}", + raw.tags.len() + ))); + } + for (i, tag) in raw.tags.iter().enumerate() { + let id = &tag.id; + if !pack_identifier(id) { + return Err(err(format!("tag {id:?}: the id must be a WGSL identifier"))); + } + if raw.tags[..i].iter().any(|t| &t.id == id) { + return Err(err(format!("tag {id:?} is declared twice"))); + } + let sprite_ok = |p: &String| !p.is_empty() && p.matches('*').count() <= 1; + if tag.sprites.is_empty() || !tag.sprites.iter().all(sprite_ok) { + return Err(err(format!( + "tag {id:?}: needs sprites, each a name with at most one *" + ))); + } + } let gpu_ms = match raw.budget.gpu_ms { Some(ms) if !(ms > 0.0 && ms <= f64::from(MAX_GPU_MS)) => { return Err(err(format!( @@ -209,10 +273,33 @@ impl Manifest { material: raw.material, post: raw.post, options, + tags: raw.tags, budget: Budget { gpu_ms }, }) } + /// The tags of each block sprite `(name, layer)` that has any, as `(layer, bits)` in + /// layer order: what the material stage's `MaterialInput.tags` reads. + pub fn sprite_tags<'a>( + &self, + sprites: impl IntoIterator, + ) -> Vec<(u32, u32)> { + let mut out: Vec<(u32, u32)> = sprites + .into_iter() + .filter_map(|(name, layer)| { + let bits = self + .tags + .iter() + .enumerate() + .filter(|(_, t)| t.matches(name)) + .fold(0, |bits, (i, _)| bits | 1 << i); + (bits != 0).then_some((layer, bits)) + }) + .collect(); + out.sort_unstable(); + out + } + /// Every WGSL file the manifest names, shared files first, each once. pub fn files(&self) -> Vec<&str> { let mut files: Vec<&str> = Vec::new(); diff --git a/crates/horizon-shader-api/src/stages.rs b/crates/horizon-shader-api/src/stages.rs index 12bd3e0381..1679442058 100644 --- a/crates/horizon-shader-api/src/stages.rs +++ b/crates/horizon-shader-api/src/stages.rs @@ -116,9 +116,10 @@ struct HorizonMaterialIn { @location(4) world: vec3, @location(5) uv: vec2, @location(6) @interpolate(flat) translucent: u32, + @location(7) @interpolate(flat) tags: u32, } @fragment fn horizon_fs_material(in: HorizonMaterialIn) -> @location(0) vec4 { - let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u); + let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags); return material(input).color; } "; diff --git a/crates/horizon-shader-api/tests/samples/schema-1/horizon-shaders.toml b/crates/horizon-shader-api/tests/samples/schema-1/horizon-shaders.toml index 6e8a545a77..a8e717d348 100644 --- a/crates/horizon-shader-api/tests/samples/schema-1/horizon-shaders.toml +++ b/crates/horizon-shader-api/tests/samples/schema-1/horizon-shaders.toml @@ -55,5 +55,9 @@ type = "choice" values = ["None", "Soft", "Filmic"] default = "Soft" +[[tags]] +id = "glass" +sprites = ["minecraft:block/glass", "block/*_stained_glass"] + [budget] gpu_ms = 1.5 diff --git a/crates/horizon-shader-api/tests/schema.rs b/crates/horizon-shader-api/tests/schema.rs index 2176805eee..2fd5140ead 100644 --- a/crates/horizon-shader-api/tests/schema.rs +++ b/crates/horizon-shader-api/tests/schema.rs @@ -87,9 +87,32 @@ fn reads_every_key_of_schema_1() { .map(|o| o.kind.default_constant()) .collect(); assert_eq!(constants, [f64::from(1.1f32), 1.0, 4.0, 1.0]); + assert_eq!(m.tags.len(), 1); + assert_eq!(m.tags[0].id, "glass"); assert_eq!(m.budget.gpu_ms, Some(1.5)); } +#[test] +fn sprite_tags_name_sprites_with_one_wildcard() { + let mut m = Manifest::parse(&sample_text()).unwrap(); + m.tags.push(horizon_shader_api::SpriteTag { + id: "ice".into(), + sprites: vec!["minecraft:block/*ice".into()], + }); + let sprites = [ + ("minecraft:block/stone", 0), + ("minecraft:block/glass", 1), + ("minecraft:block/red_stained_glass", 2), + ("minecraft:block/red_stained_glass_pane_top", 3), + ("minecraft:block/packed_ice", 4), + ("minecraft:block/ice", 5), + ]; + assert_eq!( + m.sprite_tags(sprites.iter().rev().copied()), + [(1, 1), (2, 1), (4, 2), (5, 2)] + ); +} + #[test] fn the_smallest_manifest() { let m = Manifest::parse( @@ -116,6 +139,17 @@ fn refuses_what_this_build_does_not_read() { text.replace("license = \"MIT\"", "licence = \"MIT\""), "unknown field", ), + ( + text.replace( + "[\"minecraft:block/glass\", \"block/*_stained_glass\"]", + "[]", + ), + "needs sprites", + ), + ( + text.replace("\"block/*_stained_glass\"", "\"block/*_*\""), + "at most one *", + ), ( text.replace("gpu_ms = 1.5", "gpu_ms = 40.0"), "budget.gpu_ms", diff --git a/docs/api/shaders-reference.md b/docs/api/shaders-reference.md index 400d5029ba..eac8398933 100644 --- a/docs/api/shaders-reference.md +++ b/docs/api/shaders-reference.md @@ -51,6 +51,7 @@ One point of a block's surface, for `fn material(input: MaterialInput) -> Surfac | `position` | `vec3` | The point, camera-relative, in blocks. | | `uv` | `vec2` | The texture coordinate inside the block atlas. | | `translucent` | `bool` | Drawn in the translucent pass (water, stained glass, ice). | +| `tags` | `u32` | The pack's sprite tags this surface's sprite has: bit i for the manifest's i-th `[[tags]]`. | ### `Surface` @@ -134,6 +135,10 @@ The frame, as `frame` in every stage. Positions and directions are in world axes | `dimension` | `i32` | 388 | 0 the Overworld, -1 the Nether, 1 the End, 2 any other dimension. | | `frame_index` | `u32` | 392 | Frames drawn since the pack was loaded (wraps). | | `hide_gui` | `u32` | 396 | 1 while F1 hides the HUD, else 0. | +| `precipitation` | `u32` | 400 | What falls in the biome at the camera while it rains: 0 nothing, 1 rain, 2 snow. | +| `temperature` | `f32` | 404 | The biome at the camera's temperature (vanilla's, -0.7..2.0; it snows below 0.15). | +| `downfall` | `f32` | 408 | The biome at the camera's downfall, 0..1. | +| `wetness` | `f32` | 412 | `rain`, smoothed: rises over about 30 seconds and dries over about 10, 0..1. | ## Options diff --git a/docs/api/shaders.md b/docs/api/shaders.md index faf662b0e8..eb2e779cbd 100644 --- a/docs/api/shaders.md +++ b/docs/api/shaders.md @@ -68,6 +68,10 @@ min = 0.0 max = 2.0 step = 0.05 +[[tags]] # 32 at most: bit i of MaterialInput.tags is the i-th tag +id = "glass" +sprites = ["block/glass", "block/*_stained_glass"] # sprite names, at most one * each + [budget] gpu_ms = 1.5 # optional: the GPU time the pack aims to add (0 to 16 ms) ``` @@ -96,6 +100,8 @@ stages. ## Changelog +- 2026-10-30: sprite tags (`[[tags]]`, `MaterialInput.tags`) and the frame's `precipitation`, `temperature`, + `downfall` and `wetness`. - 2026-10-26: the material stage in the game: terrain's surfaces through the pack's `material`. - 2026-10-26: the sky stage in the game: the pack's `sky` gets vanilla's colour along each ray. - 2026-10-26: the post stage in the game (`HORIZON_SHADER_PACK`, hot reload with the last good pack kept). diff --git a/docs/shaders.md b/docs/shaders.md index 41cb30d559..ad3c8a4019 100644 --- a/docs/shaders.md +++ b/docs/shaders.md @@ -103,6 +103,20 @@ Post passes also read `scene_color`, `scene_depth`, `previous` (the last pass's Rules the check enforces: your files declare no bindings (`@group`/`@binding`), no entry points and no names starting with `horizon_`. Code shared by several stages goes in files listed in `shared`, put before every stage. +## Sprite tags + +The material stage can tell block sprites apart through tags the manifest declares. Each `[[tags]]` is a bit of +`input.tags`, in the order they appear (32 at most); its `sprites` are sprite names, the namespace `minecraft` by +default, each with at most one `*`: + +```toml +[[tags]] +id = "glass" # bit 0: (input.tags & 1u) != 0u +sprites = ["block/glass", "block/*_stained_glass"] +``` + +Terrain carries its sprite, not its block, so a sprite drawn by several blocks gets the tag for all of them. + ## Options Each `[[options]]` entry is a WGSL `override` of the same name, set when the game builds the pipeline: no text From 531cb066144dc75d7ea19dad5b953b78d6f069a9 Mon Sep 17 00:00:00 2001 From: GIGABAIT93 Date: Fri, 30 Oct 2026 07:04:50 +0200 Subject: [PATCH 246/968] Riders sit in their scale, and on text displays An NPC plugin enlarges its fake players (minecraft:scale) and seats the sitting ones on an invisible text display. Three gaps showed together: - a vehicle's passenger attachment ignored its scale, so a name riding a 1.5x NPC sat at 1.8 blocks, in its face (getDimensions scales the attachments with the box); - players and mannequins had no vehicle attachment on a mob or a display (Avatar.DEFAULT_VEHICLE_ATTACHMENT, 0.6, scaled); only boats had it; - a mob riding a text display kept the server's position for it instead of its seat (rideTick), so the plugin's teleport left it in the air. Co-Authored-By: Claude Opus 5.5 --- .../src/game/boat_passengers.rs | 8 +-- crates/horizon-client/src/game/passengers.rs | 22 +++++++- .../horizon-client/src/game/text_displays.rs | 5 ++ crates/horizon-client/src/mobs/riders.rs | 56 ++++++++++++++++++- 4 files changed, 81 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game/boat_passengers.rs b/crates/horizon-client/src/game/boat_passengers.rs index 7055cccfdd..735a1ededd 100644 --- a/crates/horizon-client/src/game/boat_passengers.rs +++ b/crates/horizon-client/src/game/boat_passengers.rs @@ -356,12 +356,8 @@ impl Game<'_> { .map(|(n, &j)| { let m = &self.mobs[j]; let animal = is_animal(m.species()); - // Another player, on a server's client. - let offset = if &*m.kind.id == "minecraft:player" { - PLAYER_RIDING_OFFSET - } else { - riders::vehicle_attachment(m) - }; + // Another player's too, on a server's client. + let offset = riders::vehicle_attachment(m); let index = players + n; // calculatePassengerBodyYRot. let turn = match (animal && full, m.id.is_multiple_of(2)) { diff --git a/crates/horizon-client/src/game/passengers.rs b/crates/horizon-client/src/game/passengers.rs index c51f843653..0695fa4a93 100644 --- a/crates/horizon-client/src/game/passengers.rs +++ b/crates/horizon-client/src/game/passengers.rs @@ -95,7 +95,25 @@ impl Game<'_> { let Some(v) = self.mobs[i].extra.mount else { continue; }; - let Some(&j) = index.get(&v) else { continue }; + let Some(&j) = index.get(&v) else { + // A mob on a text display: NPC plugins seat their sitting + // NPCs on one. The server's position for a passenger + // gives way to its seat, as `rideTick` places it. + let Some((at, before)) = self.text_displays.get(&(v as i32)).map(|d| d.seat()) + else { + continue; + }; + let m = &mut self.mobs[i]; + let offset = riders::vehicle_attachment(m); + m.position = Vec3::new(at.x, at.y - offset, at.z); + m.previous = Vec3::new(before.x, before.y - offset, before.z); + m.velocity = Vec3::ZERO; + m.fall_distance = 0.0; + m.on_ground = false; + m.extra.passenger = true; + m.ai.nav.stop(); + continue; + }; let vehicle = &self.mobs[j]; let attach = riders::passenger_attachment(vehicle); let (pos, prev) = (vehicle.position, vehicle.previous); @@ -117,7 +135,7 @@ impl Game<'_> { } // Text displays riding a mob, as NPC plugins put a name over a head: // `LivingEntity.getPassengerRidingPosition`, the vehicle's passenger - // attachment (the top of its box in its pose), less the display's own + // attachment (the top of its box in its pose and scale), less the display's own // vehicle attachment, which is 0 (Display's dimensions are 0 by 0). // On the local player, the top of its box in its pose and scale (`Avatar.POSES`). let me = self.remote.as_ref().map(|r| r.entity_id as u32); diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index dea564bfbe..29aa8ddccd 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -94,6 +94,11 @@ impl RemoteTextDisplay { /// `Entity.positionRider` through `setPos`: where its vehicle seats it this /// tick. The previous position stays the last tick's, so it moves smoothly /// with the vehicle; a teleport's interpolation no longer applies. + /// Where a passenger of this display sits, this tick and the last + /// (`Display`'s box is 0 by 0, so its passenger attachment is its feet). + pub(super) fn seat(&self) -> (glam::DVec3, glam::DVec3) { + (self.position, self.previous) + } pub(super) fn ride(&mut self, at: glam::DVec3) { self.position = at; self.target = at; diff --git a/crates/horizon-client/src/mobs/riders.rs b/crates/horizon-client/src/mobs/riders.rs index 7fd820ea04..7ccf4a1517 100644 --- a/crates/horizon-client/src/mobs/riders.rs +++ b/crates/horizon-client/src/mobs/riders.rs @@ -13,9 +13,28 @@ use horizon_world::nbt::{Compound, Tag}; /// The goal flags a controlling passenger takes from its vehicle. const CONTROLLED: u8 = MOVE | JUMP | LOOK; +/// `LivingEntity.getScale`: the `minecraft:scale` a player or mannequin +/// carries (`extra.model_scale`, 0 when none came), 1 for every other mob. +fn scale(m: &Mob) -> f64 { + if m.extra.model_scale > 0.0 { + f64::from(m.extra.model_scale) + } else { + 1.0 + } +} +/// Players and mannequins (`Avatar`). +fn avatar(m: &Mob) -> bool { + matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") +} /// `EntityType` passenger attachment of a vehicle (before rotation): where a -/// rider's attachment point sits relative to the vehicle's feet. +/// rider's attachment point sits relative to the vehicle's feet, in its +/// scale (`getDimensions` scales the attachments with the box). pub fn passenger_attachment(v: &Mob) -> [f64; 3] { + let [x, y, z] = unscaled_passenger_attachment(v); + let s = scale(v); + [x * s, y * s, z * s] +} +fn unscaled_passenger_attachment(v: &Mob) -> [f64; 3] { match v.species() { Species::Chicken => [0.0, 0.7, -0.1], Species::Spider => [0.0, 0.765, 0.0], @@ -35,9 +54,16 @@ pub fn passenger_attachment(v: &Mob) -> [f64; 3] { } } /// The rider's own `VEHICLE` attachment height (`ridingOffset`): how far -/// below the vehicle's attachment point its feet hang. +/// below the vehicle's attachment point its feet hang, in its scale. pub fn vehicle_attachment(r: &Mob) -> f64 { + unscaled_vehicle_attachment(r) * scale(r) +} +fn unscaled_vehicle_attachment(r: &Mob) -> f64 { use Species as S; + // Avatar.DEFAULT_VEHICLE_ATTACHMENT. + if avatar(r) { + return 0.6; + } match r.species() { S::Zombie | S::Husk | S::Drowned if r.is_baby() => 0.1875, S::ZombieVillager if r.is_baby() => 0.125, @@ -163,6 +189,32 @@ mod tests { let [x, _, z] = rotated(behind, 90.0); assert!((x - 0.0625).abs() < 1e-9 && z.abs() < 1e-9); } + fn npc(scale: f32) -> Mob { + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = "minecraft:player".into(); + let mut m = Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0); + m.extra.model_scale = scale; + m + } + /// An NPC plugin's enlarged fake player: the name it carries sits on the + /// top of its scaled box, and on a seat it hangs `Avatar`'s 0.6 lower, scaled too. + #[test] + fn a_scaled_player_carries_and_sits_in_its_scale() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!(close(passenger_attachment(&npc(0.0))[1], 1.8)); + assert!(close(passenger_attachment(&npc(1.5))[1], 2.7)); + assert!(close(vehicle_attachment(&npc(0.0)), 0.6)); + assert!(close(vehicle_attachment(&npc(1.5)), 0.9)); + // On a seat at y 55 (a text display, whose attachment is its feet): + // a 1.5 NPC's feet at 54.1, where the server puts it. + let seat = seat( + [0.0; 3], + vehicle_attachment(&npc(1.5)), + Vec3::new(0.0, 55.0, 0.0), + 0.0, + ); + assert!(close(seat.y, 54.1)); + } #[test] fn archers_keep_their_distance_and_brawlers_close_in() { assert!(ranged(Species::Skeleton) && ranged(Species::Pillager)); From 43777b9d16967713cff223608e6a6a70441c7157 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:28:23 +0100 Subject: [PATCH 247/968] Stub the redstone ore dust in the remote mining fixture --- tests/test_remote_mining.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_remote_mining.py b/tests/test_remote_mining.py index f694ad223b..86409607a8 100644 --- a/tests/test_remote_mining.py +++ b/tests/test_remote_mining.py @@ -100,6 +100,8 @@ impl Game { fn predict_break(&mut self, pos: [i32; 3], _: u32, _: i32) { self.world.broken.push(pos); } fn send(&mut self, S::PlayerAction(packet): S) { self.packets.push(packet); } fn crack_particle(&mut self, _: [i32; 3], _: u8, _: u32) {} + // RedStoneOreBlock.attack's dust (sculk.rs): no particles in this fixture. + fn redstone_ore_particles(&mut self, _: [i32; 3]) {} // ClientLevel.addBreakingBlockEffects: the hit sound once, when the dig // starts; the server's level events play it while the block is mined. fn breaking_block_effects(&mut self, _: [i32; 3], _: u8, play_sound: bool) { From 1e5aaec352f6c4aefb99a2862dc87f0f48020781 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:29:39 +0100 Subject: [PATCH 248/968] Skip the note block head sound on a client level --- crates/horizon-client/src/game.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 3429be2096..25214948f0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1159,6 +1159,13 @@ impl LevelAccess for GameLevel<'_> { // player is this client's own: the block's `playSound(null, ...)` // (a piston's move, a note) reaches it from the server instead. LevelEvent::Sound { .. } if self.client_side => {} + // NoteBlock.triggerEvent plays the head's sound with a null + // player, so only the server's level plays it; a guest or a + // remote client hears it from the sound packet. + LevelEvent::BlockEvent { + kind: "note_custom_sound", + .. + } if self.client_side => {} // Block.popResource of the block's own item (tall flowers, flower beds). LevelEvent::BlockEvent { pos, From 1a4ee0e7ee6f11951d5506f8ba35d901d813de6b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:30:14 +0100 Subject: [PATCH 249/968] Release the probe lock when a world is checked for being open elsewhere Checking a world on the title screen takes the session lock for an instant; hand it back explicitly, as SessionLock does, so a quit world is never listed as locked. Drop the stale note that powder snow climbing is not ported for rabbits. --- crates/horizon-client/src/mobs/rabbit.rs | 3 +-- crates/horizon-client/src/save.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/mobs/rabbit.rs b/crates/horizon-client/src/mobs/rabbit.rs index 3b71c7aa10..4855d18028 100644 --- a/crates/horizon-client/src/mobs/rabbit.rs +++ b/crates/horizon-client/src/mobs/rabbit.rs @@ -24,8 +24,7 @@ const CLIENT_JUMP_DURATION: usize = 5; /// The killer bunny's variant id (`Rabbit.Variant.EVIL`). const EVIL: u8 = 99; -/// Rabbit.registerGoals. Not ported yet: ClimbOnTopOfPowderSnowGoal (1) and -/// RaidGardenGoal (5). +/// Rabbit.registerGoals. Not ported yet: RaidGardenGoal (5). pub fn register(m: &mut Mob) { let food = m.kind.food.clone(); let monsters = super::species::ALL diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 913b29afbc..d92769afba 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -166,7 +166,15 @@ impl SessionLock { Ok(file) => file, Err(e) => return e.kind() == std::io::ErrorKind::PermissionDenied, }; - matches!(file.try_lock(), Err(fs::TryLockError::WouldBlock)) + match file.try_lock() { + Err(fs::TryLockError::WouldBlock) => true, + Err(_) => false, + // Take the probe's own lock back at once, as `SessionLock` does on drop. + Ok(()) => { + let _ = file.unlock(); + false + } + } } } /// Bring a world last saved by an older game version to the current format, as From f83be65d79d3e1717ba3b99c05ecf77c621545ee Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:30:25 +0100 Subject: [PATCH 250/968] Send creative inventory edits with the click, not at tick end A creative click on a server changed the local slot but sent it at the end of the tick, while a thrown stack went out at once. A slot update the server sent in between (the pickup of an item a held Q had thrown) replaced the unsent change and was marked as synced, so the throw was never taken off the stack and the count climbed. CreativeModeInventoryScreen.slotClicked ends in broadcastChanges (CreativeInventoryListener.slotChanged -> handleCreativeModeItemAdd), so the changed slots follow the click immediately; Horizon now sends them right behind it. Fixes #3090 --- crates/horizon-client/src/game.rs | 16 ++++++++++++-- crates/horizon-client/src/game/remote.rs | 28 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2c64b8847f..e5ef6407f7 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7154,6 +7154,10 @@ impl Game<'_> { DisplayClick::Drop(stack) => self.player_drop(stack), _ => {} } + // handleCreativeModeItemAdd / broadcastChanges: a swapped-in hotbar slot goes out now. + if self.remote.is_some() { + self.send_creative_slots(); + } } /// Several clicks on one menu instance (a quick craft drag is three). fn menu_ops(&mut self, ops: &[(Option, i32, horizon_client::menu::Input)]) -> bool { @@ -7172,7 +7176,7 @@ impl Game<'_> { self.crafter_slot_clicked(*slot, *button, *input); } let ops = ops.to_vec(); - self.menu_session(move |menu, env, to_menu| { + let done = self.menu_session(move |menu, env, to_menu| { let mut clicks = Vec::new(); for &(slot, button, input) in &ops { let menu_slot = match slot { @@ -7196,7 +7200,12 @@ impl Game<'_> { } } Some(true) - }) + }); + // CreativeModeInventoryScreen.slotClicked: the changed slots go out with the click. + if done && self.remote.is_some() && self.screen == Screen::Creative { + self.send_creative_slots(); + } + done } /// A sound a menu click made the player play (a bundle's insert or /// remove): `Entity.playSound` at the player. @@ -7860,6 +7869,9 @@ impl Game<'_> { self.menu_session(|menu, env, to_menu| { Some(menu.quick_move_all(env, to_menu(slot)?, &moved).is_ok()) }); + if self.remote.is_some() { + self.send_creative_slots(); + } } } self.last_inv_click = None; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index b2e1872e21..856648f29d 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4917,6 +4917,34 @@ impl Game<'_> { fn creative_stacks(&self) -> Vec> { creative_sync_stacks(&self.inventory, &self.offhand, &self.armor) } + /// `CreativeInventoryListener.slotChanged`: `handleCreativeModeItemAdd` for every slot the + /// player's inventory menu changed. `CreativeModeInventoryScreen.slotClicked` ends in + /// `broadcastChanges`, so a click's slots go out with the click, right behind a thrown + /// stack, and not at the end of the tick: a slot update the server sends meanwhile (the + /// pickup of what a held Q threw) can then never land on a change still unsent and undo it. + pub(super) fn send_creative_slots(&mut self) { + if !self.creative { + return; + } + let stacks = self.creative_stacks(); + let Some(r) = self.remote.as_mut() else { + return; + }; + let mut changed = Vec::new(); + for (i, stack) in stacks.into_iter().enumerate() { + if stack != r.synced[i] { + r.synced[i] = stack.clone(); + changed.push((creative_sync_slot(i), stack)); + } + } + for (slot, stack) in changed { + let item = self.to_network(&stack); + self.send(S::SetCreativeModeSlot(play::SetCreativeModeSlot { + slot, + item, + })); + } + } /// Inventory as the server last described it. fn remote_synced(&mut self) { let stacks = self.creative_stacks(); From 3379afe80768118d5ea468efc8d1fc038ed4cce9 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:50:03 +0100 Subject: [PATCH 251/968] Pass a mount's fall damage on to its riders A rider took no damage when the mob under it landed hard, and a mob on a mount's back never felt it either. Entity.propagateFallToPassengers hands the fall to every passenger (LivingEntity.causeFallDamage first, AbstractHorse.causeFallDamage after its own hurt and only when hurt), and each passenger runs its own causeFallDamage with its own safe distance. A mount now raises a fall event the game turns into Player.causeFallDamage for the local player and server players, and the mob causeFallDamage for a mob rider. --- crates/horizon-client/src/game/creatures.rs | 5 ++ crates/horizon-client/src/game/mounts.rs | 57 +++++++++++++++++++++ crates/horizon-client/src/landing.rs | 6 +++ crates/horizon-client/src/mobs.rs | 7 +++ crates/horizon-client/src/mobs/living.rs | 49 ++++++++++++++++-- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index f191966879..4c1008064e 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1840,6 +1840,11 @@ impl Game<'_> { } } MobEvent::PandaSneezed { mob } => self.panda_sneezed(mob), + MobEvent::FallToPassengers { + mob, + distance, + modifier, + } => self.fall_to_passengers(mob, distance, modifier), MobEvent::ShedScute { mob, at, pitch } => { // The mob already checked `shouldDropLoot` (a baby, `mob_drops`). let table = "minecraft:gameplay/armadillo_shed"; diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index 6849a22911..e37137e171 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -7,6 +7,7 @@ use horizon_client::{ hud::HudBatch, items::ItemStack, mobs::{MobEvent, Species, equine}, + motion::MotionWorld, sprint::Keys, }; use horizon_core::{geometry::Vec3, random::RandomSource}; @@ -541,6 +542,62 @@ impl Game<'_> { _ => {} } } + /// `Entity.propagateFallToPassengers`: whoever rides the mob `mob` takes + /// `causeFallDamage` of its fall, as the mount's own damage does. The local + /// player, the server players and the mob on its back each get it, with + /// their own safe distance and armour. + pub(super) fn fall_to_passengers(&mut self, mob: u32, distance: f64, modifier: f32) { + if self.riding == Some(mob) { + self.rider_fall_damage(distance, modifier); + } + let guests: Vec = self.lan.as_ref().map_or_else(Vec::new, |h| { + h.hub + .guests + .iter() + .filter(|g| g.vehicle == Some(mob) && Some(g.conn) != self.lan_acting) + .map(|g| g.conn) + .collect() + }); + if !guests.is_empty() + && let Some(mut host) = self.lan.take() + { + for conn in guests { + self.guest_hit_by(&mut host, conn, |g| g.rider_fall_damage(distance, modifier)); + } + self.lan = Some(host); + } + let rider = self + .mobs + .iter() + .find(|m| m.id == mob) + .and_then(|m| m.extra.rider); + if let Some(rider) = rider + && let Some(r) = self.mobs.iter_mut().find(|m| m.id == rider) + { + let mut events = Vec::new(); + r.cause_fall_damage_from(distance, modifier, &mut events); + let _ = self.handle_mob_events(events); + } + } + /// The player this game runs (the local one, or the server player it acts + /// as) takes the fall of the mount under it. `cause_mount_fall` runs the + /// landing code's own fall damage for the player: creative flight is + /// exempt, and the statistic, the sounds and the damage records wait in the + /// player's events for its next tick, as a landing of its own leaves them. + fn rider_fall_damage(&mut self, distance: f64, modifier: f32) { + let Some(player) = self.player.as_mut() else { + return; + }; + let _ = MotionWorld { + entities: &[], + player, + scene: &mut self.world, + assets: &self.pack.assets, + collision_binding: self.collision, + resources: self.motion, + } + .cause_mount_fall(distance, modifier); + } /// `PlayerRideableJumping`: horses, camels and nautili (the dash). fn jumps(species: Species) -> bool { equine::is_equine(species) || horizon_client::mobs::nautilus::is_nautilus(species) diff --git a/crates/horizon-client/src/landing.rs b/crates/horizon-client/src/landing.rs index b0b05a3889..449b84b168 100644 --- a/crates/horizon-client/src/landing.rs +++ b/crates/horizon-client/src/landing.rs @@ -223,6 +223,12 @@ impl MotionWorld<'_, '_, '_> { fn fall_event(&mut self, event: FallEvent) { self.player.events.push(MotionEvent::Fall(event)); } + /// `Entity.propagateFallToPassengers`: the call on this player, whose + /// vehicle fell `distance` blocks at `multiplier`, so the player takes + /// `Player.causeFallDamage` of it. + pub fn cause_mount_fall(&mut self, distance: f64, multiplier: f32) -> Result { + BlockLandingWorld::cause_fall_damage(self, distance, multiplier, LandingDamage::Fall) + } } impl FallDamageWorld for MotionWorld<'_, '_, '_> { type Error = Box; diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 9dc6eeb1ac..9fdf2de046 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1578,6 +1578,13 @@ pub enum MobEvent { PandaSneezed { mob: u32, }, + /// `Entity.propagateFallToPassengers`: the mob landed and what rides it + /// (a player or a mob) takes `causeFallDamage` of the same fall. + FallToPassengers { + mob: u32, + distance: f64, + modifier: f32, + }, } /// How a spawned-by-kind mob should be set up. #[derive(Clone, Debug, Default)] diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 9f6da2c764..6756d6ed3c 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -872,22 +872,37 @@ impl Mob { self.position.z.floor() as i32, ] } + /// The landing of `check_fall_damage`: the whole fall, with no block + /// softening it. fn cause_fall_damage(&mut self, events: &mut Vec) { + self.cause_fall_damage_from(self.fall_distance, 1.0, events); + } + /// `LivingEntity.causeFallDamage`: a fall of `distance` blocks at `modifier` + /// (its own landing, or a vehicle's passed on by `propagateFallToPassengers`) + /// brings the species' sounds and reactions, the damage, and the fall + /// handed to whoever rides it. `AbstractHorse.causeFallDamage` hurts the + /// horse first and passes the fall on only when it was hurt. + pub fn cause_fall_damage_from( + &mut self, + distance: f64, + modifier: f32, + events: &mut Vec, + ) { // Breeze.causeFallDamage: a landing from higher than three blocks. - if self.species() == super::Species::Breeze && self.fall_distance > 3.0 { + if self.species() == super::Species::Breeze && distance > 3.0 { let at = [self.position.x, self.position.y, self.position.z]; events.push(MobEvent::Sound("entity.breeze.land", at, 1.0, 1.0)); } // Creeper.causeFallDamage: the fall swells the fuse, up to five ticks // short of exploding. if self.species() == super::Species::Creeper { - self.swell = (self.swell + (self.fall_distance * 1.5) as i32).min(30 - 5); + self.swell = (self.swell + (distance * 1.5) as i32).min(30 - 5); } // AbstractHorse.causeFallDamage (llamas override it): a landing of more // than a block thuds, and the horse's fall makes no generic fall sound. let thud = super::equine::is_equine(self.species()) && !super::llama::is_llama(self.species()); - if thud && self.fall_distance > 1.0 && !self.silent { + if thud && distance > 1.0 && !self.silent { let at = [self.position.x, self.position.y, self.position.z]; let sound = if self.is_baby() { "entity.baby_horse.land" @@ -896,15 +911,24 @@ impl Mob { }; events.push(MobEvent::Sound(sound, at, 0.4, 1.0)); } + // AbstractHorse.causeFallDamage hurts the horse first and passes the fall + // on to its riders only when it was hurt. + let horse = super::equine::is_equine(self.species()); + // Entity.causeFallDamage: nothing happens to a fall immune entity, nor + // to those on it. if self.kind.flags.fall_immune { return; } - let power = self.fall_distance + 1.0e-6 - self.kind.safe_fall_distance; - let mut damage = (power * self.kind.fall_damage_multiplier).floor(); + let power = distance + 1.0e-6 - self.kind.safe_fall_distance; + let mut damage = (power * f64::from(modifier) * self.kind.fall_damage_multiplier).floor(); // Frog.calculateFallDamage: five points less than the base damage. if self.species() == super::Species::Frog { damage -= 5.0; } + // LivingEntity.causeFallDamage lets the riders fall first. + if !horse { + self.fall_to_passengers(distance, modifier, events); + } if damage > 0.0 { let at = [self.position.x, self.position.y, self.position.z]; // Monster.getFallSounds: the hostile pair. @@ -919,6 +943,21 @@ impl Mob { events.push(MobEvent::Sound(sound, at, 1.0, 1.0)); } self.damage(damage as f32, super::Damage::fall()); + if horse { + self.fall_to_passengers(distance, modifier, events); + } + } + } + /// `Entity.propagateFallToPassengers`: the game gives the fall to each + /// passenger (`causeFallDamage`), a mob or a player, for this mob is + /// borrowed here. + fn fall_to_passengers(&self, distance: f64, modifier: f32, events: &mut Vec) { + if self.rider.is_some() || self.extra.rider.is_some() { + events.push(MobEvent::FallToPassengers { + mob: self.id, + distance, + modifier, + }); } } /// LivingEntity.getFrictionInfluencedSpeed. From b2ba13377beaf8acd180e93cb2e50252e8728d16 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:50:22 +0100 Subject: [PATCH 252/968] Hurt a llama and its riders only for falls of six blocks A llama landed with the generic fall sound and hurt on any damaging fall. Llama.causeFallDamage makes no fall sound, and hurts the llama and hands the fall to its riders only when the fall is six blocks or more. --- crates/horizon-client/src/mobs/living.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 6756d6ed3c..c141c6ac01 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -930,6 +930,11 @@ impl Mob { self.fall_to_passengers(distance, modifier, events); } if damage > 0.0 { + // Llama.causeFallDamage: only a fall of six blocks or more hurts the + // llama and its riders. + if super::llama::is_llama(self.species()) && distance < 6.0 { + return; + } let at = [self.position.x, self.position.y, self.position.z]; // Monster.getFallSounds: the hostile pair. let monster = super::ai::is_monster(self.species()); @@ -939,7 +944,8 @@ impl Mob { (false, true) => "entity.generic.big_fall", (false, false) => "entity.generic.small_fall", }; - if !thud { + // Horses thud and llamas make no generic fall sound. + if !horse { events.push(MobEvent::Sound(sound, at, 1.0, 1.0)); } self.damage(damage as f32, super::Damage::fall()); From d59d8e714719ce04b3f834391704b108993ca6f6 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:51:02 +0100 Subject: [PATCH 253/968] Shrink a sitting camel's hitbox to its sitting size A camel that sat down kept its standing box, so it blocked and was hit as if it still stood. Camel.getDefaultDimensions gives Pose.SITTING an adult box 1.43 lower with eyes at 0.845, and a baby box 0.425 tall with eyes at 0.41. Mob::awake_dims now follows the camel's synced pose, on the server and on a client. --- crates/horizon-client/src/mobs/camel.rs | 11 +++++++++++ crates/horizon-client/src/mobs/living.rs | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/crates/horizon-client/src/mobs/camel.rs b/crates/horizon-client/src/mobs/camel.rs index c837e5cbc5..86daca3712 100644 --- a/crates/horizon-client/src/mobs/camel.rs +++ b/crates/horizon-client/src/mobs/camel.rs @@ -44,6 +44,17 @@ fn pose_time(m: &Mob) -> i64 { pub fn sitting(m: &Mob) -> bool { m.extra.camel_sitting } +/// `Camel.getDefaultDimensions` for `Pose.SITTING`: the adult camel's box +/// 1.43 lower with its eyes at 0.845, or a baby's 0.425 tall box with its +/// eyes at 0.41. Standing, an adult has the type's box and a baby +/// `BABY_STANDING_DIMENSIONS` (the species' baby size). +pub fn sitting_dims(baby: bool) -> (f32, f32, f32) { + if baby { + (0.95, 0.425, 0.41) + } else { + (1.7, 2.375 - 1.43, 0.845) + } +} fn in_transition(m: &Mob) -> bool { pose_time(m) < if sitting(m) { SIT_DOWN } else { STAND_UP } } diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index c141c6ac01..2c1bb27cd3 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -423,6 +423,10 @@ impl Mob { super::species::Baby::None => {} } } + // Camel.getDefaultDimensions: a sitting camel is lower. + if super::camel::is_camel(k.species) && super::camel::sitting(self) { + (w, h, e) = super::camel::sitting_dims(self.is_baby()); + } // Warden.getDefaultDimensions: a metre tall while digging or emerging. if self.kind.species == super::Species::Warden && super::warden::digging_or_emerging(self) { h = 1.0; From fa27fb40882cd51b9dfbb7d13a5d909d61f26beb Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:51:43 +0100 Subject: [PATCH 254/968] Seat camel riders by the camel's sit and stand animation The rider's seat on a camel was a fixed height for sitting and standing, built from the old full-height box, and a mob rider sat at the top of the box when the camel sat. Camel.getPassengerAttachmentPoint takes the body anchor from the current pose box and lerps it through the sit and stand transitions (getBodyAnchorAnimationYOffset), at the camel's age scale. The player and a mob rider both use it now. --- crates/horizon-client/src/game/mounts.rs | 19 +++--- crates/horizon-client/src/mobs/camel.rs | 73 ++++++++++++++++++++++++ crates/horizon-client/src/mobs/riders.rs | 5 +- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index e37137e171..6ea5cb9f70 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -409,18 +409,15 @@ impl Game<'_> { let mut at = Vec3::new(m.position.x, m.position.y + seat(m.species()), m.position.z); if horizon_client::mobs::camel::is_camel(m.species()) { // Camel.getPassengerAttachmentPoint: the driver half a block - // forward, at the body anchor (lower while sitting). - let scale = if m.is_baby() { 0.6 } else { 1.0 }; - let anchor = if horizon_client::mobs::camel::sitting(m) { - (2.375 - 1.43) * scale - if m.is_baby() { 0.09375 } else { 0.375 } + 0.2 * scale - } else { - 2.375 * scale - if m.is_baby() { 0.09375 } else { 0.375 } - }; - let yaw = m.yaw.to_radians(); + // forward, at the body anchor (lower while sitting), turned with + // the camel. + let a = horizon_client::mobs::camel::passenger_attachment(m); + let turned = Vec3::new(a[0], a[1], a[2]) + .rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32); at = Vec3::new( - m.position.x - f64::from(0.5 * scale * yaw.sin()), - m.position.y + f64::from(anchor) - 0.6, - m.position.z + f64::from(0.5 * scale * yaw.cos()), + m.position.x + turned.x, + m.position.y + turned.y - 0.6, + m.position.z + turned.z, ); } if m.species() == Species::HappyGhast { diff --git a/crates/horizon-client/src/mobs/camel.rs b/crates/horizon-client/src/mobs/camel.rs index 86daca3712..183ad5f7fa 100644 --- a/crates/horizon-client/src/mobs/camel.rs +++ b/crates/horizon-client/src/mobs/camel.rs @@ -88,6 +88,79 @@ pub fn finish_load(m: &mut Mob, game_time: i64) { let since = (game_time - tick.abs()).clamp(0, i64::from(i32::MAX / 2)); m.extra.egg_time = (i64::from(m.age) - since) as i32; } +/// `Camel.getBodyAnchorAnimationYOffset` at the partial tick 0: how high the +/// seat of the front (`front`) or back rider is on a camel of `height` at +/// `scale`, following it down and up through the sit and stand animations. +fn body_anchor_y(m: &Mob, front: bool, height: f32, scale: f32) -> f64 { + let age_sit_y_offset = if m.is_baby() { 0.09375 } else { 0.375 }; + let mut base_sit_offset = f64::from(height) - age_sit_y_offset; + let sitting_height_difference = scale * 1.43; + let vertical_drop = sitting_height_difference - scale * 0.2; + let bottom_point = sitting_height_difference - vertical_drop; + let transition = in_transition(m); + let sits = sitting(m); + if transition { + let duration: i32 = if sits { + SIT_DOWN as i32 + } else { + STAND_UP as i32 + }; + let (half_point, flex_point_offset): (i32, f32) = if sits { + (28, if front { 0.5 } else { 0.1 }) + } else { + (if front { 24 } else { 32 }, if front { 0.6 } else { 0.35 }) + }; + let time = (pose_time(m) as f32).clamp(0.0, duration as f32); + let first_part = time < half_point as f32; + let part = if first_part { + time / half_point as f32 + } else { + (time - half_point as f32) / (duration - half_point) as f32 + }; + let flex_point = sitting_height_difference - flex_point_offset * vertical_drop; + // Mth.lerp. + let lerp = |delta: f32, a: f32, b: f32| a + delta * (b - a); + base_sit_offset += f64::from(if sits { + lerp( + part, + if first_part { + sitting_height_difference + } else { + flex_point + }, + if first_part { flex_point } else { bottom_point }, + ) + } else { + lerp( + part, + if first_part { + bottom_point - sitting_height_difference + } else { + bottom_point - flex_point + }, + if first_part { + bottom_point - flex_point + } else { + 0.0 + }, + ) + }); + } + if sits && !transition { + base_sit_offset += f64::from(bottom_point); + } + base_sit_offset +} +/// `Camel.getPassengerAttachmentPoint` of the one rider Horizon seats, before +/// the camel's yaw turns it: half a block ahead (scaled by `getAgeScale`, 0.6 +/// for a baby) at the body anchor, which sinks while the camel sits. The +/// second seat (-0.7 behind, 0.2 more for an animal) is not used: nothing +/// seats a second rider yet. +pub fn passenger_attachment(m: &Mob) -> [f64; 3] { + let scale = if m.is_baby() { 0.6 } else { 1.0 }; + let height = body_anchor_y(m, true, m.height(), scale) as f32; + [0.0, f64::from(height), f64::from(0.5 * scale)] +} /// `Camel.refuseToMove`: sitting or getting up or down. pub fn refuse_to_move(m: &Mob) -> bool { sitting(m) || in_transition(m) diff --git a/crates/horizon-client/src/mobs/riders.rs b/crates/horizon-client/src/mobs/riders.rs index 6010c05fd9..c4c89ecc80 100644 --- a/crates/horizon-client/src/mobs/riders.rs +++ b/crates/horizon-client/src/mobs/riders.rs @@ -26,10 +26,7 @@ pub fn passenger_attachment(v: &Mob) -> [f64; 3] { Species::SkeletonHorse | Species::ZombieHorse => [0.0, 1.31875, 0.0], Species::Pig => [0.0, 0.86875, 0.0], Species::Nautilus | Species::ZombieNautilus => [0.0, 1.1375, 0.0], - // Camel.getPassengerAttachmentPoint for a lone standing driver. - Species::Camel | Species::CamelHusk if !super::camel::sitting(v) => { - [0.0, f64::from(v.height()) - 0.375, 0.5] - } + Species::Camel | Species::CamelHusk => super::camel::passenger_attachment(v), // The default attachments: the top of the bounding box. _ => [0.0, f64::from(v.height()), 0.0], } From dff7cc1dda83fd796c4e97c296ee919f18d3c2ff Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:52:06 +0100 Subject: [PATCH 255/968] Slide a rider back and up while its horse rears A horse, donkey, mule or undead horse that reared kept its rider at the fixed seat. AbstractHorse.getPassengerAttachmentPoint adds (0, 0.15, -0.7) times the previous tick's stand animation (standAnimO) to the seat, turned with the horse. The player and a mob rider both slide. --- crates/horizon-client/src/game/mounts.rs | 6 ++++++ crates/horizon-client/src/mobs/equine.rs | 12 ++++++++++++ crates/horizon-client/src/mobs/riders.rs | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index 6ea5cb9f70..b32be1cf94 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -443,6 +443,12 @@ impl Game<'_> { m.position.z + z * yaw.cos(), ); } + // AbstractHorse.getPassengerAttachmentPoint: the rider slides back and + // up while the horse rears. + let rear = equine::rear_offset(m); + let rear = Vec3::new(rear[0], rear[1], rear[2]) + .rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32); + at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z); if let Some(p) = self.player.as_mut() { self.previous_position = p.position; p.position = at; diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 06730531a9..5d6a4b2506 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -82,6 +82,18 @@ fn can_rear(s: Species) -> bool { | Species::ZombieHorse ) } +/// `AbstractHorse.getPassengerAttachmentPoint`: what it adds to its seat +/// before the yaw turns it. A horse up on its hind legs slides its rider back +/// and up by the stand animation of the last tick (`standAnimO`), at +/// `getAgeScale`. Camels and llamas never rear. +pub fn rear_offset(m: &Mob) -> [f64; 3] { + if !can_rear(m.species()) { + return [0.0; 3]; + } + let stand = f64::from(m.anim.vo[STAND]); + let scale = f64::from(if m.is_baby() { 0.5_f32 } else { 1.0 }); + [0.0, 0.15 * stand * scale, -0.7 * stand * scale] +} /// `AbstractHorse.isImmobile`: no goals while it eats or rears. A mount its /// rider steers still takes their input (`travelRidden`). pub fn immobile(m: &Mob) -> bool { diff --git a/crates/horizon-client/src/mobs/riders.rs b/crates/horizon-client/src/mobs/riders.rs index c4c89ecc80..75b3388385 100644 --- a/crates/horizon-client/src/mobs/riders.rs +++ b/crates/horizon-client/src/mobs/riders.rs @@ -16,6 +16,12 @@ const CONTROLLED: u8 = MOVE | JUMP | LOOK; /// `EntityType` passenger attachment of a vehicle (before rotation): where a /// rider's attachment point sits relative to the vehicle's feet. pub fn passenger_attachment(v: &Mob) -> [f64; 3] { + let [x, y, z] = base_attachment(v); + // AbstractHorse.getPassengerAttachmentPoint: a rearing horse slides it. + let rear = super::equine::rear_offset(v); + [x + rear[0], y + rear[1], z + rear[2]] +} +fn base_attachment(v: &Mob) -> [f64; 3] { match v.species() { Species::Chicken => [0.0, 0.7, -0.1], Species::Spider => [0.0, 0.765, 0.0], From f4b7a899b8b2a4399d252390492fe7ec35284b9e Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:52:44 +0100 Subject: [PATCH 256/968] Let a horse slide on the rider's keys after a jump while it rears A horse that landed from a small hop while still rearing ignored the keys until the rear ended, and a donkey or mule never reared on a jump. AbstractHorse.onPlayerJump sets allowStandSliding and calls standIfPossible (every horse that can rear), getRiddenInput only zeroes the input while the horse is standing on the ground without a jump pending and without allowStandSliding, and tick clears it once the horse stands no more. --- crates/horizon-client/src/game/mounts.rs | 10 ++++------ crates/horizon-client/src/mobs.rs | 4 ++++ crates/horizon-client/src/mobs/equine.rs | 25 ++++++++++++++++++++---- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index b32be1cf94..e7d4130053 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -363,12 +363,10 @@ impl Game<'_> { let jump = self.charge_mount_jump(i, keys); let m = &mut self.mobs[i]; if jump > 0.0 { - // onPlayerJump: rear while jumping (horses). - if matches!( - m.species(), - Species::Horse | Species::SkeletonHorse | Species::ZombieHorse - ) { - m.extra.counter = 20; + // AbstractHorse.onPlayerJump: slide on the keys and rear (horses, + // donkeys and mules). + if equine::is_equine(m.species()) { + equine::on_player_jump(m); } // The nautilus plays its dash sound itself. if equine::is_equine(m.species()) { diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 9fdf2de046..a45fb7aa9d 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -866,6 +866,10 @@ pub struct Extra { pub swing_stab: bool, pub flag: bool, pub flag2: bool, + /// `AbstractHorse.allowStandSliding`: the rider's jump sets it, and a + /// horse still rearing then goes on with the rider's keys until the rear + /// ends (`tick` clears it). + pub stand_sliding: bool, /// Species scratch values (the wither's head targets and clocks). pub ints: [i32; 6], pub counter: i32, diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 5d6a4b2506..1bf3e9e615 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -99,6 +99,20 @@ pub fn rear_offset(m: &Mob) -> [f64; 3] { pub fn immobile(m: &Mob) -> bool { can_rear(m.species()) && (m.extra.flag || m.extra.counter > 0) && !controlled(m) } +/// `AbstractHorse.standIfPossible`: a horse that can rear does, for twenty +/// ticks (`setStanding(20)`, which also ends its grazing). +pub fn stand_if_possible(m: &mut Mob) { + if can_rear(m.species()) { + m.extra.counter = 20; + m.extra.flag = false; + } +} +/// `AbstractHorse.onPlayerJump`: a released jump bar lets the horse slide on +/// the rider's keys (`allowStandSliding`) and makes it rear. +pub fn on_player_jump(m: &mut Mob) { + m.extra.stand_sliding = true; + stand_if_possible(m); +} pub fn register(m: &mut Mob) { if super::camel::is_camel(m.species()) { return super::camel::register(m); @@ -424,8 +438,7 @@ impl Goal for RandomStand { false } fn start(&mut self, m: &mut Mob, cx: &mut Cx) { - m.extra.counter = 20; - m.extra.flag = false; + stand_if_possible(m); // playStandSound: getAmbientStandSound is the ambient sound, plain // playSound(sound) at volume and pitch 1. let sound = super::ai::voice_sounds(m)[0]; @@ -617,8 +630,10 @@ pub fn ridden_step(m: &mut Mob) { } return; } + // AbstractHorse.getRiddenInput: no input on the ground while rearing, + // unless a jump let it slide. let standing = m.extra.counter > 0; - if m.on_ground && r.jump == 0.0 && standing { + if m.on_ground && r.jump == 0.0 && standing && !m.extra.stand_sliding { m.ai.xxa = 0.0; m.ai.zza = 0.0; return; @@ -705,7 +720,8 @@ pub fn stand_tick(m: &mut Mob) { } } /// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the -/// eating and standing flags (`flag` and `counter > 0`). +/// eating and standing flags (`flag` and `counter > 0`), and the end of +/// `allowStandSliding` once it stands no more. pub fn animate(m: &mut Mob) { let (eating, standing) = (m.extra.flag, m.extra.counter > 0); let v = &mut m.anim.v; @@ -714,6 +730,7 @@ pub fn animate(m: &mut Mob) { m.anim.vo[EAT] = 0.0; v[STAND] = (v[STAND] + (1.0 - v[STAND]) * 0.4 + 0.05).min(1.0); } else { + m.extra.stand_sliding = false; if eating { v[EAT] = (v[EAT] + (1.0 - v[EAT]) * 0.4 + 0.05).min(1.0); } else { From 381e2e9a84dd800df3c6e7e7fa3e02461a464d0a Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:53:27 +0100 Subject: [PATCH 257/968] Stand a sitting camel up when its lead pulls taut A leashed sitting camel stayed down however hard the lead pulled. Camel.onElasticLeashPull stands it up when it is sitting, not changing pose, and the standing box fits where it is (canCamelChangePose, wouldNotSuffocateAtTargetPose). AbstractHorse.onElasticLeashPull, which it calls first, also ends a horse's grazing. --- crates/horizon-client/src/mobs/camel.rs | 35 +++++++++++++++++++++++- crates/horizon-client/src/mobs/equine.rs | 10 +++++++ crates/horizon-client/src/mobs/leash.rs | 1 + 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/camel.rs b/crates/horizon-client/src/mobs/camel.rs index 183ad5f7fa..f669709ec0 100644 --- a/crates/horizon-client/src/mobs/camel.rs +++ b/crates/horizon-client/src/mobs/camel.rs @@ -1,7 +1,7 @@ //! Camels (`Camel`): ridden by two, sitting down and standing up now and //! then, dashing instead of jumping, eating cactus, always "tame". use super::{ - Mob, MobEvent, + Mob, MobEvent, MobWorld, anim::{Poses, State}, goals::{ Breed, Cx, Float, FollowParent, Goal, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, @@ -9,6 +9,7 @@ use super::{ }, }; use horizon_core::geometry::trig::Mth; +use horizon_core::geometry::{Aabb, Vec3}; use horizon_core::random::RandomSource; use std::collections::HashMap; @@ -88,6 +89,38 @@ pub fn finish_load(m: &mut Mob, game_time: i64) { let since = (game_time - tick.abs()).clamp(0, i64::from(i32::MAX / 2)); m.extra.egg_time = (i64::from(m.age) - since) as i32; } +/// `Camel.getDefaultDimensions`: the standing box, the type's or a baby's +/// `BABY_STANDING_DIMENSIONS` (width and height). +fn standing_dims(m: &Mob) -> (f32, f32) { + if m.is_baby() { + (0.95, 1.4) + } else { + (m.kind.width as f32, m.kind.height as f32) + } +} +/// `Camel.canCamelChangePose`: the box of the other pose fits where the camel +/// is (`wouldNotSuffocateAtTargetPose`: no block in the way). +pub fn can_change_pose(m: &Mob, w: &MobWorld) -> bool { + let (width, height) = if sitting(m) { + standing_dims(m) + } else { + let (width, height, _) = sitting_dims(m.is_baby()); + (width, height) + }; + let half = f64::from(width) / 2.0; + let at = m.position; + w.no_collision(Aabb::new( + Vec3::new(at.x - half, at.y, at.z - half), + Vec3::new(at.x + half, at.y + f64::from(height), at.z + half), + )) +} +/// `Camel.onElasticLeashPull`: a taut lead stands a sitting camel up, once it +/// is not changing pose and has room to stand. +pub fn on_elastic_leash_pull(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + if sitting(m) && !in_transition(m) && can_change_pose(m, w) { + stand_up(m, events); + } +} /// `Camel.getBodyAnchorAnimationYOffset` at the partial tick 0: how high the /// seat of the front (`front`) or back rider is on a camel of `height` at /// `scale`, following it down and up through the sit and stand animations. diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 1bf3e9e615..2deda6781b 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -99,6 +99,16 @@ pub fn rear_offset(m: &Mob) -> [f64; 3] { pub fn immobile(m: &Mob) -> bool { can_rear(m.species()) && (m.extra.flag || m.extra.counter > 0) && !controlled(m) } +/// `AbstractHorse.onElasticLeashPull`: a lead pulled taut ends the horse's +/// grazing, and `Camel.onElasticLeashPull` stands a sitting camel up. +pub fn on_elastic_leash_pull(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + if grazes(m.species()) { + m.extra.flag = false; + } + if super::camel::is_camel(m.species()) { + super::camel::on_elastic_leash_pull(m, w, events); + } +} /// `AbstractHorse.standIfPossible`: a horse that can rear does, for twenty /// ticks (`setStanding(20)`, which also ends its grazing). pub fn stand_if_possible(m: &mut Mob) { diff --git a/crates/horizon-client/src/mobs/leash.rs b/crates/horizon-client/src/mobs/leash.rs index fb3126f098..4145e913db 100644 --- a/crates/horizon-client/src/mobs/leash.rs +++ b/crates/horizon-client/src/mobs/leash.rs @@ -274,6 +274,7 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.velocity.z + force[2] * 0.8 + rel.z * 0.11, ); check_fall_distance(m); + super::equine::on_elastic_leash_pull(m, w, events); true }); if !pulled { From 3f690a811cfd89c6fc72bf04944de254a0d59166 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 20:53:53 +0100 Subject: [PATCH 258/968] Walk a camel's legs only while it stands and does not dash A camel's legs used the default walk: they kept swinging while it sat or dashed and eased in at 0.4. Camel.updateWalkAnimation aims at 6 times the distance moved (at most 1) only in the standing pose with no dash animation running, otherwise at zero, and eases at 0.2. The remote client's own walk update uses it for camels too. --- crates/horizon-client/src/game/remote.rs | 9 +++++++-- crates/horizon-client/src/mobs/camel.rs | 13 +++++++++++++ crates/horizon-client/src/mobs/living.rs | 12 ++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index b2e1872e21..ae2d973196 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4699,8 +4699,13 @@ impl Game<'_> { horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx); // WalkAnimationState.update from the horizontal distance moved. let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z); - let target = (((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0); - m.limb_speed += (target - m.limb_speed) * 0.4; + let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) { + // Camel.updateWalkAnimation. + horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32) + } else { + ((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4) + }; + m.limb_speed += (target - m.limb_speed) * factor; m.limb_position += m.limb_speed; if m.species() == horizon_client::mobs::Species::Rabbit { horizon_client::mobs::rabbit::post_tick(m); diff --git a/crates/horizon-client/src/mobs/camel.rs b/crates/horizon-client/src/mobs/camel.rs index f669709ec0..bf4b97cd01 100644 --- a/crates/horizon-client/src/mobs/camel.rs +++ b/crates/horizon-client/src/mobs/camel.rs @@ -194,6 +194,19 @@ pub fn passenger_attachment(m: &Mob) -> [f64; 3] { let height = body_anchor_y(m, true, m.height(), scale) as f32; [0.0, f64::from(height), f64::from(0.5 * scale)] } +/// `Camel.updateWalkAnimation`: the speed the legs go toward and how fast +/// they get there (`WalkAnimationState.update`) for `distance` blocks moved. +/// A camel walks only while it stands and is not dashing, six times the +/// distance up to 1, and eases there at 0.2 (not the default 0.4). +pub fn walk_target(m: &Mob, distance: f32) -> (f32, f32) { + let dashing = m.anim.states[4].started.is_some(); + let target = if !sitting(m) && !dashing { + (distance * 6.0).min(1.0) + } else { + 0.0 + }; + (target, 0.2) +} /// `Camel.refuseToMove`: sitting or getting up or down. pub fn refuse_to_move(m: &Mob) -> bool { sitting(m) || in_transition(m) diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 2c1bb27cd3..9f4f016c30 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -1310,13 +1310,13 @@ impl Mob { let distance = (dx * dx + dy * dy + dz * dz).sqrt() as f32; self.previous_limb_speed = self.limb_speed; if self.alive() { - // Creaking.updateWalkAnimation walks the model faster. - let target = if self.species() == super::Species::Creaking { - (distance * 25.0).min(3.0) - } else { - (distance * 4.0).min(1.0) + let (target, factor) = match self.species() { + // Creaking.updateWalkAnimation walks the model faster. + super::Species::Creaking => ((distance * 25.0).min(3.0), 0.4), + s if super::camel::is_camel(s) => super::camel::walk_target(self, distance), + _ => ((distance * 4.0).min(1.0), 0.4), }; - self.limb_speed += (target - self.limb_speed) * 0.4; + self.limb_speed += (target - self.limb_speed) * factor; self.limb_position += self.limb_speed; } else { self.limb_speed = 0.0; From 2061f797ba2d085a2fa74f9b1046ca59e653665b Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Thu, 29 Oct 2026 22:18:22 +0100 Subject: [PATCH 259/968] Share stand_if_possible between the rear goal and the rider's jump --- crates/horizon-client/src/mobs/equine.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 2deda6781b..61b47563d5 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -109,14 +109,6 @@ pub fn on_elastic_leash_pull(m: &mut Mob, w: &MobWorld, events: &mut Vec Date: Fri, 30 Oct 2026 06:32:09 +0100 Subject: [PATCH 260/968] Send a cleared end crystal beam to LAN guests (#4190) A crystal whose beam target went back to none was left out of the entity state with no listed default, so guests kept the respawn beam. --- .../horizon-client/src/game/lan_entities.rs | 3 ++ crates/horizon-client/src/lan/entity.rs | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 285639113d..0270875cc1 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -623,6 +623,9 @@ impl Game<'_> { for c in self.crystals.iter().filter(|c| c.id != 0) { let mut e = state(c.id, "minecraft:end_crystal", at(c.position), 0.0, false); // EndCrystal.DATA_BEAM_TARGET and DATA_SHOW_BOTTOM (true by default). + // The beam's default is listed so that clearing it (the respawn + // sequence ending) is sent to the guests instead of leaving the old beam. + e.defaults = vec![(8, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(None))]; if c.beam.is_some() { e.meta .push((8, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(c.beam))); diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index dbd89162a2..632731b20c 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -1191,6 +1191,34 @@ mod tests { ); } + #[test] + fn a_cleared_crystal_beam_is_sent() { + let mut e = state(0.0); + e.kind = "minecraft:end_crystal".into(); + e.defaults = vec![(8, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(None))]; + e.meta = vec![( + 8, + serializer::OPTIONAL_BLOCK_POS, + Value::OptPos(Some([0, 128, 0])), + )]; + let mut s = added(&e); + e.meta.clear(); + let mut packets = Vec::new(); + s.send_dirty(&e, true, &mut packets); + let sent: Vec<_> = packets + .iter() + .filter_map(|p| match p { + play::Clientbound::SetEntityData(d) => Some(d.values.clone()), + _ => None, + }) + .flatten() + .collect(); + assert_eq!( + sent, + vec![(8, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(None))] + ); + } + #[test] fn a_value_back_at_a_listed_default_is_sent() { let mut e = state(0.0); From 237b58d9527809e709fe52dd65bbd278926daf71 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:32:12 +0100 Subject: [PATCH 261/968] Move the villager trade display out of this PR The villager changes (crossed arms item, held offers, equipment sync) now live in their own branch fix/split-villager-trades; this one keeps the map banner and frame markers. --- crates/horizon-client/src/game/creatures.rs | 5 - crates/horizon-client/src/game/lan.rs | 13 +- crates/horizon-client/src/game/mob_blocks.rs | 6 +- crates/horizon-client/src/game/remote.rs | 13 +- crates/horizon-client/src/mobs.rs | 6 - crates/horizon-client/src/mobs/finalize.rs | 2 +- crates/horizon-client/src/mobs/render.rs | 16 -- .../horizon-client/src/mobs/villager_brain.rs | 145 +----------------- 8 files changed, 12 insertions(+), 194 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 31d9394dbc..f191966879 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1813,11 +1813,6 @@ impl Game<'_> { MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob), MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target), MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other), - MobEvent::VillagerOffers { mob } => { - if let Some(i) = self.mobs.iter().position(|m| m.id == mob) { - self.ensure_offers(i); - } - } MobEvent::VillagerBirth { parent, partner, diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 69f09038de..990e2fb9a0 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -937,8 +937,7 @@ fn mob_equipment( // slot's item (6) in `head`. let body = horizon_client::mobs::drops::head_is_body(m.species()); let slots = [ - // The main hand keeps its whole stack (count and components). - (0u8, &e.main_hand, e.main_hand_stack()), + (0u8, &e.main_hand, None), (1, &e.off_hand, None), (2, &e.feet, e.armor[3].as_ref()), (3, &e.legs, e.armor[2].as_ref()), @@ -954,7 +953,7 @@ fn mob_equipment( if let Some(item) = item { // The worn armour keeps its stack: a dyed leather piece keeps // its colour. - let mut stack = worn.cloned().unwrap_or_else(|| ItemStack { + let stack = worn.cloned().unwrap_or_else(|| ItemStack { item: item.clone(), count: 1, // A wolf's armor shows its cracks. @@ -969,14 +968,6 @@ fn mob_equipment( None }, }); - // The main hand keeps its enchantments: the glint of the item a - // villager shows. - if slot == 0 - && let Some(g) = horizon_client::mobs::gear::gear(m) - && !g.slots[horizon_client::mobs::gear::MAIN].is_empty() - { - stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone(); - } out.push((slot, hub.stack(&Some(stack)))); } } diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 931038685d..3ba99d57a0 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -380,10 +380,10 @@ impl Game<'_> { owner.crossbow_charge_duration = 25; } use horizon_assets::item_model::DisplayContext as Context; - // PandaHoldsItemLayer, FoxHeldItemLayer and CrossedArmsItemLayer - // show the item as it lies on the ground. + // PandaHoldsItemLayer and FoxHeldItemLayer show the item as it lies + // on the ground. let (context, ordinal) = match (mob.species(), left) { - (Species::Panda | Species::Fox | Species::Villager, _) => (Context::Ground, 7), + (Species::Panda | Species::Fox, _) => (Context::Ground, 7), (_, true) => (Context::ThirdPersonLeftHand, 1), (_, false) => (Context::ThirdPersonRightHand, 2), }; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 40610fe8f4..1d304eadb9 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2510,18 +2510,7 @@ impl Game<'_> { for (slot, item, stack) in items { let e = &mut m.extra; match slot { - 0 => { - e.set_main_hand(item); - // Its enchantments are the glint. - let levels = stack - .as_ref() - .map_or_else(Vec::new, |s| s.enchantments().to_vec()); - horizon_client::mobs::gear::set( - m, - horizon_client::mobs::gear::MAIN, - levels, - ); - } + 0 => e.set_main_hand(item), 1 => e.off_hand = item, 2 => { e.feet = item; diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 4b580fbefc..9dc6eeb1ac 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1469,12 +1469,6 @@ pub enum MobEvent { mob: u32, other: u32, }, - /// `AbstractVillager.getOffers` for a villager whose offers are not made - /// yet: the level rolls them (the brain has no trade data), so the - /// behaviour that asked reads them from the next tick on. - VillagerOffers { - mob: u32, - }, /// Two villagers make a baby (`VillagerMakeLove.tryToGiveBirth`): the /// parent has eaten and, with a bed for the child, rests; the partner /// eats, and rests too if `child` holds the child's bed and type diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index b78bf99ca4..1b55d8f2ef 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -893,7 +893,7 @@ pub fn set_baby(m: &mut Mob, baby: bool) { /// `EquipmentSlot` names (the `drops::SLOTS` order). pub const SLOT_NAMES: [&str; 8] = super::drops::SLOTS; -pub const MAINHAND: usize = 0; +const MAINHAND: usize = 0; const OFFHAND: usize = 1; const FEET: usize = 2; const LEGS: usize = 3; diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 73b8736f60..516bb91a3b 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1680,22 +1680,6 @@ pub fn held_item_transform( .unwrap_or_else(|| m.kind.model.clone()); let mut poses = Poses::new(&model); anim::setup(m, &s, &mut poses, &data.animations); - // CrossedArmsItemLayer, the layer a villager has: the main hand's item on - // the arms; only the main hand's item is drawn. - if m.species() == Species::Villager { - m.extra.main_hand.as_deref()?; - if left != m.main_arm_left() { - return None; - } - // CrossedArmsItemLayer.applyTranslation: translateToArms, then this. - let arms = part_matrix(&model, base, &poses, "arms", 0.0)?; - return Some( - arms * glam::Mat4::from_rotation_x(0.75) - * glam::Mat4::from_scale(glam::Vec3::splat(1.07)) - * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.13, -0.34)) - * glam::Mat4::from_rotation_x(std::f32::consts::PI), - ); - } let mut t = hand_base(m, &model, base, &poses, left)?; // ItemInHandLayer: a STAB swing of this arm thrusts its item out // (SpearAnimations.thirdPersonAttackItem, the main hand's forward diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index c9fb3defc6..bf5f7b651b 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -14,12 +14,10 @@ use super::{ brain::{Timed, living_near}, path::{Path, PathNode}, }; -use crate::items::ItemStack; use horizon_core::{ geometry::Vec3, random::{LegacyRandom, RandomSource}, }; -use std::sync::Arc; /// `MemorySlot.NEVER_EXPIRE`. pub(super) const NEVER: i64 = i64::MAX; @@ -1237,7 +1235,6 @@ enum B { ShowTrades { t: Timed, look_time: i32, - display: TradeDisplay, }, TradeWithVillager(Timed), MakeLove { @@ -1617,11 +1614,7 @@ impl B { mm.look_target.set(Tracker::Entity(Who::Player, true)); true } - B::ShowTrades { - t, - look_time, - display, - } => { + B::ShowTrades { t, look_time } => { if !mem(m).interaction_target.present() || !show_trades_condition(m, cx.w) { return false; } @@ -1631,8 +1624,6 @@ impl B { mem(m).look_target.set(Tracker::Entity(target, true)); } *look_time = 40; - display.cycle_counter = 0; - display.index = 0; true } B::TradeWithVillager(t) => { @@ -2951,11 +2942,7 @@ impl B { }); } } - B::ShowTrades { - t, - look_time, - display, - } => { + B::ShowTrades { t, look_time } => { let still = show_trades_condition(m, cx.w) && *look_time > 0 && mem(m).interaction_target.present(); @@ -2966,15 +2953,8 @@ impl B { if let Some(target) = mem(m).interaction_target.value() { mem(m).look_target.set(Tracker::Entity(target, true)); } - display.find_items_to_display(m, cx, look_time); - if display.items.is_empty() { - // No offer matches the player's item: hold nothing and - // look for a while only. - clear_held_item(m); - *look_time = (*look_time).min(40); - } else { - display.display_cycling_items(m); - } + // No offer matches the player's item: look for a while only. + *look_time = (*look_time).min(40); *look_time -= 1; } B::TradeWithVillager(t) => { @@ -3226,11 +3206,9 @@ impl B { mm.walk_target.clear(); mm.look_target.clear(); } - B::ShowTrades { t, display, .. } => { + B::ShowTrades { t, .. } => { t.running = false; mem(m).interaction_target.clear(); - clear_held_item(m); - display.player_item = None; } B::TradeWithVillager(t) => { t.running = false; @@ -3384,118 +3362,6 @@ fn show_trades_condition(m: &Mob, w: &MobWorld) -> bool { && w.target_info(target) .is_some_and(|t| t.alive && m.distance_sqr(t.position) <= 17.0) } -/// `ItemStack.EMPTY`'s item. -const AIR: &str = "minecraft:air"; -/// What `ShowTradesToPlayer` remembers between ticks. -#[derive(Clone, Debug, Default)] -struct TradeDisplay { - /// `playerItemStack`: the item the player held at the last look, `None` - /// before the first look and after a stop. An empty hand is air, as - /// `ItemStack.EMPTY` is. - player_item: Option>, - /// `displayItems`: what the offers matching that item sell. - items: Vec, - /// `cycleCounter`: ticks since the held item last changed. - cycle_counter: i32, - /// `displayIndex`: the item being held up. - index: usize, -} -impl TradeDisplay { - /// `ShowTradesToPlayer.findItemsToDisplay`: when the player's item - /// changed, collect the offers that take it and hold up the first - /// (their look time becomes 900 ticks). - fn find_items_to_display(&mut self, m: &mut Mob, cx: &mut Bx, look_time: &mut i32) { - let hand = - cx.w.player - .as_ref() - .and_then(|p| p.main_hand.clone()) - .unwrap_or_else(|| Arc::from(AIR)); - if self.player_item.as_deref() == Some(&*hand) { - return; - } - self.items.clear(); - if *hand == *AIR { - self.player_item = Some(hand); - return; - } - if m.extra.trade.as_ref().is_none_or(|t| t.unset) { - // `getOffers` makes the offers on first use, which the level - // does: look again once it has. - cx.events.push(MobEvent::VillagerOffers { mob: m.id }); - self.player_item = None; - return; - } - self.player_item = Some(hand); - self.update_display_items(m); - if let Some(first) = self.items.first() { - *look_time = 900; - display_as_held_item(m, first); - } - } - /// `ShowTradesToPlayer.displayCyclingItems`: with two or more items, a - /// new one is held up every 40 ticks, round and round. - fn display_cycling_items(&mut self, m: &mut Mob) { - if self.items.len() < 2 { - return; - } - self.cycle_counter += 1; - if self.cycle_counter >= 40 { - self.index += 1; - self.cycle_counter = 0; - if self.index > self.items.len() - 1 { - self.index = 0; - } - display_as_held_item(m, &self.items[self.index]); - } - } - /// `ShowTradesToPlayer.updateDisplayItems`: the offers still in stock - /// that take the player's item as cost A or B - /// (`playerItemStackMatchesCostOfOffer`, `ItemStack.isSameItem`). - fn update_display_items(&mut self, m: &Mob) { - let (Some(hand), Some(trade)) = (self.player_item.as_deref(), m.extra.trade.as_ref()) - else { - return; - }; - for offer in &trade.offers { - if !offer.out_of_stock() - && (*offer.base_cost_a.stack.item == *hand - || offer - .cost_b - .as_ref() - .is_some_and(|c| *c.stack.item == *hand)) - { - // `MerchantOffer.assemble`. - self.items.push(offer.result.clone()); - } - } - } -} -/// `ShowTradesToPlayer.displayAsHeldItem`: the stack in the main hand, which -/// never drops. -fn display_as_held_item(m: &mut Mob, stack: &ItemStack) { - // The whole stack stays in the hand (its count and components); the - // enchantments go to the gear table, which is what draws the glint. - m.extra.set_main_hand_stack(Some(stack.clone())); - super::finalize::set_slot_enchantments( - m, - super::finalize::MAINHAND, - stack.enchantments().to_vec(), - ); - super::finalize::set_drop_chance(m, super::finalize::MAINHAND, 0.0); -} -/// `ShowTradesToPlayer.clearHeldItem`: the main hand is empty again and -/// drops with the default chance (the shown item never dropped). -fn clear_held_item(m: &mut Mob) { - m.extra.take_main_hand(); - if m.extra.gear.is_some() { - super::gear::set(m, super::gear::MAIN, Vec::new()); - } - super::finalize::set_drop_chance( - m, - super::finalize::MAINHAND, - super::drops::DEFAULT_DROP_CHANCE, - ); -} /// `BehaviorUtils.targetIsValid` for a villager. fn villager_target_valid(m: &mut Mob, w: &MobWorld, which: Mem) -> bool { let target = mem_ref(m).and_then(|mm| match which { @@ -4027,7 +3893,6 @@ fn show_trades() -> B { B::ShowTrades { t: timed(400, 1600), look_time: 0, - display: TradeDisplay::default(), } } fn interact(kind: Interact) -> B { From 85b5ff729425c2d3b1b9f59ae9771d762a5f307f Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:34:55 +0100 Subject: [PATCH 262/968] Scale the view-blocking probe height with the player's scale The block overlay (the dark stretched texture over the screen) probes eight points around the eye, 0.1 apart in height. Vanilla multiplies that by LivingEntity.getScale (LevelExtractor.getViewBlockingStateAndPos). Horizon used a fixed 0.1, so a player of scale 0.01, whose eye is 0.0162 above the feet, probed the ground block and got its overlay over the whole screen. Part of #3809 --- crates/horizon-client/src/game.rs | 7 ++++++- crates/horizon-client/src/game/render_tick.rs | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2c64b8847f..e29aba7521 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -12514,7 +12514,12 @@ impl Game<'_> { let screen = if title { Vec::new() } else { - self.screen_quads(alpha as f32, size) + let scale = self + .player + .as_mut() + .and_then(|p| p.attribute("minecraft:scale").ok()) + .unwrap_or(1.0) as f32; + self.screen_quads(alpha as f32, size, scale) }; if !title { self.end_geometry(camera, &mut entities); diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 2ffdd1e60f..d1e208f297 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -192,7 +192,8 @@ impl Game<'_> { ) != FogType::Water } /// `ScreenEffectRenderer` + `Hud` camera overlays for this frame. - pub(super) fn screen_quads(&self, partial: f32, size: [u32; 2]) -> Vec { + /// `scale` is the player's `LivingEntity.getScale`. + pub(super) fn screen_quads(&self, partial: f32, size: [u32; 2], scale: f32) -> Vec { let Some(p) = self.player.as_ref() else { return Vec::new(); }; @@ -201,7 +202,9 @@ impl Game<'_> { p.position.y + f64::from(p.dimensions.eye_height), p.position.z, ]; - // LevelExtractor.getViewBlockingState: 8 points around the eye. + // LevelExtractor.getViewBlockingState: 8 points around the eye, 0.1 + // times the scale apart in height (a player of scale 0.01 has its eye + // closer to the ground than 0.05). // ScreenEffectRenderer: none of it for a spectator (noPhysics). let spectator = self.spectator(); let mut block_overlay = None; @@ -209,7 +212,7 @@ impl Game<'_> { for i in (0..8).filter(|_| !spectator) { let t = [ p.position.x + (f64::from(i & 1) - 0.5) * w, - eye[1] + (f64::from((i >> 1) & 1) - 0.5) * 0.1, + eye[1] + (f64::from((i >> 1) & 1) - 0.5) * f64::from(0.1_f32 * scale), p.position.z + (f64::from((i >> 2) & 1) - 0.5) * w, ]; let state = self.world.block( From ccb85be48e3f5b4028f7b13a9fa64e58f737bf0f Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Fri, 30 Oct 2026 08:38:06 +0300 Subject: [PATCH 263/968] Launcher: a version stops counting as installed when its removal starts backend::remove dropped the .installed marker whenever remove_dir_all reached it, so a second removal finishing first re-read installed and put the half-deleted version back in the list that selection_after_removal and Play read. Co-Authored-By: Hermes Agent (Nous Research) --- launcher/src/backend.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 271338e9e6..505eec9337 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -662,6 +662,9 @@ pub(crate) fn install_as( pub fn remove(tag: &str) -> Result<(), String> { let dir = version_dir(tag); + // The marker goes first: a version counted as installed until its folder is gone comes back + // into `installed` when an overlapping removal re-reads the list mid-delete. + let _ = fs::remove_file(dir.join(COMPLETE)); // Windows holds a file for a moment after the game, a scan or the launcher's own reader lets // go of it, and remove_dir_all stops at the first one. Retry for a second; a folder that is // already gone is removed. From 8880df58c1444baf3273394d44023793940ce3af Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:21:51 +0100 Subject: [PATCH 264/968] End a glide when a player boards a boat A player who boarded a boat while gliding kept the FallFlying pose: passenger_pose_tick cleared swimming but not fall_flying, so update_player_pose still picked FallFlying and fall_fly_ticks kept counting. Vanilla LivingEntity.updateFallFlying (in aiStep, run by rideTick's tick) clears the glide flag once canGlide fails for a passenger (canGlide needs !isPassenger()), and LivingEntity.tick resets fallFlyTicks when not gliding (LivingEntity.java). Fixes #3093 --- crates/horizon-client/src/pose.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/horizon-client/src/pose.rs b/crates/horizon-client/src/pose.rs index 9084737b91..30841f4d43 100644 --- a/crates/horizon-client/src/pose.rs +++ b/crates/horizon-client/src/pose.rs @@ -182,8 +182,14 @@ impl MotionWorld<'_, '_, '_> { /// `tick`, not the travel): `Entity.updateSwimming` stops a swim, since it /// needs `!isPassenger()`, and `updatePlayerPose` follows. A player who /// boards a boat while swimming sits up instead of lying under it (#3308). + /// The server's `LivingEntity.updateFallFlying` ends a glide the same way + /// (`canGlide` needs `!isPassenger()`) and `LivingEntity.tick` resets + /// `fallFlyTicks`: a player who boards while gliding sits up instead of + /// keeping the glide pose (#3093). pub fn passenger_pose_tick(&mut self) -> Result<()> { self.player.swimming = false; + self.player.fall_flying = false; + self.player.fall_fly_ticks = 0; self.update_player_pose() } } From c1c7cdb3c1bc76f7e4c086ab03cb3e8ecce85ec1 Mon Sep 17 00:00:00 2001 From: Kacpherek Date: Fri, 30 Oct 2026 06:31:54 +0100 Subject: [PATCH 265/968] Clear a passenger's ground state every tick Player.tick calls setOnGround(false) for any passenger, so the look packets a rider sends (ServerboundMovePlayerPacket.Rot) never carry onGround. Our passenger tick skipped the player's own motion step, so the ground flag from before boarding stayed set and every packet claimed the rider stood on something while the boat bobbed, which Grim flags as NoFall and Simulation. Fixes #3793 --- crates/horizon-client/src/pose.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/horizon-client/src/pose.rs b/crates/horizon-client/src/pose.rs index 9084737b91..7fa092e4da 100644 --- a/crates/horizon-client/src/pose.rs +++ b/crates/horizon-client/src/pose.rs @@ -183,6 +183,11 @@ impl MotionWorld<'_, '_, '_> { /// needs `!isPassenger()`, and `updatePlayerPose` follows. A player who /// boards a boat while swimming sits up instead of lying under it (#3308). pub fn passenger_pose_tick(&mut self) -> Result<()> { + // Player.tick: a passenger is never on the ground (setOnGround(false) + // clears the supporting block too), so its look packets say so. + self.player.ground.on_ground = false; + self.player.ground.on_ground_no_blocks = false; + self.player.ground.supporting_block = None; self.player.swimming = false; self.update_player_pose() } From 4f03c8a87378dc054a1f7e06a8e411bc275972f3 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:05:03 +0700 Subject: [PATCH 266/968] Time out a server that stops answering, with vanilla's disconnect reasons The multiplayer connection only had a connect timeout: once joined, a server that froze or dropped off the network without a TCP reset left the player in a frozen world forever. Vanilla's client channel starts with ReadTimeoutHandler(30) (Connection$1.initChannel), so the client socket now gets a 30 s read timeout. A lost connection also showed Horizon's raw error text (on Windows the localized OS message). Connection.exceptionCaught's reasons replace it: disconnect.timeout ("Timed out") for the read timeout, disconnect.genericReason ("Internal Exception: ...") for any other failure, and disconnect.endOfStream when the server closed the stream (Connection.channelInactive). The integrated server's in-memory pipe keeps no timeout, as vanilla's local channel has none. Part of #3520 (Connection#exceptionCaught). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/conn.rs | 2 +- crates/horizon-client/src/net.rs | 122 +++++++++++++++++++--- crates/horizon-protocol/examples/probe.rs | 2 +- crates/horizon-protocol/src/connection.rs | 36 ++++++- crates/horizon-protocol/src/lib.rs | 18 ++++ tools/port_map/rest-010.tsv | 2 +- 6 files changed, 162 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index f786007dac..d19825eda1 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -1154,7 +1154,7 @@ fn read_loop( // The client closed the connection: Connection.channelInactive // without details, multiplayer.disconnect.generic. Err(e) - if e.0 == "Connection closed by the server" + if e.is_end_of_stream() || e.0.contains("reset by peer") || e.0.contains("aborted") => { diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index fef47385c4..58d4de8af3 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -392,6 +392,52 @@ fn tr(lang: &Language, key: &str) -> String { lang.get(key).cloned().unwrap_or_else(|| key.to_owned()) } +/// `Connection$1.initChannel`: a client socket's pipeline starts with +/// `ReadTimeoutHandler(30)`. A server sends a keep-alive every 15 s, so one +/// that sends nothing for 30 s has frozen or dropped off the network. +const READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// `Connection.connect`: the socket to a server, with `read_timeout` +/// ([`READ_TIMEOUT`]) on every read. The friends relay supplies a concrete +/// endpoint, not a Minecraft hostname, so it skips the SRV lookup. +fn connect_tcp( + host: &str, + port: u16, + relayed: bool, + read_timeout: Duration, +) -> Result { + let c = if relayed { + Connection::connect(host, port, Duration::from_secs(10)) + } else { + Connection::connect_server(host, port, Duration::from_secs(10)) + } + .map_err(|e| e.to_string())?; + c.reader + .set_read_timeout(Some(read_timeout)) + .map_err(|e| e.to_string())?; + Ok(c) +} + +/// `Connection.exceptionCaught`: a read that timed out ends with +/// `disconnect.timeout`, any other failure with `disconnect.genericReason` +/// ("Internal Exception: ..."); a stream the server closed ends with +/// `disconnect.endOfStream` (`Connection.channelInactive`). +fn connection_lost(e: horizon_protocol::Error, lang: &Language) -> String { + if e.is_timed_out() { + tr(lang, "disconnect.timeout") + } else if e.is_end_of_stream() { + tr(lang, "disconnect.endOfStream") + } else { + plain( + &translatable( + "disconnect.genericReason", + &[&format!("Internal Exception: {e}")], + ), + lang, + ) + } +} + /// The whole connection. Ok: why it ended for good (the server's reason, or /// a refusal no retry fixes); Err: the connection failed. fn run( @@ -422,17 +468,11 @@ fn run( target } }; - let c = if address.starts_with(FRIEND) { - // The friends relay supplies a concrete endpoint, not a Minecraft hostname. - Connection::connect(&host, port, Duration::from_secs(10)) - } else { - Connection::connect_server(&host, port, Duration::from_secs(10)) - } - .map_err(|e| e.to_string())?; + let c = connect_tcp(&host, port, address.starts_with(FRIEND), READ_TIMEOUT)?; (host, port, c) }; *shared_counters.lock().unwrap() = Some(c.reader.counters.clone()); - let e = |e: horizon_protocol::Error| e.to_string(); + let e = |e: horizon_protocol::Error| connection_lost(e, lang); c.send( &handshake::Serverbound::Intention(handshake::Intention { protocol: PROTOCOL_VERSION, @@ -578,7 +618,7 @@ fn receive_play( resources: &Path, ) -> Result { loop { - let (id, payload) = reader.recv().map_err(|e| e.to_string())?; + let (id, payload) = reader.recv().map_err(|e| connection_lost(e, lang))?; let packet = match play::Clientbound::decode(id, &payload) { Ok(p) => p, Err(err) => { @@ -673,13 +713,13 @@ fn configure( let mut registries = Dynamic::default(); let reply = |p: cfg::Serverbound| -> Result<(), String> { match writer.lock().unwrap().as_mut() { - Some(w) => w.send(&p.encode()).map_err(|e| e.to_string()), + Some(w) => w.send(&p.encode()).map_err(|e| connection_lost(e, lang)), None => Err("Disconnected".into()), } }; loop { - let (id, payload) = reader.recv().map_err(|e| e.to_string())?; - match cfg::Clientbound::decode(id, &payload).map_err(|e| e.to_string())? { + let (id, payload) = reader.recv().map_err(|e| connection_lost(e, lang))?; + match cfg::Clientbound::decode(id, &payload).map_err(|e| connection_lost(e, lang))? { cfg::Clientbound::KeepAlive(k) => reply(cfg::Serverbound::KeepAlive(k))?, cfg::Clientbound::CustomPayload(p) if p.channel == crate::content_identity::CHANNEL => { let hash = crate::content_identity::identity(resources) @@ -741,7 +781,7 @@ fn configure( let mut w = writer.lock().unwrap(); let w = w.as_mut().ok_or("Disconnected")?; w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode()) - .map_err(|e| e.to_string())?; + .map_err(|e| connection_lost(e, lang))?; configuring.store(false, Ordering::Relaxed); return Ok(registries); } @@ -859,6 +899,62 @@ pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128 mod tests { use super::*; + /// The en_us text of the disconnect reasons. + fn disconnect_language() -> Language { + [ + ("disconnect.timeout", "Timed out"), + ("disconnect.endOfStream", "End of stream"), + ("disconnect.genericReason", "%s"), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect() + } + + #[test] + fn a_server_that_stops_answering_times_out() { + // The server accepts and then sends nothing, like a frozen server + // or a network gone without a reset. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let held = std::thread::spawn(move || listener.accept().map(|(stream, _)| stream)); + let c = connect_tcp("127.0.0.1", port, true, Duration::from_millis(300)).unwrap(); + let _server = held.join().unwrap().unwrap(); + let (mut reader, writer) = c.split(); + let (done, result) = channel(); + std::thread::spawn(move || { + let (send, _events) = channel(); + let _ = done.send(receive_play( + &mut reader, + &Mutex::new(Some(writer)), + &AtomicBool::new(false), + &disconnect_language(), + &send, + &Cookies::default(), + Path::new("."), + )); + }); + let ended = result + .recv_timeout(Duration::from_secs(10)) + .expect("the connection never timed out"); + assert_eq!(ended, Err("Timed out".to_owned())); + } + + #[test] + fn lost_connections_read_like_vanillas_disconnect_reasons() { + let lang = disconnect_language(); + let lost = |e| connection_lost(e, &lang); + assert_eq!(lost(horizon_protocol::Error::timed_out()), "Timed out"); + assert_eq!( + lost(horizon_protocol::Error::end_of_stream()), + "End of stream" + ); + assert_eq!( + lost(horizon_protocol::Error::new("Bad VarInt")), + "Internal Exception: Bad VarInt" + ); + } + #[test] fn configuration_answers_the_content_identity_challenge() { let root = diff --git a/crates/horizon-protocol/examples/probe.rs b/crates/horizon-protocol/examples/probe.rs index 63fc0f9d3a..0e39a9f4b2 100644 --- a/crates/horizon-protocol/examples/probe.rs +++ b/crates/horizon-protocol/examples/probe.rs @@ -113,7 +113,7 @@ fn main() -> Result<(), Box> { while start.elapsed() < Duration::from_secs(seconds) { let (id, payload) = match c.recv() { Ok(p) => p, - Err(e) if e.0.contains("timed out") || e.0.contains("temporarily unavailable") => { + Err(e) if e.is_timed_out() => { continue; } Err(e) => return Err(e.into()), diff --git a/crates/horizon-protocol/src/connection.rs b/crates/horizon-protocol/src/connection.rs index 52c45f4ac2..9d7bf295c7 100644 --- a/crates/horizon-protocol/src/connection.rs +++ b/crates/horizon-protocol/src/connection.rs @@ -333,10 +333,21 @@ impl ConnReader { return Ok((id, body[start..].to_vec())); } let mut chunk = [0u8; 16384]; - let n = self.stream.read(&mut chunk)?; - if n == 0 { - return Err(Error::new("Connection closed by the server")); - } + let n = match self.stream.read(&mut chunk) { + Ok(0) => return Err(Error::end_of_stream()), + Ok(n) => n, + // The read timeout passed: Windows reports TimedOut, Unix + // WouldBlock. + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) => + { + return Err(Error::timed_out()); + } + Err(e) => return Err(e.into()), + }; self.counters .bytes_in .fetch_add(n as i64, Ordering::Relaxed); @@ -481,6 +492,23 @@ mod tests { assert_eq!(error("nowhere", Vec::new()), "Unknown host nowhere"); } + #[test] + fn a_silent_peer_times_out_and_a_closed_one_ends_the_stream() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut c = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap(); + let (server, _) = listener.accept().unwrap(); + c.reader + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let silent = c.recv().unwrap_err(); + assert!(silent.is_timed_out(), "{silent}"); + assert_eq!(silent.to_string(), "Timed out"); + drop(server); + let closed = c.recv().unwrap_err(); + assert!(closed.is_end_of_stream(), "{closed}"); + } + #[test] fn loopback_with_compression_and_encryption() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/horizon-protocol/src/lib.rs b/crates/horizon-protocol/src/lib.rs index 299688560b..9dcaebb418 100644 --- a/crates/horizon-protocol/src/lib.rs +++ b/crates/horizon-protocol/src/lib.rs @@ -44,9 +44,27 @@ pub enum Direction { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Error(pub String); impl Error { + const TIMED_OUT: &str = "Timed out"; + const END_OF_STREAM: &str = "Connection closed by the server"; pub fn new(message: impl Into) -> Self { Self(message.into()) } + /// The read timeout passed with nothing received (netty's + /// `ReadTimeoutException`, which `Connection.exceptionCaught` reports as + /// `disconnect.timeout`). + pub fn timed_out() -> Self { + Self::new(Self::TIMED_OUT) + } + pub fn is_timed_out(&self) -> bool { + self.0 == Self::TIMED_OUT + } + /// The peer closed the stream (`Connection.channelInactive`). + pub fn end_of_stream() -> Self { + Self::new(Self::END_OF_STREAM) + } + pub fn is_end_of_stream(&self) -> bool { + self.0 == Self::END_OF_STREAM + } } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { diff --git a/tools/port_map/rest-010.tsv b/tools/port_map/rest-010.tsv index a889290885..52e761cb7f 100644 --- a/tools/port_map/rest-010.tsv +++ b/tools/port_map/rest-010.tsv @@ -55,7 +55,7 @@ CompressionDecoder.decode port crates/horizon-protocol/src/frame.rs::decompress CompressionDecoder.setupInflaterInput skip:java ByteBuffer plumbing for the inflater CompressionDecoder.inflate inline crates/horizon-protocol/src/frame.rs::decompress zlib inflate with the declared-length check net/minecraft/network/Connection#channelActive skip:other Netty channel open; the blocking Connection is connected before use -net/minecraft/network/Connection#exceptionCaught inline crates/horizon-client/src/net.rs::run gap: player: a failed read or malformed packet shows Horizon's raw error text, not vanilla's Timed out or Internal Exception disconnect +net/minecraft/network/Connection#exceptionCaught port crates/horizon-client/src/net.rs::connection_lost Timed out for a read timeout (READ_TIMEOUT, ReadTimeoutHandler(30)), Internal Exception: ... for other failures net/minecraft/network/Connection#channelRead0 port crates/horizon-client/src/net.rs::receive_play play-phase dispatch; gap: minor: configuration packets dispatch in configure net/minecraft/network/Connection#validateListener skip:other the per-phase decode enums fix the listener's side and protocol net/minecraft/network/Connection#syncAfterConfigurationChange skip:other Netty future sync From e445e0edf650a3f7e768415767d012af83b361a9 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:03:13 +0700 Subject: [PATCH 267/968] Spectating players are tracked only by other spectators ChunkMap.TrackedEntity.updatePlayer pairs an entity with a player only when Entity.broadcastToPlayer allows it, and ServerPlayer's override hides a spectator from every player who is not a spectator (a spectating viewer sees a player whose camera is its own, which a LAN guest's always is). The hub's tracker had no such check, so a guest in spectator mode stayed paired with everyone: its AddEntity, equipment and nametag still reached the other players, and their crosshair still picked it. Player states now carry whether the player is a spectator, viewers too, and the pair check follows broadcastToPlayer. Going in or out of spectator mode checks the player's pairs again (vanilla's ChunkMap.move does it at the player's next move), so the others lose it, or get it back with its armour, at once. Part of #3520 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dragon.rs | 1 + .../horizon-client/src/game/lan_entities.rs | 1 + crates/horizon-client/src/lan/entity.rs | 78 ++++++++++++++++++- crates/horizon-client/src/lan/hub.rs | 14 +++- crates/horizon-client/tests/lan_bot.rs | 1 + 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index a654c2ba9e..8dc2476bda 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2887,6 +2887,7 @@ impl Game<'_> { precise: false, needs_sync: false, leash: None, + spectator: false, }) } } diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 285639113d..fc37b9f824 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -31,6 +31,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> precise: false, needs_sync: false, leash: None, + spectator: false, } } diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index dbd89162a2..10824bb315 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -67,6 +67,9 @@ pub struct EntityState { pub needs_sync: bool, /// `Leashable.getLeashHolder`: the entity holding its lead. pub leash: Option, + /// `ServerPlayer.isSpectator`: a player in spectator mode, whom only the + /// clients of other spectators track (`ServerPlayer.broadcastToPlayer`). + pub spectator: bool, } impl EntityState { @@ -90,6 +93,7 @@ impl EntityState { self.precise = false; self.needs_sync = false; self.leash = None; + self.spectator = false; } } @@ -235,6 +239,9 @@ struct ServerEntity { /// TrackedEntity.lastSectionPos: its pairs are checked again when it /// moves to another section. section: [i32; 3], + /// Whether it was a spectator last tick: its pairs are checked again when + /// that changes, as `ServerPlayer.broadcastToPlayer` answers otherwise. + spectator: bool, } /// SectionPos.of(entity). @@ -275,6 +282,7 @@ impl ServerEntity { index: 0, added: true, section: section_of(e.position), + spectator: e.spectator, } } @@ -602,6 +610,8 @@ impl Tracker { /// view distance and chunks. pub struct Viewer<'a> { pub entity_id: i32, + /// Its player is in spectator mode: it tracks the other spectators too. + pub spectator: bool, /// It accepted a move since the last tick (ChunkMap.move): every pair /// of it is checked again. pub moved: bool, @@ -624,16 +634,20 @@ struct Probe { id: i32, range: i32, chunk: u32, + spectator: bool, } /// TrackedEntity.updatePlayer: within the type's range (capped by the view -/// distance) horizontally, in a chunk the client has. `held` remembers for +/// distance) horizontally, shown to the client (`Entity.broadcastToPlayer`: +/// `ServerPlayer`'s hides a spectator from players who are not spectators; +/// a guest's camera is always its own player, so a spectating client sees +/// every player) and in a chunk the client has. `held` remembers for /// each of the tick's chunks whether the client has it (0: not looked up /// yet, 1: no, 2: yes), so each chunk is looked up once per client. fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool { let range = f64::from(p.range.min(v.view_distance * 16)); let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z); - dx * dx + dz * dz <= range * range && { + dx * dx + dz * dz <= range * range && (v.spectator || !p.spectator) && { let held = &mut held[p.chunk as usize]; if *held == 0 { *held = 1 + u8::from(v.chunks.contains(&chunks[p.chunk as usize])); @@ -718,11 +732,14 @@ impl Entities { }); s.seen = now; s.index = index; - // ChunkMap.addEntity, and ChunkMap.tick's section change: + // ChunkMap.addEntity, ChunkMap.tick's section change, and a + // player going in or out of spectator mode (which ChunkMap.move + // catches at the player's next move, a second later at most): // updatePlayers. let section = section_of(e.position); - if added || section != s.section { + if added || section != s.section || e.spectator != s.spectator { s.section = section; + s.spectator = e.spectator; self.moved.push(index); self.is_moved[index] = true; } @@ -743,6 +760,7 @@ impl Entities { .range_percent .map_or(s.range * 16, |p| p * (s.range * 16) / 100), chunk, + spectator: e.spectator, }); } // TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its @@ -921,6 +939,7 @@ mod tests { precise: false, needs_sync: false, leash: None, + spectator: false, } } @@ -1264,6 +1283,7 @@ mod tests { out: [Vec>; 2], positions: [[f64; 3]; 2], moved: [bool; 2], + spectators: [bool; 2], } impl Clients { @@ -1274,6 +1294,7 @@ mod tests { out: Default::default(), positions: [[0.0, 64.0, 0.0]; 2], moved: [false; 2], + spectators: [false; 2], } } fn tick(&mut self, level: &mut Entities, entities: &[&EntityState]) { @@ -1285,6 +1306,7 @@ mod tests { let mut viewers = [ Viewer { entity_id: 1, + spectator: self.spectators[0], moved: self.moved[0], position: self.positions[0], view_distance: 10, @@ -1294,6 +1316,7 @@ mod tests { }, Viewer { entity_id: 2, + spectator: self.spectators[1], moved: self.moved[1], position: self.positions[1], view_distance: 10, @@ -1431,6 +1454,53 @@ mod tests { } } + /// ServerPlayer.broadcastToPlayer: a spectator is shown only to other + /// spectators, who see everyone. Going in or out of spectator mode checks + /// its pairs again, though nobody moved. + #[test] + fn spectators_are_tracked_only_by_spectators() { + let mut level = Entities::default(); + let mut c = Clients::new(); + let player = |id: i32| { + let mut p = state(0.0); + (p.id, p.uuid, p.kind) = (id, id as u128, "minecraft:player".into()); + p + }; + let (mut one, mut two) = (player(1), player(2)); + // A third player, already spectating, that neither client plays. + let mut three = player(3); + three.spectator = true; + c.tick(&mut level, &[&one, &two, &three]); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.trackers[0].is_tracking(2) && c.trackers[1].is_tracking(1)); + assert!(!c.trackers[0].is_tracking(3) && !c.trackers[1].is_tracking(3)); + // The second player starts spectating: the first client loses it, + // and it sees the first player and the other spectator. + (two.spectator, c.spectators[1]) = (true, true); + c.tick(&mut level, &[&one, &two, &three]); + assert!(!c.trackers[0].is_tracking(2)); + let removed = body(play::Clientbound::RemoveEntities(play::RemoveEntities { + ids: vec![2], + })); + assert_eq!(c.out[0].first(), Some(&removed)); + assert!(c.trackers[1].is_tracking(1) && c.trackers[1].is_tracking(3)); + // Its armour changes go to nobody who doesn't track it. + two.equipment.push((5, Some(Stack::new(5, 1)))); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.out[0].is_empty()); + // The first player starts spectating too: both spectators show up. + (one.spectator, c.spectators[0]) = (true, true); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.trackers[0].is_tracking(2) && c.trackers[0].is_tracking(3)); + // Both back in survival: they still see each other, and lose the + // third player. + (one.spectator, c.spectators[0]) = (false, false); + (two.spectator, c.spectators[1]) = (false, false); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.trackers[0].is_tracking(2) && c.trackers[1].is_tracking(1)); + assert!(!c.trackers[0].is_tracking(3) && !c.trackers[1].is_tracking(3)); + } + #[test] fn a_forgotten_entity_is_paired_again_at_the_next_tick() { // A respawned player: the hub removes it from the other clients and diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 5d7d1a41f9..c2021d1c2a 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1749,6 +1749,7 @@ impl Hub { precise: false, needs_sync: false, leash: None, + spectator: false, } } @@ -1785,7 +1786,7 @@ impl Hub { let e = &h.equipment; self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]]) }; - all.push(self.player_state( + let mut state = self.player_state( h.entity_id, h.uuid, h.position, @@ -1800,7 +1801,9 @@ impl Hub { equipment, h.attributes.clone(), &[], - )); + ); + state.spectator = h.game_mode == 3; + all.push(state); } let placed: Vec = (0..self.guests.len()) .filter(|&i| self.here(&self.guests[i])) @@ -1819,7 +1822,7 @@ impl Hub { let equipment = self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]); let g = &self.guests[i]; - self.player_state( + let mut state = self.player_state( g.entity_id, g.uuid, g.position, @@ -1837,7 +1840,9 @@ impl Hub { .map(|m| super::attributes::syncable(&m.attributes)) .unwrap_or_default(), &g.effects.list, - ) + ); + state.spectator = g.spectator(); + state }) .collect(); all.extend(guest_states); @@ -1858,6 +1863,7 @@ impl Hub { .zip(outs.iter_mut()) .map(|(g, out)| Viewer { entity_id: g.entity_id, + spectator: g.spectator(), moved: std::mem::take(&mut g.moved), position: g.position, view_distance: g.view_distance, diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 8895588e7a..10458693f2 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -721,6 +721,7 @@ fn guests_see_each_other_and_the_world_entities() { precise: false, needs_sync: false, leash: None, + spectator: false, }; let first = thread::spawn(move || { let mut bot = connect(port, "Alex", false)?; From df1a26d2bdb82714de18a9a517b9c36854ceed50 Mon Sep 17 00:00:00 2001 From: repositorykreml1n Date: Fri, 30 Oct 2026 09:14:38 +0300 Subject: [PATCH 268/968] TNT: prime powered blocks only on the server Match vanilla TntBlock.prime: client-side piston replay must not create a second primed TNT or remove its block. Server-side TNT duplication remains unchanged. Refs horizon-rust/horizon#3856. Co-Authored-By: Codex --- crates/horizon-world/src/level/redstone.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/horizon-world/src/level/redstone.rs b/crates/horizon-world/src/level/redstone.rs index 6676242e12..0b0cd2b59c 100644 --- a/crates/horizon-world/src/level/redstone.rs +++ b/crates/horizon-world/src/level/redstone.rs @@ -1226,7 +1226,9 @@ impl Sim<'_, L> { } /// `TntBlock.prime` then `removeBlock`. pub(crate) fn tnt_check(&mut self, pos: BlockPos) { - if self.has_neighbor_signal(pos) { + // TntBlock.prime succeeds only on a ServerLevel. A client's piston + // replay must leave priming and block removal to the server. + if !self.level.is_client_side() && self.has_neighbor_signal(pos) { self.prime_tnt(pos); self.remove_block(pos, false); } From b87f65b1b6b7d1d0982f580e4feb8e29def286bf Mon Sep 17 00:00:00 2001 From: repositorykreml1n Date: Fri, 30 Oct 2026 09:17:05 +0300 Subject: [PATCH 269/968] TNT: cover client replay and preserved server duplication Add four regression tests for issue #3856: client neighbor updates and placement leave TNT alone, server priming is not limited per tick, and a piston duper retains its TNT block while creating exactly one server entity and no client entity. Verified against nightly-2026-10-29-4baa571 with prepared Java 26.3 resources. Co-Authored-By: Codex --- crates/horizon-world/src/level/tests.rs | 136 ++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/crates/horizon-world/src/level/tests.rs b/crates/horizon-world/src/level/tests.rs index e99d2e51bf..5359dd56da 100644 --- a/crates/horizon-world/src/level/tests.rs +++ b/crates/horizon-world/src/level/tests.rs @@ -3,6 +3,7 @@ //! repository root) and are skipped when those are absent. use super::*; use crate::chunk::BlockState; +use horizon_core::random::RandomSource; use std::{ collections::{BTreeMap, HashMap}, path::PathBuf, @@ -925,6 +926,141 @@ fn pistons_push_and_sticky_pistons_pull() { assert_eq!(w.get(BlockPos::new(2, 1, 0)), air); } +/// TntBlock.prime returns false on a client: neither the entity, the removal, +/// nor its random push may be predicted while replaying a piston update. +#[test] +fn tnt_duper_3856_client_neighbor_does_not_prime_or_remove_tnt() { + let mut w = world!(); + let f = w.f; + w.level.client_side = true; + let pos = BlockPos::new(0, 1, 0); + let tnt = w.id("tnt", &[]); + let power = w.id("redstone_block", &[]); + w.put(pos, tnt); + w.put(pos.offset(1, 0, 0), power); + let mut random = w.level.random.clone(); + f.behaviours.handle_neighbor_changed( + &mut w.level, + &mut w.ticks, + pos, + f.behaviours.block(power), + ); + assert_eq!(w.get(pos), tnt, "client must wait for server block changes"); + assert!( + w.level.events.is_empty(), + "client must not emit TNT effects" + ); + assert_eq!(w.level.random.next_long(), random.next_long()); +} + +#[test] +fn tnt_duper_3856_client_placement_does_not_prime_tnt() { + let mut w = world!(); + w.level.client_side = true; + let pos = BlockPos::new(0, 1, 0); + let tnt = w.id("tnt", &[]); + let power = w.id("redstone_block", &[]); + w.put(pos.offset(1, 0, 0), power); + w.set(pos, tnt); + assert_eq!(w.get(pos), tnt); + assert!(w.level.events.is_empty()); +} + +#[test] +fn tnt_duper_3856_server_still_primes_each_powered_tnt() { + let mut w = world!(); + let tnt = w.id("tnt", &[]); + let power = w.id("redstone_block", &[]); + for pos in [BlockPos::new(0, 1, 0), BlockPos::new(0, 1, 3)] { + w.put(pos.offset(1, 0, 0), power); + w.set(pos, tnt); + assert_eq!(w.get(pos), w.b().air()); + assert_eq!( + w.level + .events + .iter() + .filter(|e| matches!(e, LevelEvent::PrimeTnt { pos: p, .. } if *p == pos)) + .count(), + 1, + ); + } +} + +/// Breaking a wall torch next to a BUD-powered TNT updates the old block while +/// the piston already holds its saved state. This vanilla duplication must +/// continue to work on the server, without a second TNT from the client. +#[test] +fn tnt_duper_3856_piston_keeps_original_and_primes_one_tnt() { + let Some(f) = fixture() else { + return; + }; + for client in [false, true] { + let mut w = World::new(f); + w.level.client_side = client; + let base = BlockPos::new(0, 2, 0); + let piston = w.id("sticky_piston", &[("facing", "east")]); + let slime = w.id("slime_block", &[]); + let tnt = w.id("tnt", &[]); + let torch = w.id("wall_torch", &[("facing", "north")]); + let power = w.id("redstone_block", &[]); + w.put(base, piston); + w.put(base.offset(1, 0, 0), slime); + w.put(base.offset(2, 0, 0), slime); + w.put(base.offset(2, -1, 0), slime); + w.put(base.offset(2, -1, -1), tnt); + w.put(base.offset(2, 0, -1), torch); + // Raw writes model a powered TNT that has not received an update yet. + w.put(base.offset(2, -1, -2), power); + w.put(base.offset(-1, 0, 0), power); + if client { + assert!(f.behaviours.client_block_event( + &mut w.level, + &mut w.ticks, + base, + 0, + Direction::East as i32 + )); + } else { + f.behaviours.handle_neighbor_changed( + &mut w.level, + &mut w.ticks, + base, + f.behaviours.block(power), + ); + f.behaviours.run_block_events(&mut w.level, &mut w.ticks); + } + let primed = w + .level + .events + .iter() + .filter(|e| matches!(e, LevelEvent::PrimeTnt { .. })) + .count(); + assert_eq!(primed, usize::from(!client), "client={client}"); + for _ in 0..10 { + w.level.time += 1; + if client { + f.behaviours + .client_tick_block_entities(&mut w.level, &mut w.ticks); + } else { + f.behaviours.tick_block_entities(&mut w.level, &mut w.ticks); + } + } + assert_eq!( + w.get(base.offset(3, -1, -1)), + tnt, + "original must survive, client={client}" + ); + assert_eq!( + w.level + .events + .iter() + .filter(|e| matches!(e, LevelEvent::PrimeTnt { .. })) + .count(), + usize::from(!client) + ); + } +} + /// `ServerLevel.runBlockEvents` reports a handled event for the clients /// (`ClientboundBlockEventPacket`), carrying the block the event named. #[test] From 91aa9c790f55f051c8ee824669ad88c0a1fe0bb1 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:08:37 +0700 Subject: [PATCH 270/968] Get off a strider onto the shore, not into the lava beside it Dismounting used one rule for every mount: a spot either side of the mount's body, then its back, checking only for full blocks at the feet and head. Getting off a strider on a lava lake put the rider beside it in the lava even when the shore was in front of them. Strider.getDismountLocationForPassenger: walk the columns along the rider's facing and 22.5 and 45 degrees either side (Entity.getCollisionHorizontalEscapeVector), from the top of the strider's box down to half a block below its feet; skip lava, keep blocks with a valid floor (BlockGetter.getBlockFloorHeight, DismountHelper.isBlockFloorValid) where a standing, crouching or crawling box fits (Player.getDismountPoses, DismountHelper.canDismountTo), and take that pose. With none, land on top of the strider. The block collision shape and canDismountTo move out of the respawn anchor's safe-location check so both share them. Part of #3513. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mounts.rs | 342 ++++++++++++++++-- .../horizon-client/src/game/respawn_anchor.rs | 34 +- 2 files changed, 340 insertions(+), 36 deletions(-) diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index 6849a22911..628c9e04d5 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -4,12 +4,18 @@ //! mount's health and jump bar on the HUD. use super::Game; use horizon_client::{ + blocks::Fluid, hud::HudBatch, items::ItemStack, mobs::{MobEvent, Species, equine}, + motion::PlayerMotion, + pose::{Dimensions, Pose}, sprint::Keys, }; -use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_core::{ + geometry::{Aabb, Vec3, VoxelShape, trig}, + random::RandomSource, +}; /// `passengerAttachments` minus the player's 0.6 vehicle attachment. fn seat(s: Species) -> f64 { @@ -466,34 +472,32 @@ impl Game<'_> { self.dismount_happy_ghast(id); return; } - let yaw = m.yaw.to_radians(); - let side = Vec3::new(f64::from(yaw.cos()), 0.0, f64::from(yaw.sin())); - let w = f64::from(m.width()) / 2.0 + 0.4; - let base = m.position; - let candidates = [ - Vec3::new(base.x - side.x * w, base.y, base.z - side.z * w), - Vec3::new(base.x + side.x * w, base.y, base.z + side.z * w), - Vec3::new(base.x, base.y + f64::from(m.height()), base.z), - ]; - let spot = candidates - .into_iter() - .find(|c| { - let f = |dy: i32| { - self.res - .facts() - .get(self.world.block( - c.x.floor() as i32, - c.y.floor() as i32 + dy, - c.z.floor() as i32, - )) - .full_collision - }; - !f(0) && !f(1) - }) - .unwrap_or(candidates[2]); + let mount = Mount { + species: m.species(), + position: m.position, + bounds: m.bounds(), + width: m.width(), + height: m.height(), + yaw: m.yaw, + }; + let Some(p) = self.player.as_ref() else { + return; + }; + let rider = Rider { + yaw: self.yaw, + width: p.dimensions.width, + poses: rider_poses(p), + }; + let (spot, pose) = dismount_location(&mount, &rider, &GameDismountWorld(self)); if let Some(p) = self.player.as_mut() { p.position = spot; p.velocity = Vec3::ZERO; + // LivingEntity.setPose: the dismount pose that fit. + if let Some(pose) = pose + && let Err(e) = p.set_pose(pose) + { + log::warn!("dismount pose: {e}"); + } } } pub(super) fn mount_event(&mut self, e: &MobEvent) { @@ -588,3 +592,289 @@ impl Game<'_> { hearts } } + +/// The mount a rider gets off: its kind, where it stands, its box, +/// `getBbWidth`, `getBbHeight` and body yaw. +struct Mount { + species: Species, + position: Vec3, + bounds: Aabb, + width: f32, + height: f32, + yaw: f32, +} +/// The rider getting off: `getYRot`, `getBbWidth`, and +/// `getLocalBoundsForPose` for each of `Player.getDismountPoses`. +struct Rider { + yaw: f32, + width: f32, + poses: Vec<(Pose, Dimensions)>, +} +/// `Player.getDismountPoses`: standing, crouching, then crawling. +const DISMOUNT_POSES: [Pose; 3] = [Pose::Standing, Pose::Crouching, Pose::Swimming]; +/// The rider's box in each dismount pose, at their `minecraft:scale`. +fn rider_poses(p: &PlayerMotion) -> Vec<(Pose, Dimensions)> { + // EntityDimensions.scale: every player pose scales alike, so the + // standing width gives the scale without a mutable attribute lookup. + let scale = p.dimensions.width / Pose::Standing.dimensions(1.0).width; + DISMOUNT_POSES + .iter() + .map(|&pose| (pose, pose.dimensions(scale))) + .collect() +} +/// The level queries a dismount makes. +trait DismountWorld { + /// A full collision block (the generic rule's test). + fn full_collision(&self, p: [i32; 3]) -> bool; + /// `getFluidState(pos).is(FluidTags.LAVA)`. + fn lava(&self, p: [i32; 3]) -> bool; + /// `BlockGetter.getBlockFloorHeight(pos)`. + fn floor_height(&self, p: [i32; 3]) -> f64; + /// `DismountHelper.canDismountTo(level, passenger, box)`. + fn can_dismount_to(&self, bounds: Aabb) -> bool; +} +struct GameDismountWorld<'a, 'g>(&'a Game<'g>); +impl DismountWorld for GameDismountWorld<'_, '_> { + fn full_collision(&self, p: [i32; 3]) -> bool { + let g = self.0; + g.res + .facts() + .get(g.world.block(p[0], p[1], p[2])) + .full_collision + } + fn lava(&self, p: [i32; 3]) -> bool { + let g = self.0; + g.res.facts().get(g.world.block(p[0], p[1], p[2])).fluid == Fluid::Lava + } + fn floor_height(&self, p: [i32; 3]) -> f64 { + let shape = self.0.block_collision_shape(p); + let below = std::cell::OnceCell::::new(); + horizon_core::geometry::block_floor_height(&shape, || { + below.get_or_init(|| self.0.block_collision_shape([p[0], p[1] - 1, p[2]])) + }) + } + fn can_dismount_to(&self, bounds: Aabb) -> bool { + self.0.can_dismount_to(bounds) + } +} +/// `getDismountLocationForPassenger`, and the pose the rider takes there. +fn dismount_location( + mount: &Mount, + rider: &Rider, + world: &impl DismountWorld, +) -> (Vec3, Option) { + if mount.species == Species::Strider { + return strider_dismount_location(mount, rider, world); + } + // Beside the mount, else on top of it. + let yaw = mount.yaw.to_radians(); + let side = Vec3::new(f64::from(yaw.cos()), 0.0, f64::from(yaw.sin())); + let w = f64::from(mount.width) / 2.0 + 0.4; + let base = mount.position; + let candidates = [ + Vec3::new(base.x - side.x * w, base.y, base.z - side.z * w), + Vec3::new(base.x + side.x * w, base.y, base.z + side.z * w), + Vec3::new(base.x, base.y + f64::from(mount.height), base.z), + ]; + let spot = candidates + .into_iter() + .find(|c| { + let f = |dy: i32| { + world.full_collision([ + c.x.floor() as i32, + c.y.floor() as i32 + dy, + c.z.floor() as i32, + ]) + }; + !f(0) && !f(1) + }) + .unwrap_or(candidates[2]); + (spot, None) +} +/// `Entity.getCollisionHorizontalEscapeVector`: out of the vehicle's box +/// along `yaw`, to the edge of the square it spans. +fn escape_vector(vehicle_width: f64, passenger_width: f64, yaw: f32) -> Vec3 { + // 1.0E-5F: a float literal widened to double. + let distance = (vehicle_width + passenger_width + f64::from(1.0e-5_f32)) / 2.0; + let radians = f64::from(yaw * 0.017_453_292); + let (sin, cos) = (-trig::sin(radians), trig::cos(radians)); + let max = f64::from(sin.abs().max(cos.abs())); + Vec3::new( + f64::from(sin) * distance / max, + 0.0, + f64::from(cos) * distance / max, + ) +} +/// `Strider.getDismountLocationForPassenger`: the columns along the rider's +/// facing and 22.5 and 45 degrees either side of it, each from the top of +/// the strider's box down to half a block below its feet. The first block +/// with no lava, a valid floor (`DismountHelper.isBlockFloorValid`) and room +/// for one of the rider's dismount poses (`canDismountTo`) wins, with that +/// pose; without one, on top of the strider with the pose unchanged. +fn strider_dismount_location( + strider: &Mount, + rider: &Rider, + world: &impl DismountWorld, +) -> (Vec3, Option) { + let (vehicle, passenger) = (f64::from(strider.width), f64::from(rider.width)); + let top = strider.bounds.max_position().y; + let bottom = strider.bounds.min_position().y - 0.5; + // A LinkedHashSet: each block once, in the order first reached. + let mut blocks: Vec<[i32; 3]> = Vec::new(); + for turn in [0.0, -22.5, 22.5, -45.0, 45.0] { + let offset = escape_vector(vehicle, passenger, rider.yaw + turn); + let (x, z) = ( + (strider.position.x + offset.x).floor() as i32, + (strider.position.z + offset.z).floor() as i32, + ); + let mut y = top; + let mut block_y = top.floor() as i32; + while y > bottom { + if !blocks.contains(&[x, block_y, z]) { + blocks.push([x, block_y, z]); + } + block_y -= 1; + y -= 1.0; + } + } + for p in blocks { + if world.lava(p) { + continue; + } + // DismountHelper.isBlockFloorValid. + let floor = world.floor_height(p); + if floor.is_infinite() || floor >= 1.0 { + continue; + } + // Vec3.upFromBottomCenterOf. + let at = Vec3::new( + f64::from(p[0]) + 0.5, + f64::from(p[1]) + floor, + f64::from(p[2]) + 0.5, + ); + for (pose, dimensions) in &rider.poses { + if world.can_dismount_to(dimensions.bounds(at)) { + return (at, Some(*pose)); + } + } + } + (Vec3::new(strider.position.x, top, strider.position.z), None) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// A lava sea up to y 30 with full blocks placed in it; air above. + struct Sea { + blocks: HashMap<[i32; 3], ()>, + } + impl Sea { + fn with(blocks: &[[i32; 3]]) -> Self { + Self { + blocks: blocks.iter().map(|&b| (b, ())).collect(), + } + } + fn solid(&self, p: [i32; 3]) -> bool { + self.blocks.contains_key(&p) + } + } + impl DismountWorld for Sea { + fn full_collision(&self, p: [i32; 3]) -> bool { + self.solid(p) + } + fn lava(&self, p: [i32; 3]) -> bool { + p[1] <= 30 && !self.solid(p) + } + fn floor_height(&self, p: [i32; 3]) -> f64 { + if self.solid(p) { + 1.0 + } else if self.solid([p[0], p[1] - 1, p[2]]) { + 0.0 + } else { + f64::NEG_INFINITY + } + } + fn can_dismount_to(&self, bounds: Aabb) -> bool { + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + let cells = |a: f64, b: f64| a.floor() as i32..=(b - 1.0e-7).floor() as i32; + !cells(lo.x, hi.x).any(|x| { + cells(lo.y, hi.y).any(|y| cells(lo.z, hi.z).any(|z| self.solid([x, y, z]))) + }) + } + } + /// The top of a lava source at y 30 (`FlowingFluid.getOwnHeight`, 8/9). + const SURFACE: f64 = 30.0 + 8.0 / 9.0; + /// A strider (0.9 by 1.7) standing on the lava at x, z, facing south. + fn strider_at(x: f64, z: f64) -> Mount { + Mount { + species: Species::Strider, + position: Vec3::new(x, SURFACE, z), + bounds: Aabb::new( + Vec3::new(x - 0.45, SURFACE, z - 0.45), + Vec3::new(x + 0.45, SURFACE + 1.7, z + 0.45), + ), + width: 0.9, + height: 1.7, + yaw: 0.0, + } + } + fn rider(yaw: f32) -> Rider { + Rider { + yaw, + width: 0.6, + poses: DISMOUNT_POSES + .iter() + .map(|&pose| (pose, pose.dimensions(1.0))) + .collect(), + } + } + fn block_of(at: Vec3) -> [i32; 3] { + [ + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + ] + } + + #[test] + fn a_strider_in_open_lava_puts_its_rider_on_its_back() { + // #3513: getting off in a lava sea used to put the player beside the + // strider, in the lava; with no floor in reach vanilla puts the rider + // on top of the strider instead. + let sea = Sea::with(&[]); + let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea); + assert!(!sea.lava(block_of(at)), "dismounted into lava at {at:?}"); + assert_eq!(at, Vec3::new(0.5, SURFACE + 1.7, 0.5)); + assert_eq!(pose, None); + } + + #[test] + fn a_strider_by_the_shore_puts_its_rider_on_the_shore() { + // Netherrack 45 degrees to the right of a rider facing south (+z); + // the straight-ahead and 22.5 degree columns are lava. + let sea = Sea::with(&[[-1, 30, 1]]); + let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea); + assert_eq!(at, Vec3::new(-0.5, 31.0, 1.5)); + assert_eq!(pose, Some(Pose::Standing)); + } + + #[test] + fn a_low_ceiling_on_the_shore_makes_the_rider_crawl_off() { + let sea = Sea::with(&[[0, 30, 1], [0, 32, 1]]); + let (at, pose) = dismount_location(&strider_at(0.5, 0.5), &rider(0.0), &sea); + assert_eq!(at, Vec3::new(0.5, 31.0, 1.5)); + assert_eq!(pose, Some(Pose::Swimming)); + } + + #[test] + fn the_escape_vector_reaches_the_edge_of_the_square() { + let edge = (0.9 + 0.6 + f64::from(1.0e-5_f32)) / 2.0; + let v = escape_vector(0.9, 0.6, 0.0); + assert!((v.z - edge).abs() < 1.0e-6 && v.x.abs() < 1.0e-6); + // Facing west of south: the corner of the square. + let v = escape_vector(0.9, 0.6, 45.0); + assert!((v.x + edge).abs() < 1.0e-3 && (v.z - edge).abs() < 1.0e-3); + } +} diff --git a/crates/horizon-client/src/game/respawn_anchor.rs b/crates/horizon-client/src/game/respawn_anchor.rs index 81b1bc9378..8892569456 100644 --- a/crates/horizon-client/src/game/respawn_anchor.rs +++ b/crates/horizon-client/src/game/respawn_anchor.rs @@ -349,6 +349,12 @@ impl Game<'_> { if self.block_in_tag("minecraft:climbable", name) || open_trapdoor { return VoxelShape::empty(); } + self.block_collision_shape(p) + } + /// `BlockState.getCollisionShape(level, pos)`: the shape under the empty + /// collision context. + pub(super) fn block_collision_shape(&self, p: [i32; 3]) -> VoxelShape { + let state = self.world.block(p[0], p[1], p[2]); let binding = self.collision; let mut scene = SceneCollisions { world: &self.world, @@ -364,6 +370,22 @@ impl Game<'_> { ) .unwrap_or_else(|_| VoxelShape::empty()) } + /// `DismountHelper.canDismountTo(level, entity, box)`: no block collides + /// with the box and it is inside the world border. + pub(super) fn can_dismount_to(&self, bounds: Aabb) -> bool { + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + if !no_collision_empty(&mut scene, bounds) { + return false; + } + // WorldBorder.isWithinBounds(box). + let (lo, hi) = (bounds.min(Axis::X), bounds.max(Axis::X) - 1.0e-5); + let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5); + self.border.contains(lo, lz) && self.border.contains(hi, hz) + } pub(super) fn block_in_tag(&self, tag: &str, block: &str) -> bool { self.res .items() @@ -427,12 +449,7 @@ impl Game<'_> { position.z + half, ), ); - let mut scene = SceneCollisions { - world: &self.world, - binding: self.collision, - subject: None, - }; - if !no_collision_empty(&mut scene, bounds) { + if !self.can_dismount_to(bounds) { return None; } let name = |q: [i32; 3]| { @@ -450,10 +467,7 @@ impl Game<'_> { { return None; } - // WorldBorder.isWithinBounds(box). - let (lo, hi) = (bounds.min(Axis::X), bounds.max(Axis::X) - 1.0e-5); - let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5); - (self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position) + Some(position) } /// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners, /// signs and pressure plates override it to always. From dcd86510f58a026d3614a4d061cc647e42781d49 Mon Sep 17 00:00:00 2001 From: DenisHumen <56129242+DenisHumen@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:18:47 +0200 Subject: [PATCH 271/968] Frogs walk their legs at vanilla's rate Frog.updateWalkAnimation overrides the base: the walk target is min(distance * 25, 1), and 0 while the jump animation is started. Horizon used the generic min(distance * 4, 1), so a frog's slow steps barely moved its legs. Fixes #2848 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/frog.rs | 35 ++++++++++++++++++++++++ crates/horizon-client/src/mobs/living.rs | 2 ++ 2 files changed, 37 insertions(+) diff --git a/crates/horizon-client/src/mobs/frog.rs b/crates/horizon-client/src/mobs/frog.rs index ddbd33eac4..fa345724f1 100644 --- a/crates/horizon-client/src/mobs/frog.rs +++ b/crates/horizon-client/src/mobs/frog.rs @@ -138,6 +138,16 @@ pub fn tick(m: &mut Mob) { } /// `Frog.canEat`: a slime or magma cube of size 1. +/// `Frog.updateWalkAnimation`: the frog's short steps walk the legs 25 times +/// the distance (the base is 4), and not at all while the jump plays. +pub fn walk_target(m: &Mob, distance: f32) -> f32 { + if m.anim.states[JUMP].started.is_some() { + 0.0 + } else { + (distance * 25.0).min(1.0) + } +} + fn can_eat(species: Species, size: i32) -> bool { matches!(species, Species::Slime | Species::MagmaCube) && size == 1 } @@ -649,6 +659,31 @@ mod tests { Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0) } + /// `Frog.updateWalkAnimation`: target `min(distance * 25, 1)`, 0 while + /// the jump animation is started, eased by 0.4 (`WalkAnimationState.update`). + #[test] + fn a_frog_walks_its_legs_at_the_vanilla_rate() { + let mut m = frog(); + m.previous = m.position; + m.position.x += 0.02; + m.update_walk_animation(); + assert!((m.limb_speed - 0.5 * 0.4).abs() < 1e-6, "{}", m.limb_speed); + assert!((m.limb_position - m.limb_speed).abs() < 1e-6); + + let mut m = frog(); + m.previous = m.position; + m.position.x += 0.1; + m.update_walk_animation(); + assert!((m.limb_speed - 0.4).abs() < 1e-6, "{}", m.limb_speed); + + let mut m = frog(); + m.anim.states[JUMP].start(0.0); + m.previous = m.position; + m.position.x += 0.02; + m.update_walk_animation(); + assert_eq!(m.limb_speed, 0.0); + } + /// `LongJumpToRandomPos.start`: the 9 x 5 x 9 box without the frog's own /// block, x fastest, then y, then z, each weighted by its squared distance. #[test] diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 9f6da2c764..553f156ecb 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -1264,6 +1264,8 @@ impl Mob { // Creaking.updateWalkAnimation walks the model faster. let target = if self.species() == super::Species::Creaking { (distance * 25.0).min(3.0) + } else if self.species() == super::Species::Frog { + super::frog::walk_target(self, distance) } else { (distance * 4.0).min(1.0) }; From 350d5dbe2d90790f3f4c2b23e5dff81595109301 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:57:03 +0700 Subject: [PATCH 272/968] Send and handle ClientboundChunksBiomesPacket so /fillbiome recolours loaded chunks /fillbiome changed the server's biomes but no client was told: the packet existed only as an id, the server never sent it and the client had no handler, so grass, leaves and water kept the old colour until the column was sent again (single player on its server thread, LAN guests, dedicated servers). - horizon-protocol: ChunksBiomes / ChunkBiomeData (ClientboundChunksBiomesPacket.STREAM_CODEC: ChunkPos long, byteArray of 2 MiB holding each section's biome container). - Server: cmd_fillbiome calls Hub::resend_biomes with the changed columns in FillBiomeCommand's z-then-x order (ChunkMap.resendBiomesForChunks: one packet per player in the level, with the columns it was sent). - Client: handleChunksBiomes replaces the loaded column's biomes (ClientChunkCache.replaceBiomes / LevelChunk.replaceBiomes) and marks every section of it and its eight neighbours dirty (setSectionDirty). Part of #3518 (ClientPacketListener.handleChunksBiomes) Co-Authored-By: Claude Opus 5.5 --- .../src/game/commands/terrain_cmds.rs | 10 ++ crates/horizon-client/src/game/remote.rs | 1 + .../horizon-client/src/game/remote_chunks.rs | 159 ++++++++++++++---- crates/horizon-client/src/lan/chunks.rs | 38 +++-- crates/horizon-client/src/lan/hub.rs | 20 +++ crates/horizon-client/tests/lan_bot.rs | 69 ++++++++ crates/horizon-protocol/src/packets/play.rs | 106 ++++++++++++ 7 files changed, 360 insertions(+), 43 deletions(-) diff --git a/crates/horizon-client/src/game/commands/terrain_cmds.rs b/crates/horizon-client/src/game/commands/terrain_cmds.rs index 73dfb4b63d..38ad4ab25b 100644 --- a/crates/horizon-client/src/game/commands/terrain_cmds.rs +++ b/crates/horizon-client/src/game/commands/terrain_cmds.rs @@ -232,6 +232,16 @@ impl Game<'_> { self.world.dirty_sections.insert((key.0, sy, key.1)); } } + // ChunkMap.resendBiomesForChunks: the players who see the changed + // columns are sent their new biomes, in the order the columns were + // gathered (z, then x). + if let Some(host) = self.lan.as_ref() { + let columns: Vec<(i32, i32)> = ((lo[2] >> 4)..=(hi[2] >> 4)) + .flat_map(|cz| ((lo[0] >> 4)..=(hi[0] >> 4)).map(move |cx| (cx, cz))) + .filter(|key| touched.contains(key)) + .collect(); + host.hub.resend_biomes(&self.world, &columns); + } self.feedback(Text::translate( "commands.fillbiome.success.count", &[ diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 1163aa2370..37bee7098f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2053,6 +2053,7 @@ impl Game<'_> { C::PlayerLookAt(p) => self.remote_look_at(&p), C::LevelChunkWithLight(p) => self.remote_chunk(&p, None), C::LightUpdate(p) => self.remote_light_update(&p), + C::ChunksBiomes(p) => self.remote_chunks_biomes(&p), C::ForgetLevelChunk(p) => { let [x, z] = p.pos; self.streaming.forget(&mut self.world, x, z); diff --git a/crates/horizon-client/src/game/remote_chunks.rs b/crates/horizon-client/src/game/remote_chunks.rs index 47f4746190..86cd979d78 100644 --- a/crates/horizon-client/src/game/remote_chunks.rs +++ b/crates/horizon-client/src/game/remote_chunks.rs @@ -9,7 +9,7 @@ use horizon_client::{ }; use horizon_core::paletted::{PalettedContainer, Strategy}; use horizon_protocol::{ - chunk::{LightData, SectionLight}, + chunk::{LightData, Paletted, SectionLight}, packets::play::{self, Clientbound as C}, registry::DimensionType, }; @@ -53,20 +53,8 @@ impl ChunkFormat { .map_err(|e| e.to_string())?; let mut chunk = Chunk::empty(p.x, p.z); let offset = (self.min_y - MIN_Y).div_euclid(16); - // Biome table index per quart cell of the dimension's own sections. - let mut biome_cells: Vec<[u32; 64]> = Vec::with_capacity(sections.len()); for (i, s) in sections.iter().enumerate() { let target = offset + i as i32; - let mut cells = [0u32; 64]; - for (j, cell) in cells.iter_mut().enumerate() { - *cell = u32::from( - self.biome_map - .get(s.biomes.get(j) as usize) - .copied() - .unwrap_or(0), - ); - } - biome_cells.push(cells); if !(0..SECTIONS as i32).contains(&target) { continue; } @@ -97,30 +85,14 @@ impl ChunkFormat { } let mut section = Section { blocks, - biomes: PalettedContainer::from_values(BIOME_STRATEGY, &cells), + biomes: PalettedContainer::new(BIOME_STRATEGY, 0), light: Light::Uniform(0xF0), }; section.compact(); chunk.sections[target as usize] = Arc::new(section); } - // Sections outside the dimension's range take its nearest biomes - // (the column's top or bottom cell layer repeated). - if !biome_cells.is_empty() { - let last = biome_cells.len() - 1; - for si in 0..SECTIONS as i32 { - let i = si - offset; - if (0..sections.len() as i32).contains(&i) { - continue; - } - let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) }; - let mut cells = [0u32; 64]; - for (j, cell) in cells.iter_mut().enumerate() { - *cell = biome_cells[edge][layer * 16 + j % 16]; - } - Arc::make_mut(&mut chunk.sections[si as usize]).biomes = - PalettedContainer::from_values(BIOME_STRATEGY, &cells); - } - } + let biomes: Vec<_> = sections.iter().map(|s| &s.biomes).collect(); + self.put_biomes(&mut chunk, &biomes); // ClientPacketListener.applyLightData: the column keeps the light // the server sent; nothing is relit. let (layers, above) = self.light(&p.light); @@ -128,6 +100,51 @@ impl ChunkFormat { Ok(chunk) } + /// The dimension's sections' biome containers (in the server's + /// registry) into the column's sections. Sections outside the + /// dimension's range take its nearest biomes (the column's top or bottom + /// cell layer repeated). + fn put_biomes(&self, chunk: &mut Chunk, biomes: &[&Paletted]) { + if biomes.is_empty() { + return; + } + let offset = (self.min_y - MIN_Y).div_euclid(16); + // Biome table index per quart cell of the dimension's own sections. + let biome_cells: Vec<[u32; 64]> = biomes + .iter() + .map(|b| { + std::array::from_fn(|j| { + u32::from(self.biome_map.get(b.get(j) as usize).copied().unwrap_or(0)) + }) + }) + .collect(); + let last = biome_cells.len() - 1; + for si in 0..SECTIONS as i32 { + let i = si - offset; + let cells = if (0..biome_cells.len() as i32).contains(&i) { + biome_cells[i as usize] + } else { + let (edge, layer) = if i < 0 { (0, 0) } else { (last, 3) }; + std::array::from_fn(|j| biome_cells[edge][layer * 16 + j % 16]) + }; + let mut container = PalettedContainer::from_values(BIOME_STRATEGY, &cells); + if let Some(v) = container.uniform() { + container = PalettedContainer::new(BIOME_STRATEGY, v); + } + Arc::make_mut(&mut chunk.sections[si as usize]).biomes = container; + } + } + + /// `LevelChunk.replaceBiomes`: each of the dimension's sections reads + /// its biome container from the packet's buffer, in order. + fn replace_biomes(&self, chunk: &mut Chunk, data: &play::ChunkBiomeData) -> Result<(), String> { + let biomes = data + .biomes(self.sections, self.biome_bits) + .map_err(|e| e.to_string())?; + self.put_biomes(chunk, &biomes.iter().collect::>()); + Ok(()) + } + /// `ClientPacketListener.readSectionList`: the sky and block layers of a /// light packet, by section of the client's column, and the sky light /// above the column's top section (from the light section just above @@ -266,6 +283,43 @@ impl Game<'_> { } } + /// `ClientPacketListener.handleChunksBiomes`: each loaded column takes + /// its new biomes (`ClientChunkCache.replaceBiomes`), then every section + /// of it and of its neighbours is drawn again, as their colours blend + /// across columns (`LevelExtractor.setSectionDirty`). + pub(super) fn remote_chunks_biomes(&mut self, p: &play::ChunksBiomes) { + let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else { + return; + }; + for data in &p.chunks { + let [x, z] = data.pos; + if !self.remote_chunk_in_range(x, z) { + log::warn!("Ignoring chunk since it's not in the view range: {x}, {z}"); + continue; + } + let Some(chunk) = self.world.chunks.get_mut(&(x, z)) else { + log::warn!("Ignoring chunk since it's not present: {x}, {z}"); + continue; + }; + if let Err(e) = format.replace_biomes(Arc::make_mut(chunk), data) { + log::warn!("Horizon: biomes of chunk {x} {z}: {e}"); + } + } + for data in &p.chunks { + let [x, z] = data.pos; + for dz in -1..=1 { + for dx in -1..=1 { + if !self.world.chunks.contains_key(&(x + dx, z + dz)) { + continue; + } + for sy in 0..SECTIONS as i32 { + self.world.dirty_sections.insert((x + dx, sy, z + dz)); + } + } + } + } + } + /// `handleLightUpdatePacket`: the sections it names take its light. pub(super) fn remote_light_update(&mut self, p: &play::LightUpdate) { let Some(format) = self.remote.as_ref().map(|r| r.chunk_format.clone()) else { @@ -352,4 +406,45 @@ mod tests { } assert!(matches!(decoder.next(), Next::Decoding)); } + + /// `LevelChunk.replaceBiomes`: the packet's containers go to the + /// dimension's sections (y 0 to 255 in the Nether), mapped to our biome + /// table; the blocks stay, and the sections outside the dimension take + /// its nearest biomes as a chunk packet's do. + #[test] + fn chunks_biomes_replace_a_columns_biomes() { + let nether = DimensionType::vanilla("minecraft:the_nether"); + let format = ChunkFormat::new(&nether, vec![0, 7, 9], 2); + let mut chunk = Chunk::empty(2, -3); + chunk.set(3, 70, 4, 5); + // Wire biome 1 everywhere, with 2 in the lowest and the highest cells. + let containers: Vec = (0..nether.sections()) + .map(|i| { + let mut cells = [1; 64]; + if i == 0 { + cells[0] = 2; + } + if i == nether.sections() - 1 { + cells[63] = 2; + } + Paletted::from_values(&cells, Wiring::Biomes, 2) + }) + .collect(); + let data = play::ChunkBiomeData::from_biomes([2, -3], &containers); + format.replace_biomes(&mut chunk, &data).unwrap(); + assert_eq!(chunk.get(3, 70, 4), 5); + assert_eq!(chunk.biome(8, 70, 8), 7); + assert_eq!(chunk.biome(0, 0, 0), 9); + assert_eq!(chunk.biome(4, 0, 0), 7); + assert_eq!(chunk.biome(15, 255, 15), 9); + // Below and above the Nether: its bottom and top cell layers. + assert_eq!(chunk.biome(0, -64, 0), 9); + assert_eq!(chunk.biome(15, 319, 15), 9); + assert_eq!(chunk.biome(0, 319, 0), 7); + // A buffer short of the dimension's sections changes nothing. + let short = play::ChunkBiomeData::from_biomes([2, -3], &containers[..3]); + let mut other = Chunk::empty(2, -3); + assert!(format.replace_biomes(&mut other, &short).is_err()); + assert_eq!(other.biome(8, 70, 8), 0); + } } diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index c900f10a29..7db99e7b7e 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -13,7 +13,7 @@ use horizon_protocol::{ chunk::{ BIOMES, BLOCKS, BlockEntityInfo, ChunkData, Heightmaps, LightData, Paletted, Strategy, }, - packets::play::LevelChunkWithLight, + packets::play::{ChunkBiomeData, LevelChunkWithLight}, registry::{Builtin, ceil_log2}, }; use horizon_world::nbt::Tag; @@ -261,15 +261,6 @@ impl SectionCoder { values.push(v as i32); } } - let biome_values: Vec = (0..BIOMES) - .map(|j| { - let internal = section.biomes.get(j) as u16; - self.biome_ids - .get(usize::from(internal)) - .copied() - .unwrap_or(0) - }) - .collect(); ( horizon_protocol::chunk::Section { non_empty, @@ -286,11 +277,26 @@ impl SectionCoder { crate::chunk::block_strategy().global_bits, ), }, - biomes: Paletted::from_values(&biome_values, Strategy::Biomes, self.biome_bits), + biomes: self.biomes(section), }, entities, ) } + + /// The section's biome container (`PalettedContainerRO.write`'s), in + /// network biome ids. + fn biomes(&self, section: &Section) -> Paletted { + let values: Vec = (0..BIOMES) + .map(|j| { + let internal = section.biomes.get(j) as u16; + self.biome_ids + .get(usize::from(internal)) + .copied() + .unwrap_or(0) + }) + .collect(); + Paletted::from_values(&values, Strategy::Biomes, self.biome_bits) + } } /// Sections kept before the ones no longer resident are dropped. @@ -443,6 +449,16 @@ impl ChunkEncoder { } } + /// `ClientboundChunksBiomesPacket.ChunkBiomeData`: the column's biome + /// containers, one per section of the level (`extractChunkData`). + pub fn biome_data(&self, chunk: &Chunk) -> ChunkBiomeData { + let biomes: Vec = chunk.sections[self.sections.clone()] + .iter() + .map(|s| self.coder.biomes(s)) + .collect(); + ChunkBiomeData::from_biomes([chunk.x, chunk.z], &biomes) + } + /// The client heightmaps (Heightmap.prime over the column), from the /// cache while the column's sections are unchanged. fn heightmaps(&self, chunk: &Chunk, tables: &crate::save::SaveTables) -> ColumnHeightmaps { diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 5d7d1a41f9..1d4036a0eb 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1499,6 +1499,26 @@ impl Hub { (!batch.is_empty()).then_some(batch) } + /// `ChunkMap.resendBiomesForChunks`: each player in the level is sent, + /// in one packet, the biomes of those of `columns` it has been sent + /// (`getPlayers(pos, false)`), in the order given. + pub fn resend_biomes(&self, world: &World, columns: &[(i32, i32)]) { + let data: Vec<_> = columns + .iter() + .filter_map(|key| Some((*key, self.encoder.biome_data(world.chunks.get(key)?)))) + .collect(); + for g in self.guests.iter().filter(|g| self.here(g)) { + let chunks: Vec<_> = data + .iter() + .filter(|(key, _)| g.sent_chunks.contains(key)) + .map(|(_, d)| d.clone()) + .collect(); + if !chunks.is_empty() { + g.send(&C::ChunksBiomes(play::ChunksBiomes { chunks })); + } + } + } + /// `ChunkHolder.broadcastChanges`' light part: the light sections that /// changed in a column go to the players for whom it is on the border of /// what they track (`ChunkMap.isChunkOnTrackedBorder`); the others light diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 8895588e7a..7dbc75dfc4 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -910,3 +910,72 @@ fn a_spectators_container_click_is_taken_back() { assert!(s.hub.guests[0].carried.is_none()); run(&mut s, move || content(&mut bot)); } + +/// `/fillbiome` on a server: `ChunkMap.resendBiomesForChunks` sends each +/// player the new biomes of the columns it has been sent, and only those, +/// in a `ClientboundChunksBiomesPacket` the client reads section by section. +#[test] +fn changed_biomes_reach_the_players_that_see_the_column() { + let Some(resources) = resources() else { + return; + }; + let mut s = server(&resources); + let port = s.hub.lan.port; + let bot = run(&mut s, move || connect(port, "Gardener", true)); + // The column at the spawn arrives, with the batch that holds it. + let mut bot = run(&mut s, move || { + let mut bot = bot; + let mut seen = false; + loop { + match bot.recv_play()? { + C::PlayerPosition(p) => { + bot.send(S::AcceptTeleportation(play::AcceptTeleportation { + id: p.teleport, + x: p.position[0], + y: p.position[1], + z: p.position[2], + yaw: 0.0, + pitch: 0.0, + })) + } + C::LevelChunkWithLight(l) if (l.x, l.z) == (0, 0) => seen = true, + C::ChunkBatchFinished(_) => { + bot.send(S::ChunkBatchReceived(play::ChunkBatchReceived { + chunks_per_tick: 20.0, + })); + if seen { + return Ok(bot); + } + } + _ => {} + } + } + }); + assert!(s.hub.guests[0].sent_chunks.contains(&(0, 0))); + assert!(!s.hub.guests[0].sent_chunks.contains(&(4, 4))); + for key in [(0, 0), (4, 4)] { + let chunk = Arc::make_mut(s.world.chunks.get_mut(&key).unwrap()); + // The quart cell at x 4..8, y 4..8, z 8..12 of the column. + chunk.set_biome_cell(1, 17, 2, 5); + } + s.hub.resend_biomes(&s.world, &[(0, 0), (4, 4)]); + let packet = run(&mut s, move || { + loop { + if let C::ChunksBiomes(p) = bot.recv_play()? { + return Ok(p); + } + } + }); + assert_eq!(packet.chunks.len(), 1, "only the column the player sees"); + assert_eq!(packet.chunks[0].pos, [0, 0]); + let sections = packet.chunks[0].biomes(24, 7).unwrap(); + // Section 4 (y 0..16), cell (x 1, y 1, z 2). + assert_eq!(sections[4].get(1 << 4 | 2 << 2 | 1), 5); + assert_eq!(sections[4].get(0), 0); + assert!( + sections + .iter() + .enumerate() + .all(|(i, b)| i == 4 || b.get(25) == 0) + ); +} diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index a27ca12007..305c000e21 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -307,6 +307,39 @@ impl Codec for PositionPath { } packet!(EntityPositionSync { id: i32 => VarInt, path: PositionPath => PositionPath, yaw: f32 => Float, pitch: f32 => Float, on_ground: bool => Bool }); packet!(ForgetLevelChunk { pos: [i32; 2] => ChunkPos }); +packet!( + /// ClientboundChunksBiomesPacket.ChunkBiomeData: a column and its + /// sections' biome containers, in order (`extractChunkData`). + ChunkBiomeData { pos: [i32; 2] => ChunkPos, buffer: Vec => BytesN<2097152> } +); +impl ChunkBiomeData { + /// `LevelChunk.replaceBiomes`' read: `count` sections' biome + /// containers, one after another. + pub fn biomes(&self, count: usize, biome_bits: u8) -> Result> { + let mut r = Reader::new(&self.buffer); + (0..count) + .map(|_| { + crate::chunk::Paletted::read(&mut r, crate::chunk::Strategy::Biomes, biome_bits) + }) + .collect() + } + /// `extractChunkData`: the sections' biome containers, in order. + pub fn from_biomes(pos: [i32; 2], biomes: &[crate::chunk::Paletted]) -> Self { + let mut w = Writer::new(); + for b in biomes { + b.write(&mut w); + } + Self { + pos, + buffer: w.data, + } + } +} +packet!( + /// ClientboundChunksBiomesPacket: columns whose biomes changed + /// (`/fillbiome`, through `ChunkMap.resendBiomesForChunks`). + ChunksBiomes { chunks: Vec => List } +); packet!(GameEvent { event: u8 => UByte, param: f32 => Float }); packet!(HurtAnimation { id: i32 => VarInt, yaw: f32 => Float }); packet!( @@ -1845,6 +1878,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { DisguisedChat(DisguisedChat) = DISGUISED_CHAT, EntityEvent(EntityEvent) = ENTITY_EVENT, EntityPositionSync(EntityPositionSync) = ENTITY_POSITION_SYNC, + ChunksBiomes(ChunksBiomes) = CHUNKS_BIOMES, ForgetLevelChunk(ForgetLevelChunk) = FORGET_LEVEL_CHUNK, GameEvent(GameEvent) = GAME_EVENT, GameRuleValues(GameRuleValues) = GAME_RULE_VALUES, @@ -2605,6 +2639,18 @@ mod tests { pitch: 2.0, on_ground: true, }), + Clientbound::ChunksBiomes(ChunksBiomes { + chunks: vec![ + ChunkBiomeData { + pos: [-3, 7], + buffer: vec![0, 4, 1, 2], + }, + ChunkBiomeData { + pos: [i32::MAX, i32::MIN], + buffer: Vec::new(), + }, + ], + }), Clientbound::ForgetLevelChunk(ForgetLevelChunk { pos: [-1, 2] }), Clientbound::GameEvent(GameEvent { event: 13, @@ -3101,6 +3147,66 @@ mod tests { } } + /// ClientboundChunksBiomesPacket is read, not passed through as an + /// unknown packet: one column (1, -2) with two bytes of biomes. + #[test] + fn chunks_biomes_is_a_known_packet() { + let id = crate::ids::play::clientbound::CHUNKS_BIOMES; + let payload = [1, 0xFF, 0xFF, 0xFF, 0xFE, 0, 0, 0, 1, 2, 5, 6]; + let p = Clientbound::decode(id, &payload).unwrap(); + assert!(!matches!(p, Clientbound::Unknown(..)), "{p:?}"); + assert_eq!(p.encode()[1..], payload); + // Cut short, it is an error rather than a packet. + assert!(Clientbound::decode(id, &payload[..10]).is_err()); + } + + /// ClientboundChunksBiomesPacket.STREAM_CODEC: a list of (ChunkPos as + /// one long, x low and z high; `byteArray(2097152)`), the bytes being + /// each section's biome container (`ChunkBiomeData.extractChunkData`). + #[test] + fn chunks_biomes_wire_and_biomes() { + let p = Clientbound::ChunksBiomes(ChunksBiomes { + chunks: vec![ChunkBiomeData { + pos: [1, -2], + buffer: vec![5, 6], + }], + }); + let bytes = p.encode(); + assert_eq!( + bytes, + [ + crate::ids::play::clientbound::CHUNKS_BIOMES as u8, + 1, + 0xFF, + 0xFF, + 0xFF, + 0xFE, + 0, + 0, + 0, + 1, + 2, + 5, + 6 + ] + ); + assert_eq!( + Clientbound::decode(bytes[0].into(), &bytes[1..]).unwrap(), + p + ); + use crate::chunk::{Paletted, Strategy}; + let sections = [ + Paletted::from_values(&[3; 64], Strategy::Biomes, 6), + Paletted::from_values( + &(0..64).map(|i| i % 5).collect::>(), + Strategy::Biomes, + 6, + ), + ]; + let data = ChunkBiomeData::from_biomes([4, 4], §ions); + assert_eq!(data.biomes(2, 6).unwrap(), sections); + } + #[test] fn unknown_packets_keep_their_payload() { let p = From 1dd92e5f849b4885b890cabcbaabaea119125deb Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:08:02 +0700 Subject: [PATCH 273/968] Drowned, zombie and stray conversions keep gear, name and flags Zombie to drowned, husk to zombie and skeleton to stray spawned a fresh, spawn-finalized mob through MobEvent::SpawnKind with only baby, yaw and persistence, so the old mob's armour, held items, name tag, effects, NoAI/Silent and lead were lost (a name-tagged zombie that drowned came back as an unnamed drowned). Vanilla's ConversionTracker.doConversion calls Mob.convertTo(type, ConversionParams.single(this, true, true)): ConversionType.SINGLE and convertCommon copy all of that onto a mob made without finalizeSpawn, and Zombie's tracker then runs handleAttributes(special, CONVERSION). mobs/conversion.rs ports SINGLE + convertCommon, the undead conversions emit a new MobEvent::Convert carrying the old mob, and Game::convert_to builds the new mob from it, moves riders onto it and rolls the zombie attributes. The conversion sound is skipped for a silent mob, as the level event is. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 38 ++++ crates/horizon-client/src/mobs.rs | 9 + crates/horizon-client/src/mobs/conversion.rs | 156 ++++++++++++++++ crates/horizon-client/src/mobs/undead.rs | 177 +++++++++++++++---- 4 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 crates/horizon-client/src/mobs/conversion.rs diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index f191966879..839f2e5441 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -354,6 +354,41 @@ impl Game<'_> { } Some(id) } + /// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already + /// removed: a new mob of `kind`, made without spawn finalization, takes + /// its place with what `conversion::single` carries over, and the old + /// mob's riders move onto it. A zombie that comes out of it rolls + /// `Zombie.handleAttributes(special, CONVERSION)` (the + /// `postConversionStep` of its `ConversionTracker`). + pub(super) fn convert_to( + &mut self, + from: &Mob, + kind: &str, + params: horizon_client::mobs::conversion::Params, + ) -> Option { + let t = self.res.mob_types().get(kind).cloned()?; + self.next_entity += 1; + let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw); + horizon_client::mobs::conversion::single(from, &mut mob, params); + if matches!(from.species(), Species::Zombie | Species::Husk) { + let at = [from.position.x, from.position.y, from.position.z]; + let special = self.local_difficulty(at).special(); + horizon_client::mobs::finalize::zombie_attributes( + &mut mob, + special, + horizon_client::mobs::finalize::Reason::Conversion, + ); + } + let id = mob.id; + // ConversionType.SINGLE: the passengers get off and board the new mob. + for rider in self.mobs.iter_mut() { + if rider.extra.mount == Some(from.id) { + rider.extra.mount = Some(id); + } + } + self.mobs.push(mob); + Some(id) + } /// The `summon` command: spawn a mob in front of the player or at a position. pub(super) fn summon(&mut self, kind: &str, at: Option<[f64; 3]>, baby: bool) -> Result { let at = match at { @@ -1501,6 +1536,9 @@ impl Game<'_> { self.apply_setup(id, &setup); } } + MobEvent::Convert { from, kind, params } => { + self.convert_to(&from, kind, params); + } MobEvent::DropItem { item, count, at } => self.spawn_item( ItemStack { item, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 9dc6eeb1ac..472cc0ffc5 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -21,6 +21,7 @@ pub mod breeze; pub mod camel; pub mod caravan; pub mod control; +pub mod conversion; pub mod copper_golem; pub mod creaking; pub mod creaking_heart; @@ -1317,6 +1318,14 @@ pub enum MobEvent { at: [f64; 3], setup: SpawnSetup, }, + /// `Mob.convertTo` with `ConversionType.SINGLE` ([`conversion::single`]): + /// a new mob of `kind` takes the place of `from` (already removed) and + /// keeps its gear, name and flags. + Convert { + from: Box, + kind: &'static str, + params: conversion::Params, + }, DropItem { item: Arc, count: u32, diff --git a/crates/horizon-client/src/mobs/conversion.rs b/crates/horizon-client/src/mobs/conversion.rs new file mode 100644 index 0000000000..3e6d26f588 --- /dev/null +++ b/crates/horizon-client/src/mobs/conversion.rs @@ -0,0 +1,156 @@ +//! `Mob.convertTo` with `ConversionType.SINGLE`: one mob turns into another +//! of a different type in its place, keeping what the old one had. +use super::Mob; + +/// `ConversionParams` of a `SINGLE` conversion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Params { + /// `keepEquipment`: the worn and held items move to the new mob, with + /// their drop chances. + pub keep_equipment: bool, + /// `preserveCanPickUpLoot`. + pub preserve_can_pick_up_loot: bool, +} +impl Params { + /// `ConversionParams.single(mob, true, true)`, what `ConversionTracker` + /// uses (zombie to drowned, husk to zombie, skeleton to stray). + pub const KEEP_ALL: Self = Self { + keep_equipment: true, + preserve_can_pick_up_loot: true, + }; +} + +/// `ConversionType.SINGLE.convert` and `ConversionType.convertCommon`: `to` +/// is a fresh mob (`EntityType.create(level, CONVERSION)`, no spawn +/// finalization) that takes `from`'s place, motion, ride, lead, equipment, +/// effects, age, name and flags. Its health stays its own type's. +pub fn single(from: &Mob, to: &mut Mob, params: Params) { + // copyPosition, setDeltaMovement and the old mob's vehicle. + to.position = from.position; + to.previous = from.position; + to.yaw = from.yaw; + to.previous_yaw = from.yaw; + to.head_yaw = from.head_yaw; + to.previous_head_yaw = from.head_yaw; + to.head_pitch = from.head_pitch; + to.previous_head_pitch = from.head_pitch; + to.velocity = from.velocity; + to.extra.mount = from.extra.mount; + to.extra.passenger = from.extra.passenger; + if params.keep_equipment { + // Every slot, copyAndClear, and setDropChance from the old mob. + to.extra.set_main_hand(from.extra.main_hand.clone()); + if let Some(stack) = from.extra.main_hand_stack() { + to.extra.set_main_hand_stack(Some(stack.clone())); + } + to.extra.off_hand = from.extra.off_hand.clone(); + to.extra.head = from.extra.head.clone(); + to.extra.chest = from.extra.chest.clone(); + to.extra.legs = from.extra.legs.clone(); + to.extra.feet = from.extra.feet.clone(); + to.extra.armor = from.extra.armor.clone(); + to.extra.gear = from.extra.gear.clone(); + to.extra.drop_chances = from.extra.drop_chances.clone(); + } + to.fall_distance = from.fall_distance; + to.extra.fall_flying = from.extra.fall_flying; + to.ai.last_hurt_by_player = from.ai.last_hurt_by_player; + to.hurt_time = from.hurt_time; + to.body_yaw = from.body_yaw; + to.previous_body_yaw = from.body_yaw; + to.on_ground = from.on_ground; + to.extra.leash = from.extra.leash; + to.extra.leash_pending = from.extra.leash_pending; + to.extra.leash_player = from.extra.leash_player; + // convertCommon. + to.extra.effects = from.extra.effects.clone(); + to.extra.hidden_effects = from.extra.hidden_effects.clone(); + if from.is_baby() { + // setBaby(true); an ageable pair keeps the exact age. + to.extra.age = from.extra.age; + to.extra.forced_age = from.extra.forced_age; + to.extra.forced_age_timer = from.extra.forced_age_timer; + } + if params.preserve_can_pick_up_loot { + to.extra.pick_up_loot = from.extra.pick_up_loot; + } + to.extra.left_handed = from.extra.left_handed; + to.no_ai = from.no_ai; + to.persistent |= from.persistent; + to.name_visible = from.name_visible; + to.invulnerable_flag = from.invulnerable_flag; + to.invulnerable = from.invulnerable; + to.no_gravity = from.no_gravity; + to.silent = from.silent; + // COMPONENTS_TO_COPY: the custom name. + to.custom_name = from.custom_name.clone(); + to.custom_name_text = from.custom_name_text.clone(); + // A zombie that turns into another zombie keeps canBreakDoors. + if super::doors::is_zombie(from.species()) && super::doors::is_zombie(to.species()) { + super::doors::set_can_break_doors(to, from.extra.can_break_doors); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{items::ItemStack, mobs::world::with_test_world}; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + #[test] + fn a_frozen_skeleton_becomes_a_stray_that_keeps_its_bow_and_name() { + with_test_world(&[], |_, types| { + let mob = |id, kind: &str| { + Mob::new( + id, + Arc::new(types.types[kind].clone()), + Vec3::new(3.5, 70.0, -2.5), + 0.0, + ) + }; + let mut s = mob(1, "minecraft:skeleton"); + s.yaw = 90.0; + s.custom_name = Some("Frosty".into()); + s.no_ai = true; + s.extra + .set_main_hand_stack(Some(ItemStack::new("minecraft:bow", 1))); + s.extra.chest = Some("minecraft:chainmail_chestplate".into()); + s.extra.leash = Some(crate::mobs::leash::Holder::Knot([3, 71, -3])); + s.extra.mount = Some(7); + s.extra.passenger = true; + let mut st = mob(2, "minecraft:stray"); + single(&s, &mut st, Params::KEEP_ALL); + assert_eq!(st.custom_name.as_deref(), Some("Frosty")); + assert!(st.no_ai); + assert_eq!(st.yaw, 90.0); + assert_eq!( + st.extra.main_hand_stack().map(|s| &*s.item), + Some("minecraft:bow") + ); + assert_eq!( + st.extra.chest.as_deref(), + Some("minecraft:chainmail_chestplate") + ); + assert_eq!( + st.extra.leash, + Some(crate::mobs::leash::Holder::Knot([3, 71, -3])) + ); + assert_eq!(st.extra.mount, Some(7)); + // Health is the new type's own, not copied. + assert_eq!(st.health, mob(3, "minecraft:stray").health); + // Without keepEquipment the gear stays behind. + let mut bare = mob(4, "minecraft:stray"); + single( + &s, + &mut bare, + Params { + keep_equipment: false, + preserve_can_pick_up_loot: false, + }, + ); + assert!(bare.extra.main_hand.is_none() && bare.extra.chest.is_none()); + assert_eq!(bare.custom_name.as_deref(), Some("Frosty")); + }); + } +} diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index 1d1ec46987..f3d13c9b53 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -541,24 +541,20 @@ fn skeleton_freezing(m: &mut Mob, cx: &mut Cx) { } else if m.extra.counter2 > 0 { m.extra.counter2 -= 1; if m.extra.counter2 == 0 { - // levelEvent 1048: the conversion sound at the stray. - let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; - cx.events.push(MobEvent::Sound( - "entity.skeleton.converted_to_stray", - m.center(), - 2.0, - pitch, - )); - cx.events.push(MobEvent::SpawnKind { - kind: "minecraft:stray".into(), - at: [m.position.x, m.position.y, m.position.z], - setup: super::SpawnSetup { - yaw: m.yaw, - persistent: m.persistent, - ..Default::default() - }, - }); - m.removed = true; + // ConversionTracker.doConversion: convertTo(STRAY, + // ConversionParams.single(this, true, true)). + convert(m, "minecraft:stray", cx); + // levelEvent 1048: the conversion sound at the stray, unless it + // is silent. + if !m.silent { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + cx.events.push(MobEvent::Sound( + "entity.skeleton.converted_to_stray", + m.center(), + 2.0, + pitch, + )); + } } } else { m.extra.counter += 1; @@ -567,6 +563,17 @@ fn skeleton_freezing(m: &mut Mob, cx: &mut Cx) { } } } +/// `Mob.convertTo(kind, ConversionParams.single(this, true, true))`: the +/// mob is discarded and a new one of `kind` takes its place with its +/// equipment, name, effects and flags (`MobEvent::Convert`). +fn convert(m: &mut Mob, kind: &'static str, cx: &mut Cx) { + cx.events.push(MobEvent::Convert { + from: Box::new(m.clone()), + kind, + params: super::conversion::Params::KEEP_ALL, + }); + m.removed = true; +} pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { if m.species() == Species::Skeleton && m.alive() && !m.no_ai { skeleton_freezing(m, cx); @@ -588,18 +595,13 @@ pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { } else { ("minecraft:drowned", "entity.zombie.converted_to_drowned") }; - cx.events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0)); - cx.events.push(MobEvent::SpawnKind { - kind: kind.into(), - at: [m.position.x, m.position.y, m.position.z], - setup: super::SpawnSetup { - baby: m.is_baby(), - yaw: m.yaw, - persistent: m.persistent, - ..Default::default() - }, - }); - m.removed = true; + // ConversionTracker.doConversion: convertTo(type, + // ConversionParams.single(this, true, true)); levelEvent + // 1040 or 1041 unless the new mob is silent. + convert(m, kind, cx); + if !m.silent { + cx.events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0)); + } } } else if m.fluids.eye_in_water { m.extra.counter += 1; @@ -1083,3 +1085,118 @@ impl Goal for AttackTurtleEgg { self.since_reached += 1; } } + +#[cfg(test)] +mod conversion_tests { + use super::*; + use crate::{items::ItemStack, mobs::world::with_test_world}; + use std::sync::Arc; + + /// The mob the game makes of a conversion event (`Game::convert_to` or, + /// for a plain spawn, `spawn_mob` plus `apply_setup` without the spawn + /// finalization's random gear). + fn resolve(event: &MobEvent, types: &crate::mobs::MobTypes) -> Option { + let new = |kind: &str, at: Vec3, yaw: f32| { + Mob::new(2, Arc::new(types.types[kind].clone()), at, yaw) + }; + match event { + MobEvent::Convert { from, kind, params } => { + let mut m = new(kind, from.position, from.yaw); + crate::mobs::conversion::single(from, &mut m, *params); + Some(m) + } + MobEvent::SpawnKind { kind, at, setup } => { + let mut m = new(kind, Vec3::new(at[0], at[1], at[2]), setup.yaw); + m.persistent = setup.persistent; + if setup.baby { + m.extra.age = -24000; + } + Some(m) + } + _ => None, + } + } + + /// `Zombie.drowningTracker` / `Husk`: the last tick of the conversion + /// under water turns the mob into `into`; what comes out (None only + /// without the extracted vanilla data). + fn drown(kind: &str, dress: impl FnOnce(&mut Mob)) -> Option { + let mut out = None; + with_test_world(&[], |w, types| { + let mut m = Mob::new( + 1, + Arc::new(types.types[kind].clone()), + Vec3::new(0.5, 64.0, 0.5), + 30.0, + ); + dress(&mut m); + m.fluids.eye_in_water = true; + m.extra.counter2 = 1; + let mut lr = horizon_core::random::LegacyRandom::new(0); + let mut events = Vec::new(); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + custom_server_ai_step(&mut m, &mut cx); + assert!(m.removed, "the {kind} is discarded"); + let new = events.iter().find_map(|e| resolve(e, types)); + out = Some(new.expect("the conversion makes a new mob")); + }); + out + } + + #[test] + fn a_drowning_zombie_keeps_its_gear_and_name_tag() { + let Some(d) = drown("minecraft:zombie", |m| { + m.custom_name = Some("Bob".into()); + m.name_visible = true; + m.persistent = true; + m.extra.head = Some("minecraft:iron_helmet".into()); + m.extra.armor[0] = Some(ItemStack::new("minecraft:iron_helmet", 1)); + m.extra + .set_main_hand_stack(Some(ItemStack::new("minecraft:iron_sword", 1))); + m.extra.drop_chances = Some(Box::new([2.0; 8])); + m.extra.pick_up_loot = true; + m.add_effect("minecraft:speed", 600, 1); + }) else { + return; + }; + assert_eq!(d.species(), Species::Drowned); + assert_eq!(d.custom_name.as_deref(), Some("Bob")); + assert!(d.name_visible && d.persistent); + assert_eq!(d.extra.head.as_deref(), Some("minecraft:iron_helmet")); + assert_eq!( + d.extra.armor[0].as_ref().map(|s| &*s.item), + Some("minecraft:iron_helmet") + ); + assert_eq!( + d.extra.main_hand_stack().map(|s| &*s.item), + Some("minecraft:iron_sword") + ); + assert_eq!(d.extra.drop_chances.as_deref(), Some(&[2.0; 8])); + assert!(d.extra.pick_up_loot); + assert!(d.has_effect("minecraft:speed")); + assert_eq!(d.position, Vec3::new(0.5, 64.0, 0.5)); + } + + #[test] + fn a_husk_turns_into_a_zombie_with_its_flags() { + let Some(z) = drown("minecraft:husk", |m| { + m.custom_name = Some("Dusty".into()); + m.no_ai = false; + m.silent = true; + m.extra.age = i32::MIN / 2; + m.extra.feet = Some("minecraft:golden_boots".into()); + m.extra.armor[3] = Some(ItemStack::new("minecraft:golden_boots", 1)); + m.extra.left_handed = true; + }) else { + return; + }; + assert_eq!(z.species(), Species::Zombie); + assert_eq!(z.custom_name.as_deref(), Some("Dusty")); + assert!(z.silent && z.is_baby() && z.extra.left_handed); + assert_eq!(z.extra.feet.as_deref(), Some("minecraft:golden_boots")); + } +} From 3891dd9410c99bf36fb6e3e01797985969ee3c54 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:19:48 +0700 Subject: [PATCH 274/968] Break sound and crumbs for equipment a server says broke A vanilla server never plays an item's break itself: LivingEntity.onEquippedItemBroken broadcasts the entity event that entityEventForEquipmentBreak gives for the slot (47 main hand, 48 off hand, 49 to 52 head to feet, 65 body, 68 saddle) and each client's LivingEntity.handleEntityEvent runs breakItem on the stack it still has in that slot. The remote client had no case for these events, so on a server a breaking pickaxe, armour piece or wolf armour was silent and left no crumbs, for this player and for everyone else. Handle them: find the stack in the slot (the own inventory, or the mob's or other player's equipment), play its break_sound component at the entity's feet unless the entity is silent, and spawn the five item crumbs of spawnItemParticles. Entity.DATA_SILENT is now kept for remote entities so the silent check works. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/item_particles.rs | 73 +++++++++ crates/horizon-client/src/game/remote.rs | 6 + .../src/game/remote_mob_data.rs | 3 + crates/horizon-client/src/mobs.rs | 1 + crates/horizon-client/src/mobs/item_break.rs | 147 ++++++++++++++++++ 5 files changed, 230 insertions(+) create mode 100644 crates/horizon-client/src/mobs/item_break.rs diff --git a/crates/horizon-client/src/game/item_particles.rs b/crates/horizon-client/src/game/item_particles.rs index 2122ae8e1e..caa3a685eb 100644 --- a/crates/horizon-client/src/game/item_particles.rs +++ b/crates/horizon-client/src/game/item_particles.rs @@ -3,9 +3,13 @@ use super::Game; use horizon_assets::item_model::DisplayContext; use horizon_client::{ + item_render::stack_view, items::ItemStack, particles::{self, ParticleOptions, ParticleType}, }; +use horizon_core::random::RandomSource; +use horizon_world::player_inventory::EquipmentSlot; +use serde_json::Value; impl Game<'_> { /// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the @@ -36,6 +40,75 @@ impl Game<'_> { self.particles.add(&options, p, v); } } + /// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68 from a server: + /// the entity's item in `slot` broke, so `breakItem` the stack this + /// client still has there (the slot is emptied by a later update). + pub(super) fn remote_equipment_break(&mut self, id: i32, slot: EquipmentSlot) { + if self.remote.as_ref().is_some_and(|r| r.entity_id == id) { + let stack = match slot { + EquipmentSlot::MainHand => self.held().cloned(), + EquipmentSlot::OffHand => self.offhand.clone(), + EquipmentSlot::Head => self.armor[0].clone(), + EquipmentSlot::Chest => self.armor[1].clone(), + EquipmentSlot::Legs => self.armor[2].clone(), + EquipmentSlot::Feet => self.armor[3].clone(), + EquipmentSlot::Body | EquipmentSlot::Saddle => None, + }; + let Some(stack) = stack else { + return; + }; + if let Some(p) = self.player.as_ref().map(|p| p.position) { + self.break_sound_at(&stack, [p.x, p.y, p.z]); + } + self.eat_particles(&stack, 5); + return; + } + let Some(m) = self.mobs.iter().find(|m| m.id == id as u32) else { + return; + }; + let Some(stack) = m.item_by_slot(slot) else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + let (eye, pitch, yaw, silent) = (m.eye_height(), m.head_pitch, m.yaw, m.silent); + if !silent { + self.break_sound_at(&stack, at); + } + let options = self.item_particle(&stack); + let mut spawned = Vec::new(); + particles::item_use_particles( + &mut self.world.random, + pitch, + yaw, + at, + at[1] + eye, + 5, + |p, v| spawned.push((p, v)), + ); + for (p, v) in spawned { + self.particles.add(&options, p, v); + } + } + /// `LivingEntity.breakItem`'s sound: the stack's `break_sound` (none when + /// the component is removed), heard by this client only + /// (`playLocalSound`) at the entity's feet, at 0.8 volume and a pitch of + /// 0.8 to 1.2. + fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) { + let models = &self.pack.item_renderer.models; + let sound = match models + .components(&stack_view(stack)) + .get("minecraft:break_sound") + { + Some(Value::String(id)) => id.clone(), + Some(Value::Object(inline)) => match inline.get("sound_id") { + Some(Value::String(id)) => id.clone(), + _ => return, + }, + _ => return, + }; + let pitch = 0.8 + self.world.random.next_float() * 0.4; + self.audio.play_unshared(&sound, Some(at), 0.8, pitch); + } /// The slime ball, snowball and cobweb models behind `item_slime`, /// `item_snowball` and `item_cobweb`, resolved on first use. pub(super) fn ensure_item_particle_sprites(&mut self) { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 1163aa2370..9dfce36fc8 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2665,6 +2665,12 @@ impl Game<'_> { { std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand); } + // LivingEntity.handleEntityEvent 47 to 52, 65 and 68: an + // equipped item broke (onEquippedItemBroken). + if let Some(slot) = horizon_client::mobs::item_break::equipment_break_slot(p.event) + { + self.remote_equipment_break(p.id, slot); + } // EntityEvent.DEATH: start the death animation. if p.event == 3 && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index fdfea79b67..33df12dc3e 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -50,6 +50,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti m.invisible = invisible; } } + // Entity.DATA_SILENT: Entity.isSilent, which keeps the sounds + // a client plays itself quiet (LivingEntity.breakItem's). + (4, Value::Bool(silent)) => m.silent = *silent, // Entity.DATA_POSE: the pose the species animates by. (6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8), // LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 9dc6eeb1ac..f52b1b0093 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -44,6 +44,7 @@ pub mod happy_ghast; pub mod hostile; pub mod illager; pub mod illusioner; +pub mod item_break; pub mod keyframes; pub mod leash; pub mod living; diff --git a/crates/horizon-client/src/mobs/item_break.rs b/crates/horizon-client/src/mobs/item_break.rs new file mode 100644 index 0000000000..5ae564cc23 --- /dev/null +++ b/crates/horizon-client/src/mobs/item_break.rs @@ -0,0 +1,147 @@ +//! `LivingEntity.breakItem` as a client sees it. A server never plays the +//! break itself: `LivingEntity.onEquippedItemBroken` broadcasts the entity +//! event `entityEventForEquipmentBreak` gives for the slot, and each client's +//! `LivingEntity.handleEntityEvent` breaks the item it still draws in that +//! slot (`getItemBySlot`), with the sound and the crumbs. +use super::{Mob, drops::head_is_body}; +use crate::items::ItemStack; +use horizon_world::player_inventory::EquipmentSlot; + +/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68: the slot whose +/// item broke (the inverse of `entityEventForEquipmentBreak`). +pub fn equipment_break_slot(event: i8) -> Option { + Some(match event { + 47 => EquipmentSlot::MainHand, + 48 => EquipmentSlot::OffHand, + 49 => EquipmentSlot::Head, + 50 => EquipmentSlot::Chest, + 51 => EquipmentSlot::Legs, + 52 => EquipmentSlot::Feet, + 65 => EquipmentSlot::Body, + 68 => EquipmentSlot::Saddle, + _ => return None, + }) +} + +impl Mob { + /// `LivingEntity.getItemBySlot` for a mob or another player a server + /// sends: the stack it wears or holds in `slot`, with the components + /// `ClientboundSetEquipmentPacket` gave it where they are kept. + pub fn item_by_slot(&self, slot: EquipmentSlot) -> Option { + let e = &self.extra; + let body = head_is_body(self.species()); + let plain = |id: &Option>| id.as_deref().map(|i| ItemStack::new(i, 1)); + let worn = |index: usize, id: &Option>| { + e.armor[index] + .clone() + .filter(|s| id.as_ref() == Some(&s.item)) + .or_else(|| plain(id)) + }; + match slot { + EquipmentSlot::MainHand => e.main_hand_stack().cloned().or_else(|| plain(&e.main_hand)), + EquipmentSlot::OffHand => plain(&e.off_hand), + EquipmentSlot::Head if !body => worn(0, &e.head), + EquipmentSlot::Chest => worn(1, &e.chest), + EquipmentSlot::Legs => worn(2, &e.legs), + EquipmentSlot::Feet => worn(3, &e.feet), + // The body armour (a wolf's, a horse's, a llama's carpet) is + // kept in `head`, with the durability it has lost. + EquipmentSlot::Body if body => plain(&e.head).map(|s| ItemStack { + damage: e.body_damage.max(0) as u32, + ..s + }), + EquipmentSlot::Saddle => self.saddle_item(), + EquipmentSlot::Head | EquipmentSlot::Body => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::Species; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + fn mob(id: &str, species: Species) -> Mob { + let mut kind = crate::mobs::sensor_scan_tests::kind(); + let k = Arc::make_mut(&mut kind); + k.id = id.into(); + k.species = species; + Mob::new(1, kind, Vec3::ZERO, 0.0) + } + + /// `LivingEntity.entityEventForEquipmentBreak`: 47 main hand, 48 off hand, + /// 49 to 52 head to feet, 65 body, 68 saddle; other events break nothing. + #[test] + fn each_equipment_break_event_names_its_slot() { + let slots = [ + (47, EquipmentSlot::MainHand), + (48, EquipmentSlot::OffHand), + (49, EquipmentSlot::Head), + (50, EquipmentSlot::Chest), + (51, EquipmentSlot::Legs), + (52, EquipmentSlot::Feet), + (65, EquipmentSlot::Body), + (68, EquipmentSlot::Saddle), + ]; + for (event, slot) in slots { + assert_eq!(equipment_break_slot(event), Some(slot), "event {event}"); + } + for event in [2, 3, 35, 46, 53, 54, 55, 60, 64, 66, 67, 69] { + assert_eq!(equipment_break_slot(event), None, "event {event}"); + } + } + + /// The stack another player (an avatar, as a mannequin) holds or wears + /// is the one that breaks. + #[test] + fn a_player_breaks_what_it_holds_and_wears() { + let mut p = mob("minecraft:player", Species::Mannequin); + let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1); + pick.damage = 1560; + p.extra.set_main_hand_stack(Some(pick.clone())); + p.extra.off_hand = Some("minecraft:shield".into()); + p.extra.head = Some("minecraft:iron_helmet".into()); + let mut boots = ItemStack::new("minecraft:leather_boots", 1); + boots.damage = 64; + p.extra.feet = Some(boots.item.clone()); + p.extra.armor[3] = Some(boots.clone()); + let item = |slot| p.item_by_slot(slot).map(|s: ItemStack| (s.item, s.damage)); + assert_eq!( + item(EquipmentSlot::MainHand), + Some((pick.item.clone(), 1560)) + ); + assert_eq!( + item(EquipmentSlot::OffHand), + Some(("minecraft:shield".into(), 0)) + ); + assert_eq!( + item(EquipmentSlot::Head), + Some(("minecraft:iron_helmet".into(), 0)) + ); + assert_eq!(item(EquipmentSlot::Feet), Some((boots.item.clone(), 64))); + assert_eq!(item(EquipmentSlot::Chest), None); + assert_eq!(item(EquipmentSlot::Body), None); + assert_eq!(item(EquipmentSlot::Saddle), None); + } + + /// A wolf's armour is its body slot (kept in `head`), and a horse's + /// saddle its saddle slot. + #[test] + fn body_armour_and_saddles_break_from_their_slots() { + let mut wolf = mob("minecraft:wolf", Species::Wolf); + wolf.extra.head = Some("minecraft:wolf_armor".into()); + wolf.extra.body_damage = 63; + let armour = wolf.item_by_slot(EquipmentSlot::Body).unwrap(); + assert_eq!((&*armour.item, armour.damage), ("minecraft:wolf_armor", 63)); + assert_eq!(wolf.item_by_slot(EquipmentSlot::Head), None); + + let mut horse = mob("minecraft:horse", Species::Horse); + horse.set_saddle(Some(ItemStack::new("minecraft:saddle", 1))); + assert_eq!( + horse.item_by_slot(EquipmentSlot::Saddle).map(|s| s.item), + Some("minecraft:saddle".into()) + ); + } +} From 6ffe36494e1f8d943f21659505ead602ca17db75 Mon Sep 17 00:00:00 2001 From: GIGABAIT93 Date: Fri, 30 Oct 2026 08:24:38 +0200 Subject: [PATCH 275/968] port_publish: read gh's and Kira's output as UTF-8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh() and kira() ran subprocess with text=True and no encoding, so on Windows Python decoded their UTF-8 output with the code page: an issue body's → came back as →, and merged() wrote it back on the next update. Follows d3grando's review on #3587. Co-Authored-By: Claude Opus 5.5 --- tests/test_port_publish.py | 11 +++++++++++ tools/port_publish.py | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/test_port_publish.py b/tests/test_port_publish.py index 921505901e..8bde12fb05 100644 --- a/tests/test_port_publish.py +++ b/tests/test_port_publish.py @@ -1,3 +1,4 @@ +import json from pathlib import Path import unittest from unittest import mock @@ -93,6 +94,16 @@ class PublishTests(unittest.TestCase): self.assertIn("- [ ] #101 [26.4] Protocol", bodies["[26.4] Port to Minecraft 26.4"]) self.assertFalse([c for c in calls if c[:2] == ("label", "create")]) + def test_gh_and_kira_read_utf8_whatever_the_code_page(self): + # gh and node print UTF-8; without an encoding, Windows decodes it with the code page (cp1252 here). + def run(command, **kwargs): + out = json.dumps({"body": "a → b"}, ensure_ascii=False).encode("utf-8") + return mock.Mock(stdout=out.decode(kwargs.get("encoding") or "cp1252")) + + with mock.patch.object(publish.subprocess, "run", side_effect=run): + self.assertEqual(json.loads(publish.gh("issue", "view"))["body"], "a → b") + self.assertEqual(publish.kira("board_list", {})["body"], "a → b") + if __name__ == "__main__": unittest.main() diff --git a/tools/port_publish.py b/tools/port_publish.py index bc5a9dd5c1..e6bb196030 100644 --- a/tools/port_publish.py +++ b/tools/port_publish.py @@ -191,7 +191,8 @@ def plan(report, diff, store_path, open_issues): # --- GitHub and Kira (only with --yes) --------------------------------------------------------------------- def gh(*args): - return subprocess.run(["gh", *args], capture_output=True, text=True, check=True).stdout + # gh prints UTF-8, whatever the system's code page. + return subprocess.run(["gh", *args], capture_output=True, encoding="utf-8", check=True).stdout def open_version_port_issues(): @@ -241,7 +242,7 @@ def issue(action, name, text, number, new): def kira(tool, arguments): return json.loads(subprocess.run(["node", str(KIRA), tool, json.dumps(arguments)], capture_output=True, - text=True, check=True).stdout or "null") + encoding="utf-8", check=True).stdout or "null") def kira_card(new, url): From 9432147489a889bd93306246a81542860e23e30b Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:25:21 +0700 Subject: [PATCH 276/968] A chat message the server deletes becomes the deleted marker ClientboundDeleteChatPacket (31) was only a constant: Horizon never decoded it, and the PlayerChat handler dropped the signature a deletion names a message by, so a line a moderation plugin deleted stayed on screen. Decode the packet (MessageSignature.Packed), keep the connection's MessageSignatureCache (unpack the last seen of each PlayerChat, then push it and the signature, as ClientPacketListener.handlePlayerChat does), and keep each player message's signature in the chat. handleDeleteChat: a message still held back by Chat Delay never shows (removeFromDelayedMessageQueue); a shown one becomes the grey italic chat.deleted_marker line once it has shown 60 ticks (ChatComponent.deleteMessageOrDelay, with the delayed queue ChatComponent.tick processes). An unknown packed id disconnects with multiplayer.disconnect.invalid_packet, as vanilla does. Fixes #3518 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/chat.rs | 127 ++++++++++++++-- .../horizon-client-render/src/chat_tests.rs | 59 ++++++++ crates/horizon-client/src/game/remote.rs | 136 ++++++++++++++---- crates/horizon-protocol/src/packets/play.rs | 33 +++++ crates/horizon-protocol/src/secure_chat.rs | 24 ++++ tools/port_map/client-002.tsv | 4 +- tools/port_map/client-003.tsv | 2 +- tools/port_map/client2-010.tsv | 6 +- tools/port_map/rest-011.tsv | 6 +- 9 files changed, 355 insertions(+), 42 deletions(-) diff --git a/crates/horizon-client-render/src/chat.rs b/crates/horizon-client-render/src/chat.rs index ab6626a95b..3ccf17fed7 100644 --- a/crates/horizon-client-render/src/chat.rs +++ b/crates/horizon-client-render/src/chat.rs @@ -21,6 +21,12 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt"; const MAX_PERSISTED_COMMAND_HISTORY: usize = 50; /// GuiMessageTag.systemSinglePlayer's indicator colour. pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0; +/// A player message's signature (MessageSignature), which names it to a +/// server's deletion. +pub type Signature = horizon_protocol::secure_chat::Signature; +/// How long a message shows before a deletion replaces it +/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`). +const DELETION_DELAY: u64 = 60; /// `ChatVisiblity`: the Chat option's three values. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -165,6 +171,8 @@ struct Message { content: Text, indicator: Option, source: ChatSource, + /// `GuiMessage.signature`: a signed player message's, for deletion. + signature: Option>, } /// A wrapped line (`GuiMessage.Line`). #[derive(Clone, Debug)] @@ -205,6 +213,9 @@ pub struct Chat { pub received: u64, /// The Chat option's `visibleMessageFilter` (`set_abilities`). abilities: ChatAbilities, + /// `ChatComponent.messageDeletionQueue`: deletions of messages still + /// too new to replace, with the tick from which they may be. + deletions: Vec<(Box, u64)>, } impl Chat { pub fn lines_per_page(&self, focused: bool) -> usize { @@ -227,6 +238,38 @@ impl Chat { ) { self.add_message(&atlas.font, content, indicator, source, ticks, focused); } + /// `add` of a player message with its signature, which a server's + /// deletion names it by (`GuiMessage.signature`). + pub fn add_signed( + &mut self, + atlas: &HudAtlas, + content: Text, + signature: Option, + ticks: u64, + focused: bool, + ) { + self.add_signed_message(&atlas.font, content, signature, ticks, focused); + } + fn add_signed_message( + &mut self, + font: &crate::font::Font, + content: Text, + signature: Option, + ticks: u64, + focused: bool, + ) { + self.push_message( + font, + Message { + added: ticks, + content, + indicator: None, + source: ChatSource::Player, + signature: signature.map(Box::new), + }, + focused, + ); + } fn add_message( &mut self, font: &crate::font::Font, @@ -236,20 +279,28 @@ impl Chat { ticks: u64, focused: bool, ) { - if !self.abilities.shows(source) { + self.push_message( + font, + Message { + added: ticks, + content, + indicator, + source, + signature: None, + }, + focused, + ); + } + fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) { + if !self.abilities.shows(message.source) { return; } // ChatComponent.logChatMessage: every message shown is in the log. - crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain())); + let plain = message.content.plain(); + crate::logging::write_only(log::Level::Info, &format!("[CHAT] {plain}")); if self.log { - println!("[CHAT] {}", content.plain()); + println!("[CHAT] {plain}"); } - let message = Message { - added: ticks, - content, - indicator, - source, - }; self.add_lines(font, &message, focused); self.messages.insert(0, message); self.messages.truncate(100); @@ -319,8 +370,66 @@ impl Chat { pub fn clear(&mut self) { self.messages.clear(); self.lines.clear(); + self.deletions.clear(); self.scroll = 0; } + /// `ChatComponent.deleteMessage`: the server deleted a message. One + /// shown for 60 ticks becomes the grey "deleted" marker now; a newer + /// one waits until then (`messageDeletionQueue`). + pub fn delete_message(&mut self, atlas: &HudAtlas, signature: &Signature, ticks: u64) { + self.delete_message_with(&atlas.font, signature, ticks); + } + fn delete_message_with(&mut self, font: &crate::font::Font, signature: &Signature, ticks: u64) { + if let Some(delayed) = self.delete_or_delay(font, signature, ticks) { + self.deletions.push((Box::new(*signature), delayed)); + } + } + /// `ChatComponent.tick` (`processMessageDeletionQueue`): the delayed + /// deletions whose message has shown long enough. + pub fn tick(&mut self, atlas: &HudAtlas, ticks: u64) { + self.process_deletions(&atlas.font, ticks); + } + fn process_deletions(&mut self, font: &crate::font::Font, ticks: u64) { + let mut deletions = std::mem::take(&mut self.deletions); + deletions.retain(|(signature, after)| { + ticks < *after || self.delete_or_delay(font, signature, ticks).is_some() + }); + self.deletions = deletions; + } + /// `ChatComponent.deleteMessageOrDelay`: the newest message with this + /// signature, replaced by `createDeletedMarker` once 60 ticks have + /// passed since it was added; otherwise the tick to retry from. + fn delete_or_delay( + &mut self, + font: &crate::font::Font, + signature: &Signature, + ticks: u64, + ) -> Option { + let message = self + .messages + .iter_mut() + .find(|m| m.signature.as_deref() == Some(signature))?; + let deletable = message.added + DELETION_DELAY; + if ticks < deletable { + return Some(deletable); + } + *message = Message { + added: message.added, + content: Self::deleted_marker(), + indicator: Some(SYSTEM_INDICATOR), + source: ChatSource::Server, + signature: None, + }; + // refreshTrimmedMessages. + self.rewrap(font); + None + } + /// `ChatComponent.DELETED_CHAT_MESSAGE`: grey, italic. + fn deleted_marker() -> Text { + Text::translate("chat.deleted_marker", &[]) + .with_color(text::GRAY) + .italic() + } /// `new CommandHistory(gameFolder)`: the commands kept from earlier runs /// start the chat's history (`ChatComponent`'s constructor). pub fn load_history(&mut self, game_folder: &std::path::Path) { diff --git a/crates/horizon-client-render/src/chat_tests.rs b/crates/horizon-client-render/src/chat_tests.rs index beec6cd818..6bcecf9ec8 100644 --- a/crates/horizon-client-render/src/chat_tests.rs +++ b/crates/horizon-client-render/src/chat_tests.rs @@ -467,3 +467,62 @@ fn a_guests_line_follows_the_player_message_permission() { ); } } + +/// `ChatComponent.deleteMessageOrDelay`: a deleted message shows for 60 +/// ticks from when it was added, then becomes the grey italic +/// `chat.deleted_marker` line with the system bar; the other lines stay. +#[test] +fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() { + let font = font(); + let mut chat = Chat::default(); + let secret = [7; 256]; + chat.add_signed_message( + &font, + Text::literal(" secret"), + Some(secret), + 100, + false, + ); + chat.add_signed_message( + &font, + Text::literal(" hi"), + Some([8; 256]), + 110, + false, + ); + let lines = + |chat: &Chat| -> Vec { chat.lines.iter().map(|l| l.content.plain()).collect() }; + let marker = Chat::deleted_marker().plain(); + chat.delete_message_with(&font, &secret, 130); + assert_eq!( + lines(&chat), + [" hi", " secret"], + "too new: delayed" + ); + chat.process_deletions(&font, 159); + assert_eq!(lines(&chat), [" hi", " secret"]); + chat.process_deletions(&font, 160); + assert_eq!(lines(&chat), [" hi".to_owned(), marker.clone()]); + assert!(chat.deletions.is_empty()); + let line = &chat.lines[1]; + assert_eq!(line.indicator, Some(SYSTEM_INDICATOR)); + assert_eq!(line.added, 100, "the marker keeps the message's time"); + assert!( + line.content + .spans + .iter() + .all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic()) + ); + assert_eq!( + chat.messages[1].content.translation_key(), + Some("chat.deleted_marker") + ); + // A message shown 60 ticks already is replaced at once; an unknown or + // already deleted signature changes nothing. + chat.delete_message_with(&font, &[8; 256], 170); + assert_eq!(lines(&chat), [marker.clone(), marker.clone()]); + chat.delete_message_with(&font, &secret, 200); + chat.delete_message_with(&font, &[9; 256], 200); + assert_eq!(lines(&chat), [marker.clone(), marker]); + assert!(chat.deletions.is_empty()); +} diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 1163aa2370..6ee4fed115 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -36,6 +36,7 @@ use horizon_protocol::{ play::{self, Clientbound as C, Serverbound as S}, }, registry::{Builtin, DimensionType, Dynamic, ceil_log2}, + secure_chat::Signature, text::{Language, plain}, }; use horizon_world::{ @@ -63,6 +64,13 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text { remote_text(tag).resolve_legacy() } +/// A player message Chat Delay holds back, and its signature. +type QueuedChat = ( + horizon_client::text::Text, + horizon_client::chat::ChatSource, + Option, +); + /// Streaming needs a generator; a server world never generates. struct NoTerrain; impl ChunkGenerator for NoTerrain { @@ -177,8 +185,12 @@ pub(super) struct Remote { pub chat: Vec<(String, Instant)>, /// Lines received since joining, for the control socket's events. pub chat_received: u64, - /// `ChatListener.delayedMessageQueue`: player chat held back by Chat Delay. - chat_queue: VecDeque<(horizon_client::text::Text, horizon_client::chat::ChatSource)>, + /// `ChatListener.delayedMessageQueue`: player chat held back by Chat + /// Delay, with the signature a deletion names it by. + chat_queue: VecDeque, + /// `ClientPacketListener.messageSignatureCache`: the signatures packed + /// ones index, as the server's copy for this connection holds them. + signatures: horizon_protocol::secure_chat::SignatureCache, /// `ChatListener.previousMessageTime`: when a message last showed, if one has. chat_previous: Option, /// The server's command tree (ClientboundCommandsPacket). @@ -516,6 +528,7 @@ impl Game<'_> { chat: Vec::new(), chat_received: 0, chat_queue: VecDeque::new(), + signatures: Default::default(), chat_previous: None, commands: None, players: HashMap::new(), @@ -768,23 +781,39 @@ impl Game<'_> { &mut self, content: horizon_client::text::Text, source: horizon_client::chat::ChatSource, + ) -> bool { + self.signed_chat_line(content, source, None) + } + /// `chat_line` of a player message, keeping its signature for a + /// deletion (`GuiMessage.signature`). + fn signed_chat_line( + &mut self, + content: horizon_client::text::Text, + source: horizon_client::chat::ChatSource, + signature: Option, ) -> bool { if !self.ui.chat.shows(source) { return false; } let text = content.plain(); - // addServerSystemMessage and addClientSystemMessage both carry - // GuiMessageTag.systemSinglePlayer: the grey bar at the line's left. - let indicator = (source != horizon_client::chat::ChatSource::Player) - .then_some(horizon_client::chat::SYSTEM_INDICATOR); - self.ui.chat.add( - &self.gui.atlas, - content, - indicator, - source, - self.ticks, - self.screen == Screen::Chat, - ); + let focused = self.screen == Screen::Chat; + if source == horizon_client::chat::ChatSource::Player { + self.ui + .chat + .add_signed(&self.gui.atlas, content, signature, self.ticks, focused); + } else { + // addServerSystemMessage and addClientSystemMessage both carry + // GuiMessageTag.systemSinglePlayer: the grey bar at the line's left. + let indicator = Some(horizon_client::chat::SYSTEM_INDICATOR); + self.ui.chat.add( + &self.gui.atlas, + content, + indicator, + source, + self.ticks, + focused, + ); + } if let Some(r) = self.remote.as_mut() { r.chat.push((text, Instant::now())); r.chat_received += 1; @@ -797,24 +826,54 @@ impl Game<'_> { /// `ChatListener.handleMessage` for player and disguised chat: held in the /// queue while Chat Delay is set, shown at once otherwise. - pub(super) fn handle_player_chat(&mut self, line: horizon_client::text::Text) { + pub(super) fn handle_player_chat( + &mut self, + line: horizon_client::text::Text, + signature: Option, + ) { let source = horizon_client::chat::ChatSource::Player; if self.chat_delay_ms() > 0 { if let Some(r) = self.remote.as_mut() { - r.chat_queue.push_back((line, source)); + r.chat_queue.push_back((line, source, signature)); } } else { - self.accept_chat(line, source); + self.accept_chat(line, source, signature); } } + /// `ClientPacketListener.handleDeleteChat`: the packed signature names a + /// message; one still held back by Chat Delay never shows + /// (`ChatListener.removeFromDelayedMessageQueue`), a shown one is + /// replaced (`ChatComponent.deleteMessage`). A signature the cache + /// doesn't hold is an invalid packet. + fn handle_delete_chat(&mut self, packed: &(i32, Option>)) -> Result<()> { + let Some(r) = self.remote.as_mut() else { + return Ok(()); + }; + let Some(signature) = r.signatures.unpack(packed) else { + let reason = + horizon_client::text::Text::translate("multiplayer.disconnect.invalid_packet", &[]); + return self.leave_server(Some(reason.plain())); + }; + let queued = r.chat_queue.len(); + r.chat_queue + .retain(|(_, _, s)| s.as_ref() != Some(&signature)); + if r.chat_queue.len() == queued { + self.ui + .chat + .delete_message(&self.gui.atlas, &signature, self.ticks); + } + Ok(()) + } + /// A message's `accept`: shown, it starts the Chat Delay wait again. fn accept_chat( &mut self, line: horizon_client::text::Text, source: horizon_client::chat::ChatSource, + signature: Option, ) -> bool { - let shown = self.chat_line(line, source); + let shown = self.signed_chat_line(line, source, signature); if shown && let Some(r) = self.remote.as_mut() { r.chat_previous = Some(Instant::now()); } @@ -860,9 +919,10 @@ impl Game<'_> { } // A message the Chat option hides is dropped and the next one is tried, // until one shows. - while let Some((line, source)) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front()) + while let Some((line, source, signature)) = + self.remote.as_mut().and_then(|r| r.chat_queue.pop_front()) { - if self.accept_chat(line, source) { + if self.accept_chat(line, source, signature) { break; } } @@ -878,8 +938,8 @@ impl Game<'_> { else { return; }; - for (line, source) in queue { - self.chat_line(line, source); + for (line, source, signature) in queue { + self.signed_chat_line(line, source, signature); } if let Some(r) = self.remote.as_mut() { r.chat_previous = None; @@ -1056,6 +1116,7 @@ impl Game<'_> { r.players.clear(); r.tab_players.clear(); r.listed.clear(); + r.signatures = Default::default(); } self.set_registries(registries); } @@ -3079,18 +3140,43 @@ impl Game<'_> { } C::PlayerChat(p) => { use horizon_client::scoreboard::tag_to_json; + // SignedMessageBody.Packed.unpack: the last seen it names must + // be in the cache; then the message joins it + // (MessageSignatureCache.push). + let Some(r) = self.remote.as_mut() else { + return Ok(()); + }; + let last_seen: Option> = + p.last_seen.iter().map(|s| r.signatures.unpack(s)).collect(); + let Some(last_seen) = last_seen else { + log::error!( + "Message from player with ID {:032x} referenced unrecognized signature id", + p.sender + ); + let reason = horizon_client::text::Text::translate( + "multiplayer.disconnect.invalid_packet", + &[], + ); + return self.leave_server(Some(reason.plain())); + }; + let signature = p + .signature + .as_deref() + .and_then(horizon_protocol::secure_chat::signature); + r.signatures.push(&last_seen, signature.as_ref()); let content = p .unsigned_content .as_ref() .map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json); let line = self.format_chat(&p.chat_type, content); - self.handle_player_chat(line); + self.handle_player_chat(line, signature); } C::DisguisedChat(p) => { let content = horizon_client::scoreboard::tag_to_json(&p.message); let line = self.format_chat(&p.chat_type, content); - self.handle_player_chat(line); + self.handle_player_chat(line, None); } + C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?, C::PlayerInfoUpdate(p) => { if let Some(r) = self.remote.as_mut() { // handlePlayerInfoUpdate: new entries first, then every @@ -4022,6 +4108,8 @@ impl Game<'_> { fn tick_remote_level(&mut self) -> Result<()> { self.ticks += 1; + // Hud.tick ends with ChatComponent.tick. + self.ui.chat.tick(&self.gui.atlas, self.ticks); // AbstractSignEditScreen.tick: the editor closes once its sign is removed. if let Some(edit) = &self.sign_edit && !matches!( diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index a27ca12007..fe36f2345c 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -598,6 +598,11 @@ packet!(PlayerChat { filter: (i32, Vec) => FilterMask, chat_type: ChatTypeBound => ChatTypeBound, }); +packet!( + /// ClientboundDeleteChatPacket: the server deleted a player message, + /// named by its packed signature. + DeleteChat { signature: (i32, Option>) => PackedSignature } +); packet!(PlayerCombatKill { id: i32 => VarInt, message: Tag => Nbt }); packet!(PlayerInfoRemove { uuids: Vec => List }); /// One ClientboundPlayerInfoUpdatePacket entry; fields are set for the @@ -1842,6 +1847,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { Cooldown(Cooldown) = COOLDOWN, CustomPayload(CustomPayload) = CUSTOM_PAYLOAD, Disconnect(Disconnect) = DISCONNECT, + DeleteChat(DeleteChat) = DELETE_CHAT, DisguisedChat(DisguisedChat) = DISGUISED_CHAT, EntityEvent(EntityEvent) = ENTITY_EVENT, EntityPositionSync(EntityPositionSync) = ENTITY_POSITION_SYNC, @@ -2693,6 +2699,12 @@ mod tests { show_progress: true, can_restock: true, }), + Clientbound::DeleteChat(DeleteChat { + signature: (4, None), + }), + Clientbound::DeleteChat(DeleteChat { + signature: (-1, Some(vec![9; 256])), + }), Clientbound::PlayerChat(PlayerChat { global_index: 0, sender: 5, @@ -2889,6 +2901,27 @@ mod tests { } } + #[test] + fn delete_chat_decodes_a_packed_signature() { + use crate::ids::play::clientbound::DELETE_CHAT; + // MessageSignature.Packed: id + 1, so 5 names cache slot 4... + assert_eq!( + Clientbound::decode(DELETE_CHAT, &[5]).unwrap(), + Clientbound::DeleteChat(DeleteChat { + signature: (4, None) + }) + ); + // ...and 0 carries the full 256-byte signature. + let mut full = vec![0]; + full.extend([3; 256]); + assert_eq!( + Clientbound::decode(DELETE_CHAT, &full).unwrap(), + Clientbound::DeleteChat(DeleteChat { + signature: (-1, Some(vec![3; 256])) + }) + ); + } + #[test] fn serverbound_round_trips() { let packets = vec![ diff --git a/crates/horizon-protocol/src/secure_chat.rs b/crates/horizon-protocol/src/secure_chat.rs index 534c132f64..1a079aab64 100644 --- a/crates/horizon-protocol/src/secure_chat.rs +++ b/crates/horizon-protocol/src/secure_chat.rs @@ -357,6 +357,16 @@ impl SignatureCache { None => (-1, Some(signature.to_vec())), } } + /// `MessageSignature.Packed.unpack`: a full signature as it is, an + /// index as the cache holds it; `None` for an empty or unknown slot. + pub fn unpack(&self, packed: &(i32, Option>)) -> Option { + match packed { + (_, Some(full)) => signature(full), + (id, None) => usize::try_from(*id) + .ok() + .and_then(|i| self.entries.get(i).copied().flatten()), + } + } /// push(body, signature): the message's last seen and its signature go /// to the front, most recent first; what they displace moves back. pub fn push(&mut self, last_seen: &[Signature], signature: Option<&Signature>) { @@ -473,6 +483,20 @@ mod tests { assert!(v.apply_update(1, ack, 99).is_err(), "checksum"); } + #[test] + fn packed_signatures_resolve_against_the_cache() { + let mut c = SignatureCache::default(); + assert_eq!(c.unpack(&(-1, Some(sig(4).to_vec()))), Some(sig(4))); + assert_eq!(c.unpack(&(0, None)), None, "an empty slot"); + c.push(&[sig(1)], Some(&sig(2))); + assert_eq!(c.unpack(&(0, None)), Some(sig(2))); + assert_eq!(c.unpack(&(1, None)), Some(sig(1))); + assert_eq!(c.unpack(&(2, None)), None); + assert_eq!(c.unpack(&(128, None)), None, "past the cache"); + // What the server packs, the client unpacks to the same signature. + assert_eq!(c.unpack(&c.pack(&sig(1))), Some(sig(1))); + } + #[test] fn cache_moves_recent_signatures_first() { let mut c = SignatureCache::default(); diff --git a/tools/port_map/client-002.tsv b/tools/port_map/client-002.tsv index 667ffbd0a9..f4b823cfd0 100644 --- a/tools/port_map/client-002.tsv +++ b/tools/port_map/client-002.tsv @@ -20,9 +20,9 @@ ClientPacketListener.queueLightRemoval inline crates/horizon-client-render/src/s ClientPacketListener.handleBlockUpdate port crates/horizon-client/src/game/remote.rs::Game::server_block ClientPacketListener.handleConfigurationStart port crates/horizon-client/src/net.rs::receive_play gap: player: no reconfiguring screen is shown and the chat state is not stored ClientPacketListener.handleSystemChat port crates/horizon-client/src/game/remote.rs::Game::handle_packet SystemChat arm: overlay to the action bar, otherwise the chat -ClientPacketListener.handlePlayerChat port crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerChat arm; gap: minor: no signature, chat index or validity checks +ClientPacketListener.handlePlayerChat port crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerChat arm; gap: minor: no chat index or validity checks ClientPacketListener.handleDisguisedChat port crates/horizon-client/src/game/remote.rs::Game::handle_packet DisguisedChat arm -ClientPacketListener.handleDeleteChat missing player: a chat line the server deletes (moderation) stays on screen +ClientPacketListener.handleDeleteChat port crates/horizon-client/src/game/remote.rs::Game::handle_delete_chat gap: minor: no LastSeenMessagesTracker.ignorePending, the client signs no chat ClientPacketListener.handleAnimate port crates/horizon-client/src/game/remote.rs::Game::remote_animate action 0 stops sleeping, 1 and 2 emit crit and enchanted hit particles ClientPacketListener.handleHurtAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet HurtAnimation arm: own player's hurt tilt, or a mob's hurt time ClientPacketListener.handleSetSpawn port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetDefaultSpawnPosition arm; gap: minor: only an overworld spawn is kept, other dimensions are dropped diff --git a/tools/port_map/client-003.tsv b/tools/port_map/client-003.tsv index ad6f5cb97a..e00d2d8064 100644 --- a/tools/port_map/client-003.tsv +++ b/tools/port_map/client-003.tsv @@ -23,7 +23,7 @@ ChatAbilities.Builder.build inline crates/horizon-client-render/src/chat.rs::Cha ChatListener.tick missing player: the Chat Delay option does nothing; lines show at once instead of after the delay ChatListener.setMessageDelay missing player: no delayed-message queue, so Chat Delay has no effect ChatListener.flushQueue missing player: no queue to flush (part of Chat Delay) -ChatListener.removeFromDelayedMessageQueue missing minor: only matters while a delayed message is queued +ChatListener.removeFromDelayedMessageQueue inline crates/horizon-client/src/game/remote.rs::Game::handle_delete_chat ChatListener.handleMessage missing player: nothing is held back, so Chat Delay is ignored ChatListener.handlePlayerChatMessage port crates/horizon-client/src/game/remote.rs::Game::handle_packet gap: player: no Only Show Secure Chat filter, no server filter-mask text, no block or friend-only hiding of chat lines ChatListener.handleChatMessageError missing player: a signed chat line that fails validation shows no red chat.validation_error line; Horizon checks no chat signatures (rare on most servers) diff --git a/tools/port_map/client2-010.tsv b/tools/port_map/client2-010.tsv index 4ea6acdd7a..cf790bebf1 100644 --- a/tools/port_map/client2-010.tsv +++ b/tools/port_map/client2-010.tsv @@ -4,9 +4,9 @@ Button.Builder.build skip:java builder: makes a Plain button and sets its toolt ChatComponent.forEachLine port crates/horizon-client-render/src/chat.rs::Chat::layout visible lines bottom-up with the alpha filter, inlined in layout ChatComponent.addMessageToDisplayQueue port crates/horizon-client-render/src/chat.rs::Chat::add_lines wrap at width/scale, keep the scroll when chatting, trim to 100 lines ChatComponent.addMessageToQueue port crates/horizon-client-render/src/chat.rs::Chat::add_message newest first, trimmed to 100 messages -ChatComponent.processMessageDeletionQueue missing player: a message the server deletes stays in chat; the delete-chat packet is only named in horizon-protocol/src/ids.rs and never decoded -ChatComponent.deleteMessage missing player: a server message delete does nothing in Horizon; the removed line stays visible -ChatComponent.deleteMessageOrDelay missing player: no 60-tick delayed removal and no deleted marker; see deleteMessage +ChatComponent.processMessageDeletionQueue port crates/horizon-client-render/src/chat.rs::Chat::process_deletions +ChatComponent.deleteMessage port crates/horizon-client-render/src/chat.rs::Chat::delete_message +ChatComponent.deleteMessageOrDelay port crates/horizon-client-render/src/chat.rs::Chat::delete_or_delay ChatComponent.refreshTrimmedMessages port crates/horizon-client-render/src/chat.rs::Chat::rewrap gap: minor: rewrap also resets the scroll to 0; vanilla keeps the position ChatComponent.scrollChat port crates/horizon-client-render/src/chat.rs::Chat::scroll_by clamp to the page and clear new-message marker at the bottom ChatComponent.getHeight port crates/horizon-client-render/src/chat.rs::ChatOptions::height_px focused or unfocused chat height in px diff --git a/tools/port_map/rest-011.tsv b/tools/port_map/rest-011.tsv index fa3c927128..559856dabf 100644 --- a/tools/port_map/rest-011.tsv +++ b/tools/port_map/rest-011.tsv @@ -49,7 +49,7 @@ HoverEvent.EntityTooltipInfo.getTooltipLines missing player: hovering a player HoverEvent.EntityTooltipInfo.equals skip:java value equality HoverEvent.EntityTooltipInfo.hashCode skip:java hashing LastSeenMessages.updateSignature inline crates/horizon-protocol/src/secure_chat.rs::signed_payload the last-seen list written into the signed payload -LastSeenMessages.Packed.unpack missing minor: the client does not unpack the packed last-seen list of other players' messages +LastSeenMessages.Packed.unpack inline crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerChat arm LastSeenMessages.Update.verifyChecksum inline crates/horizon-protocol/src/secure_chat.rs::LastSeenValidator::apply_update the checksum check; 0 means not checked LastSeenMessagesTracker.addPending missing minor: the client-side last-seen tracker is absent; Horizon's client sends an empty last-seen update LastSeenMessagesTracker.addEntry missing minor: part of the client-side last-seen tracker, which is absent @@ -58,7 +58,7 @@ LastSeenMessagesTracker.generateAndApplyUpdate missing minor: the client never LastSeenTrackedEntry.acknowledge missing minor: part of the client-side last-seen tracker, which is absent LocalChatSession.createMessageEncoder missing player: Horizon's client never signs chat (no chat key); on a server that enforces secure chat, its messages are refused MessageSignature.equals skip:java value equality -MessageSignature.Packed.unpack missing minor: the client does not unpack packed signatures from other players' messages +MessageSignature.Packed.unpack port crates/horizon-protocol/src/secure_chat.rs::SignatureCache::unpack MessageSignatureCache.push port crates/horizon-protocol/src/secure_chat.rs::SignatureCache::push MutableComponent.withColor port crates/horizon-client-core/src/text.rs::Text::with_color MutableComponent.getVisualOrderText skip:ui bidi reordering for drawing; Horizon lays text out in logical order @@ -78,7 +78,7 @@ RemoteChatSession.createMessageValidator port crates/horizon-protocol/src/secure ResolutionContext.Builder.build skip:java builder SignableCommand.getArgument port crates/horizon-client/src/lan/hub/chat.rs::Hub::signed_arguments the command's message arguments, looked up by name SignedMessageBody.updateSignature inline crates/horizon-protocol/src/secure_chat.rs::signed_payload salt, time, content and last seen in the signed payload -SignedMessageBody.Packed.unpack missing minor: the client does not unpack packed bodies of other players' messages +SignedMessageBody.Packed.unpack inline crates/horizon-client/src/game/remote.rs::Game::handle_packet PlayerChat arm: the last seen only, the body is not kept SignedMessageChain.encoder missing player: client-side signing; Horizon's client never signs, so chat is refused on servers that enforce secure chat SignedMessageChain.Decoder.unsigned port crates/horizon-protocol/src/secure_chat.rs::Chain::unpack no session: unsigned, or refused when the server enforces secure chat SignedMessageLink.updateSignature inline crates/horizon-protocol/src/secure_chat.rs::signed_payload sender, session and index in the signed payload From 9bdb5ec7fd91323ea5363dd730fc421720a2b261 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:18:48 +0700 Subject: [PATCH 277/968] List worlds with an unreadable level.dat and offer recovery A world whose level.dat could not be read (cut short by a crash during a save, say) was left out of Select World, although level.dat_old would load it, so the player believed the world was gone. list_worlds now follows LevelStorageSource.findLevelCandidates and readLevelSummary: any folder with a level.dat or level.dat_old is a world, and when level.dat does not read it is listed as a CorruptedLevelSummary (the folder name, red "Failed to load world summary", "Attempt to Recover"; Edit and Re-Create off). Opening it, like any world whose level.dat does not read (WorldOpenFlows.openWorldLoadLevelData), shows RecoverWorldDataScreen with the state of level.dat and level.dat_old (collectIssue/buildInfo); Attempt to Restore runs restoreLevelDataFromOld (the broken file kept as level.dat_corrupted_"), + [0xff, 0xfe, b'<', 0, b'a', 0, b'>', 0] + ); + } + + /// Creates and deletes a real task as this user, unelevated, as the launcher's toggle does. + /// By hand on Windows: `cargo test -p horizon-server -- --ignored windows_task`. + #[cfg(windows)] + #[test] + #[ignore = "creates a scheduled task on this computer"] + fn windows_task_installs_without_elevation() { + let name = format!("horizon-server-test-{}", std::process::id()); + let task = windows_task_xml( + &windows_user().unwrap(), + Path::new(r"C:\Windows\System32\cmd.exe"), + &std::env::temp_dir(), + ); + let file = std::env::temp_dir().join(format!("{name}.xml")); + std::fs::write(&file, utf16_with_bom(&task)).unwrap(); + let created = run( + "schtasks", + &[ + "/Create", + "/TN", + &name, + "/XML", + &file.display().to_string(), + "/F", + ], + ); + std::fs::remove_file(&file).unwrap(); + created.unwrap(); + run("schtasks", &["/Delete", "/TN", &name, "/F"]).unwrap(); + } + #[cfg(unix)] #[test] fn an_installed_release_runs_through_current() { From 2d41c9d4327c102d3725014c119db4def47aed47 Mon Sep 17 00:00:00 2001 From: hhamaj Date: Fri, 30 Oct 2026 09:51:13 +0300 Subject: [PATCH 281/968] A pearl turns the wind charge it hits, which then speeds up Projectile.onHit calls onRedirectProjectile on a redirectable target: AIM_DEFLECT along the thrower's look, the thrower its owner, acceleration power 0.1. #3599 made the pearl teleport but left the charge as it was. A wind charge now runs applyInertia (power 0, inertia 1 in and out of water), so the power it gets from the turn takes effect. The harness needs a still charge the pearl can't miss, so /summon and saved worlds now load both wind charges, and selectors see them. Fixes #3299 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity_nbt.rs | 16 +++++ crates/horizon-client/src/game/commands.rs | 9 ++- crates/horizon-client/src/game/creatures.rs | 32 +++++++-- crates/horizon-client/src/game/deflect.rs | 73 +++++++++++++++++++++ crates/horizon-client/src/game/persist.rs | 7 +- crates/horizon-client/src/projectiles.rs | 30 ++++++++- tools/test_pearl_wind_charge_parity.py | 66 +++++++++++++++++++ 7 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 tools/test_pearl_wind_charge_parity.py diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 2106b6f594..089fb44508 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1986,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option ProjectileKind::SmallFireball, + "minecraft:wind_charge" => ProjectileKind::WindCharge, + "minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge, _ => return None, }; let owner = uuid_from_tag(tag.get("Owner")); @@ -3419,6 +3421,20 @@ mod fireball_nbt_tests { (p.kind, p.acceleration), (ProjectileKind::LargeFireball { power: 1 }, 0.1) ); + // `/summon` and a saved world give back both wind charges. + for (id, kind) in [ + ("minecraft:wind_charge", ProjectileKind::WindCharge), + ( + "minecraft:breeze_wind_charge", + ProjectileKind::BreezeWindCharge, + ), + ] { + let tag = Tag::compound([ + ("id", Tag::String(id.into())), + ("Pos", doubles(&[0.0, 0.0, 0.0])), + ]); + assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind); + } } } diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index cea056268c..2ce37b87d0 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -689,7 +689,14 @@ impl Game<'_> { .iter() // The kinds /data can write (entity_nbt). .filter(|p| { - p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball) + p.is_arrow() + || matches!( + p.kind, + K::LargeFireball { .. } + | K::SmallFireball + | K::WindCharge + | K::BreezeWindCharge + ) }) .map(|p| Target::Projectile(p.id)), ); diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 5ae2fda81e..d73aa2deaf 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -130,21 +130,35 @@ impl ProjectileWorld for ProjWorld<'_, '_> { /// `AbstractWindCharge` is pickable, so a thrown pearl's /// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`). -const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1; +/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below +/// the dragon's reserved ids. +const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000; -fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> { +/// The index of the wind charge a `wind_charge_hitboxes` id stands for. +fn wind_charge_hit(id: u32) -> Option { + (WIND_CHARGE_IDS..super::dragon::SPECIAL) + .contains(&id) + .then(|| (id - WIND_CHARGE_IDS) as usize) +} + +fn wind_charge_hitboxes( + projectiles: &[Projectile], + alive: &[bool], + skip: usize, +) -> Vec<(Who, Aabb)> { projectiles .iter() .enumerate() .filter(|(j, q)| { *j != skip + && alive[*j] && !q.stuck && matches!( q.kind, ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge ) }) - .map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb())) + .map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb())) .collect() } @@ -2643,7 +2657,7 @@ impl Game<'_> { let mut keep_flags = vec![true; projectiles.len()]; for i in 0..projectiles.len() { let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) { - wind_charge_hitboxes(&projectiles, i) + wind_charge_hitboxes(&projectiles, &keep_flags, i) } else { Vec::new() }; @@ -2752,6 +2766,16 @@ impl Game<'_> { hits.push((p.clone(), hit)); } keep_flags[i] = keep; + // Projectile.onHit: the wind charge it hit turns before its own tick. + if let Hit::Entity { + who: Who::Mob(id), .. + } = hit + && let Some(j) = wind_charge_hit(id) + { + let (owner, guest) = (p.owner, p.guest); + let look = self.owner_look(owner, guest); + super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest); + } } let mut idx = 0; projectiles.retain(|_| { diff --git a/crates/horizon-client/src/game/deflect.rs b/crates/horizon-client/src/game/deflect.rs index 2d1f728a28..68db7ae4bf 100644 --- a/crates/horizon-client/src/game/deflect.rs +++ b/crates/horizon-client/src/game/deflect.rs @@ -25,6 +25,26 @@ pub(super) fn redirectable(p: &Projectile) -> bool { ) } +/// `Projectile.onRedirectProjectile`: another projectile hit `p`, which turns +/// with `deflect(AIM_DEFLECT, hitter's owner, hitter's owner, true, 1.0)`. +/// It flies along the owner's look (`Entity.getLookAngle`; unchanged when the +/// owner is gone), is that owner's now, and its acceleration power is back +/// to 0.1 (`AbstractHurtingProjectile.onDeflection` by an attack). +pub(super) fn aim_deflect( + p: &mut Projectile, + look: Option, + owner: Option, + guest: Option, +) { + if let Some(look) = look { + p.velocity = look; + p.needs_sync = true; + } + p.owner = owner; + p.guest = guest; + p.acceleration = 0.1; +} + /// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge /// 0.3125, from its feet up) grown by the pick radius. fn pick_box(p: &Projectile) -> Aabb { @@ -106,6 +126,27 @@ impl Game<'_> { d2 < range * range } + /// `Entity.getLookAngle` of a projectile's owner: the host, the guest who + /// threw it, or a mob; none when the owner is not here. + pub(super) fn owner_look(&self, owner: Option, guest: Option) -> Option { + let (pitch, yaw) = match (owner?, guest) { + (Who::Player, Some(conn)) => { + let lan = self.lan.as_ref()?; + let g = lan.hub.guests.iter().find(|g| g.conn == conn)?; + (g.pitch, g.yaw) + } + (Who::Player, None) => { + self.player.as_ref()?; + (self.pitch, self.yaw) + } + (Who::Mob(id), _) => { + let m = self.mobs.iter().find(|m| m.id == id)?; + (m.head_pitch, m.yaw) + } + }; + Some(horizon_world::view_direction::view_vector(pitch, yaw)) + } + /// `Projectile.deflect(AIM_DEFLECT, player, player, true)`. pub(super) fn deflect(&mut self, i: usize) { let dir = view_vector(self.yaw, self.pitch); @@ -156,4 +197,36 @@ mod tests { assert_eq!(b.min_position().x, -1.5); assert_eq!(b.max_position().y, 66.0); } + + fn wind_charge() -> Projectile { + let mut p = Projectile::new( + ProjectileKind::WindCharge, + Vec3::new(0.0, 70.0, 0.0), + Some(Who::Mob(7)), + ); + p.velocity = Vec3::new(0.0, 1.5, 0.0); + p + } + + #[test] + fn a_redirected_wind_charge_flies_along_the_new_owners_look() { + let mut p = wind_charge(); + let look = Vec3::new(0.0, -1.0, 0.0); + aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3)); + assert_eq!(p.velocity, look); + assert_eq!(p.owner, Some(Who::Player)); + assert_eq!(p.guest, Some(3)); + assert_eq!(p.acceleration, 0.1); + assert!(p.needs_sync); + } + + #[test] + fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() { + let mut p = wind_charge(); + aim_deflect(&mut p, None, None, None); + assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0)); + assert_eq!(p.owner, None); + assert_eq!(p.acceleration, 0.1); + assert!(!p.needs_sync); + } } diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index b41419718c..88808facea 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -255,6 +255,8 @@ impl Game<'_> { | "minecraft:arrow" | "minecraft:fireball" | "minecraft:small_fireball" + | "minecraft:wind_charge" + | "minecraft:breeze_wind_charge" | "minecraft:falling_block" | "minecraft:end_crystal" | "minecraft:ender_dragon" @@ -294,7 +296,10 @@ impl Game<'_> { self.add_projectile(p); } } - "minecraft:fireball" | "minecraft:small_fireball" => { + "minecraft:fireball" + | "minecraft:small_fireball" + | "minecraft:wind_charge" + | "minecraft:breeze_wind_charge" => { if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) { self.add_projectile(p); } diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index f9c01a92b3..e0e7973505 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -178,6 +178,15 @@ fn axis_of(d: u8) -> u8 { impl Projectile { pub fn new(kind: ProjectileKind, position: Vec3, owner: Option) -> Self { let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. } | ProjectileKind::Trident); + // `AbstractWindCharge`'s constructors set it to 0. + let acceleration = if matches!( + kind, + ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge + ) { + 0.0 + } else { + 0.1 + }; Self { id: 0, kind, @@ -192,7 +201,7 @@ impl Projectile { stuck: false, left_owner: false, bonus: Default::default(), - acceleration: 0.1, + acceleration, needs_sync: false, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. @@ -649,6 +658,8 @@ impl Projectile { // WitherSkull.getInertia: 0.73 for dangerous skulls. let inertia = match self.kind { ProjectileKind::WitherSkull { dangerous: true } => 0.73, + // AbstractWindCharge.getInertia and getLiquidInertia. + ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => 1.0, _ if water => 0.8, _ => 0.95, }; @@ -672,12 +683,13 @@ impl Projectile { match self.kind { ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } - | ProjectileKind::WitherSkull { .. } => { + | ProjectileKind::WitherSkull { .. } + | ProjectileKind::WindCharge + | ProjectileKind::BreezeWindCharge => { self.apply_hurting_inertia(water); } // Arrows and tridents tick with `arrows::tick`. ProjectileKind::Arrow { .. } | ProjectileKind::Trident => {} - ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => {} // LlamaSpit.tick: gone in water; drag and gravity follow the move. ProjectileKind::LlamaSpit => { if water { @@ -914,6 +926,18 @@ mod tests { assert!((p.position.x - 0.285).abs() < 1e-9); } + #[test] + fn a_wind_charge_holds_its_speed_until_a_deflection_gives_it_power() { + let mut p = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None); + p.velocity = Vec3::new(0.0, 1.5, 0.0); + p.apply_hurting_inertia(false); + assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0)); + // Inertia 1 in water too, and the power adds along the movement. + p.acceleration = 0.1; + p.apply_hurting_inertia(true); + assert!((p.velocity.y - 1.6).abs() < 1e-12, "{p:?}"); + } + #[test] fn ender_pearl_hits_a_wind_charge_box() { let mut pearl = Projectile::new( diff --git a/tools/test_pearl_wind_charge_parity.py b/tools/test_pearl_wind_charge_parity.py new file mode 100644 index 0000000000..e6fc6c917a --- /dev/null +++ b/tools/test_pearl_wind_charge_parity.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""An ender pearl that meets a wind charge, on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world +vanilla generated first, with Alice an operator in survival. A still wind charge is summoned above her and +LocatorBarClient (a player made of vanilla's protocol code) throws an ender pearl straight up +(ServerboundUseItemPacket) into it: the charge is `#redirectable_projectile`, so `Projectile.isPickable`, and +`ThrownEnderpearl.onHit` teleports her in the air. `Projectile.onHit` also turns the charge along her look +(`onRedirectProjectile`, AIM_DEFLECT) and gives it acceleration power 0.1 (`AbstractHurtingProjectile.onDeflection`), +which `applyInertia` adds each tick: a second later it is 35 to 47 blocks up, past where a speed of 1 would take it. + +The pearl's inaccuracy is random, so only the height of the teleport, to the block, is compared, with vanilla's +answer to whether the charge is in that band. +""" +import sys + +try: + from . import test_locator_bar_parity as locator +except ImportError: + import test_locator_bar_parity as locator + +NAME = 'pearl-wind-charge-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival') +SCRIPT = [ + ('setup', ['chat on', 'cmd Alice give @s minecraft:ender_pearl', + 'cmd Alice execute at @s run summon minecraft:wind_charge ~ ~4.5 ~']), + ('throw', ['use Alice -90']), + ('asked', ['cmd Alice execute if entity @e[type=minecraft:wind_charge,x=-8,y=-30,z=-8,dx=16,dy=40,dz=16]']), +] + + +def shown(lines): + """The height of Alice's teleports from the throw on, and the answer about the charge.""" + out = [] + for line in lines: + parts = line.split(' ', 3) + if len(parts) < 3 or parts[1] == 'step=setup': + continue + if parts[2] == 'teleport' and parts[1] != 'step=joins': + out.append(f'{parts[1]} teleport y={round(float(parts[3].split()[1]))}') + elif parts[1] == 'step=asked' and parts[2] == 'chat': + out.append(f'{parts[1]} {parts[3]}') + return out + + +def check(vanilla, vanilla_all): + if not any(' teleport y=' in line and int(line.rsplit('=', 1)[1]) > -59 for line in vanilla): + return 'vanilla did not teleport Alice in the air: the pearl missed the wind charge' + if not any('commands.execute.conditional.pass' in line or 'passed' in line for line in vanilla): + return 'the wind charge was not where a turned, accelerating one flies' + return None + + +def main(): + lines = locator.one_player(NAME, SCRIPT, shown, check, 'An ender pearl thrown straight up into a still wind ' + 'charge on a dedicated server: it takes the thrower to where they met, and the ' + 'charge turns along their look and speeds up.', step_seconds=1.0) + print(f'pearl wind charge: {len(lines)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From 558a9a13e95f895934aa031c2efe7e297a2927fc Mon Sep 17 00:00:00 2001 From: bloodulon Date: Fri, 30 Oct 2026 11:55:42 +0500 Subject: [PATCH 282/968] Draw the click boxes of server interaction entities Servers (Denizen, Nexo, ModelEngine) spawn minecraft:interaction entities: invisible boxes with a width, height and response flag that a script answers. The client knew nothing of them, so clicks aimed at the box hit the block behind or nothing, and the map's NPC models could not be clicked. A spawned interaction becomes a RemoteInteraction (SetEntityData ids 8, 9 and 10), pickable for use and attack from its feet up its height, and its teleports replace its position at once (Entity's no-op InterpolationHandler). The click ends at the box: SUCCESS when it responds, and the arm swings; CONSUME otherwise. Either way the Interact packet carries the hand, nothing behind the box is used, and pick-empty leaves no entity to pick. Co-Authored-By: Qoder --- crates/horizon-client/src/game.rs | 3 + crates/horizon-client/src/game/hand.rs | 1 + .../horizon-client/src/game/interactions.rs | 111 ++++++++++++++++++ .../horizon-client/src/game/lan_vehicles.rs | 6 +- crates/horizon-client/src/game/pick_entity.rs | 4 +- crates/horizon-client/src/game/remote.rs | 22 +++- .../horizon-client/src/game/remote_objects.rs | 16 +++ .../src/game/remote_vehicles.rs | 9 ++ tools/port_map/game-006.tsv | 16 +-- 9 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 crates/horizon-client/src/game/interactions.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index c782c568fc..23e491db96 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -117,6 +117,7 @@ mod growth; mod hand; mod initial_spawn; mod integrated; +mod interactions; mod item_draw; mod item_particles; mod jukebox; @@ -1901,6 +1902,7 @@ struct Game<'a> { /// Map textures waiting for the next HUD batch to upload them. map_patches: Vec, text_displays: std::collections::HashMap, + interactions: std::collections::HashMap, /// The fence knots a server tracks to this client, by entity id, at their /// block (`LeashFenceKnotEntity`). remote_knots: HashMap, @@ -14693,6 +14695,7 @@ impl<'a> Game<'a> { dragon_model: prepared.dragon_model, map_patches: Vec::new(), text_displays: Default::default(), + interactions: Default::default(), remote_knots: Default::default(), knot_ids: Default::default(), text_display_vertices: 0, diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index 0e3c2da384..696fec6a9c 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -199,6 +199,7 @@ impl Game<'_> { || before(self.cushion_hit().map(|h| h.1)) || before(self.boat_hit().map(|h| h.1)) || before(self.cart_hit().map(|h| h.1)) + || before(self.interaction_hit().map(|h| h.1)) || before(self.dragon_hit().map(|h| h.1)) || before(self.mob_hit().map(|h| h.1)) || before(self.guest_hit().map(|h| h.1)) diff --git a/crates/horizon-client/src/game/interactions.rs b/crates/horizon-client/src/game/interactions.rs new file mode 100644 index 0000000000..890ab94156 --- /dev/null +++ b/crates/horizon-client/src/game/interactions.rs @@ -0,0 +1,111 @@ +//! Server `minecraft:interaction` entities (`Interaction`): invisible boxes a +//! server (Denizen, Nexo) uses for clickable areas. They are pickable, render +//! nothing, and an interact ends at them: `SUCCESS` when they respond (the arm +//! swings), `CONSUME` otherwise (the click still goes to the server, nothing +//! behind them is used). +use super::{Game, ray_box}; +use horizon_core::geometry::{Aabb, Vec3}; +use horizon_protocol::entity_data::Value; + +/// `Interaction`'s synced data and box: `DATA_WIDTH` (8), `DATA_HEIGHT` (9) +/// and `DATA_RESPONSE` (10), from its `defineSynchedData`. +pub(super) struct RemoteInteraction { + pub position: Vec3, + pub width: f32, + pub height: f32, + pub response: bool, +} + +impl RemoteInteraction { + pub fn new(position: [f64; 3]) -> Self { + Self { + position: Vec3::new(position[0], position[1], position[2]), + width: 1.0, + height: 1.0, + response: false, + } + } + /// `Interaction.refreshDimensions`: `width` square about the entity's + /// x and z, from its feet up `height` (its position is the box's bottom + /// centre, not the model's). + pub fn bounds(&self) -> Aabb { + let half = f64::from(self.width) / 2.0; + let p = self.position; + Aabb::new( + Vec3::new(p.x - half, p.y, p.z - half), + Vec3::new( + p.x + half, + p.y + f64::from(self.height.max(0.0)), + p.z + half, + ), + ) + } + pub fn metadata(&mut self, values: &[(u8, i32, Value)]) { + for (index, _, value) in values { + match (index, value) { + (8, Value::Float(width)) => self.width = *width, + (9, Value::Float(height)) => self.height = *height, + (10, Value::Bool(response)) => self.response = *response, + _ => {} + } + } + } +} + +impl Game<'_> { + /// `Interaction` under the crosshair within the entity reach, as its id + /// and the hit distance along the view. + pub(super) fn interaction_hit(&self) -> Option<(i32, f64)> { + let (eye, dir) = self.aim()?; + let reach = if self.creative { 5.0 } else { 3.0 }; + self.interactions + .iter() + .filter_map(|(&id, i)| ray_box(eye, dir, reach, i.bounds()).map(|d| (id, d))) + .min_by(|a, b| a.1.total_cmp(&b.1)) + } + /// An `Interaction` keeps `Entity`'s no-op interpolation handler: the + /// server's position replaces its own at once. + pub(super) fn move_remote_interaction(&mut self, id: i32, position: [f64; 3]) { + if let Some(i) = self.interactions.get_mut(&id) { + i.position = Vec3::new(position[0], position[1], position[2]); + } + } + pub(super) fn remote_interaction_data(&mut self, id: i32, values: &[(u8, i32, Value)]) -> bool { + let Some(i) = self.interactions.get_mut(&id) else { + return false; + }; + i.metadata(values); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_box_is_centred_on_the_feet_and_grows_with_the_data() { + let mut i = RemoteInteraction::new([10.0, 64.0, -3.0]); + let b = i.bounds(); + assert_eq!( + (b.min_position().components(), b.max_position().components()), + ([9.5, 64.0, -3.5], [10.5, 65.0, -2.5]) + ); + i.metadata(&[ + (8, 0, Value::Float(2.2)), + (9, 0, Value::Float(2.2)), + (10, 0, Value::Bool(true)), + ]); + let b = i.bounds(); + let half = f64::from(2.2f32) / 2.0; + let height = f64::from(2.2f32); + let (lo, hi) = (b.min_position().components(), b.max_position().components()); + for (got, want) in lo.iter().zip([10.0 - half, 64.0, -3.0 - half]) { + assert!((got - want).abs() < 1e-6, "{lo:?}"); + } + for (got, want) in hi.iter().zip([10.0 + half, 64.0 + height, -3.0 + half]) { + assert!((got - want).abs() < 1e-6, "{hi:?}"); + } + assert!(i.response); + } +} diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index 45ec936241..469eb573e9 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -41,9 +41,13 @@ impl Game<'_> { /// the point an interact packet's location is measured from (the /// client's `remote_object_hit` uses the same points). pub(super) fn object_target(&self, id: i32) -> Option<([f64; 3], [f64; 3], [f64; 3])> { - let id = u32::try_from(id).ok()?; let at = |v: Vec3| [v.x, v.y, v.z]; let centre = |p: [i32; 3]| p.map(|v| f64::from(v) + 0.5); + if let Some(i) = self.interactions.get(&id) { + let (lo, hi) = corners(i.bounds()); + return Some((lo, hi, at(i.position))); + } + let id = u32::try_from(id).ok()?; if let Some(b) = self.boats.iter().find(|b| b.id == id) { let (lo, hi) = corners(b.bounds()); return Some((lo, hi, at(b.position))); diff --git a/crates/horizon-client/src/game/pick_entity.rs b/crates/horizon-client/src/game/pick_entity.rs index 2537da96a3..fa33e53c96 100644 --- a/crates/horizon-client/src/game/pick_entity.rs +++ b/crates/horizon-client/src/game/pick_entity.rs @@ -16,7 +16,9 @@ impl Game<'_> { } }; if let Some(hit) = self.remote_object_hit(true) { - offer(hit.distance, Some(hit.id)); + // Interaction.getPickResult is Entity's: nothing to pick. + let id = (!self.interactions.contains_key(&hit.id)).then_some(hit.id); + offer(hit.distance, id); } if let Some((i, d)) = self.mob_hit() { offer(d, Some(self.mobs[i].id as i32)); diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 1163aa2370..f53b356a39 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1680,6 +1680,14 @@ impl Game<'_> { m.on_ground = on_ground; return; } + // Interaction extends Entity and keeps its NO_OP interpolation + // handler: the server's position replaces its own at once. + if let Some(i) = self.interactions.get_mut(&id) { + if path.is_some() { + i.position = Vec3::new(position[0], position[1], position[2]); + } + return; + } // Projectiles keep Entity's NO_OP InterpolationHandler too: the // server's position replaces theirs at once and their own tick // flies them on (`tick_remote_projectiles`). Eased towards it @@ -2193,6 +2201,7 @@ impl Game<'_> { ); } self.move_remote_text_display(p.id, p.position, Some((rotation[0], rotation[1]))); + self.move_remote_interaction(p.id, p.position); } C::RemoveEntities(p) => { for id in p.ids { @@ -5176,6 +5185,10 @@ impl Game<'_> { && self.mob_hit().is_none_or(|(_, d)| hit.distance < d) && self.before_block(hit.distance) { + // Interaction.interact answers SUCCESS when it responds and + // CONSUME otherwise: both end the click (nothing behind the box + // is used), but only SUCCESS swings; the packet carries the hand. + let response = self.interactions.get(&hit.id).map(|i| i.response); // A cart whose interact passes (a TNT minecart) leaves each hand's // turn to its item: a crossbow aimed at it shoots. let passes = self @@ -5189,7 +5202,7 @@ impl Game<'_> { .interact_passes(sneaking, ridden, self.can_use_game_master_blocks()) }); // Otherwise the main hand's turn takes it; there is no off hand turn. - if hand == Hand::Off && !passes { + if response.is_none() && hand == Hand::Off && !passes { return false; } self.send(S::Interact(play::Interact { @@ -5201,7 +5214,9 @@ impl Game<'_> { if passes { return false; } - self.swing_arm(); + if response.unwrap_or(true) { + self.swing_arm(); + } return true; } if let Some((i, distance)) = self.mob_hit() @@ -7329,8 +7344,7 @@ impl Remote { .iter() .find(|(_, u)| **u == uuid) .map(|(id, _)| *id as u32) - } -} + }} /// The textures to resolve skins from: every list entry's, and for a player /// entity whose entry is gone the profile it kept. `kept` first takes, for each diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index df2dbab004..8390706050 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -49,6 +49,12 @@ impl Game<'_> { ); } else if kind == "minecraft:ominous_item_spawner" { self.add_remote_ominous_item_spawner(id, position); + } else if kind == "minecraft:interaction" { + // The width, height and response follow in SetEntityData. + self.interactions.insert( + id as i32, + super::interactions::RemoteInteraction::new(position), + ); } else if let Some(cart) = super::minecarts::CartKind::from_id(kind) { let mut c = super::minecarts::Minecart::new(id, cart, at); c.yaw = yaw; @@ -142,6 +148,9 @@ impl Game<'_> { } return; } + if self.remote_interaction_data(id, values) { + return; + } let id = id as u32; for (index, _, value) in values { if let (8, Value::Item(stack)) = (index, value) @@ -346,6 +355,11 @@ impl Game<'_> { "yaw": c.yaw, "color": c.color_name()}), ); } + for (&id, i) in &self.interactions { + out.push(json!({"id": id, "kind": "minecraft:interaction", + "position": at(i.position), "width": i.width, "height": i.height, + "response": i.response})); + } for f in &self.item_frames { out.push( json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing, @@ -362,6 +376,7 @@ impl Game<'_> { /// Every object a server sent (a new connection, level or dimension). pub(super) fn clear_remote_objects(&mut self) { self.text_displays.clear(); + self.interactions.clear(); self.ominous_spawners.clear(); self.remote_knots.clear(); self.carts.clear(); @@ -374,6 +389,7 @@ impl Game<'_> { pub(super) fn remove_remote_object(&mut self, id: u32) { self.text_displays.remove(&(id as i32)); + self.interactions.remove(&(id as i32)); self.ominous_spawners.retain(|s| s.id != id); self.remote_knots.remove(&id); self.carts.retain(|c| c.id != id); diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 79e7b078f8..9c224d9d44 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -68,6 +68,15 @@ impl Game<'_> { let p = self.carts[i].position; hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z])); } + // Interaction.isPickable: an invisible box the server clicks through + // to its scripts, for both uses and attacks. + let reach = if self.creative { 5.0 } else { 3.0 }; + for (&id, i) in &self.interactions { + if let Some(d) = ray_box(eye, dir, reach, i.bounds()) { + let p = i.position; + hits.push((id, d, [p.x, p.y, p.z])); + } + } if attacking { // EndCrystal.isPickable: its 2 x 2 box, with the server's entity // id, not special_targets' local projectile target id. diff --git a/tools/port_map/game-006.tsv b/tools/port_map/game-006.tsv index d5bafb2e2b..dd594b54af 100644 --- a/tools/port_map/game-006.tsv +++ b/tools/port_map/game-006.tsv @@ -1,11 +1,11 @@ -Interaction.defineSynchedData missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.readAdditionalSaveData missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.addAdditionalSaveData missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.onSyncedDataUpdated missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.skipAttackInteraction missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.interact missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.getLastAttacker missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing -Interaction.getTarget missing minor: no interaction entity; /summon interaction (the invisible click hitbox map makers use) creates nothing +Interaction.defineSynchedData port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::new width 1.0, height 1.0, response false +Interaction.readAdditionalSaveData missing server side: Horizon's own server has no interaction entity, and a client never saves a remote one +Interaction.addAdditionalSaveData missing server side: Horizon's own server has no interaction entity, and a client never saves a remote one +Interaction.onSyncedDataUpdated port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::metadata DATA_WIDTH 8, DATA_HEIGHT 9, DATA_RESPONSE 10 +Interaction.skipAttackInteraction inline crates/horizon-client/src/game/remote_vehicles.rs::Game::remote_object_hit false: an attack click picks the box like any entity, and the box ignores the hurt on the server +Interaction.interact port crates/horizon-client/src/game/remote.rs::Game::remote_use_entity SUCCESS when the response is set (the arm swings), CONSUME otherwise; both end the click and nothing behind the box is used +Interaction.getLastAttacker missing client copy only: the server keeps the attack targets; a client's copy answers only the click +Interaction.getTarget missing client copy only: the server keeps the attack targets; a client's copy answers only the click Leashable.isLeashed inline crates/horizon-client/src/mobs/leash.rs::tick the mob's extra.leash being set Leashable.setDelayedLeashHolderId inline crates/horizon-client/src/game/remote.rs::Game::handle_packet SetEntityLink arm: a holder id not known yet waits as Holder::Mob until the knot arrives (remote_objects.rs) Leashable.readLeashData port crates/horizon-client/src/mobs/leash.rs::pending_from_nbt holder UUID or fence knot block; kept pending until the holder exists From aff1d9864f6e31d76c59eb0647c76512bfa0024c Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:59:23 +0700 Subject: [PATCH 283/968] The mount a rider's client moves ignores the server's moves of it ClientPacketListener.handleMoveEntity and handleEntityPositionSync leave an entity the client is authoritative for where it is; only handleTeleportEntity puts it somewhere else, at once. The steps the server's moves queued while the rider drove the horse replayed after the dismount: the horse jumped back about five blocks on a vanilla server. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 149efc05b0..aa5e62e71f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1624,6 +1624,7 @@ impl Game<'_> { // copy in the mob list draws nothing: given the move, it kept the // dragon where it spawned (#2083). let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id); + let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id); let mob = self .mobs .iter_mut() @@ -1647,6 +1648,13 @@ impl Game<'_> { if let Some(t) = r.entities.get_mut(&id) { t.position = position; } + if driven { + // handleMoveEntity, handleEntityPositionSync: the mount this + // client moves (isLocalInstanceAuthoritative) keeps its own + // position, and no steps pile up for after the ride. + r.stepped.remove(&id); + return; + } if let Some(m) = mob { // A living entity: LivingEntity.createInterpolationHandler's // SteppedInterpolationHandler, over its type's update interval. @@ -2291,6 +2299,11 @@ impl Game<'_> { p.on_ground, false, ); + // handleTeleportEntity: the mount this client moves is put + // there at once. + if self.driven_mount().is_some_and(|m| m.id as i32 == p.id) { + self.remote_move_vehicle(position, p.yaw); + } } C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers), // ClientPacketListener.handleEntityLinkPacket: who holds a mob's From 84ac601f3aab9aa4760ec09f738af8e98a5becae Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:39:35 +0700 Subject: [PATCH 284/968] Slimes and magma cubes hurt the iron golems they touch AbstractCubeMob.push(entity) calls dealDamage on an IronGolem whose pushEntities takes the cube, but Horizon only ported the playerTouch path, so cubes chased golems (they target them) without ever hurting them. The cube's ai_step now hits every alive iron golem whose box overlaps its own (EntitySelector.pushableBy, doTeamsAllowDamage, hasLineOfSight) for getAttackDamage with a mobAttack source, unless the cube cannot deal damage (a tiny slime, or a NoAI cube, which is not isEffectiveAi). A new CubeHurtMob event plays entity.slime.attack only when the hit landed, as dealDamage does after hurtServer; its two pitch draws from the cube's random happen even for a silent cube. Part of #3513. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/creatures.rs | 25 ++++ crates/horizon-client/src/mobs.rs | 9 ++ crates/horizon-client/src/mobs/cube.rs | 138 ++++++++++++++++++-- 3 files changed, 164 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 8b2dd65326..53ee93525f 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1361,6 +1361,31 @@ impl Game<'_> { } => { self.mob_hits_mob(target, amount, source); } + MobEvent::CubeHurtMob { + cube, + target, + amount, + source, + } => { + // AbstractCubeMob.dealDamage: the attack sound (and its + // two pitch draws, made even for a silent cube, whose + // Entity.playSound is then skipped) only follows a hit + // that landed. + if self.mob_hits_mob(target, amount, source) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube) + { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + let at = m.center(); + if !m.silent { + self.handle_mob_events(vec![MobEvent::Sound( + "entity.slime.attack", + at, + 1.0, + pitch, + )])?; + } + } + } MobEvent::SonicBoom { target, attacker, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index f79e6f5a92..3e03a66e49 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -1292,6 +1292,15 @@ pub enum MobEvent { amount: f32, source: Damage, }, + /// `AbstractCubeMob.dealDamage` on a mob (an iron golem it touches): the + /// hit, then, only when `hurtServer` let it land, the cube's + /// `entity.slime.attack` with its pitch drawn from the cube's random. + CubeHurtMob { + cube: u32, + target: u32, + amount: f32, + source: Damage, + }, /// `LivingEntity.knockback(power, x, z)`: pushed away from the direction. Knockback { target: Who, diff --git a/crates/horizon-client/src/mobs/cube.rs b/crates/horizon-client/src/mobs/cube.rs index 3cc16463ca..45c615729f 100644 --- a/crates/horizon-client/src/mobs/cube.rs +++ b/crates/horizon-client/src/mobs/cube.rs @@ -2,7 +2,7 @@ //! movement (`CubeMobMoveControl`), random directions, contact damage, //! squish animation and splitting. use super::{ - Mob, MobEvent, MobWorld, Species, + Damage, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, control::{MoveKind, Operation, rotlerp}, goals::{Cx, Goal, JUMP, LOOK, MOVE, NearestTarget, Prey, goal_clone, reduced}, @@ -228,14 +228,8 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { .inflate(Vec3::new(reach, 0.0, reach)) .intersects(p.bounds) { - let damage = m.attrs.attack_damage - + if m.species() == Species::MagmaCube { - 2.0 - } else { - 0.0 - }; events.push(MobEvent::AttackPlayer( - damage, + attack_damage(m), [m.position.x, m.position.y, m.position.z], )); if m.species() == Species::Slime { @@ -249,6 +243,55 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } } } + hurt_touching_golems(m, w, events); +} +/// `AbstractCubeMob.getAttackDamage`: the attack damage attribute, plus 2 for +/// a magma cube (`MagmaCube.getAttackDamage`). +fn attack_damage(m: &Mob) -> f32 { + m.attrs.attack_damage + + if m.species() == Species::MagmaCube { + 2.0 + } else { + 0.0 + } +} +/// `AbstractCubeMob.push(entity)`: an iron golem whose `pushEntities` takes +/// this cube (its box overlaps the cube's, `EntitySelector.pushableBy`) gets +/// `dealDamage`: alive, `doTeamsAllowDamage`, `isWithinMeleeAttackRange` +/// (always true for overlapping boxes) and `hasLineOfSight`, then a +/// `mobAttack` hit for `getAttackDamage`. +fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + // canDealDamage also needs Mob.isEffectiveAi: a NoAI cube never hurts. + if !can_deal_damage(m) || m.no_ai || !m.alive() { + return; + } + let bounds = m.bounds(); + let own = w.teams.of(Who::Mob(m.id)); + let pushable = m.pushable(); + let golems: Vec = w + .others_near(bounds) + .filter(|o| { + let theirs = w.teams.of(Who::Mob(o.id)); + o.id != m.id + && o.species == Species::IronGolem + && o.alive + && o.bounds.intersects(bounds) + && crate::scoreboard::pushable_by(theirs, own, pushable) + && crate::scoreboard::teams_allow_damage(own, theirs) + }) + .map(|o| o.id) + .collect(); + let from = [m.position.x, m.position.y, m.position.z]; + for golem in golems { + if m.sees(w, Who::Mob(golem)) { + events.push(MobEvent::CubeHurtMob { + cube: m.id, + target: golem, + amount: attack_damage(m), + source: Damage::mob(m.id, from), + }); + } + } } /// MagmaCubeModel.setupAnim (slimes have no animation). pub fn anim(m: &Mob, s: &State, p: &mut Poses) { @@ -369,3 +412,82 @@ impl Goal for CubeKeepJumping { set_wanted(m, 1.0); } } + +#[cfg(test)] +mod golem_touch_tests { + use super::*; + use crate::mobs::world::with_test_world; + use std::sync::Arc; + + /// The golems a cube of this kind and size, `x` blocks east of an iron + /// golem, hurts in one `ai_step`, and the damage. + fn hits(name: &str, size: i32, x: f64) -> Option> { + hits_with(name, size, x, false) + } + fn hits_with(name: &str, size: i32, x: f64, no_ai: bool) -> Option> { + let mut out = None; + with_test_world(&[], |_, types| { + let mob = |id: u32, name: &str, at: Vec3| { + Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0) + }; + let golem = mob(2, "minecraft:iron_golem", Vec3::new(0.5, 64.0, 0.5)); + let mut cube = mob(1, name, Vec3::new(0.5 + x, 64.0, 0.5)); + set_size(&mut cube, size); + cube.no_ai = no_ai; + out = Some((cube, golem.seen(None))); + }); + let (mut cube, golem) = out?; + let mut events = Vec::new(); + with_test_world(&[golem], |w, _| ai_step(&mut cube, w, &mut events)); + Some( + events + .iter() + .filter_map(|e| match e { + MobEvent::CubeHurtMob { + cube: 1, + target, + amount, + source, + } => { + assert_eq!(source.attacker, Some(Who::Mob(1))); + Some((*target, *amount)) + } + _ => None, + }) + .collect(), + ) + } + + /// `AbstractCubeMob.push`: a slime or magma cube the iron golem bumps + /// into hurts it like a touching player (#3513), for `getAttackDamage`; + /// a tiny slime (`canDealDamage`) and a cube clear of the golem do not. + #[test] + fn a_cube_touching_an_iron_golem_hurts_it() { + let Some(slime) = hits("minecraft:slime", 2, 1.0) else { + return; + }; + assert_eq!(slime, vec![(2, 2.0)], "a size 2 slime hits the golem for 2"); + let magma = hits("minecraft:magma_cube", 4, 1.5).unwrap(); + assert_eq!(magma, vec![(2, 6.0)], "a size 4 magma cube hits for 4 + 2"); + let tiny = hits("minecraft:magma_cube", 1, 0.9).unwrap(); + assert_eq!( + tiny, + vec![(2, 3.0)], + "a tiny magma cube still hits for 1 + 2" + ); + assert!( + hits("minecraft:slime", 1, 0.9).unwrap().is_empty(), + "a tiny slime never hurts" + ); + assert!( + hits("minecraft:slime", 2, 3.0).unwrap().is_empty(), + "the golem is out of touch" + ); + assert!( + hits_with("minecraft:slime", 2, 1.0, true) + .unwrap() + .is_empty(), + "a NoAI cube is not isEffectiveAi, so it cannot deal damage" + ); + } +} From 912a9febc7d8b579dcdc2342eb21dfe65109a4f2 Mon Sep 17 00:00:00 2001 From: bloodulon Date: Fri, 30 Oct 2026 12:02:24 +0500 Subject: [PATCH 285/968] Seat displays and hitboxes on whatever carries them ModelEngine mounts a model's item displays (its bones) and interaction hitboxes (its click boxes) on a pivot, usually an area effect cloud, and moves only the pivot. #3776 seats text displays on mobs and the local player through the passenger attachment; an item display riding a pivot display or an area effect cloud, and a hitbox riding a pivot, had no seat at all and stayed where the server first spawned them. Every display and interaction box now asks its vehicle where to sit: mobs through their passenger attachment, boats and minecarts from their seat, armor stands from their box, another display or interaction from its position, and any other tracked entity (the area effect cloud) from its feet. Riders of riders settle over three passes, as mobs already did. Co-Authored-By: Qoder --- .../horizon-client/src/game/interactions.rs | 21 +++ crates/horizon-client/src/game/passengers.rs | 126 ++++++++++++++---- crates/horizon-client/src/game/remote.rs | 7 +- .../src/game/remote_vehicles.rs | 7 + .../horizon-client/src/game/text_displays.rs | 3 + 5 files changed, 138 insertions(+), 26 deletions(-) diff --git a/crates/horizon-client/src/game/interactions.rs b/crates/horizon-client/src/game/interactions.rs index 890ab94156..9c826e5809 100644 --- a/crates/horizon-client/src/game/interactions.rs +++ b/crates/horizon-client/src/game/interactions.rs @@ -14,6 +14,9 @@ pub(super) struct RemoteInteraction { pub width: f32, pub height: f32, pub response: bool, + /// `Entity.getVehicle`: ModelEngine's hitboxes ride the pivot that + /// carries them. + pub vehicle: Option, } impl RemoteInteraction { @@ -23,8 +26,14 @@ impl RemoteInteraction { width: 1.0, height: 1.0, response: false, + vehicle: None, } } + /// `Entity.positionRider`'s move: a passenger is placed on its vehicle's + /// seat, not moved on its own. + pub fn ride_to(&mut self, at: glam::DVec3) { + self.position = Vec3::new(at.x, at.y, at.z); + } /// `Interaction.refreshDimensions`: `width` square about the entity's /// x and z, from its feet up `height` (its position is the box's bottom /// centre, not the model's). @@ -108,4 +117,16 @@ mod tests { } assert!(i.response); } + #[test] + fn a_riding_hitbox_moves_with_its_pivot() { + let mut i = RemoteInteraction::new([10.0, 64.0, -3.0]); + i.vehicle = Some(9); + i.ride_to(glam::DVec3::new(12.0, 65.5, -3.0)); + assert_eq!(i.position, Vec3::new(12.0, 65.5, -3.0)); + let b = i.bounds(); + assert_eq!( + (b.min_position().components(), b.max_position().components()), + ([11.5, 65.5, -3.5], [12.5, 66.5, -2.5]) + ); + } } diff --git a/crates/horizon-client/src/game/passengers.rs b/crates/horizon-client/src/game/passengers.rs index 893b47009b..bbb6635f2b 100644 --- a/crates/horizon-client/src/game/passengers.rs +++ b/crates/horizon-client/src/game/passengers.rs @@ -106,39 +106,115 @@ impl Game<'_> { m.ai.nav.stop(); } } - // Text displays riding a mob, as NPC plugins put a name over a head: - // `LivingEntity.getPassengerRidingPosition`, the vehicle's passenger - // attachment (the top of its box in its pose), less the display's own - // vehicle attachment, which is 0 (Display's dimensions are 0 by 0). - // On the local player, the top of its box in its pose and scale (`Avatar.POSES`). + // Displays and interaction boxes riding anything the client tracks, + // as NPC plugins put a name over a head or a model's bones on a + // pivot: `Entity.rideTick` -> `positionRider`, the vehicle's passenger + // attachment (the top of its box in its pose) less the rider's own, + // which is 0 for both (Display and Interaction are 0 by 0). On the + // local player, the top of its box in its pose and scale + // (`Avatar.POSES`). let me = self.remote.as_ref().map(|r| r.entity_id as u32); let own_seat = self.player.as_ref().map(|p| { p.position .add_vector(Vec3::new(0.0, f64::from(p.dimensions.height), 0.0)) }); - for display in self.text_displays.values_mut() { - let Some(vehicle) = display.vehicle else { - continue; - }; - let at = if Some(vehicle) == me { - let Some(at) = own_seat else { continue }; - at - } else { - let Some(&j) = index.get(&vehicle) else { - continue; - }; - let vehicle = &self.mobs[j]; - riders::seat( - riders::passenger_attachment(vehicle), - 0.0, - vehicle.position, - vehicle.yaw, - ) - }; - display.ride(glam::DVec3::new(at.x, at.y, at.z)); + // A rider of a rider settles over a few passes, as the mobs above. + for _ in 0..3 { + let mut vehicles: Vec = self + .text_displays + .values() + .filter_map(|d| d.vehicle) + .chain(self.interactions.values().filter_map(|i| i.vehicle)) + .collect(); + vehicles.sort_unstable(); + vehicles.dedup(); + let seats: HashMap = vehicles + .into_iter() + .filter_map(|v| { + self.passenger_riding_position(v, me, own_seat, &index) + .map(|at| (v, at)) + }) + .collect(); + for display in self.text_displays.values_mut() { + if let Some(at) = display.vehicle.and_then(|v| seats.get(&v)) { + display.ride(*at); + } + } + for interaction in self.interactions.values_mut() { + if let Some(at) = interaction.vehicle.and_then(|v| seats.get(&v)) { + interaction.ride_to(*at); + } + } } } + /// `Entity.getPassengerRidingPosition` of a vehicle: where the feet of a + /// passenger whose own vehicle attachment is 0 land. + fn passenger_riding_position( + &self, + vehicle: u32, + me: Option, + own_seat: Option, + index: &HashMap, + ) -> Option { + if Some(vehicle) == me { + let at = own_seat?; + return Some(glam::DVec3::new(at.x, at.y, at.z)); + } + if let Some(&j) = index.get(&vehicle) { + let v = &self.mobs[j]; + let at = riders::seat(riders::passenger_attachment(v), 0.0, v.position, v.yaw); + return Some(glam::DVec3::new(at.x, at.y, at.z)); + } + if let Some(d) = self.text_displays.get(&(vehicle as i32)) { + return Some(d.position()); + } + if let Some(i) = self.interactions.get(&(vehicle as i32)) { + let at = i.position; + return Some(glam::DVec3::new(at.x, at.y, at.z)); + } + if let Some(b) = self.boats.iter().find(|b| b.id == vehicle) { + let count = self.boat_players(vehicle).max(1) + self.boat_mobs(vehicle).len(); + let at = b.seat(0, count, false, 0.0, false); + return Some(glam::DVec3::new(at.x, at.y, at.z)); + } + if let Some(c) = self.carts.iter().find(|c| c.id == vehicle) { + let at = c + .position + .add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0)); + return Some(glam::DVec3::new(at.x, at.y, at.z)); + } + if let Some(a) = self.armor_stands.iter().find(|a| a.id == vehicle) { + // ArmorStand.getDefaultDimensions: a marker has no box to sit on. + let height = if a.marker { + 0.0 + } else if a.small { + 0.9875 + } else { + 1.975 + }; + return Some(glam::DVec3::new( + a.position.x, + a.position.y + height, + a.position.z, + )); + } + // Any other tracked entity sits on its feet; an area effect cloud is + // 0.5 tall, and ModelEngine parks a model's pivot half a block below + // its feet for exactly that. + let (kind, position) = self.remote.as_ref()?.tracked(vehicle as i32)?; + let lift = if kind == "minecraft:area_effect_cloud" { + 0.5 + } else { + 0.0 + }; + Some(glam::DVec3::new( + position[0], + position[1] + lift, + position[2], + )) + } + /// `finalizeSpawn` jockeys of a naturally spawned mob: baby zombies on /// chickens, skeletons on spiders, piglins and baby striders on striders, /// zombies on zombie horses. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index f53b356a39..9d739bb309 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -7344,7 +7344,12 @@ impl Remote { .iter() .find(|(_, u)| **u == uuid) .map(|(id, _)| *id as u32) - }} + } + /// The kind and last sent position of a tracked server entity. + pub(super) fn tracked(&self, id: i32) -> Option<(&str, [f64; 3])> { + self.entities.get(&id).map(|t| (&*t.kind, t.position)) + } +} /// The textures to resolve skins from: every list entry's, and for a player /// entity whose entry is gone the profile it kept. `kept` first takes, for each diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 9c224d9d44..55e77567a7 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -132,6 +132,13 @@ impl Game<'_> { display.vehicle = None; } } + for (&id, i) in &mut self.interactions { + if passengers.contains(&id) { + i.vehicle = Some(vehicle_id); + } else if i.vehicle == Some(vehicle_id) { + i.vehicle = None; + } + } if passengers.contains(&me) { if self.riding != Some(vehicle_id) { self.riding = Some(vehicle_id); diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index dea564bfbe..125352de83 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -99,6 +99,9 @@ impl RemoteTextDisplay { self.target = at; self.steps = 0; } + pub(super) fn position(&self) -> glam::DVec3 { + self.position + } fn progress(&self, partial: f32) -> f32 { if self.duration <= 0 { 1.0 From 958522ce677ed001fd9ab61bd03ee14c73928b75 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:54:18 +0700 Subject: [PATCH 286/968] Let armadillos breed, follow spider eyes and their parents ArmadilloAi.initIdleActivity runs AnimalMakeLove(1.0), then a RunOne of FollowTemptation(1.25, armadillo_food, close enough at 1 block for a baby and 2 for an adult) and BabyFollowAdult(5..16, 1.25). The armadillo's goals had none of them, so spider eyes gave hearts but no baby, armadillos ignored a player holding them, and babies wandered off. Add Breed, Tempt and FollowParent, each wrapped so that it only runs in the IDLE activity: not while ArmadilloAi.updateActivity picks PANIC (danger in mind and not panicking) and not while it is a ball. Part of #3512 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/mobs/armadillo.rs | 192 +++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/mobs/armadillo.rs b/crates/horizon-client/src/mobs/armadillo.rs index 8ef7419554..2f7abeb5fc 100644 --- a/crates/horizon-client/src/mobs/armadillo.rs +++ b/crates/horizon-client/src/mobs/armadillo.rs @@ -8,7 +8,8 @@ use super::{ Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who, goals::{ - Cx, Float, Goal, LOOK, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone, + Breed, Cx, Float, FollowParent, Goal, LOOK, LookAtPlayer, MOVE, Panic, RandomLookAround, + Stroll, Tempt, goal_clone, }, }; use horizon_core::{geometry::Vec3, random::RandomSource}; @@ -60,6 +61,12 @@ pub fn register(m: &mut Mob) { g.add(0, Float); g.add(1, ArmadilloPanic(panic)); g.add(2, Rolled); + // ArmadilloAi.initIdleActivity: AnimalMakeLove(1.0), then RunOne of + // FollowTemptation(1.25, armadillo_food) and BabyFollowAdult(5..16, 1.25). + g.add(3, Idle(Breed::new(1.0))); + let tempt = Tempt::new(1.25, m.kind.food.clone(), false); + g.add(4, Idle(ArmadilloTempt(tempt))); + g.add(4, Idle(FollowParent::new(1.25))); g.add(5, Stroll::avoiding_water(1.0)); g.add(6, LookAtPlayer::new(6.0)); g.add(7, RandomLookAround::default()); @@ -392,6 +399,70 @@ impl Goal for ArmadilloPanic { self.0.stop(m, cx); } } +/// A behaviour of the IDLE activity: `ArmadilloAi.updateActivity` picks +/// PANIC over it while danger is in mind and it is not panicking, and a +/// ball does not walk (`ArmadilloAi$1`, its `MoveToTargetSink`). +#[derive(Clone, Debug)] +struct Idle(G); +fn idle(m: &Mob) -> bool { + !scared(m) && !m.extra.armadillo.panic_activity +} +impl Goal for Idle { + goal_clone!(); + fn flags(&self) -> u8 { + self.0.flags() + } + fn name(&self) -> &'static str { + self.0.name() + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + idle(m) && self.0.can_use(m, cx) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + idle(m) && self.0.can_continue(m, cx) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx); + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop(m, cx); + } + fn every_tick(&self) -> bool { + self.0.every_tick() + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.tick(m, cx); + } +} +/// `FollowTemptation` with `ArmadilloAi.initIdleActivity`'s close enough +/// distance: 1 block for a baby, 2 for an adult. +#[derive(Clone, Debug)] +struct ArmadilloTempt(Tempt); +impl Goal for ArmadilloTempt { + goal_clone!(); + fn flags(&self) -> u8 { + self.0.flags() + } + fn name(&self) -> &'static str { + self.0.name() + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_use(m, cx) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_continue(m, cx) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx); + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop(m, cx); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop_distance = if m.is_baby() { 1.0 } else { 2.0 }; + self.0.tick(m, cx); + } +} /// The behaviours that do not run while the armadillo is a ball: its /// `MoveToTargetSink` and the idle activity's walking and looking. #[derive(Clone, Debug)] @@ -445,6 +516,125 @@ mod tests { assert!(hiding_in_shell(&m), "the model shows the shell"); } + /// Runs the goals of `m` for `ticks` ticks; the events. + fn run_goals(m: &mut Mob, w: &MobWorld, ticks: u32) -> Vec { + let mut events = Vec::new(); + let mut goals = std::mem::take(&mut m.ai.goals); + let mut lr = horizon_core::random::LegacyRandom::new(0); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + for _ in 0..ticks { + goals.tick(m, &mut cx); + } + m.ai.goals = goals; + events + } + /// Real armadillos from the vanilla data, with their goals, at these + /// spots; `None` without the data. + fn real(at: [[f64; 3]; N]) -> Option<[Mob; N]> { + let mut out = None; + crate::mobs::world::with_test_world(&[], |_, types| { + let kind = Arc::new(types.types["minecraft:armadillo"].clone()); + let mut id = 0; + out = Some(at.map(|[x, y, z]| { + id += 1; + let mut m = Mob::new(id, kind.clone(), Vec3::new(x, y, z), 0.0); + m.on_ground = true; + m + })); + }); + out + } + + #[test] + fn two_armadillos_in_love_make_a_baby() { + // ArmadilloAi.initIdleActivity: AnimalMakeLove(ARMADILLO, 1.0), so two + // armadillos fed spider eyes breed (#3512). + let Some([mut a, mut b, mut c]) = + real([[0.5, 64.0, 0.5], [2.5, 64.0, 0.5], [0.5, 64.0, 1.5]]) + else { + return; + }; + for m in [&mut a, &mut b, &mut c] { + m.extra.in_love = 600; + } + let partner = [b.seen(None)]; + crate::mobs::world::with_test_world(&partner, |w, _| { + let events = run_goals(&mut a, w, 80); + assert!( + events.iter().any(|e| matches!( + e, + MobEvent::Breed { + parent: 1, + partner: 2, + .. + } + )), + "the pair breeds" + ); + assert_eq!(a.extra.in_love, 0); + assert_eq!(a.extra.age, 6000, "the parent rests"); + + // ArmadilloAi.updateActivity: with danger in mind and no panic, + // PANIC replaces IDLE and its behaviours do not run, nor do a + // ball's. + c.extra.armadillo.panic_activity = true; + let events = run_goals(&mut c, w, 80); + assert!(!events.iter().any(|e| matches!(e, MobEvent::Breed { .. }))); + assert!(!c.ai.goals.is_running("Breed")); + c.extra.armadillo.panic_activity = false; + set_state(&mut c, SCARED); + run_goals(&mut c, w, 1); + assert!(!c.ai.goals.is_running("Breed"), "a ball does not breed"); + set_state(&mut c, IDLE); + run_goals(&mut c, w, 1); + assert!(c.ai.goals.is_running("Breed"), "it looks for its mate"); + }); + } + + #[test] + fn a_baby_armadillo_follows_an_adult() { + // BabyFollowAdult(5..16, 1.25) in the idle activity. + let Some([mut baby, adult]) = real([[0.5, 64.0, 0.5], [6.5, 64.0, 0.5]]) else { + return; + }; + baby.extra.age = -24000; + crate::mobs::world::with_test_world(&[adult.seen(None)], |w, _| { + run_goals(&mut baby, w, 1); + assert!(baby.ai.goals.is_running("FollowParent")); + set_state(&mut baby, SCARED); + run_goals(&mut baby, w, 1); + assert!(!baby.ai.goals.is_running("FollowParent"), "not as a ball"); + }); + } + + #[test] + fn a_tempted_baby_comes_closer_than_an_adult() { + // ArmadilloAi.initIdleActivity: FollowTemptation is close enough at + // 1 block for a baby and 2 for an adult. + let Some([mut baby, mut adult]) = real([[0.5, 64.0, 0.5], [4.5, 64.0, 0.5]]) else { + return; + }; + baby.extra.age = -24000; + crate::mobs::world::with_test_world(&[], |w, _| { + let mut events = Vec::new(); + let mut lr = horizon_core::random::LegacyRandom::new(0); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + let mut tempt = ArmadilloTempt(Tempt::new(1.25, baby.kind.food.clone(), false)); + tempt.tick(&mut baby, &mut cx); + assert_eq!(tempt.0.stop_distance, 1.0); + tempt.tick(&mut adult, &mut cx); + assert_eq!(tempt.0.stop_distance, 2.0); + }); + } + #[test] fn campfire_damage_is_fire_and_has_no_knockback_but_does_not_cause_panic() { // `campfire` is in #is_fire and #no_knockback, but in neither From e9eda298fd0f4ee3d5c9be88e0adbc6613596dfd Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 13:57:28 +0700 Subject: [PATCH 287/968] Complete chat with the server's custom chat completions ClientboundCustomChatCompletionsPacket was only an id constant, so the names and tags proxies and plugins add with it (players on other backend servers, chat tags) never showed when Tab completes chat. Decode it, keep the connection's set like ClientSuggestionProvider.modifyCustomCompletions (ADD, REMOVE, SET; an unknown action id is ADD), clear it on a reconfiguration, and complete chat from getCustomTabSuggestions: the players and the custom completions. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-core/src/registries.rs | 52 +++++++++++++++++++ crates/horizon-client-render/src/chat.rs | 4 +- .../horizon-client-render/src/chat_tests.rs | 52 +++++++++++++++++++ crates/horizon-client/src/game/chat.rs | 1 + crates/horizon-client/src/game/remote.rs | 29 ++++++++++- crates/horizon-command/src/args.rs | 4 +- crates/horizon-command/src/lib.rs | 2 +- crates/horizon-protocol/src/packets/play.rs | 41 +++++++++++++++ tools/port_map/client-002.tsv | 6 +-- 9 files changed, 184 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client-core/src/registries.rs b/crates/horizon-client-core/src/registries.rs index d638637c39..6393e90369 100644 --- a/crates/horizon-client-core/src/registries.rs +++ b/crates/horizon-client-core/src/registries.rs @@ -28,6 +28,10 @@ pub struct Registries { /// Game rule name -> integer-valued. pub game_rules: Vec<(String, bool)>, pub players: Vec, + /// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat + /// completions the server sent (ClientboundCustomChatCompletionsPacket), + /// each once. + pub custom_completions: std::collections::BTreeSet, /// Scoreboard objective names, team names and tracked score holders. pub objectives: Vec, pub teams: Vec, @@ -87,7 +91,55 @@ pub struct RegistryIds { pub tags: Vec, } const EMPTY: &[String] = &[]; +/// ClientboundCustomChatCompletionsPacket.Action. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CustomCompletionAction { + Add, + Remove, + Set, +} +impl CustomCompletionAction { + /// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`, + /// so an unknown id is `ADD`. + pub fn from_id(id: i32) -> Self { + match id { + 1 => Self::Remove, + 2 => Self::Set, + _ => Self::Add, + } + } +} +/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds, +/// removes or replaces the extra chat completions. +pub fn modify_custom_completions( + set: &mut std::collections::BTreeSet, + action: CustomCompletionAction, + entries: &[String], +) { + match action { + CustomCompletionAction::Add => set.extend(entries.iter().cloned()), + CustomCompletionAction::Remove => { + for e in entries { + set.remove(e); + } + } + CustomCompletionAction::Set => { + set.clear(); + set.extend(entries.iter().cloned()); + } + } +} impl Registries { + /// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a + /// command) completes, the online players' names with the server's custom + /// completions, each once. + pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> { + self.players + .iter() + .chain(&self.custom_completions) + .map(String::as_str) + .collect() + } /// Fill what the game did not build itself from vanilla's exported /// registries (`command-registries.json`, written by /// `VanillaCommandParseOracle registries`): built-in registries such as diff --git a/crates/horizon-client-render/src/chat.rs b/crates/horizon-client-render/src/chat.rs index ab6626a95b..8c34e388ac 100644 --- a/crates/horizon-client-render/src/chat.rs +++ b/crates/horizon-client-render/src/chat.rs @@ -929,7 +929,9 @@ impl ChatScreen { } else if !command.chars().all(char::is_whitespace) { let partial = &command[..cursor_b]; let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial)); - builder.suggest_matching(c.registries.players.iter().map(String::as_str)); + // ClientSuggestionProvider.getCustomTabSuggestions: the players + // and the server's custom chat completions. + builder.suggest_matching(c.registries.custom_tab_suggestions()); let (range, list) = merge_suggestions(partial, builder.out); self.awaiting = None; self.pending = Some((range.0, list)); diff --git a/crates/horizon-client-render/src/chat_tests.rs b/crates/horizon-client-render/src/chat_tests.rs index beec6cd818..c4a02cc0c4 100644 --- a/crates/horizon-client-render/src/chat_tests.rs +++ b/crates/horizon-client-render/src/chat_tests.rs @@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() { assert_eq!(screen.value(), "/coords"); } +#[test] +fn chat_completes_the_servers_custom_completions_with_the_players() { + // CommandSuggestions.updateCommandInfo completes chat from + // ClientSuggestionProvider.getCustomTabSuggestions: the online players + // and what ClientboundCustomChatCompletionsPacket added. + use crate::command::{CustomCompletionAction as Action, modify_custom_completions}; + let font = font(); + let m = Measure(&font); + let mut registries = Registries { + players: vec!["Steve".into()], + ..Registries::default() + }; + let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::>(); + let set = &mut registries.custom_completions; + modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"])); + modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"])); + modify_custom_completions(set, Action::Remove, &entries(&["Alex"])); + // An id past the last action is ADD (OutOfBoundsStrategy.ZERO). + modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"])); + assert_eq!( + registries + .custom_tab_suggestions() + .into_iter() + .collect::>(), + ["#trade", "Notch", "Steve", "Zuri"] + ); + let completed = |registries: &Registries, line: &str| { + let dispatcher = crate::command::vanilla::game_dispatcher(); + let c = Commands { + dispatcher, + registries, + }; + let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full()); + screen.type_text(&m, &c, line); + // Chat shows no list by itself: Tab shows it, Tab again uses it. + for _ in 0..2 { + screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false); + } + screen.value().to_owned() + }; + assert_eq!(completed(®istries, "hi no"), "hi Notch"); + assert_eq!(completed(®istries, "sell #tr"), "sell #trade"); + assert_eq!(completed(®istries, "hi st"), "hi Steve"); + assert_eq!(completed(®istries, "hi al"), "hi al"); + // SET replaces them all; the players stay. + let set = &mut registries.custom_completions; + modify_custom_completions(set, Action::Set, &entries(&["Alex"])); + assert_eq!(completed(®istries, "hi no"), "hi no"); + assert_eq!(completed(®istries, "hi al"), "hi Alex"); + assert_eq!(completed(®istries, "hi st"), "hi Steve"); +} + #[test] fn ask_server_nodes_wait_for_the_servers_completions() { // ClientSuggestionProvider.customSuggestion: an `ask_server` node at the diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 09d0d9d0bd..a93a52bc09 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -474,6 +474,7 @@ impl Game<'_> { if let Some(names) = self.remote_online_names() { self.ui.registries.players = names; } + self.ui.registries.custom_completions = self.remote_custom_completions(); } /// `ChatComponent.createScreen`: T or / opens the chat with the draft kept /// last time, when this method can restore it (a command draft only for diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 8e715916eb..b5ac4d8e69 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -183,6 +183,9 @@ pub(super) struct Remote { chat_previous: Option, /// The server's command tree (ClientboundCommandsPacket). pub commands: Option, + /// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat + /// completions this server sent (ClientboundCustomChatCompletionsPacket). + custom_completions: std::collections::BTreeSet, players: HashMap, /// `ClientPacketListener.playerInfoMap` as player-list entries, and /// which of them are listed. @@ -518,6 +521,7 @@ impl Game<'_> { chat_queue: VecDeque::new(), chat_previous: None, commands: None, + custom_completions: Default::default(), players: HashMap::new(), tab_players: HashMap::new(), skin_textures: HashMap::new(), @@ -908,6 +912,14 @@ impl Game<'_> { ) } + /// `ClientSuggestionProvider.customCompletionSuggestions` of the server + /// connection (none without one). + pub(super) fn remote_custom_completions(&self) -> std::collections::BTreeSet { + self.remote + .as_ref() + .map(|r| r.custom_completions.clone()) + .unwrap_or_default() + } /// The names in the server's player list, sorted. pub(super) fn remote_online_names(&self) -> Option> { let r = self.remote.as_ref()?; @@ -1049,14 +1061,18 @@ impl Game<'_> { } Event::Reconfigured(registries) => { // handleConfigurationStart → clearClientLevel, and the next - // ClientPacketListener starts with no players or scoreboard. + // ClientPacketListener starts with no players or scoreboard, + // and its new ClientSuggestionProvider with no custom + // chat completions. self.hud_disconnected(); self.score.board = Default::default(); if let Some(r) = self.remote.as_mut() { r.players.clear(); r.tab_players.clear(); r.listed.clear(); + r.custom_completions.clear(); } + self.ui.registries.custom_completions.clear(); self.set_registries(registries); } // handleTransfer: "Cannot transfer to server from @@ -3052,6 +3068,17 @@ impl Game<'_> { { self.handle_command_suggestions(&data); } + C::CustomChatCompletions(p) => { + // ClientPacketListener.handleCustomChatCompletions. + if let Some(r) = self.remote.as_mut() { + horizon_client::command::modify_custom_completions( + &mut r.custom_completions, + horizon_client::command::CustomCompletionAction::from_id(p.action), + &p.entries, + ); + self.ui.registries.custom_completions = r.custom_completions.clone(); + } + } C::Commands(p) => { let tree = horizon_client::command::network::from_packet( &p, diff --git a/crates/horizon-command/src/args.rs b/crates/horizon-command/src/args.rs index fa309e7241..d49778698a 100644 --- a/crates/horizon-command/src/args.rs +++ b/crates/horizon-command/src/args.rs @@ -2,7 +2,9 @@ //! the vanilla error messages and completion suggestions. use super::{CmdResult, CommandError, Context, Reader, SuggestionsBuilder}; -pub use horizon_client_core::registries::{Registries, RegistryIds}; +pub use horizon_client_core::registries::{ + CustomCompletionAction, Registries, RegistryIds, modify_custom_completions, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Resource { diff --git a/crates/horizon-command/src/lib.rs b/crates/horizon-command/src/lib.rs index 838c8de87d..3fa61ca421 100644 --- a/crates/horizon-command/src/lib.rs +++ b/crates/horizon-command/src/lib.rs @@ -22,7 +22,7 @@ pub mod tree_world; pub mod vanilla; mod vanilla_order; -pub use args::{Arg, ArgType, Registries}; +pub use args::{Arg, ArgType, CustomCompletionAction, Registries, modify_custom_completions}; use horizon_client_core::text::Text; /// The command id of a mod's command (`Dispatcher::with_mod_commands`). diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index a27ca12007..639a0e648f 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -211,6 +211,18 @@ packet!(ContainerSetData { container: i32 => VarInt, id: i16 => Short, value: i1 // ClientboundCooldownPacket: the cooldown group (an identifier) and the ticks // its entry lasts (0 ends it). packet!(Cooldown { group: String => Ident, duration: i32 => VarInt }); +packet!( + /// ClientboundCustomChatCompletionsPacket: how the server changes the + /// extra chat completions (`custom_chat_action`) and the entries. + CustomChatCompletions { action: i32 => VarInt, entries: Vec => List } +); +/// ClientboundCustomChatCompletionsPacket.Action, by id (`ByIdMap.continuous` +/// with `OutOfBoundsStrategy.ZERO`: an unknown id is `ADD`). +pub mod custom_chat_action { + pub const ADD: i32 = 0; + pub const REMOVE: i32 = 1; + pub const SET: i32 = 2; +} packet!(ContainerSetSlot { container: i32 => VarInt, state: i32 => VarInt, slot: i16 => Short, item: Option => Slot }); /// A holder reference: registry id + 1, or 0 for an inline value (unsupported). pub struct HolderId; @@ -1840,6 +1852,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { ContainerSetSlot(ContainerSetSlot) = CONTAINER_SET_SLOT, CookieRequest(CookieRequest) = COOKIE_REQUEST, Cooldown(Cooldown) = COOLDOWN, + CustomChatCompletions(CustomChatCompletions) = CUSTOM_CHAT_COMPLETIONS, CustomPayload(CustomPayload) = CUSTOM_PAYLOAD, Disconnect(Disconnect) = DISCONNECT, DisguisedChat(DisguisedChat) = DISGUISED_CHAT, @@ -2401,6 +2414,34 @@ mod tests { } } + #[test] + fn custom_chat_completions_are_an_action_and_a_string_list() { + // ClientboundCustomChatCompletionsPacket.STREAM_CODEC: Action.STREAM_CODEC + // (ByteBufCodecs.idMapper, a VarInt), then STRING_UTF8 in a list. + let payload = b"Steve#tag"; + let packet = Clientbound::CustomChatCompletions(CustomChatCompletions { + action: custom_chat_action::SET, + entries: vec!["Steve".into(), "#tag".into()], + }); + assert_eq!( + Clientbound::decode( + crate::ids::play::clientbound::CUSTOM_CHAT_COMPLETIONS, + payload + ) + .unwrap(), + packet + ); + assert_eq!(&packet.encode()[1..], payload); + round_trip(packet, Clientbound::encode, Clientbound::decode); + assert!( + Clientbound::decode( + crate::ids::play::clientbound::CUSTOM_CHAT_COMPLETIONS, + &payload[..payload.len() - 1] + ) + .is_err() + ); + } + #[test] fn serverbound_abilities_carry_only_flying() { // ServerboundPlayerAbilitiesPacket.write: one byte, 2 when flying. diff --git a/tools/port_map/client-002.tsv b/tools/port_map/client-002.tsv index 667ffbd0a9..ee312af030 100644 --- a/tools/port_map/client-002.tsv +++ b/tools/port_map/client-002.tsv @@ -65,7 +65,7 @@ ClientPacketListener.handleSetBorderSize port crates/horizon-client/src/game/rem ClientPacketListener.handleSetBorderWarningDistance port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDistance arm ClientPacketListener.handleSetBorderWarningDelay port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDelay arm ClientPacketListener.handleTitlesClear port crates/horizon-client/src/game/remote.rs::Game::handle_packet ClearTitles arm: clear, optionally reset the times -ClientPacketListener.handleCustomChatCompletions missing minor: server-sent custom chat completions are not used +ClientPacketListener.handleCustomChatCompletions port crates/horizon-client/src/game/remote.rs::Game::handle_packet CustomChatCompletions arm: modify_custom_completions on the connection's set ClientPacketListener.setTitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitleText arm ClientPacketListener.setSubtitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetSubtitleText arm ClientPacketListener.setTitlesAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitlesAnimation arm @@ -116,12 +116,12 @@ ClientPacketListener.notifyPlayerLoaded port crates/horizon-client/src/game/remo ClientPacketListener.setKeyPair missing player: no chat key pair is ever set, so chat and commands stay unsigned ClientRegistryLayer.createRegistryAccess skip:data registry layers come from Horizon's own tables ClientSuggestionProvider.getOnlinePlayerNames port crates/horizon-client/src/game/remote.rs::Game::remote_online_names sorted names of the server's player list -ClientSuggestionProvider.getCustomTabSuggestions missing minor: server-sent custom completions are not used +ClientSuggestionProvider.getCustomTabSuggestions port crates/horizon-client-core/src/registries.rs::Registries::custom_tab_suggestions the player names and the server's custom completions, each once ClientSuggestionProvider.getSelectedEntities missing minor: no suggestion for the entity under the crosshair ClientSuggestionProvider.suggestRegistryElements port crates/horizon-command/src/args.rs::suggest_resources registry ids come from Horizon's registry tables ClientSuggestionProvider.getRelevantCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates the block under the crosshair for block positions ClientSuggestionProvider.getAbsoluteCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates gap: minor: the exact hit point is not offered; Vec3 arguments only get ~ -ClientSuggestionProvider.modifyCustomCompletions missing minor: server-sent custom completions are not stored +ClientSuggestionProvider.modifyCustomCompletions port crates/horizon-client-core/src/registries.rs::modify_custom_completions ADD, REMOVE or SET on the set the connection keeps KnownPacksManager.trySelectingPacks skip:data Horizon answers SelectKnownPacks with no packs, so the server sends full registries (net.rs configure) LegacyServerPinger.channelActive skip:other legacy pre-1.7 server-list ping, not used by this client LegacyServerPinger.channelRead0 skip:other legacy pre-1.7 server-list ping, not used by this client From ef024a082ca97b9beec7409e4a4e18a024592855 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:00:34 +0700 Subject: [PATCH 288/968] F3+I copies the server's data on servers and LAN On a connection (a server, a LAN world, and single player, whose world runs on its own server thread) F3+I copied the client's partial copy of the block or mob and called it server-side: a chest lost its items, a mob its tags and brain. Vanilla's KeyboardHandler.copyRecreateCommand asks the server through DebugQueryHandler (ServerboundBlockEntityTagQueryPacket, ServerboundEntityTagQueryPacket) and copies the ClientboundTagQueryPacket answer to the newest query. Add the three packets, the query state on the connection and its answer, and the server side for guests (ServerGamePacketListenerImpl .handleBlockEntityTagQuery and handleEntityTagQuery, for game masters only). Part of #3520 (its handleBlockEntityTagQuery and handleEntityTagQuery rows). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/commands.rs | 21 ++ crates/horizon-client/src/game/debug_keys.rs | 224 +++++++++++++++--- crates/horizon-client/src/game/lan_actions.rs | 52 ++++ crates/horizon-client/src/game/remote.rs | 5 + crates/horizon-protocol/src/packets/play.rs | 66 ++++++ tools/port_map/client-002.tsv | 2 +- tools/port_map/rest-003.tsv | 8 +- tools/port_map/rest-012.tsv | 6 +- tools/port_map/rest-018.tsv | 4 +- 9 files changed, 350 insertions(+), 38 deletions(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index cea056268c..8437eb3ba5 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -719,6 +719,27 @@ impl Game<'_> { }); dims } + /// `Level.getEntity`: the entity of the level in place with a network + /// id, players aside. + pub(in crate::game) fn level_entity(&self, id: i32) -> Option { + let id = u32::try_from(id).ok()?; + [ + Target::Mob(id), + Target::Item(id), + Target::Orb(id), + Target::Projectile(id), + Target::Stand(id), + Target::Frame(id), + Target::Painting(id), + Target::Boat(id), + Target::Cart(id), + Target::Other(id), + Target::Crystal(id), + Target::Dragon(id), + ] + .into_iter() + .find(|t| self.level_holds(*t)) + } /// Whether the level in place holds the entity a target names. fn level_holds(&self, t: Target) -> bool { match t { diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index 6e8cd9fb8c..b8b82666f1 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -14,6 +14,8 @@ use horizon_client::{ text::Text, }; use horizon_core::geometry::{Axis, Vec3}; +use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound}; +use horizon_world::nbt::Tag; use winit::keyboard::KeyCode; /// `ChatFormatting.YELLOW` and `RED`. @@ -217,7 +219,7 @@ impl Game<'_> { self.debug_charts.log_packets([sent, received]); } if shown { - use horizon_protocol::packets::{play::Serverbound, status::Ping}; + use horizon_protocol::packets::status::Ping; let time = self.debug_millis(); self.send(Serverbound::PingRequest(Ping { time })); } @@ -682,7 +684,7 @@ impl Game<'_> { /// `GameModeCommand.setGameMode` with its feedback. pub(super) fn change_game_mode(&mut self, mode: GameMode) { if self.remote.is_some() { - use horizon_protocol::packets::play::{ChangeGameMode, Serverbound}; + use horizon_protocol::packets::play::ChangeGameMode; let mode = match mode { GameMode::Survival => 0, GameMode::Creative => 1, @@ -698,10 +700,11 @@ impl Game<'_> { } } /// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a - /// `/setblock` or `/summon` command. In a single-player world the - /// server's data (no Shift) is the game's own. + /// `/setblock` or `/summon` command. The server's data (no Shift) comes + /// from the server the game is connected to, the integrated one included + /// (`DebugQueryHandler`); without one, the game's own data is the server's. fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) { - use horizon_client::java_text::format_fixed2 as f2; + let query = add_nbt && from_server && self.remote.is_some(); let side = if add_nbt && from_server { "server" } else { @@ -710,45 +713,135 @@ impl Game<'_> { if self.entity_targeted() && let Some((index, _)) = self.mob_hit() { - let (id, pos, mob) = { + let (kind, position, id) = { let m = &self.mobs[index]; (m.kind.id.clone(), m.position, m.id) }; - let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z)); - if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) { - if let horizon_world::nbt::Tag::Compound(c) = &mut tag { - for key in ["id", "UUID", "Pos"] { - c.remove(&key.into()); - } + if query { + // DebugQueryHandler.queryEntityTag: the answer copies it. + let reply = QueryReply::Entity { id, kind, position }; + if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) + { + self.send(Serverbound::EntityTagQuery(EntityTagQuery { + transaction, + entity: id as i32, + })); } - command.push(' '); - command.push_str(&horizon_client::snbt::pretty(&tag).plain()); + return; } - self.set_clipboard(&command); + // Entity.saveWithoutId. + let tag = if add_nbt { + self.entity_nbt(super::commands::Target::Mob(id)) + .map(|mut tag| { + if let Tag::Compound(c) = &mut tag { + c.remove(&"id".into()); + } + tag + }) + } else { + None + }; + self.copy_create_entity_command(&kind, position, tag); self.debug_translated(&format!("debug.inspect.{side}.entity")); return; } let Some(hit) = self.pick() else { return; }; - let p = hit.pos; - let mut description = self.state_string(self.world.block(p[0], p[1], p[2])); - if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) { - // BlockEntity.saveWithoutMetadata: no id or position. - let name = self.data_block_name(p); - let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name); - if let horizon_world::nbt::Tag::Compound(c) = &mut tag { - for key in ["id", "x", "y", "z", "keepPacked"] { - c.remove(&key.into()); - } + let pos = hit.pos; + let state = self.state_string(self.world.block(pos[0], pos[1], pos[2])); + if query { + // DebugQueryHandler.queryBlockEntityTag: the answer copies it. + let reply = QueryReply::Block { pos, state }; + if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) { + self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery { + transaction, + pos, + })); } - description.push_str(&horizon_client::snbt::to_snbt(&tag)); + return; + } + let tag = if add_nbt { + self.block_entity_tag(pos) + } else { + None + }; + self.copy_create_block_command(pos, &state, tag.as_ref()); + self.debug_translated(&format!("debug.inspect.{side}.block")); + } + /// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if + /// there is one: its data without the id or the position. + pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option { + let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?; + let name = self.data_block_name(pos); + let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name); + if let Tag::Compound(c) = &mut tag { + for key in ["id", "x", "y", "z", "keepPacked"] { + c.remove(&key.into()); + } + } + Some(tag) + } + /// `ClientPacketListener.handleTagQueryPacket`: the server's answer to + /// the last debug query (`DebugQueryHandler.handleResponse`) completes + /// it; an answer to an older query is ignored. + pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) { + let Some(reply) = self + .remote + .as_mut() + .and_then(|r| r.debug_query.answer(transaction)) + else { + return; + }; + // FriendlyByteBuf.readNbt: an End tag is no compound. + let tag = match tag { + Tag::End => None, + tag => Some(tag), + }; + match reply { + QueryReply::Block { pos, state } => { + self.copy_create_block_command(pos, &state, tag.as_ref()); + self.debug_translated("debug.inspect.server.block"); + } + QueryReply::Entity { id, kind, position } => { + // Entity.position() when the answer comes: where it is now, + // else where it was last. + let position = self + .mobs + .iter() + .find(|m| m.id == id) + .map_or(position, |m| m.position); + self.copy_create_entity_command(&kind, position, tag); + self.debug_translated("debug.inspect.server.entity"); + } + } + } + /// `KeyboardHandler.copyCreateBlockCommand`. + fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) { + let mut description = state.to_owned(); + if let Some(tag) = tag { + description.push_str(&horizon_client::snbt::to_snbt(tag)); } self.set_clipboard(&format!( "/setblock {} {} {} {description}", - p[0], p[1], p[2] + pos[0], pos[1], pos[2] )); - self.debug_translated(&format!("debug.inspect.{side}.block")); + } + /// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID + /// or position. + fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option) { + use horizon_client::java_text::format_fixed2 as f2; + let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z)); + if let Some(mut tag) = tag { + if let Tag::Compound(c) = &mut tag { + for key in ["UUID", "Pos"] { + c.remove(&key.into()); + } + } + command.push(' '); + command.push_str(&horizon_client::snbt::pretty(&tag).plain()); + } + self.set_clipboard(&command); } /// `KeyboardHandler.setClipboard`. fn set_clipboard(&mut self, text: &str) { @@ -1010,3 +1103,78 @@ fn step_view(step: [i32; 3]) -> (f32, f32) { (-x.atan2(z)).to_degrees() as f32, ) } + +/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server +/// and what its answer completes. Only the newest query is answered. +#[derive(Debug)] +pub(super) struct DebugQuery { + transaction: i32, + pending: Option, +} +impl Default for DebugQuery { + fn default() -> Self { + Self { + transaction: -1, + pending: None, + } + } +} +/// What a debug query's answer is copied into: the block command +/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`). +#[derive(Clone, Debug, PartialEq)] +pub(super) enum QueryReply { + Block { + pos: [i32; 3], + state: String, + }, + Entity { + id: u32, + kind: String, + position: Vec3, + }, +} +impl DebugQuery { + /// `DebugQueryHandler.startTransaction`: the next transaction id, which + /// replaces any query still waiting. + fn start(&mut self, reply: QueryReply) -> i32 { + self.pending = Some(reply); + self.transaction = self.transaction.wrapping_add(1); + self.transaction + } + /// `DebugQueryHandler.handleResponse`: what the answer to `transaction` + /// completes, once, when it answers the newest query. + fn answer(&mut self, transaction: i32) -> Option { + if transaction == self.transaction { + self.pending.take() + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block(x: i32) -> QueryReply { + QueryReply::Block { + pos: [x, 64, 0], + state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(), + } + } + + #[test] + fn a_debug_query_is_answered_once_and_only_the_newest() { + let mut q = DebugQuery::default(); + // The first transaction is 0 (the handler starts at -1). + assert_eq!(q.start(block(1)), 0); + assert_eq!(q.answer(0), Some(block(1))); + assert_eq!(q.answer(0), None); + // A second press before the first answer: the older answer is ignored + // and the newer one copies the newer target. + assert_eq!(q.start(block(2)), 1); + assert_eq!(q.start(block(3)), 2); + assert_eq!(q.answer(1), None); + assert_eq!(q.answer(2), Some(block(3))); + } +} diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 550c1d4340..d3185fc4aa 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1578,6 +1578,33 @@ impl Game<'_> { self.send_admin_feedback(host, Some(conn)); } } + // handleEntityTagQuery: a game master's F3+I on an entity of their + // level gets its Entity.saveWithoutId. + S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => { + if let Some(tag) = self.level_entity_tag(host, p.entity) { + host.hub.send_to( + conn, + &C::TagQuery(play::TagQuery { + transaction: p.transaction, + tag, + }), + ); + } + } + // handleBlockEntityTagQuery: BlockEntity.saveWithoutMetadata, or + // null where there is no block entity. + S::BlockEntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => { + let tag = self + .block_entity_tag(p.pos) + .unwrap_or(horizon_world::nbt::Tag::End); + host.hub.send_to( + conn, + &C::TagQuery(play::TagQuery { + transaction: p.transaction, + tag, + }), + ); + } S::Unknown(id, payload) if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION => { @@ -1631,6 +1658,31 @@ impl Game<'_> { } } + /// `ServerLevel.getEntity(id)`'s `Entity.saveWithoutId`: the player or + /// other entity of the level in place with that network id. + fn level_entity_tag(&mut self, host: &mut LanHost, id: i32) -> Option { + use super::commands::Target; + let player = host + .hub + .guests + .iter() + .find(|g| g.entity_id == id && host.hub.here(g)) + .map(|g| g.conn); + let mut tag = match player { + Some(player) => self + .as_guest(host, player, |g| g.entity_nbt(Target::Player)) + .flatten()?, + None => { + let target = self.level_entity(id)?; + self.entity_nbt(target)? + } + }; + if let horizon_world::nbt::Tag::Compound(c) = &mut tag { + c.remove(&"id".into()); + } + Some(tag) + } + /// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack /// is selected, else a creative player gets the item; the held slot is sent /// back and the changed slots follow with the tick's inventory changes. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 8e715916eb..ac4f547716 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -250,6 +250,9 @@ pub(super) struct Remote { /// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server /// Links..." in the pause menu lists (only `http`/`https` URIs kept). pub(super) server_links: Vec, + /// `ClientPacketListener.getDebugQueryHandler`: the F3+I query waiting + /// for the server's data. + pub(super) debug_query: super::debug_keys::DebugQuery, } /// The time a frame spends on chunk packets before it leaves the rest of @@ -551,6 +554,7 @@ impl Game<'_> { backlog: VecDeque::new(), backlog_since: None, server_links: Vec::new(), + debug_query: Default::default(), transfer: None, }); // The level's TickRateManager follows the server's ticking packets. @@ -3047,6 +3051,7 @@ impl Game<'_> { self.capture(true); } } + C::TagQuery(p) => self.answer_tag_query(p.transaction, p.tag), C::Unknown(id, data) if id == horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS => { diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index a27ca12007..ff5495ec81 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -838,6 +838,11 @@ packet!(Sound { seed: i64 => Long, }); packet!(SystemChat { content: Tag => Nbt, overlay: bool => Bool }); +packet!( + /// ClientboundTagQueryPacket: the answer to a debug NBT query (F3+I), with + /// the transaction it answers; `Tag::End` when there was no block entity. + TagQuery { transaction: i32 => VarInt, tag: Tag => Nbt } +); packet!(TakeItemEntity { item: i32 => VarInt, player: i32 => VarInt, amount: i32 => VarInt }); packet!(TeleportEntity { id: i32 => VarInt, @@ -1925,6 +1930,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { SwingAnimation(SwingAnimation) = SWING_ANIMATION, SystemChat(SystemChat) = SYSTEM_CHAT, TabList(TabList) = TAB_LIST, + TagQuery(TagQuery) = TAG_QUERY, TakeItemEntity(TakeItemEntity) = TAKE_ITEM_ENTITY, TeleportEntity(TeleportEntity) = TELEPORT_ENTITY, TickingState(TickingState) = TICKING_STATE, @@ -2162,6 +2168,16 @@ packet!( } ); packet!(SelectTrade { item: i32 => VarInt }); +packet!( + /// ServerboundBlockEntityTagQueryPacket: an operator's F3+I asks for the + /// block entity's data at a position. + BlockEntityTagQuery { transaction: i32 => VarInt, pos: [i32; 3] => Pos } +); +packet!( + /// ServerboundEntityTagQueryPacket: an operator's F3+I asks for an + /// entity's data, by its network id. + EntityTagQuery { transaction: i32 => VarInt, entity: i32 => VarInt } +); packet!( /// ServerboundContainerButtonClickPacket: `AbstractContainerMenu.clickMenuButton` /// (the loom's patterns, the stonecutter's recipes, the enchanting table's offers). @@ -2310,6 +2326,8 @@ packet_enum!(Serverbound, crate::ids::play::serverbound { SetGameRule(SetGameRule) = SET_GAME_RULE, AcceptTeleportation(AcceptTeleportation) = ACCEPT_TELEPORTATION, Attack(Attack) = ATTACK, + BlockEntityTagQuery(BlockEntityTagQuery) = BLOCK_ENTITY_TAG_QUERY, + EntityTagQuery(EntityTagQuery) = ENTITY_TAG_QUERY, TeleportToEntity(TeleportToEntity) = TELEPORT_TO_ENTITY, ChangeGameMode(ChangeGameMode) = CHANGE_GAME_MODE, Chat(Chat) = CHAT, @@ -3049,6 +3067,54 @@ mod tests { assert_eq!(body[body.len() - 1], 0); } + #[test] + fn debug_tag_queries_decode_as_their_packets() { + use crate::ids::play::{clientbound, serverbound}; + // ServerboundBlockEntityTagQueryPacket: VarInt transaction, BlockPos. + let pos = [3, 64, -2]; + let mut body = vec![7]; + body.extend_from_slice(&crate::buf::pack_block_pos(pos).to_be_bytes()); + let packet = Serverbound::decode(serverbound::BLOCK_ENTITY_TAG_QUERY, &body).unwrap(); + assert_eq!( + packet, + Serverbound::BlockEntityTagQuery(BlockEntityTagQuery { + transaction: 7, + pos + }) + ); + round_trip(packet, Serverbound::encode, Serverbound::decode); + // ServerboundEntityTagQueryPacket: VarInt transaction, VarInt entity id. + let packet = Serverbound::decode(serverbound::ENTITY_TAG_QUERY, &[0, 42]).unwrap(); + assert_eq!( + packet, + Serverbound::EntityTagQuery(EntityTagQuery { + transaction: 0, + entity: 42 + }) + ); + round_trip(packet, Serverbound::encode, Serverbound::decode); + // ClientboundTagQueryPacket: VarInt transaction, then the nullable + // compound of FriendlyByteBuf.writeNbt (a bare End tag for null). + let packet = Clientbound::decode(clientbound::TAG_QUERY, &[1, 0]).unwrap(); + assert_eq!( + packet, + Clientbound::TagQuery(TagQuery { + transaction: 1, + tag: Tag::End + }) + ); + let mut items = horizon_world::nbt::Compound::new(); + items.insert("Lock".into(), Tag::String("key".into())); + round_trip( + Clientbound::TagQuery(TagQuery { + transaction: 2, + tag: Tag::Compound(items), + }), + Clientbound::encode, + Clientbound::decode, + ); + } + #[test] fn change_game_mode_uses_the_game_type_varint() { for mode in 0..=3 { diff --git a/tools/port_map/client-002.tsv b/tools/port_map/client-002.tsv index 667ffbd0a9..18523a905c 100644 --- a/tools/port_map/client-002.tsv +++ b/tools/port_map/client-002.tsv @@ -48,7 +48,7 @@ ClientPacketListener.handleCommands port crates/horizon-client/src/game/remote.r ClientPacketListener.handleStopSoundEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet StopSound arm: audio stop_sounds ClientPacketListener.handleUpdateRecipes skip:data Horizon reads recipes from its own tables; the server's list is not read ClientPacketListener.handleLookAt missing player: a server command that turns the camera (/faceplayer, /tp with facing) does not turn this player's view -ClientPacketListener.handleTagQueryPacket skip:debug answer to a debug NBT query; Horizon sends none +ClientPacketListener.handleTagQueryPacket port crates/horizon-client/src/game/debug_keys.rs::Game::answer_tag_query reached from the TagQuery arm of remote.rs handle_packet ClientPacketListener.handleAwardStats port crates/horizon-client/src/game/remote.rs::Game::handle_packet AwardStats arm: known stat keys into the stats values ClientPacketListener.handleRecipeBookAdd port crates/horizon-client/src/game/recipe_book.rs::Game::remote_recipe_book_add ClientPacketListener.handleRecipeBookRemove port crates/horizon-client/src/game/recipe_book.rs::Game::remote_recipe_book_remove diff --git a/tools/port_map/rest-003.tsv b/tools/port_map/rest-003.tsv index a516d91e72..e2712197f6 100644 --- a/tools/port_map/rest-003.tsv +++ b/tools/port_map/rest-003.tsv @@ -42,10 +42,10 @@ ClientRecipeBook.categorizeAndGroupRecipes port crates/horizon-client/src/recipe CommandHistory.addCommand port crates/horizon-client-render/src/chat.rs::Chat::add_recent no repeats; keeps the last 50 saved commands CommandHistory.save inline crates/horizon-client-render/src/chat.rs::Chat::add_recent writes command_history.txt there ComponentCollector.getResult skip:java collector helper -DebugQueryHandler.handleResponse skip:debug F3 NBT query: packet ids exist in horizon-protocol but nothing sends them -DebugQueryHandler.startTransaction skip:debug F3 NBT query transaction; see handleResponse -DebugQueryHandler.queryEntityTag skip:debug F3 entity NBT query; see handleResponse -DebugQueryHandler.queryBlockEntityTag skip:debug F3 block entity NBT query; see handleResponse +DebugQueryHandler.handleResponse port crates/horizon-client/src/game/debug_keys.rs::DebugQuery::answer +DebugQueryHandler.startTransaction port crates/horizon-client/src/game/debug_keys.rs::DebugQuery::start +DebugQueryHandler.queryEntityTag inline crates/horizon-client/src/game/debug_keys.rs::Game::copy_recreate_command the query waits in Remote.debug_query +DebugQueryHandler.queryBlockEntityTag inline crates/horizon-client/src/game/debug_keys.rs::Game::copy_recreate_command the query waits in Remote.debug_query DeltaTracker.Timer.pause port crates/horizon-client/src/clock.rs::Timer::update_pause_state residual kept while paused DeltaTracker.Timer.unPause port crates/horizon-client/src/clock.rs::Timer::update_pause_state residual restored on resume GameNarrator missing minor: no text-to-speech; the narrator option is stored but nothing is spoken diff --git a/tools/port_map/rest-012.tsv b/tools/port_map/rest-012.tsv index d4ec2e8c9c..6a335497d2 100644 --- a/tools/port_map/rest-012.tsv +++ b/tools/port_map/rest-012.tsv @@ -88,11 +88,11 @@ ClientboundSetPlayerTeamPacket.shouldHavePlayerList port crates/horizon-protocol ClientboundSetTitlesAnimationPacket.write port crates/horizon-protocol/src/buf.rs::write packet! TitlesAnimation {fade_in, stay, fade_out Int} ClientboundSoundEntityPacket.write port crates/horizon-protocol/src/buf.rs::write packet! SoundEntity with the sound holder ClientboundSoundPacket.write port crates/horizon-protocol/src/buf.rs::write packet! Sound with the sound holder and fixed-point position -ClientboundTagQueryPacket.write missing minor: answers to F3+I NBT queries are not handled; Horizon has no tag_query packet (ids.rs only) +ClientboundTagQueryPacket.write port crates/horizon-protocol/src/buf.rs::write packet! TagQuery {transaction VarInt, tag Nbt} ClientboundTakeItemEntityPacket.write port crates/horizon-protocol/src/buf.rs::write packet! TakeItemEntity {item, player, amount VarInt} DebugEntityNameGenerator.getEntityName skip:debug debug name for entities -ServerboundBlockEntityTagQueryPacket.write missing minor: the F3+I block NBT query is not sent; Horizon has no such packet (ids.rs only) -ServerboundEntityTagQueryPacket.write missing minor: the F3+I entity NBT query is not sent; Horizon has no such packet (ids.rs only) +ServerboundBlockEntityTagQueryPacket.write port crates/horizon-protocol/src/buf.rs::write packet! BlockEntityTagQuery {transaction VarInt, pos Pos} +ServerboundEntityTagQueryPacket.write port crates/horizon-protocol/src/buf.rs::write packet! EntityTagQuery {transaction VarInt, entity VarInt} ServerboundJigsawGeneratePacket.write missing minor: no jigsaw generate packet; the client has no jigsaw screen (only block_entity_nbt reads jigsaw data) ServerboundMovePlayerPacket.packFlags inline crates/horizon-client/src/player_sync.rs::PositionSender::update on-ground and horizontal-collision bits set when the move is sent ServerboundPlayerActionPacket.write port crates/horizon-protocol/src/buf.rs::write packet! PlayerAction {action VarInt, pos, direction UByte, sequence VarInt} diff --git a/tools/port_map/rest-018.tsv b/tools/port_map/rest-018.tsv index f6c41fbcce..2aa0dae7dc 100644 --- a/tools/port_map/rest-018.tsv +++ b/tools/port_map/rest-018.tsv @@ -24,8 +24,8 @@ ServerGamePacketListenerImpl.handleJigsawGenerate missing player: the jigsaw bl ServerGamePacketListenerImpl.handleSelectTrade port crates/horizon-client/src/game/lan_trading.rs::Game::lan_select_trade ServerGamePacketListenerImpl.updateBookContents port crates/horizon-client/src/game/books.rs::Game::write_book writes the pages into the writable book in the slot ServerGamePacketListenerImpl.filterableFromOutgoing skip:service text filter pass-through: with no filter on the LAN host the page is the raw text -ServerGamePacketListenerImpl.handleEntityTagQuery missing player: an op's entity NBT query (ENTITY_TAG_QUERY) gets no reply -ServerGamePacketListenerImpl.handleBlockEntityTagQuery missing player: an op's block entity NBT query (BLOCK_ENTITY_TAG_QUERY) gets no reply +ServerGamePacketListenerImpl.handleEntityTagQuery port crates/horizon-client/src/game/lan_actions.rs::Game::level_entity_tag reached from the EntityTagQuery arm of lan_guest_packet, which checks permission 2 or more +ServerGamePacketListenerImpl.handleBlockEntityTagQuery inline crates/horizon-client/src/game/lan_actions.rs::Game::lan_guest_packet the BlockEntityTagQuery arm, with debug_keys.rs block_entity_tag ServerGamePacketListenerImpl.shouldCheckPlayerMovement inline crates/horizon-client/src/lan/hub.rs::Hub::position_change gap: minor: the changing-dimension exemption is not checked; the single-player owner, movement and elytra gates are ServerGamePacketListenerImpl.forceSendPlayerSupportBlocks missing minor: the blocks under a player standing on air are never sent again (vanilla's desync fix) ServerGamePacketListenerImpl.updateAwaitingTeleport port crates/horizon-client/src/lan/hub.rs::Hub::move_player gap: minor: vanilla runs it every tick; here it runs only when a move packet arrives From 6be3dad37bd36795434267db5089e10edd2f943b Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:00:36 +0700 Subject: [PATCH 289/968] lan_e2e: tag-query check for an operator guest's F3+I Co-Authored-By: Claude Opus 5.5 --- tools/lan_e2e.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index fe247b119c..ba647e6802 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -328,7 +328,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sp 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'occupied_horse', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', - 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks'] + 'breath', 'lava', 'levels', 'drops', 'underwater', 'fireworks', 'tag-query'] class Game: @@ -3499,6 +3499,44 @@ def sparked(player, other, kinds, act, ticks=20): return seen +def copy_recreate_command(game, check): + """F3+I (KeyboardHandler.copyRecreateCommand): what it copied once `check` accepts it.""" + game.raw('key', 'f3', 'down') + game.raw('key', 'i', 'tap') + game.raw('key', 'f3', 'up') + return game.until(lambda: game('status')['debug']['clipboard'], lambda c: bool(c) and check(c), ticks=40) + + +def check_tag_query(host, guest, out): + """An operator guest's F3+I copies the server's data (#3518): DebugQueryHandler's tag queries, + answered by ServerGamePacketListenerImpl.handleBlockEntityTagQuery and handleEntityTagQuery. + A chest's items and an entity's tags never reach the client, so only the server's answer has them.""" + x, y, z = (int(v // 1) for v in pos_of(guest)) + chest = (x, y, z + 2) + guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} ' + 'minecraft:chest{Items:[{Slot:0b,id:"minecraft:diamond",count:5}]}') + guest.wait(10) + guest.raw('look-at-block', *chest) + guest.wait(2) + block = copy_recreate_command(guest, lambda c: c.startswith(f'/setblock {chest[0]} {chest[1]} {chest[2]} ')) + block_said = guest.raw('messages', 1)['chat'] + guest('command', command=f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:air') + guest('command', command=f'summon minecraft:pig {x + 0.5} {y} {z + 2.5} {{NoAI:1b,Tags:["tag_query"]}}') + guest.wait(20) + pig = guest.raw('entities', 6, 'pig')['entities'][0] + guest.raw('look-at-entity', pig['id']) + guest.wait(2) + entity = copy_recreate_command(guest, lambda c: c.startswith('/summon minecraft:pig ')) + entity_said = guest.raw('messages', 1)['chat'] + guest.shot(out, 'tag-query') + guest('command', command='kill @e[type=minecraft:pig,tag=tag_query]') + assert 'id:"minecraft:diamond"' in block, f"the chest's items are missing: {block}" + assert block_said == ['[Debug]: Copied server-side block data to clipboard'], block_said + assert 'Tags: ["tag_query"]' in entity, f"the pig's tags are missing: {entity}" + assert entity_said == ['[Debug]: Copied server-side entity data to clipboard'], entity_said + return {'block': block, 'entity': entity} + + def check_fireworks(host, guest, out): """Event 17 must arrive before removal, with the item's colored, trailing explosion (#1478). @@ -5407,7 +5445,8 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'hud': check_hud, 'sidebar': check_sidebar, 'border': check_border, - 'particles': check_particles, 'fireworks': check_fireworks} + 'particles': check_particles, 'fireworks': check_fireworks, + 'tag-query': check_tag_query} def run(ctl, out, resources, checks): From 16356bdc5799d7ce2dc5e1c851bd0366d081cc71 Mon Sep 17 00:00:00 2001 From: d3grando <36463198+d3grando@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:07:16 +0700 Subject: [PATCH 290/968] Explosions break paintings, cushions and armour stands The explosion entity pass only reached players, mobs, items, primed TNT, minecarts and item frames, so a blast left paintings, cushions and armour stands standing. Vanilla's ServerExplosion.hurtEntities hurts them too: BlockAttachedEntity.hurtServer kills a painting or cushion and drops its item, and ArmorStand.hurtServer with an IS_EXPLOSION source runs brokenByAnything (its equipment pops, the stand item does not) and kill. An invisible or marker stand is spared (ArmorStand.ignoreExplosion and hurtServer), and without mobGriefing a block-keeping or mob-caused blast spares them all (shouldAffectBlocklikeEntities, the Mob source check); item frames now share that gate, which also counts a projectile's owner. Vanilla lists the entities in reach before hurting any, so the items these entities drop are not caught by the same blast. Frames and the new decorations now break after the item pass, so their drops survive instead of being destroyed at once. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/armor_stands.rs | 46 +++++-- crates/horizon-client/src/game/explosion.rs | 114 +++++++++++++++++- crates/horizon-client/src/game/frames.rs | 10 +- 3 files changed, 151 insertions(+), 19 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 7019dc8245..c7f6d10bbc 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -635,17 +635,23 @@ impl Game<'_> { } } for i in dead.into_iter().rev() { - let a = self.armor_stands.remove(i); - let at = [a.position.x, a.position.y, a.position.z]; - let (pos, drops) = burnt_armor_stand_equipment(a); - for stack in drops { - self.pop_resource(pos, stack); - } - self.audio - .play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0); - self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + self.break_armor_stand_by_anything(i); } } + /// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast + /// breaks a stand: its equipment pops above its block, the stand item does + /// not drop. + pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) { + let a = self.armor_stands.remove(i); + let at = [a.position.x, a.position.y, a.position.z]; + let (pos, drops) = broken_armor_stand_equipment(a); + for stack in drops { + self.pop_resource(pos, stack); + } + self.audio + .play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0); + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + } /// ArmorStandRenderer: the stand with its default pose, then armor layers. pub(super) fn armor_stand_geometry( &self, @@ -1055,7 +1061,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> { }) } -fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { +/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer` +/// refusing an invisible or marker stand: such a stand outlasts any blast. +pub(super) fn spared_by_blast(a: &ArmorStand) -> bool { + a.invisible || a.marker +} + +/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing +/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`. +fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { let pos = [ a.position.x.floor() as i32, a.position.y.floor() as i32 + 1, @@ -1073,6 +1087,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { mod burn_drop_tests { use super::*; #[test] + fn only_visible_non_marker_stands_are_broken_by_blasts() { + let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0); + assert!(!spared_by_blast(&stand)); + stand.invisible = true; + assert!(spared_by_blast(&stand)); + stand.invisible = false; + stand.marker = true; + assert!(spared_by_blast(&stand)); + } + #[test] fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() { let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0); let kept = ItemStack::new("minecraft:diamond_boots", 1); @@ -1083,7 +1107,7 @@ mod burn_drop_tests { .push(("minecraft:vanishing_curse".into(), 1)); stand.equipment[0] = Some(kept.clone()); stand.equipment[1] = Some(vanished); - let (pos, drops) = burnt_armor_stand_equipment(stand); + let (pos, drops) = broken_armor_stand_equipment(stand); assert_eq!(pos, [-1, 5, 0]); assert_eq!(drops, vec![kept]); } diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 12e136ae11..5945ad1f49 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -89,6 +89,29 @@ pub(super) enum BlockInteraction { DestroyWithDecay, } +/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is +/// within the blast's diameter of the centre. +pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool { + let d = + ((at[0] - center[0]).powi(2) + (at[1] - center[1]).powi(2) + (at[2] - center[2]).powi(2)) + .sqrt(); + d / diameter <= 1.0 +} + +/// Whether a blast leaves item frames, paintings, cushions and armour stands +/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored +/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their +/// `ignoreExplosion`; no wind charge goes through `server_explode`), and one a +/// mob causes does no harm (`BlockAttachedEntity.hurtServer` and +/// `ArmorStand.hurtServer`: `source.getEntity() instanceof Mob`). +pub(super) fn blast_spares_decorations( + mob_griefing: bool, + interaction: BlockInteraction, + by_mob: bool, +) -> bool { + !mob_griefing && (interaction == BlockInteraction::Keep || by_mob) +} + thread_local! { /// Client-side randomness (explosion sound pitch), separate from the level random. static CLIENT_RANDOM: std::cell::RefCell = std::cell::RefCell::new(LegacyRandom::new(0x5eed)); @@ -446,7 +469,6 @@ impl Game<'_> { ); } self.mobs = mobs; - self.explosion_breaks_frames(center, diameter, interaction); // Dropped items: 5 health, destroyed by the explosion damage (nether stars resist). let mut items = std::mem::take(&mut self.dropped); items.retain_mut(|item| { @@ -525,6 +547,73 @@ impl Game<'_> { c.velocity.z + dir[2] * power, ); } + // Frames, paintings, cushions and armour stands last: vanilla lists + // the entities in reach before hurting any, so the items they drop + // here are neither destroyed nor pushed by the same blast. + self.explosion_breaks_frames(center, diameter, interaction); + self.explosion_breaks_decorations(center, diameter, interaction); + } + /// `blast_spares_decorations` for the blast going off now: the mob that + /// owns its projectile or explodes itself is the source's entity. + pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool { + let by_mob = self + .combat + .explosion_owner + .or(self.combat.explosion_source) + .is_some(); + blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob) + } + /// `ServerExplosion.hurtEntities` for paintings, cushions and armour + /// stands (item frames: `explosion_breaks_frames`). A painting or cushion + /// in reach is killed and dropped (`BlockAttachedEntity.hurtServer`, + /// `Painting.dropItem`, `Cushion.dropItem`). An armour stand that is + /// neither invisible nor a marker breaks (`ArmorStand.hurtServer` with + /// `IS_EXPLOSION`: `brokenByAnything`, then `kill`): its equipment drops, + /// the stand itself does not. + /// Not ported: `BlockAttachedEntity.ignoreExplosion` also spares paintings + /// and cushions when the exploding entity is in water. + fn explosion_breaks_decorations( + &mut self, + center: [f64; 3], + diameter: f64, + interaction: BlockInteraction, + ) { + if self.blast_spares_decorations(interaction) { + return; + } + let mut i = 0; + while i < self.paintings.len() { + // HangingEntity.recalculateBoundingBox: a painting stands at the + // centre of its box. + let (lo, hi) = self.paintings[i].bounds(); + let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0); + if blast_reaches(center, diameter, at) { + self.break_painting(i, false); + } else { + i += 1; + } + } + let mut i = 0; + while i < self.cushions.len() { + let p = self.cushions[i].position; + if blast_reaches(center, diameter, [p.x, p.y, p.z]) { + self.break_cushion(i, false); + } else { + i += 1; + } + } + let mut i = 0; + while i < self.armor_stands.len() { + let a = &self.armor_stands[i]; + let p = a.position; + if !super::armor_stands::spared_by_blast(a) + && blast_reaches(center, diameter, [p.x, p.y, p.z]) + { + self.break_armor_stand_by_anything(i); + } else { + i += 1; + } + } } /// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying /// and plain destruction. @@ -758,6 +847,29 @@ fn add_or_append_stack( mod tests { use super::*; + #[test] + fn blasts_reach_entities_within_their_diameter() { + // A TNT blast: radius 4, so the entity pass reaches 8 blocks out. + let center = [0.5, 64.0, 0.5]; + assert!(blast_reaches(center, 8.0, [8.5, 64.0, 0.5])); + assert!(blast_reaches(center, 8.0, [-3.5, 68.0, 4.5])); + assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5])); + } + + #[test] + fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() { + use BlockInteraction::{Destroy, DestroyWithDecay, Keep}; + // mobGriefing on: every blast breaks them, a creeper's included. + assert!(!blast_spares_decorations(true, DestroyWithDecay, true)); + assert!(!blast_spares_decorations(true, Keep, true)); + // Off: TNT still breaks them (ExplosionInteraction.TNT destroys blocks). + assert!(!blast_spares_decorations(false, Destroy, false)); + assert!(!blast_spares_decorations(false, DestroyWithDecay, false)); + // Off: a blast that keeps blocks, or one a mob causes, does not. + assert!(blast_spares_decorations(false, Keep, false)); + assert!(blast_spares_decorations(false, Destroy, true)); + } + #[test] fn block_pos_hash_matches_vec3i() { // Vec3i.hashCode: (y + z * 31) * 31 + x diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index aa108c01a7..db9e4a4998 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -298,9 +298,8 @@ impl Game<'_> { } /// `ServerExplosion.hurtEntities` for item frames: a frame within the blast /// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`). - /// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast - /// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through - /// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames. + /// A fixed frame is not hurt by an explosion, and `blast_spares_decorations` + /// keeps every frame without `mobGriefing`. /// Not ported: `ignoreExplosion` also spares frames when the exploding /// entity is in water. pub(super) fn explosion_breaks_frames( @@ -309,10 +308,7 @@ impl Game<'_> { diameter: f64, interaction: super::explosion::BlockInteraction, ) { - if !self.ui.rules.bool("mob_griefing") - && (interaction == super::explosion::BlockInteraction::Keep - || self.combat.explosion_source.is_some()) - { + if self.blast_spares_decorations(interaction) { return; } let mut i = 0; From 4b14acc1f6a811bea05f20fd3a63815b0b9e8e02 Mon Sep 17 00:00:00 2001 From: abduvaliy-hbai Date: Fri, 30 Oct 2026 12:07:20 +0500 Subject: [PATCH 291/968] Sync block entity data edits to connected clients Mark block entity NBT changes dirty and queue an update even when the block state is unchanged, so spawner mob previews refresh after data commands. Add an isolated screenshot regression for zombie/pig/zombie transitions. Co-Authored-By: OpenAI Codex --- .../horizon-client/src/game/commands/data.rs | 3 + docs/media/spawner-data/after-pig.png | Bin 0 -> 209915 bytes docs/media/spawner-data/after-zombie.png | Bin 0 -> 216091 bytes docs/media/spawner-data/before.png | Bin 0 -> 214198 bytes tools/spawner_display_e2e.py | 105 ++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 docs/media/spawner-data/after-pig.png create mode 100644 docs/media/spawner-data/after-zombie.png create mode 100644 docs/media/spawner-data/before.png create mode 100644 tools/spawner_display_e2e.py diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 3657391e79..0999423210 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -318,6 +318,9 @@ impl Game<'_> { self.take_loot_table(key, &tag, &mut new); self.world.block_entities.insert(key, new); self.update_spawner_display(key); + // BlockDataAccessor.setData: setChanged and sendBlockUpdated(3), + // even when the block state itself stays the same. + self.mark_block_entity_updated(key); let state = self.world.block(p[0], p[1], p[2]); // Redraw what shows the block entity (signs, banners). let _ = self.world.set(p[0], p[1], p[2], state); diff --git a/docs/media/spawner-data/after-pig.png b/docs/media/spawner-data/after-pig.png new file mode 100644 index 0000000000000000000000000000000000000000..f132f2f1b016022a662fb479b0ab5b347bd13b49 GIT binary patch literal 209915 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 `qz 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