mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge d2dafa504c into pvp/view-time-weather
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
cd788cd4c7
235 files changed
+10267
-801
No files matched your search
@@ -1,3 +1,4 @@
|
||||
self-hosted-runner:
|
||||
# The Mac mini's runner (ludovics-mac-mini), which holds the maintainer's own Minecraft files.
|
||||
labels: [horizon-game, ludovics-mac-mini]
|
||||
# windows-11-vs2026-arm is GitHub's Windows ARM64 image (2026-08), newer than actionlint's label list.
|
||||
labels: [horizon-game, ludovics-mac-mini, windows-11-vs2026-arm]
|
||||
@@ -159,6 +159,89 @@ jobs:
|
||||
cargo +stable fmt --check
|
||||
cargo +stable clippy --all-targets --locked -- -D warnings
|
||||
cargo +stable test --locked --lib
|
||||
# Compile and test Windows ARM64 on an actual ARM64 host, and inspect the resulting PE headers. By hand only
|
||||
# (`gh workflow run checks --ref <branch>`): GitHub's runner queue is the bottleneck, and main's runs gate the
|
||||
# releases (tools/release_train.py waits for them), so neither pull requests nor main's pushes start this
|
||||
# 90-minute job. The Nightly and launcher releases build and check these binaries on every run.
|
||||
windows-arm64:
|
||||
name: Windows ARM64 game and launcher
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: windows-11-vs2026-arm
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
RUSTFLAGS: -C target-feature=+crt-static
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Use every runner core for Cargo
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cores = (Get-CimInstance Win32_Processor | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
|
||||
"CARGO_BUILD_JOBS=$cores" >> $env:GITHUB_ENV
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: windows-arm64-game
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: launcher
|
||||
shared-key: windows-arm64-launcher
|
||||
- name: Check LLVM assembler used by ring
|
||||
shell: pwsh
|
||||
run: |
|
||||
$llvmBin = Join-Path $env:ProgramFiles 'LLVM\bin'
|
||||
if (-not (Get-Command clang -ErrorAction SilentlyContinue) -and (Test-Path (Join-Path $llvmBin 'clang.exe'))) {
|
||||
$env:PATH = "$llvmBin;$env:PATH"
|
||||
"PATH=$env:PATH" >> $env:GITHUB_ENV
|
||||
}
|
||||
$clang = Get-Command clang -ErrorAction Stop
|
||||
Write-Output "Using $($clang.Source)"
|
||||
& $clang.Source --version
|
||||
- name: Build the game for native Windows ARM64
|
||||
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Check the game's PE machine
|
||||
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Check game DLL imports
|
||||
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
|
||||
- name: Start every game executable
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
|
||||
$output = & $client --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
|
||||
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --help 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --help failed" }
|
||||
}
|
||||
foreach ($binary in @('horizon-prepare.exe', 'horizon-server.exe')) {
|
||||
$path = "target/aarch64-pc-windows-msvc/release/$binary"
|
||||
$output = & $path --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw "$binary --version failed" }
|
||||
Write-Output ($output -join "`n")
|
||||
}
|
||||
- name: Test and build the native Windows ARM64 launcher
|
||||
working-directory: launcher
|
||||
run: |
|
||||
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
|
||||
cargo build --release --locked --target aarch64-pc-windows-msvc
|
||||
- name: Check the launcher's PE machine
|
||||
working-directory: launcher
|
||||
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
|
||||
- name: Check launcher DLL imports and command-line startup
|
||||
working-directory: launcher
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
|
||||
python ../tools/check_imports.py $launcher
|
||||
$output = & $launcher --version 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
|
||||
Write-Output ($output -join "`n")
|
||||
# Most pull requests leave the harnesses to parity-main, which runs them on main a batch of merges at a time
|
||||
# (.github/workflows/parity-main.yml; releases wait for it). A pull request still runs them itself when it changes
|
||||
# a harness, an oracle, a probe or the runner, or carries the run-parity label (run-parity.yml, when it is added).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: game build
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
|
||||
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server, Windows x64
|
||||
# and ARM64 on GitHub, and macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
|
||||
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
|
||||
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
|
||||
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
|
||||
@@ -83,6 +83,10 @@ jobs:
|
||||
build:
|
||||
needs: exporters
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -99,8 +103,18 @@ jobs:
|
||||
dir: horizon-windows-x64
|
||||
asset: horizon-windows-x64.zip
|
||||
exe: .exe
|
||||
pe_arch: x64
|
||||
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
dir: horizon-windows-arm64
|
||||
asset: horizon-windows-arm64.zip
|
||||
exe: .exe
|
||||
pe_arch: arm64
|
||||
# The ARM64 runner builds and runs native ARM64 executables; keep the C runtime in the archive.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: macos
|
||||
mac: true
|
||||
dir: horizon-macos-arm64
|
||||
@@ -156,6 +170,12 @@ jobs:
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
|
||||
done
|
||||
- name: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
python tools/check_pe_arch.py "${{ matrix.pe_arch }}" \
|
||||
"$BIN/horizon-client${{ matrix.exe }}" "$BIN/horizon-ctl${{ matrix.exe }}" \
|
||||
"$BIN/horizon-prepare${{ matrix.exe }}" "$BIN/horizon-server${{ matrix.exe }}"
|
||||
- name: The signing keychain, unlocked
|
||||
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
|
||||
env:
|
||||
@@ -188,7 +208,7 @@ jobs:
|
||||
- name: Baseline x86-64 CPUs only
|
||||
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
|
||||
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
|
||||
if: runner.os != 'macOS'
|
||||
if: runner.os != 'macOS' && matrix.pe_arch != 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup component add llvm-tools
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: launcher
|
||||
# Builds the launcher on Linux, macOS and Windows, and releases it by itself (docs/release-flow.md): after a
|
||||
# Builds the launcher on Linux, macOS, Windows x64 and ARM64, and releases it by itself (docs/release-flow.md): after a
|
||||
# push to main that changes the launcher or a crate it builds in (the paths below),
|
||||
# every 10 minutes (a release waiting for merges to settle goes out) and by hand, tools/release_train.py decides as
|
||||
# for the game.
|
||||
@@ -15,8 +15,9 @@ name: launcher
|
||||
# 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.
|
||||
# Windows ships the launcher's own binary twice: in the ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe, which opens the per-user installer instead of Home (launcher/src/installer.rs).
|
||||
# Windows ships each architecture's launcher in its ZIP (what a launcher updates itself from) and as
|
||||
# horizon-launcher-setup.exe or horizon-launcher-setup-arm64.exe, which opens the per-user installer instead of Home
|
||||
# (launcher/src/installer.rs).
|
||||
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
|
||||
# gh workflow run launcher --ref main -f dry_run=false -f force=true
|
||||
|
||||
@@ -102,6 +103,10 @@ jobs:
|
||||
needs: decide
|
||||
if: needs.decide.outputs.release == 'true'
|
||||
name: build ${{ matrix.name }}
|
||||
# Windows ARM64 is new: its failure never holds the other platforms' release (the signing job
|
||||
# needs this job, and a failed entry of it would fail it). The release steps leave the missing
|
||||
# ARM64 files out with a warning.
|
||||
continue-on-error: ${{ matrix.name == 'windows-arm64' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -125,9 +130,20 @@ jobs:
|
||||
os: windows-latest
|
||||
asset: horizon-launcher-windows-x64.zip
|
||||
binary: horizon-launcher.exe
|
||||
pe_arch: x64
|
||||
setup_asset: horizon-launcher-setup.exe
|
||||
# The C runtime linked in: without it the launcher asks for VCRUNTIME140.dll,
|
||||
# which only the Visual C++ redistributable installs.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
- name: windows-arm64
|
||||
os: windows-11-vs2026-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
asset: horizon-launcher-windows-arm64.zip
|
||||
binary: horizon-launcher.exe
|
||||
pe_arch: arm64
|
||||
setup_asset: horizon-launcher-setup-arm64.exe
|
||||
# The C runtime linked in, so the standalone ARM64 launcher needs no redistributable.
|
||||
rustflags: -C target-feature=+crt-static
|
||||
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
|
||||
# Notarizing the app and then the .dmg can take a few minutes each.
|
||||
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
|
||||
@@ -182,6 +198,9 @@ jobs:
|
||||
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
|
||||
- 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: Verify Windows executable architecture
|
||||
if: runner.os == 'Windows'
|
||||
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
|
||||
- name: The version is the release's
|
||||
run: |
|
||||
out=$("$BIN/${{ matrix.binary }}" --version)
|
||||
@@ -212,7 +231,8 @@ jobs:
|
||||
"$launcher" links --json
|
||||
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
|
||||
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
|
||||
# (exit 3); a launcher or a script that fails still fails the step.
|
||||
# (exit 3); a launcher or a script that fails still fails the step. The Windows ARM64 launcher always has
|
||||
# one: it installs the x64 build where a release has no ARM64 one (backend.rs, GAME_FALLBACK).
|
||||
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
|
||||
versions=$(retry "$launcher" --json versions)
|
||||
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
|
||||
@@ -236,6 +256,11 @@ jobs:
|
||||
# release linked to a Nix store is a warning here, not a broken launcher.
|
||||
soft() { if [[ "$RUNNER_OS" == "Linux" ]]; then "$@" || echo "::warning::$* failed"; else "$@"; fi; }
|
||||
if [[ "$RUNNER_OS" == "Windows" ]]; then
|
||||
if ! python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$client"; then
|
||||
# A Nightly without an ARM64 build: the ARM64 launcher installs the x64 one (backend.rs, GAME_FALLBACK).
|
||||
[[ "${{ matrix.pe_arch }}" == arm64 ]] && python ../tools/check_pe_arch.py x64 "$client" || exit 1
|
||||
echo "::warning::the latest game has no ARM64 build: the launcher installed the x64 one"
|
||||
fi
|
||||
python ../tools/check_imports.py "$client"
|
||||
elif [[ "$RUNNER_OS" == "Linux" ]]; then
|
||||
soft python3 ../tools/check_imports.py "$client"
|
||||
@@ -295,7 +320,7 @@ jobs:
|
||||
# installer instead of Home (launcher/src/installer.rs). Its name is what makes it the setup,
|
||||
# so the ZIP's copy stays the launcher. Both go into the release's signed SHA256SUMS.
|
||||
if: runner.os == 'Windows'
|
||||
run: cp "$BIN/${{ matrix.binary }}" out/horizon-launcher-setup.exe
|
||||
run: cp "$BIN/${{ matrix.binary }}" "out/${{ matrix.setup_asset }}"
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
@@ -373,6 +398,13 @@ jobs:
|
||||
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
|
||||
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
|
||||
# Never marked latest: Horizon's own releases stay the repository's latest.
|
||||
- name: The Windows ARM64 launcher, if it built
|
||||
# Its build may fail without holding the release (continue-on-error): the release then has no ARM64
|
||||
# files, and ARM64 launchers stay on their version until one has (backend::launcher_update_from).
|
||||
run: |
|
||||
for file in horizon-launcher-windows-arm64.zip horizon-launcher-setup-arm64.exe; do
|
||||
[[ -f "dist/$file" ]] || echo "::warning::$file is missing: this release goes out without it"
|
||||
done
|
||||
- name: Publish ${{ needs.decide.outputs.tag }}
|
||||
if: needs.decide.outputs.dry_run != 'true'
|
||||
env:
|
||||
|
||||
@@ -171,7 +171,11 @@ jobs:
|
||||
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
|
||||
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
|
||||
run: |
|
||||
# Windows ARM64's build may fail without holding the Nightly (game-build.yml): then it is left out.
|
||||
arm64=(); if [[ -f dist/horizon-windows-arm64.zip ]]; then arm64=(dist/horizon-windows-arm64.zip)
|
||||
else echo "::warning::horizon-windows-arm64.zip is missing: SHA256SUMS goes out without it"; fi
|
||||
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
|
||||
"${arm64[@]}" \
|
||||
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
|
||||
dist/horizon-source.tar.gz
|
||||
- uses: actions/upload-artifact@v7
|
||||
@@ -230,12 +234,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
python3 tools/ci/nightly.py rotate
|
||||
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
|
||||
# The ARM64 archive only when its build succeeded (the sign job left it out of SHA256SUMS otherwise).
|
||||
arm64=()
|
||||
if [[ -f dist/horizon-windows-arm64.zip ]]; then
|
||||
arm64=("dist/horizon-windows-arm64.zip#Game files the launcher installs (Nightly): Windows ARM64 (horizon-windows-arm64.zip)")
|
||||
else
|
||||
echo "::warning::horizon-windows-arm64.zip is missing: the Nightly goes out without it (ARM64 launchers install the x64 build)"
|
||||
fi
|
||||
# A pre-release, never GitHub's latest: the latest stays the last vX.Y.Z, which launchers and servers from
|
||||
# before the Nightly-only switch keep (they don't read Nightlies).
|
||||
gh release create nightly --prerelease --latest=false --target "$SHA" \
|
||||
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
|
||||
"dist/horizon-linux-x86_64.tar.gz#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)" \
|
||||
"${arm64[@]}" \
|
||||
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
|
||||
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
|
||||
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
|
||||
|
||||
@@ -30,7 +30,7 @@ Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same t
|
||||
|
||||
## Download
|
||||
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
|
||||
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
|
||||
|
||||
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
|
||||
|
||||
|
||||
@@ -693,17 +693,27 @@ fn open_device(
|
||||
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
|
||||
use cpal::SampleFormat as F;
|
||||
let config = device.default_output_config()?;
|
||||
let stream = match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
|
||||
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
|
||||
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
|
||||
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
|
||||
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
|
||||
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
|
||||
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
|
||||
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
|
||||
let build = |stream_config: &cpal::StreamConfig| match config.sample_format() {
|
||||
F::F32 => build_stream::<f32>(device, &config, stream_config, mixer.clone()),
|
||||
F::F64 => build_stream::<f64>(device, &config, stream_config, mixer.clone()),
|
||||
F::I16 => build_stream::<i16>(device, &config, stream_config, mixer.clone()),
|
||||
F::I32 => build_stream::<i32>(device, &config, stream_config, mixer.clone()),
|
||||
F::I8 => build_stream::<i8>(device, &config, stream_config, mixer.clone()),
|
||||
F::U16 => build_stream::<u16>(device, &config, stream_config, mixer.clone()),
|
||||
F::U32 => build_stream::<u32>(device, &config, stream_config, mixer.clone()),
|
||||
F::U8 => build_stream::<u8>(device, &config, stream_config, mixer.clone()),
|
||||
other => Err(format!("unsupported sample format {other}").into()),
|
||||
}?;
|
||||
};
|
||||
let short = audio_stream_config(&config);
|
||||
let stream = match build(&short) {
|
||||
Ok(stream) => stream,
|
||||
// A device that refuses the short fixed buffer still plays with cpal's default one.
|
||||
Err(e) if short != config.config() => {
|
||||
log::info!("Horizon: audio output refused a short buffer ({e}); using the default");
|
||||
build(&config.config())?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
stream.play()?;
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
m.device_name = device.name().unwrap_or_default();
|
||||
@@ -784,10 +794,35 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
|
||||
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
|
||||
/// come out about 100 ms late: request a short fixed buffer there. Other
|
||||
/// platforms keep cpal's default behaviour.
|
||||
#[cfg(feature = "audio")]
|
||||
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let range = match config.buffer_size() {
|
||||
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
|
||||
cpal::SupportedBufferSize::Unknown => (256, 4096),
|
||||
};
|
||||
let target = 1024u32.clamp(range.0, range.1);
|
||||
let mut sc = config.config();
|
||||
sc.buffer_size = cpal::BufferSize::Fixed(target);
|
||||
sc
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
config.config()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
device: &cpal::Device,
|
||||
config: &cpal::SupportedStreamConfig,
|
||||
stream_config: &cpal::StreamConfig,
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
) -> Result<cpal::Stream> {
|
||||
let rate = config.sample_rate().0;
|
||||
@@ -795,7 +830,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
|
||||
let mut scratch = Vec::new();
|
||||
let on_error = mixer.clone();
|
||||
let stream = device.build_output_stream(
|
||||
&config.config(),
|
||||
stream_config,
|
||||
move |data: &mut [T], _| {
|
||||
scratch.resize(data.len(), 0.0f32);
|
||||
if let Ok(mut m) = mixer.lock() {
|
||||
@@ -1301,6 +1336,41 @@ impl Audio {
|
||||
preview.preview(self, source, volume);
|
||||
self.preview = preview;
|
||||
}
|
||||
/// An incoming entity-sound packet: retain its seed and category without rebroadcasting it.
|
||||
pub fn play_entity_seeded(
|
||||
&mut self,
|
||||
event: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
seed: i64,
|
||||
entity: crate::sound_engine::SoundEntity,
|
||||
) {
|
||||
*self.heard.entry(event.to_owned()).or_default() += 1;
|
||||
let event = crate::sound_events::normalize(event);
|
||||
let instance = SoundInstance::entity_bound(
|
||||
&event,
|
||||
source,
|
||||
volume,
|
||||
pitch,
|
||||
LegacyRandom::new(seed),
|
||||
entity,
|
||||
);
|
||||
self.play_instance(instance, false);
|
||||
}
|
||||
/// Removal belongs to the old object even if its numeric id is immediately reused.
|
||||
pub fn remove_sound_entity(&mut self, id: u32) {
|
||||
for entity in self
|
||||
.engine
|
||||
.bound_entities()
|
||||
.into_iter()
|
||||
.filter(|e| e.id == id)
|
||||
{
|
||||
let mut state = entity.state();
|
||||
state.removed = true;
|
||||
entity.update(state);
|
||||
}
|
||||
}
|
||||
/// `play` with the seed a sound packet carries.
|
||||
pub fn play_seeded(
|
||||
&mut self,
|
||||
@@ -1451,6 +1521,42 @@ impl Sounds for Audio {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn entity_sound_packets_neither_rebroadcast_nor_advance_the_local_seed_generator() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
audio.capture = Some(Vec::new());
|
||||
let mut expected = audio.random.clone();
|
||||
let entity = crate::sound_engine::SoundEntity::new(7, [1.0, 2.0, 3.0], false);
|
||||
audio.play_entity_seeded("minecraft:fixture.bound", 6, 0.7, 1.2, 42, entity);
|
||||
assert!(audio.capture.as_ref().unwrap().is_empty());
|
||||
assert_eq!(audio.random.next_long(), expected.next_long());
|
||||
}
|
||||
#[test]
|
||||
fn removal_marks_only_existing_references_to_that_entity() {
|
||||
use crate::sound_engine::SoundEntity;
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
|
||||
let old = SoundEntity::new(7, [0.0; 3], false);
|
||||
let other = SoundEntity::new(8, [0.0; 3], false);
|
||||
for entity in [old.clone(), other.clone()] {
|
||||
audio.engine.add(
|
||||
SoundInstance::entity_bound(
|
||||
"minecraft:fixture.bound",
|
||||
6,
|
||||
1.0,
|
||||
1.0,
|
||||
LegacyRandom::new(42),
|
||||
entity,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
audio.remove_sound_entity(7);
|
||||
let replacement = SoundEntity::new(7, [10.0; 3], false);
|
||||
assert!(old.state().removed);
|
||||
assert!(!other.state().removed);
|
||||
assert!(!replacement.state().removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
|
||||
@@ -49,6 +49,10 @@ impl AuthSession {
|
||||
access_token: access_token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn access_token(&self) -> &str {
|
||||
&self.access_token
|
||||
}
|
||||
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let v: serde_json::Value =
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct BlockEntities {
|
||||
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
|
||||
/// Like `mods`, one taken out stays listed until `put` or `remove`.
|
||||
boxes: FxHashSet<Pos>,
|
||||
asleep: FxHashSet<Pos>,
|
||||
}
|
||||
|
||||
/// A structure or test instance block: the block entities whose box the frame may draw.
|
||||
@@ -229,6 +230,7 @@ impl BlockEntities {
|
||||
}
|
||||
/// `removeBlockEntityTicker`: no slot, made or pending.
|
||||
fn cancel(&mut self, pos: &Pos) {
|
||||
self.asleep.remove(pos);
|
||||
self.freed |= self.live.remove(pos).is_some();
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
@@ -297,14 +299,25 @@ impl BlockEntities {
|
||||
self.map.get(pos)
|
||||
}
|
||||
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
|
||||
self.asleep.remove(pos);
|
||||
self.map.get_mut(pos)
|
||||
}
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&Pos, &mut BlockEntity)> {
|
||||
self.asleep.clear();
|
||||
self.map.iter_mut()
|
||||
}
|
||||
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut BlockEntity> {
|
||||
self.asleep.clear();
|
||||
self.map.values_mut()
|
||||
}
|
||||
pub fn sleep(&mut self, pos: Pos) {
|
||||
if self.map.contains_key(&pos) {
|
||||
self.asleep.insert(pos);
|
||||
}
|
||||
}
|
||||
pub fn is_asleep(&self, pos: &Pos) -> bool {
|
||||
self.asleep.contains(pos)
|
||||
}
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
/// One of the same type rewrites it in place (a jukebox taking a disc,
|
||||
@@ -320,6 +333,7 @@ impl BlockEntities {
|
||||
self.attachments.remove(&pos);
|
||||
}
|
||||
let new_box = shows_box(&entity);
|
||||
self.asleep.remove(&pos);
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
@@ -346,6 +360,7 @@ impl BlockEntities {
|
||||
if absent {
|
||||
self.add(pos);
|
||||
}
|
||||
self.asleep.remove(&pos);
|
||||
let entity = self.map.entry(pos).or_insert_with(make);
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
@@ -358,6 +373,7 @@ impl BlockEntities {
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.attachments.remove(pos);
|
||||
self.asleep.remove(pos);
|
||||
self.cancel(pos);
|
||||
let removed = self.map.remove(pos);
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
@@ -369,6 +385,7 @@ impl BlockEntities {
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
/// for `put` to bring it back (a tick working on it).
|
||||
pub fn take(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.asleep.remove(pos);
|
||||
let taken = self.map.remove(pos);
|
||||
if taken.is_some() {
|
||||
self.out.insert(*pos);
|
||||
@@ -378,6 +395,7 @@ impl BlockEntities {
|
||||
/// Brings back what `take` took, in its slot.
|
||||
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
|
||||
self.out.remove(&pos);
|
||||
self.asleep.remove(&pos);
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
@@ -399,6 +417,7 @@ impl BlockEntities {
|
||||
&mut self.attachments,
|
||||
);
|
||||
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
|
||||
self.asleep.clear();
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
@@ -416,6 +435,7 @@ impl BlockEntities {
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.boxes.clear();
|
||||
self.asleep.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -433,6 +453,7 @@ impl BlockEntities {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
self.attachments.remove(&p);
|
||||
self.asleep.remove(&p);
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
self.note_box(p, Some(&e));
|
||||
let old = self.map.insert(p, e);
|
||||
@@ -452,6 +473,7 @@ impl BlockEntities {
|
||||
/// `LevelChunk.setBlockState` on a block entity's position, from state
|
||||
/// `old` to `new`: a ticker made, kept or dropped.
|
||||
pub fn state_changed(&mut self, pos: Pos, old: u32, new: u32) {
|
||||
self.asleep.remove(&pos);
|
||||
if self.states.is_empty() || !self.map.contains_key(&pos) {
|
||||
return;
|
||||
}
|
||||
@@ -542,6 +564,51 @@ mod tests {
|
||||
found
|
||||
}
|
||||
#[test]
|
||||
fn a_sleeping_block_entity_wakes_on_any_change() {
|
||||
let p = (1, 64, 1);
|
||||
let mut b = level();
|
||||
b.sleep(p);
|
||||
assert!(!b.is_asleep(&p));
|
||||
b.insert(p, hopper());
|
||||
let asleep = |b: &mut BlockEntities, wake: &dyn Fn(&mut BlockEntities)| {
|
||||
b.sleep(p);
|
||||
assert!(b.is_asleep(&p));
|
||||
wake(b);
|
||||
b.is_asleep(&p)
|
||||
};
|
||||
assert!(asleep(&mut b, &|_| {}));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
b.get_mut(&p);
|
||||
}));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
b.insert(p, hopper());
|
||||
}));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
b.get_or_insert_with(p, hopper);
|
||||
}));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
let held = b.take(&p).unwrap();
|
||||
b.put(p, held);
|
||||
}));
|
||||
assert!(!asleep(&mut b, &|b| b.state_changed(p, 1, 1)));
|
||||
assert!(!asleep(&mut b, &|b| b.forget(&p)));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
b.iter_mut().count();
|
||||
}));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
b.values_mut().count();
|
||||
}));
|
||||
assert!(!asleep(&mut b, &|b| b.retain(|_, _| true)));
|
||||
assert!(!asleep(&mut b, &|b| b.load_chunk(vec![(p, hopper())])));
|
||||
assert!(!asleep(&mut b, &|b| {
|
||||
b.remove(&p);
|
||||
b.insert(p, hopper());
|
||||
}));
|
||||
b.sleep(p);
|
||||
b.clear();
|
||||
assert!(!b.is_asleep(&p));
|
||||
}
|
||||
#[test]
|
||||
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
|
||||
let mut b = level();
|
||||
let modded = || BlockEntity::Mod(Box::default());
|
||||
|
||||
@@ -696,11 +696,19 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
text_vertices(scene.glyph, call, out);
|
||||
}
|
||||
|
||||
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
|
||||
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
|
||||
/// y down as the font's, with its outline when it has one. A native mod's
|
||||
/// block entity draws its text this way (`native_draw_text`).
|
||||
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
let g = (scene.glyph)(ch);
|
||||
let g = glyph(ch);
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
|
||||
@@ -340,6 +340,17 @@ impl BrewingStand {
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn idle(&self) -> bool {
|
||||
let bottles = [
|
||||
self.items[0].is_some(),
|
||||
self.items[1].is_some(),
|
||||
self.items[2].is_some(),
|
||||
];
|
||||
self.brew_time <= 0
|
||||
&& self.items[3].is_none()
|
||||
&& (self.fuel > 0 || self.items[4].is_none())
|
||||
&& self.last_bottles == Some(bottles)
|
||||
}
|
||||
fn brew(&mut self, brewing: &Brewing, items: &Items, out: &mut BrewTick) {
|
||||
let Some(ingredient) = self.items[3].clone() else {
|
||||
return;
|
||||
@@ -455,6 +466,27 @@ mod tests {
|
||||
assert_eq!(stand.brew_time, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_brewing_stand_is_idle_only_while_its_tick_changes_nothing() {
|
||||
let mut stand = BrewingStand::new();
|
||||
assert!(!stand.idle());
|
||||
stand.last_bottles = Some([false; 3]);
|
||||
assert!(stand.idle());
|
||||
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1));
|
||||
assert!(!stand.idle());
|
||||
stand.last_bottles = Some([true, false, false]);
|
||||
assert!(stand.idle());
|
||||
stand.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
|
||||
assert!(!stand.idle());
|
||||
stand.fuel = 3;
|
||||
assert!(stand.idle());
|
||||
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
|
||||
assert!(!stand.idle());
|
||||
stand.items[3] = None;
|
||||
stand.brew_time = 5;
|
||||
assert!(!stand.idle());
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
|
||||
@@ -215,6 +215,13 @@ impl Campfire {
|
||||
}
|
||||
changed
|
||||
}
|
||||
pub fn idle(&self, lit: bool) -> bool {
|
||||
if lit {
|
||||
self.items.iter().all(Option::is_none)
|
||||
} else {
|
||||
self.progress.iter().all(|&p| p <= 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Skull {
|
||||
pub fn new(
|
||||
@@ -729,6 +736,19 @@ mod tests {
|
||||
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_campfire_is_idle_only_while_its_tick_changes_nothing() {
|
||||
let mut c = Campfire::default();
|
||||
assert!(c.idle(true) && c.idle(false));
|
||||
assert!(c.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
|
||||
assert!(!c.idle(true));
|
||||
assert!(c.idle(false));
|
||||
c.cook_tick();
|
||||
assert!(!c.idle(false));
|
||||
c.cooldown_tick();
|
||||
assert!(c.idle(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -1823,18 +1823,28 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
|
||||
/// The backends to open a device on, each tried in turn until one gives a
|
||||
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
|
||||
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
|
||||
/// which picks the backends itself. Auto tries Vulkan, then DirectX 12 on Windows,
|
||||
/// then OpenGL (Windows and Linux) for GPUs with neither driver, each when the one
|
||||
/// before has no surface, adapter or device. A native crash in the driver can't be
|
||||
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
|
||||
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
|
||||
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
|
||||
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
|
||||
/// crash in the driver can't be
|
||||
/// caught in the game; the launcher switches the setting to DirectX 12 after one at
|
||||
/// the renderer's start (#746). macOS has Metal only.
|
||||
fn backend_order(api: GraphicsApi, os: &str, wgpu_backend: bool) -> Vec<wgpu::Backends> {
|
||||
fn backend_order(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
arch: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<wgpu::Backends> {
|
||||
use wgpu::Backends;
|
||||
if os == "macos" || wgpu_backend {
|
||||
return vec![Backends::default()];
|
||||
}
|
||||
let windows = os == "windows";
|
||||
match api {
|
||||
GraphicsApi::Auto if windows && arch == "aarch64" => {
|
||||
vec![Backends::DX12, Backends::VULKAN, Backends::GL]
|
||||
}
|
||||
GraphicsApi::Auto if windows => vec![Backends::VULKAN, Backends::DX12, Backends::GL],
|
||||
GraphicsApi::Auto => vec![Backends::VULKAN, Backends::GL],
|
||||
GraphicsApi::Dx12 if windows => vec![Backends::DX12],
|
||||
@@ -1975,7 +1985,12 @@ async fn open_device(
|
||||
) -> Result<Opened> {
|
||||
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
|
||||
let mut first = None;
|
||||
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
|
||||
for (order, allow_cpu) in device_search(
|
||||
api.with_env(),
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
wgpu_backend,
|
||||
) {
|
||||
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
|
||||
Ok(opened) => {
|
||||
if let Some(first) = &first {
|
||||
@@ -2007,10 +2022,11 @@ async fn open_device(
|
||||
fn device_search(
|
||||
api: GraphicsApi,
|
||||
os: &str,
|
||||
arch: &str,
|
||||
wgpu_backend: bool,
|
||||
) -> Vec<(Vec<wgpu::Backends>, bool)> {
|
||||
let chosen = backend_order(api, os, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
|
||||
let chosen = backend_order(api, os, arch, wgpu_backend);
|
||||
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, arch, wgpu_backend)
|
||||
.into_iter()
|
||||
.filter(|b| !chosen.contains(b))
|
||||
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
|
||||
@@ -11696,27 +11712,49 @@ mod tests {
|
||||
assert!(!is_software(wgpu::DeviceType::VirtualGpu));
|
||||
}
|
||||
#[test]
|
||||
fn auto_tries_vulkan_then_directx_12_then_opengl() {
|
||||
fn auto_uses_directx_12_first_on_windows_arm64() {
|
||||
use super::{GraphicsApi, backend_order};
|
||||
use wgpu::Backends;
|
||||
let order = |api, os| backend_order(api, os, false);
|
||||
let order = |api, os, arch| backend_order(api, os, arch, false);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Auto, "windows"),
|
||||
order(GraphicsApi::Auto, "windows", "x86_64"),
|
||||
[Backends::VULKAN, Backends::DX12, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Vulkan, "windows"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "windows"), [Backends::DX12]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "windows"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Auto, "linux"),
|
||||
order(GraphicsApi::Auto, "windows", "aarch64"),
|
||||
[Backends::DX12, Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Vulkan, "windows", "aarch64"),
|
||||
[Backends::VULKAN]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Dx12, "windows", "aarch64"),
|
||||
[Backends::DX12]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::OpenGl, "windows", "aarch64"),
|
||||
[Backends::GL]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Auto, "linux", "x86_64"),
|
||||
[Backends::VULKAN, Backends::GL]
|
||||
);
|
||||
assert_eq!(order(GraphicsApi::Dx12, "linux"), [Backends::VULKAN]);
|
||||
assert_eq!(order(GraphicsApi::OpenGl, "linux"), [Backends::GL]);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::Dx12, "linux", "x86_64"),
|
||||
[Backends::VULKAN]
|
||||
);
|
||||
assert_eq!(
|
||||
order(GraphicsApi::OpenGl, "linux", "x86_64"),
|
||||
[Backends::GL]
|
||||
);
|
||||
// WGPU_BACKEND picks the backends itself; macOS has Metal only.
|
||||
for api in GraphicsApi::ALL {
|
||||
assert_eq!(backend_order(api, "windows", true), [Backends::default()]);
|
||||
assert_eq!(order(api, "macos"), [Backends::default()]);
|
||||
assert_eq!(
|
||||
backend_order(api, "windows", "aarch64", true),
|
||||
[Backends::default()]
|
||||
);
|
||||
assert_eq!(order(api, "macos", "aarch64"), [Backends::default()]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -11724,28 +11762,33 @@ mod tests {
|
||||
use super::{GraphicsApi, device_search};
|
||||
use wgpu::Backends;
|
||||
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
|
||||
let search = |api, os, arch| device_search(api, os, arch, false);
|
||||
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Dx12, "windows", false),
|
||||
search(GraphicsApi::Dx12, "windows", "x86_64"),
|
||||
[(vec![d], false), (vec![g], false), (vec![d], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, "windows", false),
|
||||
search(GraphicsApi::Vulkan, "windows", "x86_64"),
|
||||
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Auto, "windows", false),
|
||||
search(GraphicsApi::Auto, "windows", "x86_64"),
|
||||
[(vec![v, d, g], false), (vec![v, d, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::OpenGl, "linux", false),
|
||||
search(GraphicsApi::Auto, "windows", "aarch64"),
|
||||
[(vec![d, v, g], false), (vec![d, v, g], true)]
|
||||
);
|
||||
assert_eq!(
|
||||
search(GraphicsApi::OpenGl, "linux", "x86_64"),
|
||||
[(vec![g], false), (vec![v], false), (vec![g], true)]
|
||||
);
|
||||
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
|
||||
let all = Backends::default();
|
||||
for (os, env) in [("windows", true), ("macos", false)] {
|
||||
assert_eq!(
|
||||
device_search(GraphicsApi::Vulkan, os, env),
|
||||
device_search(GraphicsApi::Vulkan, os, "x86_64", env),
|
||||
[(vec![all], false), (vec![all], true)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ fn hdr_pack(sized: bool, format: &str) -> Post {
|
||||
add(
|
||||
&format!("{program}.fsh"),
|
||||
&format!(
|
||||
"#version 120\n/* RENDERTARGETS:{buffer} */\n/* const int colortex{buffer}Format={format}; */\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
|
||||
"#version 120\n/* RENDERTARGETS:{buffer} */\n/*\nconst int colortex{buffer}Format={format};\n*/\nvoid main() {{ gl_FragData[0]=vec4(0,0.5,0,1); }}\n"
|
||||
),
|
||||
);
|
||||
if sized {
|
||||
@@ -62,6 +62,16 @@ fn hdr_pack(sized: bool, format: &str) -> Post {
|
||||
post
|
||||
}
|
||||
|
||||
// Check the fixture contract even when the render tests cannot find an adapter.
|
||||
#[test]
|
||||
fn clear_fixture_loads_the_requested_formats_without_a_gpu() {
|
||||
for sized in [false, true] {
|
||||
for format in ["RGBA16F", "RGBA8"] {
|
||||
hdr_pack(sized, format);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hdr_clear_case(sized: bool, attachment_limit: u32, byte_budget: u32, format: &str) {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
|
||||
@@ -1115,7 +1115,7 @@ impl HudAtlas {
|
||||
}
|
||||
files.retain(|p| {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
!s.contains("title/background") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
|
||||
@@ -184,12 +184,18 @@ impl Combo {
|
||||
pub fn attacked(&mut self, id: i32, now: f64) {
|
||||
self.target = Some((id, now));
|
||||
}
|
||||
/// The server reports entity `id` hurt.
|
||||
pub fn hurt(&mut self, id: i32, now: f64) {
|
||||
if self
|
||||
.target
|
||||
.is_some_and(|(t, at)| t == id && now - at <= Self::CONFIRM)
|
||||
{
|
||||
/// The server reports entity `id` hurt. `by_me`: whether this player is
|
||||
/// the damage's direct entity, when the report names one (melee and
|
||||
/// spear hits do; Horizon's LAN hub names none for player victims). A
|
||||
/// report that names nobody counts only as the answer to the last swing.
|
||||
pub fn hurt(&mut self, id: i32, by_me: Option<bool>, now: f64) {
|
||||
let confirmed = match by_me {
|
||||
Some(mine) => mine,
|
||||
None => self
|
||||
.target
|
||||
.is_some_and(|(t, at)| t == id && now - at <= Self::CONFIRM),
|
||||
};
|
||||
if confirmed {
|
||||
self.target = None;
|
||||
self.count += 1;
|
||||
self.last_hit = now;
|
||||
@@ -263,26 +269,32 @@ mod tests {
|
||||
fn combos_count_confirmed_hits_until_this_player_is_hurt() {
|
||||
let mut c = Combo::default();
|
||||
c.attacked(7, 0.0);
|
||||
c.hurt(7, 0.1);
|
||||
c.hurt(7, None, 0.1);
|
||||
c.attacked(7, 0.6);
|
||||
c.hurt(7, 0.7);
|
||||
c.hurt(7, None, 0.7);
|
||||
assert_eq!(c.count(0.8), 2);
|
||||
// Another entity hurt, or a report too late after the swing, adds nothing.
|
||||
c.attacked(7, 1.0);
|
||||
c.hurt(9, 1.1);
|
||||
c.hurt(7, 2.5);
|
||||
c.hurt(9, None, 1.1);
|
||||
c.hurt(7, None, 2.5);
|
||||
assert_eq!(c.count(2.5), 2);
|
||||
// One report per swing: a second one (thorns, fire) for the same attack doesn't count.
|
||||
c.attacked(7, 3.0);
|
||||
c.hurt(7, 3.1);
|
||||
c.hurt(7, 3.2);
|
||||
c.hurt(7, None, 3.1);
|
||||
c.hurt(7, None, 3.2);
|
||||
assert_eq!(c.count(3.2), 3);
|
||||
c.broken();
|
||||
assert_eq!(c.count(3.3), 0);
|
||||
c.attacked(7, 4.0);
|
||||
c.hurt(7, 4.1);
|
||||
c.hurt(7, None, 4.1);
|
||||
assert_eq!(c.count(7.0), 1);
|
||||
assert_eq!(c.count(7.2), 0, "more than 3 s since the last hit");
|
||||
// A report that names who hit decides alone: a spear jab (no swing
|
||||
// remembered) counts, a teammate's hit right after a swing doesn't.
|
||||
c.hurt(7, Some(true), 8.0);
|
||||
c.attacked(7, 8.5);
|
||||
c.hurt(7, Some(false), 8.6);
|
||||
assert_eq!(c.count(8.7), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
//! backend through `stopped`.
|
||||
use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::collections::HashMap;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
|
||||
/// `SoundSource` indices (vanilla order).
|
||||
pub const MASTER: usize = 0;
|
||||
@@ -41,11 +44,45 @@ pub enum Attenuation {
|
||||
None,
|
||||
Linear,
|
||||
}
|
||||
/// The object an entity-bound sound follows. Clones retain the same object,
|
||||
/// even after the world replaces another entity with the same numeric id.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SoundEntity {
|
||||
pub id: u32,
|
||||
state: Arc<RwLock<SoundEntityState>>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SoundEntityState {
|
||||
pub position: [f64; 3],
|
||||
pub silent: bool,
|
||||
pub removed: bool,
|
||||
}
|
||||
impl SoundEntity {
|
||||
pub fn new(id: u32, position: [f64; 3], silent: bool) -> Self {
|
||||
Self {
|
||||
id,
|
||||
state: Arc::new(RwLock::new(SoundEntityState {
|
||||
position,
|
||||
silent,
|
||||
removed: false,
|
||||
})),
|
||||
}
|
||||
}
|
||||
pub fn state(&self) -> SoundEntityState {
|
||||
*self.state.read().unwrap()
|
||||
}
|
||||
pub fn update(&self, state: SoundEntityState) {
|
||||
*self.state.write().unwrap() = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Ticking {
|
||||
/// Not tickable.
|
||||
No,
|
||||
/// `EntityBoundSoundInstance`.
|
||||
EntityBound,
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
BiomeLoop { fade: i32, direction: i32 },
|
||||
/// `UnderLiquidAmbientSoundInstance`.
|
||||
@@ -145,6 +182,8 @@ pub struct SoundInstance {
|
||||
pub random: LegacyRandom,
|
||||
pub can_start_silent: bool,
|
||||
pub ticking: Ticking,
|
||||
/// A stable entity reference, separate from its reusable network id.
|
||||
pub bound_entity: Option<SoundEntity>,
|
||||
/// `AbstractTickableSoundInstance.stopped`.
|
||||
pub stopped: bool,
|
||||
/// `sound` after `getOrResolve`, or the sound a caller chose for the
|
||||
@@ -184,6 +223,7 @@ impl SoundInstance {
|
||||
random,
|
||||
can_start_silent: false,
|
||||
ticking: Ticking::No,
|
||||
bound_entity: None,
|
||||
stopped: false,
|
||||
sound: None,
|
||||
event: None,
|
||||
@@ -212,6 +252,27 @@ impl SoundInstance {
|
||||
false,
|
||||
)
|
||||
}
|
||||
/// `EntityBoundSoundInstance`: initial coordinates are widened floats too.
|
||||
pub fn entity_bound(
|
||||
id: &str,
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
random: LegacyRandom,
|
||||
entity: SoundEntity,
|
||||
) -> Self {
|
||||
let position = entity.state().position.map(|v| f64::from(v as f32));
|
||||
let mut instance = Self::at(id, source, volume, pitch, random, position);
|
||||
instance.ticking = Ticking::EntityBound;
|
||||
instance.bound_entity = Some(entity);
|
||||
instance
|
||||
}
|
||||
/// `EntityBoundSoundInstance.canPlaySound`, also checked on replay.
|
||||
pub fn can_play_sound(&self) -> bool {
|
||||
self.bound_entity
|
||||
.as_ref()
|
||||
.is_none_or(|entity| !entity.state().silent)
|
||||
}
|
||||
/// `SimpleSoundInstance.forUI(sound, pitch, volume)`.
|
||||
pub fn for_ui(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self {
|
||||
Self::new(
|
||||
@@ -551,6 +612,18 @@ impl SoundInstance {
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::EntityBound => {
|
||||
let entity = self
|
||||
.bound_entity
|
||||
.as_ref()
|
||||
.expect("entity-bound sound target")
|
||||
.state();
|
||||
if entity.removed {
|
||||
self.stop();
|
||||
} else {
|
||||
self.position = entity.position.map(|v| f64::from(v as f32));
|
||||
}
|
||||
}
|
||||
Ticking::BiomeLoop { fade, direction } => {
|
||||
let stop = *fade < 0;
|
||||
*fade += *direction;
|
||||
@@ -966,6 +1039,13 @@ impl SoundEngine {
|
||||
pub fn category_gain(&self, source: usize) -> f32 {
|
||||
self.gain_by_source[source]
|
||||
}
|
||||
/// Live object references used by entity-bound instances, including queued ones.
|
||||
pub fn bound_entities(&self) -> Vec<SoundEntity> {
|
||||
self.instances
|
||||
.values()
|
||||
.filter_map(|s| s.bound_entity.clone())
|
||||
.collect()
|
||||
}
|
||||
/// `stop(SoundInstance)`.
|
||||
pub fn stop(&mut self, id: InstanceId, backend: &mut impl Backend) {
|
||||
if !self.loaded {
|
||||
@@ -1111,6 +1191,9 @@ impl SoundEngine {
|
||||
if !self.loaded || !self.instances.contains_key(&id) {
|
||||
return PlayResult::NotStarted;
|
||||
}
|
||||
if !self.instances[&id].can_play_sound() {
|
||||
return PlayResult::NotStarted;
|
||||
}
|
||||
self.resolve(id);
|
||||
let instance = &self.instances[&id];
|
||||
let Some(subtitle) = instance.event.clone() else {
|
||||
@@ -1250,9 +1333,14 @@ impl SoundEngine {
|
||||
self.play(id, backend);
|
||||
}
|
||||
for id in self.ticking.clone() {
|
||||
let Some(instance) = self.instances.get_mut(&id) else {
|
||||
let Some(instance) = self.instances.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
if !instance.can_play_sound() {
|
||||
// SoundEngine still ticks the instance after stopping its channel.
|
||||
self.stop(id, backend);
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
instance.tick(ctx);
|
||||
if instance.stopped {
|
||||
self.stop(id, backend);
|
||||
@@ -1764,3 +1852,66 @@ mod tests {
|
||||
assert!(underwater.stopped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_bound_tests {
|
||||
use super::*;
|
||||
fn instance(entity: SoundEntity) -> SoundInstance {
|
||||
SoundInstance::entity_bound(
|
||||
"minecraft:entity.allay.item_given",
|
||||
NEUTRAL,
|
||||
0.7,
|
||||
1.2,
|
||||
LegacyRandom::new(42),
|
||||
entity,
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn an_entity_bound_sound_tracks_float_positions_and_stops_before_the_removed_position() {
|
||||
let entity = SoundEntity::new(7, [0.123456789, 64.123456789, -3.123456789], false);
|
||||
let mut sound = instance(entity.clone());
|
||||
assert_eq!(sound.position[0].to_bits(), 0x3fbf9add40000000);
|
||||
entity.update(SoundEntityState {
|
||||
position: [0.248456789, 65.25, -3.623456789],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
sound.tick(&TickContext::default());
|
||||
assert_eq!(sound.position[0].to_bits(), 0x3fcfcd6ea0000000);
|
||||
let last = sound.position;
|
||||
entity.update(SoundEntityState {
|
||||
position: [100.0; 3],
|
||||
silent: false,
|
||||
removed: true,
|
||||
});
|
||||
sound.tick(&TickContext::default());
|
||||
assert!(sound.stopped);
|
||||
assert_eq!(sound.position, last);
|
||||
}
|
||||
#[test]
|
||||
fn silence_is_read_from_the_entity_including_before_a_replay() {
|
||||
let entity = SoundEntity::new(7, [0.0; 3], true);
|
||||
let sound = instance(entity.clone());
|
||||
assert!(!sound.can_play_sound());
|
||||
entity.update(SoundEntityState {
|
||||
position: [0.0; 3],
|
||||
silent: false,
|
||||
removed: false,
|
||||
});
|
||||
assert!(sound.can_play_sound());
|
||||
}
|
||||
#[test]
|
||||
fn reusing_a_network_id_does_not_retarget_an_old_sound() {
|
||||
let old = SoundEntity::new(7, [0.0; 3], false);
|
||||
let mut sound = instance(old.clone());
|
||||
old.update(SoundEntityState {
|
||||
position: [0.0; 3],
|
||||
silent: false,
|
||||
removed: true,
|
||||
});
|
||||
let replacement = SoundEntity::new(7, [10.0; 3], false);
|
||||
sound.tick(&TickContext::default());
|
||||
assert!(sound.stopped);
|
||||
assert_ne!(sound.position, replacement.state().position);
|
||||
}
|
||||
}
|
||||
@@ -502,6 +502,10 @@ pub struct Streaming {
|
||||
pub radius: i32,
|
||||
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
||||
pub forced: HashSet<(i32, i32)>,
|
||||
/// Chunks a native mod's tickets hold (NeoForge's `TicketController`):
|
||||
/// kept loaded and ticking as forced ones are, but neither saved with
|
||||
/// them nor listed by `/forceload`.
|
||||
pub mod_forced: HashSet<(i32, i32)>,
|
||||
/// No work is scheduled while paused (title screen).
|
||||
pub paused: bool,
|
||||
/// Dimension flags: sky light, Nether face shading.
|
||||
@@ -569,6 +573,7 @@ struct GenerationScan {
|
||||
radius: i32,
|
||||
extra: Vec<((i32, i32), i32)>,
|
||||
forced: HashSet<(i32, i32)>,
|
||||
mod_forced: HashSet<(i32, i32)>,
|
||||
band: Option<Band>,
|
||||
chunk_revision: u64,
|
||||
plan: Vec<(i64, i32, i32)>,
|
||||
@@ -584,6 +589,7 @@ impl GenerationScan {
|
||||
&& self.radius == ranges.radius
|
||||
&& self.extra == ranges.extra
|
||||
&& self.forced == *ranges.forced
|
||||
&& self.mod_forced == *ranges.mod_forced
|
||||
&& self.band.as_ref() == ranges.band
|
||||
}
|
||||
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
||||
@@ -622,6 +628,7 @@ impl GenerationScan {
|
||||
self.extra.clear();
|
||||
self.extra.extend_from_slice(ranges.extra);
|
||||
self.forced.clone_from(ranges.forced);
|
||||
self.mod_forced.clone_from(ranges.mod_forced);
|
||||
self.band = ranges.band.copied();
|
||||
self.chunk_revision = world.chunks.membership_revision();
|
||||
}
|
||||
@@ -719,7 +726,7 @@ impl GenerationScan {
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for &(fx, fz) in input.ranges.forced_chunks() {
|
||||
for dz in -reach..=reach {
|
||||
for dx in -reach..=reach {
|
||||
let (x, z) = (fx + dx, fz + dz);
|
||||
@@ -739,6 +746,7 @@ impl GenerationScan {
|
||||
radius: input.ranges.radius,
|
||||
extra: input.ranges.extra,
|
||||
forced: input.ranges.forced,
|
||||
mod_forced: input.ranges.mod_forced,
|
||||
cover: input.ranges.cover,
|
||||
band: input.ranges.band,
|
||||
};
|
||||
@@ -1063,6 +1071,7 @@ impl Streaming {
|
||||
center: (0, 0),
|
||||
radius: 8,
|
||||
forced: HashSet::new(),
|
||||
mod_forced: HashSet::new(),
|
||||
paused: false,
|
||||
sky_light: true,
|
||||
nether_shading: false,
|
||||
@@ -1737,7 +1746,7 @@ impl Streaming {
|
||||
> r4 * r4)
|
||||
&& !ranges.in_extra(x, z, 4)
|
||||
&& !ranges.in_band(x, z, 4)
|
||||
&& !ranges.forced.iter().any(|f| {
|
||||
&& !ranges.forced_chunks().any(|f| {
|
||||
(x - f.0).abs() <= FORCED_TERRAIN_RING
|
||||
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
|
||||
})
|
||||
@@ -1808,6 +1817,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
@@ -1974,7 +1984,11 @@ impl Streaming {
|
||||
// vanilla draws). Sections set aside past it come back
|
||||
// when the player moves: a column that loads with its light gets no
|
||||
// other request (#228).
|
||||
let area = (self.center, self.radius, self.forced.len());
|
||||
let area = (
|
||||
self.center,
|
||||
self.radius,
|
||||
self.forced.len() + self.mod_forced.len(),
|
||||
);
|
||||
if self.waiting_area != area {
|
||||
self.waiting_area = area;
|
||||
let ranges = Ranges {
|
||||
@@ -1982,6 +1996,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
};
|
||||
@@ -2077,6 +2092,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
},
|
||||
@@ -2210,6 +2226,7 @@ impl Streaming {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &self.mod_forced,
|
||||
cover: ExtraCover::matching(&self.cover, &self.extra),
|
||||
band: self.band.as_ref(),
|
||||
}
|
||||
@@ -2486,6 +2503,8 @@ struct Ranges<'a> {
|
||||
radius: i32,
|
||||
extra: &'a [((i32, i32), i32)],
|
||||
forced: &'a HashSet<(i32, i32)>,
|
||||
/// The chunks native mods' tickets hold, kept as forced ones are.
|
||||
mod_forced: &'a HashSet<(i32, i32)>,
|
||||
/// `extra` indexed by a grid or buckets: coverage queries without a
|
||||
/// scan of every area.
|
||||
cover: Option<&'a ExtraCover>,
|
||||
@@ -2830,10 +2849,20 @@ impl Ranges<'_> {
|
||||
.iter()
|
||||
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
|
||||
}
|
||||
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
|
||||
fn is_forced(&self, x: i32, z: i32) -> bool {
|
||||
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
|
||||
}
|
||||
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
|
||||
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
|
||||
self.forced
|
||||
.iter()
|
||||
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
|
||||
}
|
||||
/// The columns meshed for drawing: those vanilla draws around the
|
||||
/// player (`in_view_distance`), and forced ones.
|
||||
fn in_view(&self, x: i32, z: i32) -> bool {
|
||||
in_view_distance(self.center, self.radius, x, z) || self.forced.contains(&(x, z))
|
||||
in_view_distance(self.center, self.radius, x, z) || self.is_forced(x, z)
|
||||
}
|
||||
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
||||
let r = i64::from(self.radius + margin);
|
||||
@@ -2851,8 +2880,7 @@ impl Ranges<'_> {
|
||||
3 => FORCED_TERRAIN_RING,
|
||||
_ => margin + 1,
|
||||
};
|
||||
self.forced
|
||||
.iter()
|
||||
self.forced_chunks()
|
||||
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
||||
}
|
||||
}
|
||||
@@ -3075,6 +3103,10 @@ fn forget_pending_sections(
|
||||
}
|
||||
}
|
||||
|
||||
/// No chunk held by a native mod's ticket, for the tests' ranges.
|
||||
#[cfg(test)]
|
||||
static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock::new(HashSet::new);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -3291,6 +3323,7 @@ mod tests {
|
||||
radius: 32,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -3561,6 +3594,7 @@ mod tests {
|
||||
radius: self.radius,
|
||||
extra: &self.extra,
|
||||
forced: &self.forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
},
|
||||
@@ -3637,7 +3671,7 @@ mod tests {
|
||||
} else {
|
||||
FORCED_FEATURES_RING
|
||||
};
|
||||
for &(fx, fz) in input.ranges.forced {
|
||||
for &(fx, fz) in input.ranges.forced_chunks() {
|
||||
for dz in -forced_reach..=forced_reach {
|
||||
for dx in -forced_reach..=forced_reach {
|
||||
let (x, z) = (fx + dx, fz + dz);
|
||||
@@ -3943,6 +3977,7 @@ mod tests {
|
||||
radius,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -3999,6 +4034,7 @@ mod tests {
|
||||
radius,
|
||||
extra: &[((radius * 2 + 9, 3), 4)],
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -4035,6 +4071,7 @@ mod tests {
|
||||
radius: -1,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -4068,6 +4105,7 @@ mod tests {
|
||||
radius: 2,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -4122,6 +4160,7 @@ mod tests {
|
||||
radius: 2,
|
||||
extra: &[],
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover: None,
|
||||
band: None,
|
||||
};
|
||||
@@ -4193,6 +4232,7 @@ mod cover_tests {
|
||||
radius: 10,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
band: None,
|
||||
};
|
||||
@@ -4235,6 +4275,7 @@ mod cover_tests {
|
||||
radius: 10,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
band: None,
|
||||
};
|
||||
@@ -4300,6 +4341,7 @@ mod cover_tests {
|
||||
radius: 10,
|
||||
extra: &extra,
|
||||
forced: &forced,
|
||||
mod_forced: &NO_CHUNKS,
|
||||
cover,
|
||||
band: None,
|
||||
};
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"horizon.moderation.remaining": "für weitere %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
|
||||
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <Spieler> [Grund]",
|
||||
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -369,5 +369,8 @@
|
||||
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
|
||||
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s"
|
||||
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
|
||||
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
|
||||
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
|
||||
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
|
||||
}
|
||||
@@ -222,6 +222,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
|
||||
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.realms.not_signed_in": "Sign in with your Microsoft account in the Horizon launcher to play on Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
|
||||
@@ -536,7 +537,7 @@
|
||||
"horizon.moderation.remaining": "for another %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [reason]",
|
||||
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <player>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -548,6 +549,9 @@
|
||||
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
|
||||
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
|
||||
"horizon.tempban.list.empty": "There are no temporary bans",
|
||||
"horizon.tempban.list.count": "There are %s temporary ban(s):",
|
||||
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)",
|
||||
"horizon.options.pointer_speed": "Menu Pointer Speed",
|
||||
"horizon.options.scroll_speed": "Menu Scroll Speed",
|
||||
"horizon.options.repeat_delay": "Navigation Repeat Delay",
|
||||
|
||||
@@ -240,7 +240,7 @@
|
||||
"horizon.moderation.remaining": "durante %s más",
|
||||
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <jugador> [motivo]",
|
||||
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <jugador>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -251,5 +251,8 @@
|
||||
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
|
||||
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
|
||||
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s"
|
||||
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
|
||||
"horizon.tempban.list.empty": "No hay baneos temporales",
|
||||
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
|
||||
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
|
||||
}
|
||||
@@ -456,7 +456,7 @@
|
||||
"horizon.moderation.remaining": "pour encore %s",
|
||||
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
|
||||
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
|
||||
"horizon.moderation.usage.mute": "/mute <joueur> [raison]",
|
||||
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
|
||||
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
|
||||
"horizon.moderation.usage.unmute": "/unmute <joueur>",
|
||||
"horizon.moderation.usage.mutelist": "/mutelist",
|
||||
@@ -468,6 +468,9 @@
|
||||
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
|
||||
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
|
||||
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
|
||||
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
|
||||
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
|
||||
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)",
|
||||
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
|
||||
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
|
||||
"horizon.options.repeat_delay": "Délai de répétition de navigation",
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
|
||||
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
|
||||
|
||||
@@ -23,6 +23,9 @@ impl World for Empty {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
(false, false)
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
use crate::audio_common as common;
|
||||
use common::{Fake, d, describe, f, flush, result_name};
|
||||
use horizon_client::sound_engine::{
|
||||
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
|
||||
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundEntity, SoundEntityState,
|
||||
SoundInstance, TickContext,
|
||||
};
|
||||
use horizon_client::sound_events::SoundRegistry;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
@@ -21,6 +22,7 @@ pub fn main() {
|
||||
// Script cart names to entity ids (a removed cart leaves the level).
|
||||
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
|
||||
let mut bound_entities: BTreeMap<String, SoundEntity> = BTreeMap::new();
|
||||
let input = std::fs::read_to_string(&args[2]).expect("input");
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
|
||||
for line in input.lines() {
|
||||
@@ -51,6 +53,14 @@ pub fn main() {
|
||||
"loop" => SoundInstance::biome_loop(&event, random),
|
||||
"uloop" => SoundInstance::under_liquid_loop(&event, random),
|
||||
"usub" => SoundInstance::under_liquid_sub(&event, random),
|
||||
"bound" => SoundInstance::entity_bound(
|
||||
&event,
|
||||
source(4),
|
||||
f(a[5]),
|
||||
f(a[6]),
|
||||
random,
|
||||
bound_entities[a[15]].clone(),
|
||||
),
|
||||
"cart" | "rcart" | "rcartu" => {
|
||||
let id = cart_ids[a[3]];
|
||||
let cart = ctx
|
||||
@@ -109,7 +119,7 @@ pub fn main() {
|
||||
ctx.player_underwater = a[1] == "1";
|
||||
ctx.player_removed = a[2] == "1";
|
||||
}
|
||||
"cart" => {
|
||||
"cart" | "cartnew" => {
|
||||
let next = cart_ids.len() as u32 + 1;
|
||||
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
|
||||
ctx.carts.retain(|c| c.id != id);
|
||||
@@ -119,6 +129,22 @@ pub fn main() {
|
||||
// Vec3.horizontalDistance.
|
||||
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
|
||||
};
|
||||
let silent = a.get(9) == Some(&"1");
|
||||
if a[0] == "cartnew"
|
||||
&& let Some(old) = bound_entities.remove(a[1])
|
||||
{
|
||||
let mut state = old.state();
|
||||
state.removed = true;
|
||||
old.update(state);
|
||||
}
|
||||
let entity = bound_entities
|
||||
.entry(a[1].to_owned())
|
||||
.or_insert_with(|| SoundEntity::new(id, cart.position, silent));
|
||||
entity.update(SoundEntityState {
|
||||
position: cart.position,
|
||||
silent,
|
||||
removed: a[8] == "1",
|
||||
});
|
||||
if a[8] == "1" {
|
||||
// Out of the level, still there for a later play.
|
||||
removed.insert(id, cart);
|
||||
|
||||
@@ -40,6 +40,7 @@ pub struct Rewards {
|
||||
pub recipes: Vec<String>,
|
||||
pub experience: i32,
|
||||
pub loot: Vec<String>,
|
||||
pub function: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Advancement {
|
||||
@@ -327,6 +328,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
recipes: strings(&r["recipes"]),
|
||||
experience: r["experience"].as_i64().unwrap_or(0) as i32,
|
||||
loot: strings(&r["loot"]),
|
||||
function: r["function"].as_str().map(|id| full(id).into_owned()),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -455,6 +457,8 @@ impl Progress {
|
||||
/// What predicates may ask about the world.
|
||||
pub trait World {
|
||||
fn dimension(&self) -> &str;
|
||||
/// Effective rain/thunder booleans, including the level's dimension rules.
|
||||
fn weather(&self) -> (bool, bool);
|
||||
fn biome(&self, pos: [i32; 3]) -> String;
|
||||
fn structures(&self, pos: [i32; 3]) -> Vec<String>;
|
||||
/// Block name and properties.
|
||||
@@ -1029,6 +1033,12 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
block(&Value::Object(pred), b, w)
|
||||
}),
|
||||
"minecraft:match_tool" => ctx.tool.is_some_and(|t| item(&v["predicate"], t, w)),
|
||||
"minecraft:weather_check" => {
|
||||
// WeatherCheck.test: absent fields impose no constraint.
|
||||
let (rain, thunder) = w.weather();
|
||||
v["raining"].as_bool().is_none_or(|r| r == rain)
|
||||
&& v["thundering"].as_bool().is_none_or(|t| t == thunder)
|
||||
}
|
||||
"minecraft:inverted" => !conditions(&v["term"], ctx, w),
|
||||
"minecraft:all_of" => v["terms"]
|
||||
.as_array()
|
||||
@@ -1348,6 +1358,31 @@ impl Tree {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn advancement_reward_function_is_optional_and_namespaced() {
|
||||
for (raw, expected) in [
|
||||
("parity:reward", "parity:reward"),
|
||||
("reward", "minecraft:reward"),
|
||||
] {
|
||||
let input = serde_json::json!({
|
||||
"criteria": {"never": {"trigger": "minecraft:impossible"}},
|
||||
"rewards": {"function": raw, "experience": 7, "recipes": ["minecraft:bread"]}
|
||||
});
|
||||
let advancement = parse("parity:test", &input).unwrap();
|
||||
assert_eq!(advancement.rewards.function.as_deref(), Some(expected));
|
||||
assert_eq!(advancement.rewards.experience, 7);
|
||||
assert_eq!(advancement.rewards.recipes, ["minecraft:bread"]);
|
||||
}
|
||||
let without = parse(
|
||||
"parity:empty",
|
||||
&serde_json::json!({
|
||||
"criteria": {"never": {"trigger": "minecraft:impossible"}}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(without.rewards.function.is_none());
|
||||
}
|
||||
|
||||
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
|
||||
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
|
||||
/// with HORIZON_RESOURCES set to the prepared resources.
|
||||
@@ -1423,6 +1458,9 @@ mod tests {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
(false, false)
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
@@ -1451,6 +1489,47 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn weather_conditions_leave_absent_fields_unconstrained_and_compose() {
|
||||
let ctx = Entities {
|
||||
this: None,
|
||||
origin: [0.0; 3],
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
for (mut predicate, expected) in [
|
||||
(serde_json::json!({}), true),
|
||||
(serde_json::json!({"raining": false}), true),
|
||||
(serde_json::json!({"raining": true}), false),
|
||||
(serde_json::json!({"thundering": false}), true),
|
||||
(serde_json::json!({"thundering": true}), false),
|
||||
(
|
||||
serde_json::json!({"raining": false, "thundering": false}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
serde_json::json!({"raining": false, "thundering": true}),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
predicate["type"] = "minecraft:weather_check".into();
|
||||
assert_eq!(conditions(&predicate, &ctx, &Flat), expected, "{predicate}");
|
||||
}
|
||||
let dry = serde_json::json!({"type": "minecraft:weather_check", "raining": false});
|
||||
let wet = serde_json::json!({"type": "minecraft:inverted", "term": dry});
|
||||
assert!(!conditions(&wet, &ctx, &Flat));
|
||||
assert!(conditions(
|
||||
&serde_json::json!({"type": "minecraft:all_of", "terms": [dry]}),
|
||||
&ctx,
|
||||
&Flat
|
||||
));
|
||||
assert!(!conditions(
|
||||
&serde_json::json!({"type": "minecraft:any_of", "terms": [wet]}),
|
||||
&ctx,
|
||||
&Flat
|
||||
));
|
||||
}
|
||||
|
||||
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
|
||||
/// the recipe book and the trigger the same whole id (`award_crafted`
|
||||
/// qualifies). The bare name the crafting copy uses never matched —
|
||||
|
||||
@@ -112,6 +112,7 @@ pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod random_tick_sections;
|
||||
pub mod realms;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
pub mod reconnect;
|
||||
@@ -129,6 +130,7 @@ pub mod sprint;
|
||||
pub mod stations;
|
||||
pub mod stopwatches;
|
||||
pub mod structure_blocks;
|
||||
pub mod studio;
|
||||
pub mod text_display;
|
||||
pub mod tick_chunk_membership;
|
||||
pub mod ticket_levels;
|
||||
|
||||
@@ -90,7 +90,7 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
|
||||
}
|
||||
|
||||
mod fluids;
|
||||
pub use fluids::update_entity_fluids;
|
||||
pub use fluids::{fluid_flow, update_entity_fluids};
|
||||
|
||||
#[cfg(test)]
|
||||
mod drop_motion_tests {
|
||||
|
||||
@@ -26,6 +26,25 @@ struct EntityFluidWorld<'a> {
|
||||
world: &'a World,
|
||||
fluids: &'a FluidResources,
|
||||
}
|
||||
/// `FluidState.getFlow(level, pos)`: the current of the fluid at `pos`, the
|
||||
/// one that pushes an item or a boat in it, normalised (zero where there is no
|
||||
/// fluid or it doesn't move: a source with only sources and walls around it).
|
||||
/// A falling fluid's current points down.
|
||||
pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64; 3] {
|
||||
let mut velocity = Vec3::ZERO;
|
||||
let mut scene = EntityFluidWorld {
|
||||
bounds: Aabb::new(Vec3::ZERO, Vec3::ZERO),
|
||||
position: Vec3::ZERO,
|
||||
eye_y: 0.0,
|
||||
velocity: &mut velocity,
|
||||
world,
|
||||
fluids,
|
||||
};
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let state = fluids.fluid(world.block(pos[0], pos[1], pos[2]));
|
||||
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
|
||||
[flow.x, flow.y, flow.z]
|
||||
}
|
||||
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
|
||||
/// immersion heights, then the current of each fluid it sits in, added to
|
||||
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
|
||||
@@ -215,6 +234,45 @@ impl FluidFlowWorld for EntityFluidWorld<'_> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// `FluidState.getFlow`: water running out of a pool flows away from it,
|
||||
/// while the pool itself, its edge on a solid floor and the air beside it
|
||||
/// have no current.
|
||||
#[test]
|
||||
fn fluid_flow_runs_away_from_a_pool_and_is_zero_in_still_water() {
|
||||
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 render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = FluidResources::new(&assets, &facts).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
let water = assets.registry.default_state("minecraft:water").unwrap();
|
||||
let mut state = assets.registry.state(water).unwrap().clone();
|
||||
state.properties.insert("level".into(), "1".into());
|
||||
let flowing = assets.registry.resolve(&state).unwrap();
|
||||
for x in 16..=22 {
|
||||
for z in 16..=20 {
|
||||
world.set(x, 3, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
for x in 17..=19 {
|
||||
for z in 17..=19 {
|
||||
world.set(x, 4, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
world.set(20, 4, 18, flowing).unwrap();
|
||||
// A level-1 flow beside sources: the source is 1/9 higher to the west,
|
||||
// the air to the east on stone gives nothing, so the current is +x.
|
||||
assert_eq!(fluid_flow(&world, &fluids, [20, 4, 18]), [1.0, 0.0, 0.0]);
|
||||
for still in [[18, 4, 18], [17, 4, 17], [21, 4, 18]] {
|
||||
assert_eq!(fluid_flow(&world, &fluids, still), [0.0; 3], "{still:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
|
||||
@@ -30,6 +30,7 @@ pub trait FurnaceCooking {
|
||||
providers: &Providers,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) -> Option<bool>;
|
||||
fn idle(&self) -> bool;
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
@@ -171,6 +172,11 @@ impl FurnaceCooking for Furnace {
|
||||
}
|
||||
(was_lit != lit).then_some(lit)
|
||||
}
|
||||
fn idle(&self) -> bool {
|
||||
self.lit_remaining <= 0
|
||||
&& self.cooking_timer <= 0
|
||||
&& !(self.items[0].is_some() && self.items[1].is_some())
|
||||
}
|
||||
}
|
||||
fn total_time(time: u32, speed: f32) -> i32 {
|
||||
if speed > 0.0 {
|
||||
@@ -187,6 +193,27 @@ mod tests {
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fs};
|
||||
|
||||
#[test]
|
||||
fn a_furnace_is_idle_only_while_its_tick_changes_nothing() {
|
||||
let stack = |id: &str| Some(ItemStack::new(id, 1));
|
||||
let furnace = |input, fuel, lit_remaining, cooking_timer| Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [input, fuel, stack("minecraft:iron_ingot")],
|
||||
lit_remaining,
|
||||
lit_total: 0,
|
||||
cooking_timer,
|
||||
cooking_total: 200,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
assert!(furnace(None, None, 0, 0).idle());
|
||||
assert!(furnace(stack("minecraft:iron_ore"), None, 0, 0).idle());
|
||||
assert!(furnace(None, stack("minecraft:coal"), 0, 0).idle());
|
||||
assert!(!furnace(stack("minecraft:iron_ore"), stack("minecraft:coal"), 0, 0).idle());
|
||||
assert!(!furnace(None, None, 1, 0).idle());
|
||||
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
|
||||
/// data root holding that one recipe and an item table with two fuels: `test:coal`
|
||||
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
|
||||
|
||||
@@ -204,6 +204,7 @@ mod narrator;
|
||||
mod native_biome_colors;
|
||||
pub mod native_block_entity_draw;
|
||||
mod native_block_sprites;
|
||||
mod native_chunk_tickets;
|
||||
mod native_claimed_items;
|
||||
mod native_components;
|
||||
mod native_configs;
|
||||
@@ -216,6 +217,7 @@ mod native_entity_refs;
|
||||
mod native_explosions;
|
||||
mod native_falling;
|
||||
mod native_hooks;
|
||||
mod native_host_bridge;
|
||||
mod native_inventories;
|
||||
mod native_item_container;
|
||||
mod native_item_energy;
|
||||
@@ -223,6 +225,7 @@ mod native_item_fluids;
|
||||
mod native_level_refs;
|
||||
mod native_lifecycle;
|
||||
mod native_living_refs;
|
||||
mod native_machines;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
mod native_mobs;
|
||||
@@ -235,6 +238,7 @@ mod native_projectiles;
|
||||
mod native_quick_move;
|
||||
mod native_recipes;
|
||||
mod native_reload_listeners;
|
||||
mod native_scene;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_ticks;
|
||||
@@ -416,19 +420,23 @@ use horizon_world::{
|
||||
},
|
||||
registry::BlockStateId,
|
||||
};
|
||||
pub use machines::native_fluids::NativeFluidTank;
|
||||
pub use maps::{NativeMapDecoration, NativeMapInfo};
|
||||
pub use native_block_entity_draw::BlockEntityDraw;
|
||||
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
|
||||
pub use native_components::NativeComponentError;
|
||||
pub use native_configs::{NativeConfig, NativeConfigFile, NativeConfigKind};
|
||||
pub use native_criteria::NativeCriterion;
|
||||
pub use native_data_maps::NativeDataMapType;
|
||||
pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeShake};
|
||||
pub use native_hooks::{NativeClock, NativeItemEntity, NativeShake};
|
||||
pub use native_item_energy::ItemEnergyBuffer;
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
pub use native_world_read::NativeBlockReadError;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -991,6 +999,12 @@ struct GameLevel<'w> {
|
||||
debug: bool,
|
||||
native_sounds: &'w mut Vec<creatures::NativeSound>,
|
||||
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
|
||||
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
|
||||
/// `Block.dropResources` rolls the loot with it instead of with none.
|
||||
drop_tool: Option<ItemStack>,
|
||||
/// Where `Block.dropResources` puts the drops instead of popping them,
|
||||
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
|
||||
drop_into: Option<Vec<ItemStack>>,
|
||||
}
|
||||
struct PlayerCollision {
|
||||
y: f64,
|
||||
@@ -1815,6 +1829,14 @@ impl LevelAccess for GameLevel<'_> {
|
||||
Some(BlockEntity::Mod(_))
|
||||
)
|
||||
}
|
||||
/// `ShulkerBoxBlockEntity.isClosed`: the lid state the block-entity tick
|
||||
/// copied from `be_anims` (`ShulkerBoxBlock.getBlockSupportShape`).
|
||||
fn shulker_is_closed(&self, pos: BlockPos) -> Option<bool> {
|
||||
self.world
|
||||
.shulkers
|
||||
.get(&[pos.x, pos.y, pos.z])
|
||||
.map(|s| s.status == horizon_client::block_entity_anim::ShulkerStatus::Closed)
|
||||
}
|
||||
fn remove_block_entity(&mut self, pos: BlockPos, old: BlockStateId, side_effects: bool) -> u8 {
|
||||
self.remove_entity(pos, old, side_effects)
|
||||
}
|
||||
@@ -2919,6 +2941,9 @@ struct Game<'a> {
|
||||
mod_screen: Option<native_hooks::OpenNativeScreen>,
|
||||
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
|
||||
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
|
||||
/// The owners of each chunk a native mod's ticket holds, by level
|
||||
/// (`native_force_chunk`); `streaming.mod_forced` is the level's chunks.
|
||||
mod_chunk_tickets: HashMap<Dim, HashMap<(i32, i32), std::collections::BTreeSet<String>>>,
|
||||
/// Where item entities are, while the block entities tick (hoppers).
|
||||
item_columns: Option<machines::ItemColumns>,
|
||||
creative_screen: Creative,
|
||||
@@ -4674,6 +4699,8 @@ impl Game<'_> {
|
||||
debug: self.world_debug,
|
||||
native_sounds: &mut self.creatures.lan_sounds,
|
||||
native_particles: &mut self.creatures.lan_particles,
|
||||
drop_tool: None,
|
||||
drop_into: None,
|
||||
};
|
||||
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
|
||||
self.flush_spill();
|
||||
@@ -4865,7 +4892,20 @@ impl Game<'_> {
|
||||
.filter(|&(_, spectator)| game_mode::loads_chunks(rule, spectator))
|
||||
.map(|(chunk, _)| chunk)
|
||||
.collect(),
|
||||
forced: self.streaming.forced.iter().copied().collect(),
|
||||
// A native mod's ticket holds its chunk as `/forceload` does
|
||||
// (NeoForge's `TicketController` uses the same `FORCED` level).
|
||||
forced: self
|
||||
.streaming
|
||||
.forced
|
||||
.iter()
|
||||
.chain(
|
||||
self.streaming
|
||||
.mod_forced
|
||||
.iter()
|
||||
.filter(|c| !self.streaming.forced.contains(c)),
|
||||
)
|
||||
.copied()
|
||||
.collect(),
|
||||
// Filled from the `PORTAL` tickets by `update`.
|
||||
portal: Vec::new(),
|
||||
// EnderDragonFight.tick: while a player watches the fight, the
|
||||
@@ -11612,6 +11652,35 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerLevel.destroyBlockProgress`: breaker `breaker`'s crack on `pos`
|
||||
/// (0..=9, anything else takes it away), for every player within 64 blocks.
|
||||
pub(super) fn server_destroy_block_progress(
|
||||
&mut self,
|
||||
breaker: i32,
|
||||
pos: [i32; 3],
|
||||
progress: i32,
|
||||
) {
|
||||
self.destroy_block_progress(breaker, pos, progress);
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.broadcast_near(
|
||||
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
|
||||
64.0,
|
||||
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
|
||||
horizon_protocol::packets::play::BlockDestruction {
|
||||
id: breaker,
|
||||
pos,
|
||||
progress: if (0..10).contains(&progress) {
|
||||
progress as u8
|
||||
} else {
|
||||
255
|
||||
},
|
||||
},
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The cracks to draw and their stage sprite's atlas origin: our own mining and the
|
||||
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
|
||||
/// furthest progress on each block).
|
||||
@@ -12799,7 +12868,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Let go of every entity of the level (saved first): the next world
|
||||
/// opened must not inherit them, nor save them as its own.
|
||||
fn clear_entities(&mut self) {
|
||||
pub(super) fn clear_entities(&mut self) {
|
||||
self.mobs.clear();
|
||||
self.dropped.clear();
|
||||
self.orbs.clear();
|
||||
@@ -12826,6 +12895,19 @@ impl Game<'_> {
|
||||
self.riding = None;
|
||||
self.persist = Default::default();
|
||||
self.entity_attachments.clear();
|
||||
self.clear_remote_objects();
|
||||
self.clear_remote_projectiles();
|
||||
self.levels.clear();
|
||||
self.entity_vertices.clear();
|
||||
self.see_through_vertices.clear();
|
||||
self.glowing_vertices.get_mut().clear();
|
||||
self.shadow_only_vertices.get_mut().clear();
|
||||
self.entity_spans.borrow_mut().clear();
|
||||
self.entity_motion.borrow_mut().clear();
|
||||
self.skin_models
|
||||
.borrow_mut()
|
||||
.restart(&mut self.skin_frame.borrow_mut());
|
||||
self.skin_frame.borrow_mut().clear_draws();
|
||||
}
|
||||
/// Equipment attribute modifiers (LivingEntity.collectEquipmentChanges).
|
||||
fn apply_equipment(&mut self) {
|
||||
@@ -18874,6 +18956,14 @@ impl Game<'_> {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
// A Realm's address came back (GetServerDetailsTask hands over to ConnectScreen).
|
||||
if let Some(action) = self.title.as_mut().and_then(|t| t.take_connect_request())
|
||||
&& let Err(error) = self.title_action(event_loop, action)
|
||||
{
|
||||
self.error = Some(error);
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
horizon_client::spans::time("server_packs", || self.tick_server_packs());
|
||||
horizon_client::spans::time("control", || self.poll_control(event_loop));
|
||||
if self.pending_save.is_some() {
|
||||
@@ -19337,6 +19427,7 @@ impl<'a> Game<'a> {
|
||||
hoppers: HashMap::new(),
|
||||
mod_screen: None,
|
||||
mod_container_viewers: HashMap::new(),
|
||||
mod_chunk_tickets: HashMap::new(),
|
||||
item_columns: None,
|
||||
creative_screen: prepared.creative_screen,
|
||||
control: None,
|
||||
@@ -21407,6 +21498,7 @@ impl Loading {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
self.poll_menu_realms(event_loop);
|
||||
if let Some(w) = &self.window {
|
||||
if self.options.as_ref().is_some_and(|o| o.offscreen) {
|
||||
self.draw();
|
||||
|
||||
@@ -137,6 +137,11 @@ impl adv::World for View<'_, '_> {
|
||||
fn dimension(&self) -> &str {
|
||||
self.0.dim.id()
|
||||
}
|
||||
/// `WeatherCheck.test`'s level queries, using dimension-aware effective levels.
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
let (rain, thunder) = self.0.weather_levels(1.0);
|
||||
(f64::from(rain) > 0.2, f64::from(thunder) > 0.9)
|
||||
}
|
||||
fn biome(&self, p: [i32; 3]) -> String {
|
||||
self.0
|
||||
.world
|
||||
@@ -439,17 +444,18 @@ impl Game<'_> {
|
||||
&View(self),
|
||||
)
|
||||
}
|
||||
/// The player as a command's predicates see it: with what it looks at.
|
||||
pub(super) fn player_entity(&self) -> EntityView {
|
||||
self.player_entity_looking(true)
|
||||
}
|
||||
/// [`Self::player_entity`] for `trigger`'s criteria: what the player
|
||||
/// looks at only when one of them may ask (a ray against every entity
|
||||
/// each tick otherwise).
|
||||
fn player_entity_for(&self, trigger: &str) -> EntityView {
|
||||
/// otherwise).
|
||||
pub(super) fn player_entity_for(&self, trigger: &str) -> EntityView {
|
||||
self.player_entity_looking(self.adv.tree.may_look(trigger))
|
||||
}
|
||||
/// The player as predicates see it, with what it looks at when `looking`.
|
||||
fn player_entity_looking(&self, looking: bool) -> EntityView {
|
||||
pub(super) fn player_entity_looking(&self, looking: bool) -> EntityView {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return EntityView::default();
|
||||
};
|
||||
@@ -497,7 +503,7 @@ impl Game<'_> {
|
||||
.collect(),
|
||||
gamemode: Some(self.mode.name().to_owned()),
|
||||
// Vanilla looks for it inside PlayerPredicate.matches whatever the
|
||||
// trigger; skipped for a tick or location whose criteria can't ask.
|
||||
// trigger; skipped for a trigger whose criteria can't ask.
|
||||
looking_at: looking
|
||||
.then(|| self.looking_at_entity().map(Box::new))
|
||||
.flatten(),
|
||||
@@ -749,7 +755,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `PlayerAdvancements.award` once done: rewards, toast, announcement.
|
||||
/// `PlayerAdvancements.award` once done: `AdvancementRewards.grant`,
|
||||
/// then toast and announcement.
|
||||
fn award(&mut self, index: usize) {
|
||||
let tree = self.adv.tree.clone();
|
||||
let a = &tree.list[index];
|
||||
@@ -788,6 +795,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(function) = &a.rewards.function {
|
||||
self.run_advancement_reward_function(function);
|
||||
}
|
||||
if let Some(d) = &a.display {
|
||||
// A network player's client shows its own toast.
|
||||
if d.show_toast && !self.adv.quiet && self.lan_acting.is_none() {
|
||||
@@ -965,7 +975,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A player condition plus one entity (`player`, `entity` keys).
|
||||
pub(super) fn trigger_entity(&mut self, name: &str, key: &str, target: &EntityView) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
let target = target.clone();
|
||||
let key = key.to_owned();
|
||||
self.trigger(name, move |c, w| {
|
||||
@@ -998,7 +1008,7 @@ impl Game<'_> {
|
||||
target: &EntityView,
|
||||
blow: Option<&adv::DamageView>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
let target = target.clone();
|
||||
let blow = blow.cloned();
|
||||
self.trigger(name, move |c, w| {
|
||||
@@ -1027,7 +1037,7 @@ impl Game<'_> {
|
||||
victims: &[EntityView],
|
||||
weapon: Option<&ItemStack>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:killed_by_arrow");
|
||||
let victims = victims.to_vec();
|
||||
let weapon = weapon.map(item_view);
|
||||
self.trigger("minecraft:killed_by_arrow", move |c, w| {
|
||||
@@ -1037,7 +1047,7 @@ impl Game<'_> {
|
||||
/// `LevitationTrigger`: how far the player rose from where it started
|
||||
/// levitating, and for how long.
|
||||
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:levitation");
|
||||
self.trigger("minecraft:levitation", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1128,7 +1138,7 @@ impl Game<'_> {
|
||||
/// `DistanceTrigger`: the player's `player` condition, `start_position`
|
||||
/// at `start`, and the `distance` from `start` to the player.
|
||||
fn trigger_distance(&mut self, name: &str, start: [f64; 3]) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
self.trigger(name, move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1146,7 +1156,7 @@ impl Game<'_> {
|
||||
/// player is now. `matches` tests `cause` only when the instance has one,
|
||||
/// and a missing cause fails it.
|
||||
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3], cause: Option<EntityView>) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:fall_after_explosion");
|
||||
self.trigger("minecraft:fall_after_explosion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1172,7 +1182,7 @@ impl Game<'_> {
|
||||
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
|
||||
/// `source` gave, that entity (`source`).
|
||||
fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:effects_changed");
|
||||
self.trigger("minecraft:effects_changed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1209,7 +1219,7 @@ impl Game<'_> {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:item_durability_changed");
|
||||
let item = item_view(stack);
|
||||
let max = self
|
||||
.res
|
||||
@@ -1237,7 +1247,7 @@ impl Game<'_> {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:using_item");
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:using_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1310,7 +1320,7 @@ impl Game<'_> {
|
||||
victim: &EntityView,
|
||||
damage: &adv::DamageView,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:player_hurt_entity");
|
||||
let (victim, damage) = (victim.clone(), damage.clone());
|
||||
self.trigger("minecraft:player_hurt_entity", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1338,7 +1348,7 @@ impl Game<'_> {
|
||||
direct: Option<EntityView>,
|
||||
amount: f32,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:player_hurt_entity");
|
||||
self.trigger_player_hurt_entity(
|
||||
victim,
|
||||
&adv::DamageView {
|
||||
@@ -1354,7 +1364,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
|
||||
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:entity_hurt_player");
|
||||
let damage = damage.clone();
|
||||
self.trigger("minecraft:entity_hurt_player", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1369,7 +1379,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A player condition plus one item (`item` key).
|
||||
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1387,7 +1397,7 @@ impl Game<'_> {
|
||||
stack: &ItemStack,
|
||||
target: &EntityView,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
|
||||
}
|
||||
@@ -1403,7 +1413,12 @@ impl Game<'_> {
|
||||
thrower: [i32; 4],
|
||||
) {
|
||||
let target = if thrower == self.acting_uuid() {
|
||||
let me = self.player_entity();
|
||||
// The player is the entity of both triggers.
|
||||
let tree = &self.adv.tree;
|
||||
let me = self.player_entity_looking(
|
||||
tree.may_look("minecraft:thrown_item_picked_up_by_entity")
|
||||
|| tree.may_look("minecraft:thrown_item_picked_up_by_player"),
|
||||
);
|
||||
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
|
||||
me
|
||||
} else if let Some(id) = self.mob_by_uuid(thrower) {
|
||||
@@ -1418,7 +1433,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
|
||||
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:construct_beacon");
|
||||
self.trigger("minecraft:construct_beacon", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1431,7 +1446,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
|
||||
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:enchanted_item");
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:enchanted_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1448,7 +1463,7 @@ impl Game<'_> {
|
||||
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
|
||||
/// brewing stand.
|
||||
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:brewed_potion");
|
||||
let potion = stack.potion().map(str::to_owned);
|
||||
self.trigger("minecraft:brewed_potion", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1463,7 +1478,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `TradeTrigger`: the villager and the item the trade gave.
|
||||
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:villager_trade");
|
||||
let villager = villager.clone();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:villager_trade", move |c, w| {
|
||||
@@ -1490,7 +1505,7 @@ impl Game<'_> {
|
||||
hooked: &EntityView,
|
||||
items: &[ItemStack],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:fishing_rod_hooked");
|
||||
let rod = item_view(rod);
|
||||
let hooked = hooked.clone();
|
||||
let items: Vec<ItemView> = items.iter().map(item_view).collect();
|
||||
@@ -1516,7 +1531,7 @@ impl Game<'_> {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:enter_block");
|
||||
self.trigger("minecraft:enter_block", move |c, w| {
|
||||
let block_ok = adv::id_matches(&c["blocks"], "block", &definition.name, w);
|
||||
let state_ok = c["state"]
|
||||
@@ -1535,7 +1550,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SlideDownBlockTrigger`: match the actual block and its state.
|
||||
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:slide_down_block");
|
||||
self.trigger("minecraft:slide_down_block", move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1550,7 +1565,7 @@ impl Game<'_> {
|
||||
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
|
||||
/// the shooter's context, including distance from the shooter.
|
||||
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:target_hit");
|
||||
let projectile = projectile.clone();
|
||||
self.trigger("minecraft:target_hit", move |c, w| {
|
||||
let player = adv::Entities {
|
||||
@@ -1571,7 +1586,7 @@ impl Game<'_> {
|
||||
|
||||
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
|
||||
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
let it = item_view(stack);
|
||||
self.trigger(name, move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
@@ -1595,7 +1610,7 @@ impl Game<'_> {
|
||||
self.trigger_block_use("minecraft:any_block_use", pos, Some(stack));
|
||||
}
|
||||
fn trigger_block_use(&mut self, name: &str, pos: [i32; 3], stack: Option<&ItemStack>) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
let it = stack.map(item_view);
|
||||
let centre = pos.map(|c| f64::from(c) + 0.5);
|
||||
self.trigger(name, move |c, w| {
|
||||
@@ -1618,7 +1633,7 @@ impl Game<'_> {
|
||||
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
|
||||
/// not hit (`bystander`), each tested from this player's position.
|
||||
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:lightning_strike");
|
||||
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
|
||||
self.trigger("minecraft:lightning_strike", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1649,7 +1664,7 @@ impl Game<'_> {
|
||||
/// `ChanneledLightningTrigger`: each victim condition matched by some
|
||||
/// mob the bolt hit, from the player who channelled it.
|
||||
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:channeled_lightning");
|
||||
let victims = victims.to_vec();
|
||||
self.trigger("minecraft:channeled_lightning", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1675,7 +1690,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
|
||||
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:spear_mobs");
|
||||
self.trigger("minecraft:spear_mobs", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1698,7 +1713,7 @@ impl Game<'_> {
|
||||
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:bee_nest_destroyed");
|
||||
let tool = tool.map(item_view).unwrap_or_default();
|
||||
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1721,7 +1736,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// A trigger with only the player condition (slept_in_bed, started_riding).
|
||||
pub(super) fn trigger_player(&mut self, name: &str) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(name);
|
||||
self.trigger(name, move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1739,7 +1754,7 @@ impl Game<'_> {
|
||||
recipe: &str,
|
||||
ingredients: &[Option<ItemStack>],
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:recipe_crafted");
|
||||
let used: Vec<ItemView> = ingredients
|
||||
.iter()
|
||||
.map(|s| {
|
||||
@@ -1768,7 +1783,7 @@ impl Game<'_> {
|
||||
/// `LootTableTrigger`: a container's loot table unpacked for this player
|
||||
/// (`RandomizableContainer.unpackLootTable`).
|
||||
pub(super) fn trigger_loot_table(&mut self, table: &str) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:player_generates_container_loot");
|
||||
let table = table.to_owned();
|
||||
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1788,7 +1803,7 @@ impl Game<'_> {
|
||||
partner: &EntityView,
|
||||
child: Option<&EntityView>,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:bred_animals");
|
||||
let (parent, partner, child) = (parent.clone(), partner.clone(), child.cloned());
|
||||
self.trigger("minecraft:bred_animals", move |c, w| {
|
||||
let ctx = |e: &EntityView| {
|
||||
|
||||
@@ -670,6 +670,7 @@ impl Game<'_> {
|
||||
json!({ "mode": "multiplayer", "disconnected": reason })
|
||||
}
|
||||
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
|
||||
Mode::Realms(_) => json!({ "mode": "realms" }),
|
||||
Mode::MultiplayerWarning { check } => {
|
||||
json!({ "mode": "multiplayer-warning", "check": check })
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ impl Game<'_> {
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let pos = slot.0;
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if !self.world.block_entities.is_live(slot)
|
||||
if self.world.block_entities.is_asleep(&pos)
|
||||
|| !self.world.block_entities.is_live(slot)
|
||||
|| !ticking(pos.0, pos.2)
|
||||
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
|
||||
{
|
||||
@@ -264,6 +265,10 @@ impl Game<'_> {
|
||||
self.campfire_drop(pos, stack);
|
||||
}
|
||||
let Some(lit) = lit else {
|
||||
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Furnace(f)) if f.idle())
|
||||
{
|
||||
self.world.block_entities.sleep(pos);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
|
||||
@@ -91,6 +91,7 @@ impl Game<'_> {
|
||||
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
|
||||
}
|
||||
if !lit {
|
||||
self.sleep_idle_campfire(pos, false);
|
||||
return Ok(());
|
||||
}
|
||||
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
|
||||
@@ -113,9 +114,17 @@ impl Game<'_> {
|
||||
Some(state),
|
||||
);
|
||||
}
|
||||
self.sleep_idle_campfire(pos, true);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sleep_idle_campfire(&mut self, pos: Pos, lit: bool) {
|
||||
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Campfire(c)) if c.idle(lit))
|
||||
{
|
||||
self.world.block_entities.sleep(pos);
|
||||
}
|
||||
}
|
||||
|
||||
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
|
||||
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
|
||||
/// (90° about X), at a corner and three eighths of its size, drawn as a
|
||||
|
||||
@@ -80,6 +80,26 @@ const MUTE_LEVEL: u8 = 3;
|
||||
/// The commands of `mute_command`.
|
||||
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
|
||||
|
||||
/// The mute commands as operators' clients get them in the command tree (`command_tree`'s usages).
|
||||
pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
"mute <player:player> [duration_and_reason:text]",
|
||||
"tempmute <player:player> <duration:word> [reason:text]",
|
||||
"mutelist",
|
||||
"unmute <player:player>",
|
||||
];
|
||||
|
||||
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
|
||||
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_chat_filter::parse_duration(w))
|
||||
{
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason a temporary ban gives at login.
|
||||
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
|
||||
|
||||
@@ -295,7 +315,10 @@ impl Game<'_> {
|
||||
lines
|
||||
}
|
||||
["unmute", name] => vec![self.unmute(host, name, by)],
|
||||
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
|
||||
["mute", name, ..] => {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
@@ -498,4 +521,17 @@ mod tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mute_takes_a_duration_after_the_name_or_mutes_until_unmuted() {
|
||||
assert_eq!(mute_time(&["mute", "Steve"]), (None, 2));
|
||||
assert_eq!(mute_time(&["mute", "Steve", "spamming", "chat"]), (None, 2));
|
||||
assert_eq!(
|
||||
mute_time(&["mute", "Steve", "1h", "spam"]),
|
||||
(Some(3_600_000), 3)
|
||||
);
|
||||
assert_eq!(mute_time(&["mute", "Steve", "30m"]), (Some(1_800_000), 3));
|
||||
// Not a duration: the word starts the reason.
|
||||
assert_eq!(mute_time(&["mute", "Steve", "0m", "spam"]), (None, 2));
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,26 @@ impl Game<'_> {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
"weather_state" => {
|
||||
// Saved state guards, separate from effective-weather predicates.
|
||||
let state = self.harness_owner(|g| {
|
||||
let w = g.world.weather;
|
||||
Ok([
|
||||
i32::from(w.raining),
|
||||
i32::from(w.thundering),
|
||||
w.clear_time,
|
||||
w.rain_time,
|
||||
w.thunder_time,
|
||||
])
|
||||
})?;
|
||||
let record = json!({
|
||||
"cmd": format!("#weather_state {}", op["label"].as_str().unwrap_or("")),
|
||||
"messages": [],
|
||||
"results": state.iter().map(|value| json!([true, value])).collect::<Vec<_>>(),
|
||||
});
|
||||
out.push_str(&record.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
"mobs" => {
|
||||
// Observe authoritative AI state without a control-socket race.
|
||||
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
|
||||
|
||||
@@ -2761,7 +2761,7 @@ impl Game<'_> {
|
||||
.take()
|
||||
.map(std::sync::Arc::unwrap_or_clone)
|
||||
.unwrap_or_default();
|
||||
extras.rolled_loot.retain(|r| r.pos != key);
|
||||
horizon_client::save::consume_container_loot(&mut extras, key);
|
||||
chunk.saved = Some(std::sync::Arc::new(extras));
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
}
|
||||
|
||||
@@ -1488,7 +1488,16 @@ impl Game<'_> {
|
||||
/// causing entity, and the projectile when one dealt it.
|
||||
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
let source = match d.attacker {
|
||||
Some(Who::Player) => Some(self.player_entity()),
|
||||
// Its callers are KilledTrigger's: the source as their killing_blow sees it.
|
||||
Some(Who::Player) => Some(
|
||||
self.player_entity_looking(
|
||||
self.adv.tree.may_look("minecraft:player_killed_entity")
|
||||
|| self
|
||||
.adv
|
||||
.tree
|
||||
.may_look("minecraft:kill_mob_near_sculk_catalyst"),
|
||||
),
|
||||
),
|
||||
Some(Who::Mob(id)) => self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -2687,26 +2696,7 @@ impl Game<'_> {
|
||||
self.native_entity_break_door(mob, &kind, pos);
|
||||
}
|
||||
MobEvent::DestroyProgress { mob, pos, progress } => {
|
||||
self.destroy_block_progress(mob as i32, pos, progress);
|
||||
// ServerLevel.destroyBlockProgress: every player nearby sees it.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
host.hub.broadcast_near(
|
||||
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
|
||||
64.0,
|
||||
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
|
||||
horizon_protocol::packets::play::BlockDestruction {
|
||||
id: mob as i32,
|
||||
pos,
|
||||
progress: if (0..10).contains(&progress) {
|
||||
progress as u8
|
||||
} else {
|
||||
255
|
||||
},
|
||||
},
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
self.server_destroy_block_progress(mob as i32, pos, progress);
|
||||
}
|
||||
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
|
||||
MobEvent::SetBlockName {
|
||||
|
||||
@@ -62,6 +62,22 @@ pub(super) fn record_events(events: &[horizon_world::level::LevelEvent]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `S` line for a level event whose sound `LevelEventHandler` plays on the
|
||||
/// client (`playLocalSound`), so Horizon plays it unshared and never captures
|
||||
/// it: the lava fizz (1501, #7652) and `ComposterBlock.handleFill` (1500,
|
||||
/// `data > 0` = success, #9069).
|
||||
fn event_sound(line: &str) -> Option<String> {
|
||||
let mut parts = line.strip_prefix("L ")?.split(' ');
|
||||
let (id, pos, data) = (parts.next()?, parts.next()?, parts.next()?);
|
||||
let sound = match id {
|
||||
"1501" => "minecraft:block.lava.extinguish",
|
||||
"1500" if data.parse::<i32>().ok()? > 0 => "minecraft:block.composter.fill_success",
|
||||
"1500" => "minecraft:block.composter.fill",
|
||||
_ => return None,
|
||||
};
|
||||
Some(format!("S {pos} {sound}"))
|
||||
}
|
||||
|
||||
/// Java's `String.format("%.6f")`: the exact value rounded half up (away
|
||||
/// from zero) where Rust rounds a tie to even.
|
||||
fn num(v: f64) -> String {
|
||||
@@ -431,16 +447,9 @@ impl Game<'_> {
|
||||
out.push(self.fixture_describe(i, t));
|
||||
}
|
||||
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
|
||||
// LevelEventHandler's lava fizz (1501): the client plays it from the
|
||||
// level event, so it is not captured as a shared sound (#7652).
|
||||
let fizzes: Vec<String> = log
|
||||
.iter()
|
||||
.filter_map(|line| line.strip_prefix("L 1501 "))
|
||||
.filter_map(|rest| rest.split(' ').next())
|
||||
.map(|pos| format!("S {pos} minecraft:block.lava.extinguish"))
|
||||
.collect();
|
||||
let event_sounds: Vec<String> = log.iter().filter_map(|l| event_sound(l)).collect();
|
||||
out.extend(log);
|
||||
out.extend(fizzes);
|
||||
out.extend(event_sounds);
|
||||
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
|
||||
out.push(format!("S {} {sound}", floor_pos(at)));
|
||||
}
|
||||
|
||||
@@ -391,6 +391,8 @@ pub(super) struct Dragon {
|
||||
/// The player a crystal destroyed while it held its pattern sends it
|
||||
/// after (`strafe_after_crystal`), taken on its next tick.
|
||||
strafe_request: Option<FightWho>,
|
||||
/// `Mob.isNoAi` (`NoAI`): it flaps in place, with no phase or flight.
|
||||
no_ai: bool,
|
||||
random: LegacyRandom,
|
||||
}
|
||||
/// A player in the fight: the window's own player, or a guest by
|
||||
@@ -520,6 +522,7 @@ impl Dragon {
|
||||
nodes: None,
|
||||
nearest_crystal: None,
|
||||
strafe_request: None,
|
||||
no_ai: false,
|
||||
random: LegacyRandom::new(seed),
|
||||
}
|
||||
}
|
||||
@@ -2112,6 +2115,12 @@ impl Game<'_> {
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
// EnderDragon.aiStep with isNoAi: the flap stops half way, and nothing
|
||||
// else moves it (no flight history, phase, steering or parts).
|
||||
if d.no_ai {
|
||||
d.flap = 0.5;
|
||||
return;
|
||||
}
|
||||
d.record();
|
||||
self.tick_phase();
|
||||
// Steering towards the phase's fly target.
|
||||
@@ -2380,6 +2389,16 @@ impl Game<'_> {
|
||||
d.in_wall = in_wall;
|
||||
}
|
||||
}
|
||||
/// `Entity.isSilent` of the dragon: its `Silent`, kept with the fields
|
||||
/// Horizon models nowhere (`remember_tags`: `/summon`, a load).
|
||||
fn dragon_silent(&self) -> bool {
|
||||
let raw = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.filter(|d| d.id != 0)
|
||||
.and_then(|d| self.persist.raw.get(&d.id));
|
||||
saved_silent(raw)
|
||||
}
|
||||
/// `tickDeath`: rising for 200 ticks, explosions, experience, then the
|
||||
/// exit portal, the egg and a gateway.
|
||||
fn tick_dragon_death(&mut self) {
|
||||
@@ -2414,9 +2433,9 @@ impl Game<'_> {
|
||||
if t > 150 && t % 5 == 0 && drops {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
|
||||
}
|
||||
if t == 1 {
|
||||
if t == 1 && !self.dragon_silent() {
|
||||
// EnderDragon.tickDeath: global level event 1028, the death
|
||||
// roar, heard everywhere.
|
||||
// roar, heard everywhere, unless the dragon is silent.
|
||||
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
|
||||
self.global_level_event(1028, at, 0);
|
||||
}
|
||||
@@ -3245,6 +3264,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.isSilent` from an entity's saved compound: `Silent`, written only
|
||||
/// when set.
|
||||
fn saved_silent(raw: Option<&Tag>) -> bool {
|
||||
matches!(raw.and_then(|t| t.get("Silent")), Some(Tag::Byte(b)) if *b != 0)
|
||||
}
|
||||
/// Saving and loading the dragon and the crystals as chunk entities.
|
||||
impl Dragon {
|
||||
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
@@ -3260,6 +3284,10 @@ impl Dragon {
|
||||
map.insert("Health".into(), Tag::Float(self.health.to_bits()));
|
||||
map.insert("DragonPhase".into(), Tag::Int(self.phase.id()));
|
||||
map.insert("DragonDeathTime".into(), Tag::Int(self.death_time));
|
||||
// Mob.addAdditionalSaveData writes NoAI only when set.
|
||||
if self.no_ai {
|
||||
map.insert("NoAI".into(), Tag::Byte(1));
|
||||
}
|
||||
map.insert(
|
||||
"sitting_damage_received".into(),
|
||||
Tag::Float(self.sitting_damage.to_bits()),
|
||||
@@ -3280,6 +3308,7 @@ impl Dragon {
|
||||
if let Some(Tag::Int(t)) = tag.get("DragonDeathTime") {
|
||||
d.death_time = *t;
|
||||
}
|
||||
d.no_ai = matches!(tag.get("NoAI"), Some(Tag::Byte(b)) if *b != 0);
|
||||
Some(d)
|
||||
}
|
||||
}
|
||||
@@ -3397,6 +3426,10 @@ impl Game<'_> {
|
||||
if phase != 10 {
|
||||
meta.push((16, serializer::INT, Value::Int(phase)));
|
||||
}
|
||||
// Mob.DATA_MOB_FLAGS_ID: NoAI is its first bit.
|
||||
if d.no_ai {
|
||||
meta.push((15, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
Some(horizon_client::lan::entity::EntityState {
|
||||
id: d.id as i32,
|
||||
uuid: super::lan::entity_uuid(d.id),
|
||||
@@ -3972,6 +4005,8 @@ impl Game<'_> {
|
||||
match (index, value) {
|
||||
(9, Value::Float(health)) => d.health = *health,
|
||||
(16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)),
|
||||
// Mob.DATA_MOB_FLAGS_ID: isNoAi.
|
||||
(15, Value::Byte(flags)) => d.no_ai = flags & 1 != 0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -4128,6 +4163,11 @@ impl Game<'_> {
|
||||
}
|
||||
let d = self.dragon.as_mut().expect("dragon");
|
||||
d.yaw = wrap(d.yaw);
|
||||
// EnderDragon.aiStep on a client with isNoAi, as on the server.
|
||||
if d.no_ai {
|
||||
d.flap = 0.5;
|
||||
return;
|
||||
}
|
||||
d.record();
|
||||
self.remote_dragon_phase_tick();
|
||||
self.place_dragon_parts();
|
||||
@@ -4319,6 +4359,24 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// `EnderDragon.tickDeath` roars (`globalLevelEvent(1028)`) only when
|
||||
/// `!isSilent()`; `Silent` is saved only when set, as a byte.
|
||||
#[test]
|
||||
fn a_dragon_is_silent_only_with_a_set_silent_byte() {
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
let with = |v: Option<i8>| {
|
||||
let mut map = Compound::new();
|
||||
if let Some(b) = v {
|
||||
map.insert("Silent".into(), Tag::Byte(b));
|
||||
}
|
||||
Tag::Compound(map)
|
||||
};
|
||||
assert!(!super::saved_silent(None));
|
||||
assert!(!super::saved_silent(Some(&with(None))));
|
||||
assert!(!super::saved_silent(Some(&with(Some(0)))));
|
||||
assert!(super::saved_silent(Some(&with(Some(1)))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breath_lifetime_excludes_the_wait() {
|
||||
assert!(!breath_expired(219, 200));
|
||||
|
||||
@@ -29,6 +29,11 @@ enum State {
|
||||
Bobbing,
|
||||
Hooked(u32),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum EntityCollision {
|
||||
Hooked,
|
||||
Deflected { first: bool },
|
||||
}
|
||||
pub(super) struct Hook {
|
||||
/// Its entity id, which a server's players track it under.
|
||||
id: u32,
|
||||
@@ -36,6 +41,8 @@ pub(super) struct Hook {
|
||||
pub(super) previous: Vec3,
|
||||
velocity: Vec3,
|
||||
state: State,
|
||||
last_deflected_by: Option<u32>,
|
||||
needs_sync: bool,
|
||||
tick_count: i32,
|
||||
life: i32,
|
||||
on_ground: bool,
|
||||
@@ -145,6 +152,21 @@ fn minecart_hook_targets<'a>(
|
||||
.map(|c| (c.id, c.bounds()))
|
||||
}
|
||||
impl Hook {
|
||||
fn hit_entity(&mut self, id: u32, deflects: bool) -> EntityCollision {
|
||||
if deflects {
|
||||
let first = self.last_deflected_by != Some(id);
|
||||
if first {
|
||||
self.velocity = self.velocity.scale(-0.5);
|
||||
self.last_deflected_by = Some(id);
|
||||
self.needs_sync = true;
|
||||
}
|
||||
return EntityCollision::Deflected { first };
|
||||
}
|
||||
self.state = State::Hooked(id);
|
||||
self.velocity = Vec3::ZERO;
|
||||
EntityCollision::Hooked
|
||||
}
|
||||
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
bounds(self.position)
|
||||
}
|
||||
@@ -261,6 +283,8 @@ impl Game<'_> {
|
||||
previous: start,
|
||||
velocity: v,
|
||||
state: State::Flying,
|
||||
last_deflected_by: None,
|
||||
needs_sync: false,
|
||||
tick_count: 0,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
@@ -651,6 +675,7 @@ impl Game<'_> {
|
||||
let Some(mut h) = self.hook.take() else {
|
||||
return;
|
||||
};
|
||||
h.needs_sync = false;
|
||||
let Some(owner) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
@@ -759,18 +784,36 @@ impl Game<'_> {
|
||||
),
|
||||
);
|
||||
if let Some(id) = hit {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[h.position.x, h.position.y, h.position.z],
|
||||
);
|
||||
h.state = State::Hooked(id);
|
||||
h.velocity = Vec3::ZERO;
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
if let Some(hit) = block_hit {
|
||||
let breeze = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| {
|
||||
m.id == id && m.species() == horizon_client::mobs::Species::Breeze
|
||||
})
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z]);
|
||||
match h.hit_entity(id, breeze.is_some()) {
|
||||
EntityCollision::Deflected { first } => {
|
||||
if first {
|
||||
self.audio.play(
|
||||
"minecraft:entity.breeze.deflect",
|
||||
breeze,
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
EntityCollision::Hooked => {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
[h.position.x, h.position.y, h.position.z],
|
||||
);
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if let Some(hit) = block_hit {
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
@@ -1218,6 +1261,8 @@ impl Hook {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
state: State::Flying,
|
||||
last_deflected_by: None,
|
||||
needs_sync: false,
|
||||
tick_count: 0,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
@@ -1245,6 +1290,7 @@ impl Hook {
|
||||
e.position = [p.x, p.y, p.z];
|
||||
e.velocity = [self.velocity.x, self.velocity.y, self.velocity.z];
|
||||
e.on_ground = self.on_ground;
|
||||
e.needs_sync = self.needs_sync;
|
||||
e.data = owner;
|
||||
if let State::Hooked(id) = self.state {
|
||||
e.meta.push((8, serializer::INT, Value::Int(id as i32 + 1)));
|
||||
@@ -1543,6 +1589,86 @@ fn cast_direction(yaw: f32, pitch: f32) -> (f32, f32, f32) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Hook, cast_direction, entity_hit_margin, first_entity_hit, pull_item, rebound};
|
||||
#[test]
|
||||
fn a_breeze_deflects_the_hook_without_attaching_it() {
|
||||
use super::{EntityCollision, Hook, State};
|
||||
let mut hook = Hook::remote(1, Vec3::ZERO);
|
||||
hook.velocity = Vec3::new(2.0, -0.4, 1.0);
|
||||
assert_eq!(
|
||||
hook.hit_entity(10, true),
|
||||
EntityCollision::Deflected { first: true }
|
||||
);
|
||||
assert_eq!(hook.state, State::Flying);
|
||||
assert!(hook.needs_sync);
|
||||
assert_eq!(hook.velocity, Vec3::new(-1.0, 0.2, -0.5));
|
||||
assert_eq!(
|
||||
hook.hit_entity(10, true),
|
||||
EntityCollision::Deflected { first: false }
|
||||
);
|
||||
assert_eq!(hook.velocity, Vec3::new(-1.0, 0.2, -0.5));
|
||||
assert_eq!(
|
||||
hook.hit_entity(11, true),
|
||||
EntityCollision::Deflected { first: true }
|
||||
);
|
||||
assert_eq!(hook.velocity, Vec3::new(0.5, -0.1, 0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_targets_still_hook_and_a_deflection_is_tracked_immediately() {
|
||||
use super::{EntityCollision, Hook, State};
|
||||
let mut hook = Hook::remote(1, Vec3::ZERO);
|
||||
hook.velocity = Vec3::new(2.0, 0.0, 0.0);
|
||||
hook.hit_entity(10, true);
|
||||
let mut states = horizon_client::lan::entity::States::default();
|
||||
hook.track(99, &mut states);
|
||||
let state = &states.as_slice()[0];
|
||||
assert!(state.needs_sync);
|
||||
assert_eq!(state.velocity, [-1.0, -0.0, -0.0]);
|
||||
assert!(!state.meta.iter().any(|entry| entry.0 == 8));
|
||||
assert_eq!(hook.hit_entity(20, false), EntityCollision::Hooked);
|
||||
assert_eq!(hook.state, State::Hooked(20));
|
||||
assert_eq!(hook.velocity, Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by tools/test_breeze_hook_parity.py"]
|
||||
fn breeze_hook_parity_fixture() {
|
||||
use super::{EntityCollision, Hook, State};
|
||||
use std::io::Write;
|
||||
let input =
|
||||
std::fs::read_to_string(std::env::var("HORIZON_BREEZE_HOOK_INPUT").unwrap()).unwrap();
|
||||
let mut out =
|
||||
std::fs::File::create(std::env::var("HORIZON_BREEZE_HOOK_OUTPUT").unwrap()).unwrap();
|
||||
for line in input.lines() {
|
||||
let parts: Vec<_> = line.split_whitespace().collect();
|
||||
let mut hook = Hook::remote(1, Vec3::ZERO);
|
||||
hook.velocity = Vec3::new(
|
||||
parts[1].parse().unwrap(),
|
||||
parts[2].parse().unwrap(),
|
||||
parts[3].parse().unwrap(),
|
||||
);
|
||||
let mut trace = Vec::new();
|
||||
for target in &parts[4..] {
|
||||
hook.needs_sync = false;
|
||||
let response = hook.hit_entity(target.parse::<u32>().unwrap() + 10, true);
|
||||
let sounds = i32::from(matches!(
|
||||
response,
|
||||
EntityCollision::Deflected { first: true }
|
||||
));
|
||||
let landed = i32::from(matches!(response, EntityCollision::Hooked));
|
||||
trace.push(format!(
|
||||
"{:016x},{:016x},{:016x}:{sounds}:{landed}:{}:{}",
|
||||
hook.velocity.x.to_bits(),
|
||||
hook.velocity.y.to_bits(),
|
||||
hook.velocity.z.to_bits(),
|
||||
i32::from(hook.needs_sync),
|
||||
i32::from(matches!(hook.state, State::Hooked(_)))
|
||||
));
|
||||
}
|
||||
writeln!(out, "{}", trace.join(" ")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
use horizon_client::{entities::ItemEntity, items::ItemStack};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::{
|
||||
Game,
|
||||
commands::{
|
||||
R, err,
|
||||
R, Target, err,
|
||||
execute::{ExecState, Source},
|
||||
},
|
||||
};
|
||||
@@ -403,6 +403,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The function branch of advancement rewards: resolve the optional function
|
||||
/// and execute it as the rewarded player, with suppressed output and GAMEMASTER permission.
|
||||
pub(super) fn run_advancement_reward_function(&mut self, id: &str) {
|
||||
self.ensure_datapacks();
|
||||
let Some(function) = self.fns.store.functions.get(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(pos) = self.target_position(Target::Player) else {
|
||||
return;
|
||||
};
|
||||
// createCommandSourceStack belongs to the player, not an enclosing
|
||||
// execute's position, rotation, output sink or store callback.
|
||||
let source = Source {
|
||||
pos,
|
||||
rot: self.target_rotation(Target::Player),
|
||||
silent: true,
|
||||
..self.player_source()
|
||||
};
|
||||
// The existing runner applies this level to nested function calls too.
|
||||
let permission = self
|
||||
.fns
|
||||
.permission
|
||||
.replace(horizon_client::command::vanilla::GAMEMASTERS);
|
||||
let callback = self.exec.callback.take();
|
||||
let result = self.run_function(&function, None, source);
|
||||
self.exec.callback = callback;
|
||||
self.fns.permission = permission;
|
||||
if let Err(error) = result {
|
||||
log::warn!(
|
||||
"Horizon: failed to run advancement reward function {id}: {}",
|
||||
error.message.plain()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- running -----------------------------------------------------------------
|
||||
|
||||
/// Run one function with `source`: its commands in order, until a `return`.
|
||||
|
||||
@@ -103,7 +103,7 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:summoned_entity");
|
||||
let half = f64::from(built.width()) / 2.0;
|
||||
let height = f64::from(built.height());
|
||||
let [x, y, z] = me.position;
|
||||
|
||||
@@ -505,6 +505,7 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
@@ -1067,10 +1068,25 @@ fn unhappy_counter(
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
|
||||
/// Raider.IS_CELEBRATING: also list false so tracking sends the end of a celebration.
|
||||
pub(super) fn mob_raider_meta(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
use horizon_client::mobs::raider;
|
||||
if raider::is_raider(m.species()) {
|
||||
if raider::state(m).is_some_and(|state| state.celebrating) {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
|
||||
/// AgeableMob's two and DATA_BOOST_TIME).
|
||||
fn mob_freeze_meta(
|
||||
pub(super) fn mob_freeze_meta(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
@@ -1081,15 +1097,12 @@ fn mob_freeze_meta(
|
||||
meta.push((7, serializer::INT, Value::Int(m.ticks_frozen)));
|
||||
}
|
||||
defaults.push((7, serializer::INT, Value::Int(0)));
|
||||
// Entity.DATA_AIR_SUPPLY_ID (1): only an axolotl ticks `Mob.air` (its
|
||||
// default is its 6000 max air supply).
|
||||
if m.species() == horizon_client::mobs::Species::Axolotl {
|
||||
let max = horizon_client::mobs::axolotl::MAX_AIR;
|
||||
if m.air != max {
|
||||
meta.push((1, serializer::INT, Value::Int(m.air)));
|
||||
}
|
||||
defaults.push((1, serializer::INT, Value::Int(max)));
|
||||
// Every Entity tracks air, including changes back to its species' default.
|
||||
let max = m.max_air_supply();
|
||||
if m.air != max {
|
||||
meta.push((1, serializer::INT, Value::Int(m.air)));
|
||||
}
|
||||
defaults.push((1, serializer::INT, Value::Int(max)));
|
||||
if m.kind.id == "minecraft:strider" {
|
||||
if m.extra.flag {
|
||||
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
|
||||
@@ -3225,7 +3238,7 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Entity.setTicksFrozen can go negative; index 7 must carry it. Index 1
|
||||
/// (air supply) goes out for an axolotl only, against its 6000 default.
|
||||
/// (air supply) goes out for every mob, with the species' own default.
|
||||
#[test]
|
||||
fn negative_frozen_ticks_and_axolotl_air_are_synced() {
|
||||
let Some(mut cow) = mob_of("minecraft:cow") else {
|
||||
@@ -3235,7 +3248,11 @@ mod tests {
|
||||
let (meta, _) = freeze_meta(&cow);
|
||||
assert!(meta.contains(&(7, serializer::INT, Value::Int(-3))));
|
||||
cow.air = 100;
|
||||
assert!(freeze_meta(&cow).0.iter().all(|e| e.0 != 1));
|
||||
assert!(
|
||||
freeze_meta(&cow)
|
||||
.0
|
||||
.contains(&(1, serializer::INT, Value::Int(100)))
|
||||
);
|
||||
|
||||
let Some(mut axolotl) = mob_of("minecraft:axolotl") else {
|
||||
return;
|
||||
@@ -3248,6 +3265,60 @@ mod tests {
|
||||
assert!(freeze_meta(&axolotl).0.iter().all(|e| e.0 != 1));
|
||||
}
|
||||
|
||||
/// Raider.IS_CELEBRATING goes out through the tracker's own metadata
|
||||
/// (`mob_meta`), not only through the helper: false is listed as a
|
||||
/// default so the end of a celebration is sent, and true is sent while
|
||||
/// the raider celebrates.
|
||||
#[test]
|
||||
fn a_celebrating_raider_is_sent_by_mob_meta() {
|
||||
use horizon_client::lan::{Lan, NetConfig, chunks::ChunkEncoder, data, hub};
|
||||
let Some(mut pillager) = mob_of("minecraft:pillager") else {
|
||||
return;
|
||||
};
|
||||
let sync = std::sync::Arc::new(data::SyncData::empty());
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::lan(0, "RaiderMetaTest".into(), 2)
|
||||
};
|
||||
let lan = Lan::start_with(sync.clone(), config).unwrap();
|
||||
let encoder = ChunkEncoder::new(
|
||||
[] as [(std::sync::Arc<str>, bool); 0],
|
||||
&horizon_protocol::registry::Builtin::default(),
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let hub_config = hub::HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 10,
|
||||
simulation_distance: 10,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 20,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
};
|
||||
let hub = hub::Hub::new(lan, sync, encoder, hub_config);
|
||||
let meta_of = |m: &Mob| {
|
||||
let (mut meta, mut defaults) = (Vec::new(), Vec::new());
|
||||
super::mob_meta(m, &hub, None, 0, &mut meta, &mut defaults);
|
||||
(meta, defaults)
|
||||
};
|
||||
|
||||
let (meta, defaults) = meta_of(&pillager);
|
||||
assert!(defaults.contains(&(16, serializer::BOOLEAN, Value::Bool(false))));
|
||||
assert!(meta.iter().all(|e| e.0 != 16), "{meta:?}");
|
||||
horizon_client::mobs::raider::state_mut(&mut pillager).celebrating = true;
|
||||
let (meta, _) = meta_of(&pillager);
|
||||
assert!(
|
||||
meta.contains(&(16, serializer::BOOLEAN, Value::Bool(true))),
|
||||
"{meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LivingEntity.startUsingItem` sets flag 1 for the use and flag 2 for
|
||||
/// the off hand, so an off-hand use is 3, not 2. A reader takes bit 0 as
|
||||
/// `isUsingItem` and bit 1 as `getUsedItemHand`.
|
||||
|
||||
@@ -4032,7 +4032,7 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.entity_id as u32 == id)
|
||||
{
|
||||
Some(self.player_entity())
|
||||
Some(self.player_entity_for("minecraft:kill_mob_near_sculk_catalyst"))
|
||||
} else {
|
||||
host.hub
|
||||
.guests
|
||||
|
||||
@@ -73,6 +73,28 @@ fn vehicle_meta(hurt_time: i32, hurt_dir: i32, damage: f32) -> Vec<(u8, i32, Val
|
||||
meta
|
||||
}
|
||||
|
||||
/// `AbstractMinecart`'s DATA_ID_CUSTOM_DISPLAY_BLOCK (entry 11, an optional
|
||||
/// block state, empty by default) and DATA_ID_DISPLAY_OFFSET (entry 12, the
|
||||
/// kind's `getDefaultDisplayOffset` by default).
|
||||
fn cart_display_data(
|
||||
e: &mut EntityState,
|
||||
shown: Option<BlockStateId>,
|
||||
offset: Option<i32>,
|
||||
default_offset: i32,
|
||||
) {
|
||||
e.defaults
|
||||
.push((11, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
|
||||
e.defaults
|
||||
.push((12, serializer::INT, Value::Int(default_offset)));
|
||||
if let Some(s) = shown {
|
||||
e.meta
|
||||
.push((11, serializer::OPTIONAL_BLOCK_STATE, Value::Id(s.0 as i32)));
|
||||
}
|
||||
if let Some(o) = offset.filter(|&o| o != default_offset) {
|
||||
e.meta.push((12, serializer::INT, Value::Int(o)));
|
||||
}
|
||||
}
|
||||
|
||||
/// An armor stand's pose (`ArmorStandPose`), each part that differs from the
|
||||
/// default, as DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21).
|
||||
fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)> {
|
||||
@@ -204,6 +226,11 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
e.meta = vehicle_meta(c.hurt_time, c.hurt_dir, c.damage);
|
||||
let shown = c.custom_display.as_ref().and_then(|t| {
|
||||
horizon_client::entity_nbt::state_from_nbt(self.pack.assets.registry, t)
|
||||
});
|
||||
let default = super::minecarts::default_display_offset(c.kind) as i32;
|
||||
cart_display_data(&mut e, shown, c.custom_offset, default);
|
||||
match c.kind {
|
||||
// MinecartFurnace.DATA_ID_FUEL.
|
||||
super::minecarts::CartKind::Furnace => {
|
||||
@@ -907,4 +934,30 @@ mod tests {
|
||||
let plain = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
|
||||
assert!(stand_poses(&plain).is_empty());
|
||||
}
|
||||
|
||||
/// Issue #8050: a cart's custom display block and offset reach clients
|
||||
/// as entries 11 and 12, the offset only off the kind's default.
|
||||
#[test]
|
||||
fn cart_display_block_and_offset_are_synched() {
|
||||
let mut e = state(1, "minecraft:minecart", [0.0; 3], 0.0, false);
|
||||
cart_display_data(&mut e, Some(BlockStateId(42)), Some(14), 6);
|
||||
assert_eq!(
|
||||
e.meta,
|
||||
vec![
|
||||
(11, serializer::OPTIONAL_BLOCK_STATE, Value::Id(42)),
|
||||
(12, serializer::INT, Value::Int(14)),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
e.defaults,
|
||||
vec![
|
||||
(11, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)),
|
||||
(12, serializer::INT, Value::Int(6)),
|
||||
]
|
||||
);
|
||||
let mut plain = state(2, "minecraft:hopper_minecart", [0.0; 3], 0.0, false);
|
||||
cart_display_data(&mut plain, None, Some(1), 1);
|
||||
assert!(plain.meta.is_empty());
|
||||
assert_eq!(plain.defaults[1], (12, serializer::INT, Value::Int(1)));
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,12 @@ pub(super) struct Levels {
|
||||
}
|
||||
|
||||
impl Levels {
|
||||
/// Clear every parked level and dimensions recorded as heard open.
|
||||
pub(super) fn clear(&mut self) {
|
||||
self.parked.clear();
|
||||
self.heard_open.clear();
|
||||
}
|
||||
|
||||
/// A parked level, without opening or loading it.
|
||||
pub(super) fn parked_world(&self, dim: Dim) -> Option<&World> {
|
||||
self.parked.get(&dim).map(|level| &level.world)
|
||||
@@ -628,13 +634,15 @@ impl Game<'_> {
|
||||
let mut dims: Vec<Dim> = [Dim::Overworld, Dim::Nether, Dim::End]
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
// `/forceload`ed chunks, or a native mod's tickets.
|
||||
let held = |s: &Streaming| !s.forced.is_empty() || !s.mod_forced.is_empty();
|
||||
let forced = if *d == self.dim {
|
||||
!self.streaming.forced.is_empty()
|
||||
held(&self.streaming)
|
||||
} else {
|
||||
self.levels
|
||||
.parked
|
||||
.get(d)
|
||||
.is_some_and(|p| !p.streaming.forced.is_empty())
|
||||
.is_some_and(|p| held(&p.streaming))
|
||||
};
|
||||
guests.iter().any(|g| g.dimension == d.id())
|
||||
// A viewer count not yet told to 0 (`tick_mod_container_viewers`).
|
||||
@@ -773,3 +781,18 @@ fn slowest_steps(notes: &[(f32, String)]) -> String {
|
||||
.collect();
|
||||
format!("; slowest: {}", list.join(", "))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn levels_clear_empties_parked_and_heard_open() {
|
||||
let mut levels = Levels::default();
|
||||
levels.heard_open.push(Dim::Nether);
|
||||
assert!(!levels.heard_open.is_empty());
|
||||
levels.clear();
|
||||
assert!(levels.heard_open.is_empty());
|
||||
assert!(levels.parked.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -493,7 +493,7 @@ impl Game<'_> {
|
||||
};
|
||||
let pos = [p.position.x, p.position.y, p.position.z];
|
||||
if in_viewer_box(at, pos) && !struck(pos) {
|
||||
viewers.push(g.player_entity());
|
||||
viewers.push(g.player_entity_for("minecraft:lightning_strike"));
|
||||
}
|
||||
});
|
||||
self.for_each_player(|g| {
|
||||
|
||||
@@ -584,6 +584,29 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
/// `IItemHandler.isItemValid` for a mod's container: the mods' say
|
||||
/// (`native_container_accepts`) on `stack` in `slot`, once its
|
||||
/// `ModContainer` lets the slot be filled. Any other container: true.
|
||||
fn mod_container_accepts(
|
||||
&self,
|
||||
view: &View,
|
||||
slot: usize,
|
||||
stack: &ItemStack,
|
||||
face: Option<Direction>,
|
||||
) -> bool {
|
||||
if view.kind != Kind::Mod {
|
||||
return true;
|
||||
}
|
||||
let Some((pos, i)) = view.locate(slot) else {
|
||||
return false;
|
||||
};
|
||||
let Some(BlockEntity::Mod(entity)) = self.world.block_entities.get(&pos) else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_container_accepts([pos.0, pos.1, pos.2], entity, i, stack, face, &mut cancel);
|
||||
!cancel
|
||||
}
|
||||
/// `HopperBlockEntity.tryMoveInItem`.
|
||||
fn try_move_in_item(
|
||||
&mut self,
|
||||
@@ -591,8 +614,9 @@ impl Game<'_> {
|
||||
view: &View,
|
||||
stack: ItemStack,
|
||||
slot: usize,
|
||||
face: Option<Direction>,
|
||||
) -> Option<ItemStack> {
|
||||
let (rest, moved) = self.move_in_item(from_hopper, view, stack, slot);
|
||||
let (rest, moved) = self.move_in_item(from_hopper, view, stack, slot, face);
|
||||
if moved {
|
||||
self.view_changed(view);
|
||||
// JukeboxBlockEntity.setTheItem through a hopper's or a dropper's
|
||||
@@ -605,16 +629,18 @@ impl Game<'_> {
|
||||
rest
|
||||
}
|
||||
/// `tryMoveInItem` before its `setChanged`: the remainder, and whether
|
||||
/// anything moved.
|
||||
/// anything moved. `face` is the side it goes in by, for a mod's say.
|
||||
fn move_in_item(
|
||||
&mut self,
|
||||
from_hopper: Option<Pos>,
|
||||
view: &View,
|
||||
mut stack: ItemStack,
|
||||
slot: usize,
|
||||
face: Option<Direction>,
|
||||
) -> (Option<ItemStack>, bool) {
|
||||
let c = self.containers();
|
||||
if !c.can_place(view, slot, &stack) {
|
||||
if !c.can_place(view, slot, &stack) || !self.mod_container_accepts(view, slot, &stack, face)
|
||||
{
|
||||
return (Some(stack), false);
|
||||
}
|
||||
let was_empty = c.is_empty(view);
|
||||
@@ -676,7 +702,7 @@ impl Game<'_> {
|
||||
let mut rest = Some(stack);
|
||||
for slot in view.slots_to_add(face) {
|
||||
let Some(stack) = rest.take() else { break };
|
||||
rest = self.try_move_in_item(from_hopper, view, stack, slot);
|
||||
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
|
||||
}
|
||||
rest
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ impl Game<'_> {
|
||||
});
|
||||
if near {
|
||||
let id = format!("minecraft:{recipe}");
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:crafter_recipe_crafted");
|
||||
self.trigger("minecraft:crafter_recipe_crafted", move |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
|
||||
@@ -13,18 +13,39 @@ use std::sync::Arc;
|
||||
/// A tank as a side reaches it (`native_fluid_tanks`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct NativeFluidTank {
|
||||
pub struct NativeFluidTank {
|
||||
/// Its number, for the mod's own use.
|
||||
pub(crate) tank: usize,
|
||||
/// `minecraft:water` or `minecraft:lava`; none when the tank is empty.
|
||||
pub(crate) fluid: Option<String>,
|
||||
pub tank: usize,
|
||||
/// `minecraft:water`, `minecraft:lava` or a mod's own (`create:honey`);
|
||||
/// none when the tank is empty.
|
||||
pub fluid: Option<String>,
|
||||
/// In millibuckets: a bucket is a thousand.
|
||||
pub(crate) amount: i32,
|
||||
pub(crate) capacity: i32,
|
||||
pub amount: i32,
|
||||
pub capacity: i32,
|
||||
/// Whether a fill may reach it through this side.
|
||||
pub(crate) can_fill: bool,
|
||||
pub can_fill: bool,
|
||||
/// Whether a drain may take it through this side.
|
||||
pub(crate) can_drain: bool,
|
||||
pub can_drain: bool,
|
||||
}
|
||||
|
||||
/// A fluid a tank may hold: vanilla's water and lava, or a mod's own
|
||||
/// (`create:chocolate`), any id in a namespace other than `minecraft`, of
|
||||
/// vanilla's identifier characters. A mod's fluid lives in tanks only, the
|
||||
/// world has vanilla's alone, and its mod draws it; a cauldron takes only
|
||||
/// water and lava. Variants of one fluid (a potion's kinds) are fluids of
|
||||
/// their own (`create:potion/healing`): equal ids merge, others don't.
|
||||
fn tank_fluid(fluid: &str) -> bool {
|
||||
if matches!(fluid, "minecraft:water" | "minecraft:lava") {
|
||||
return true;
|
||||
}
|
||||
let ok = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.-".contains(&b);
|
||||
fluid.split_once(':').is_some_and(|(namespace, path)| {
|
||||
!namespace.is_empty()
|
||||
&& namespace != "minecraft"
|
||||
&& namespace.bytes().all(ok)
|
||||
&& !path.is_empty()
|
||||
&& path.bytes().all(|b| ok(b) || b == b'/')
|
||||
})
|
||||
}
|
||||
|
||||
/// A cauldron level's contents: level `level` holds `level * 1000 / 3`
|
||||
@@ -191,11 +212,12 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// `native_fluid_insert`: up to `amount` millibuckets of `fluid`
|
||||
/// (`minecraft:water`, `minecraft:lava`) into the block at `pos`, through
|
||||
/// its side `face`, in the level in place: a mod block's tanks first
|
||||
/// those with the same fluid, then empty ones, by the sides and tanks its
|
||||
/// table gave; a cauldron by thirds of a bucket, a lava one only whole.
|
||||
/// What did not fit. API 54.
|
||||
/// (`minecraft:water`, `minecraft:lava`, or since API
|
||||
/// @API:mod_fluids@ a mod's own, `create:chocolate`) into the block at
|
||||
/// `pos`, through its side `face`, in the level in place: a mod block's
|
||||
/// tanks first those with the same fluid, then empty ones, by the sides
|
||||
/// and tanks its table gave; a cauldron by thirds of a bucket, a lava one
|
||||
/// only whole, and none of a mod's fluid. What did not fit. API 54.
|
||||
pub(in crate::game) fn native_fluid_insert(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
@@ -203,7 +225,7 @@ impl Game<'_> {
|
||||
fluid: &str,
|
||||
amount: i32,
|
||||
) -> i32 {
|
||||
if fluid != "minecraft:water" && fluid != "minecraft:lava" {
|
||||
if !tank_fluid(fluid) {
|
||||
return amount;
|
||||
}
|
||||
if !self.native_simulates() {
|
||||
@@ -501,6 +523,16 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.find(|t| t.can_drain && t.amount > 0)?
|
||||
.fluid?;
|
||||
// A cauldron reports itself fillable, but holds only vanilla's fluids:
|
||||
// no room for a mod's, so a move answers none rather than draining
|
||||
// and putting it all back.
|
||||
if matches!(
|
||||
self.native_fluid_at(dimension, to),
|
||||
Some(FluidAt::Cauldron(_))
|
||||
) && !matches!(fluid.as_str(), "minecraft:water" | "minecraft:lava")
|
||||
{
|
||||
return Some(0);
|
||||
}
|
||||
let room = self
|
||||
.native_fluid_tanks(dimension, to, to_face)?
|
||||
.iter()
|
||||
@@ -553,6 +585,31 @@ impl Game<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tanks hold vanilla's two fluids and any of a mod's own; not a
|
||||
/// made-up vanilla one, nor a name that isn't an identifier.
|
||||
#[test]
|
||||
fn a_tank_holds_vanilla_fluids_and_mods_own() {
|
||||
for fluid in [
|
||||
"minecraft:water",
|
||||
"minecraft:lava",
|
||||
"create:chocolate",
|
||||
"create:potion/healing",
|
||||
] {
|
||||
assert!(tank_fluid(fluid), "{fluid}");
|
||||
}
|
||||
for fluid in [
|
||||
"minecraft:milk",
|
||||
"minecraft:empty",
|
||||
"chocolate",
|
||||
":x",
|
||||
"create:",
|
||||
"Create:honey",
|
||||
"create:honey cake",
|
||||
] {
|
||||
assert!(!tank_fluid(fluid), "{fluid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cauldron_level_holds_a_third_of_a_bucket() {
|
||||
assert_eq!(third(1), 333);
|
||||
|
||||
@@ -65,7 +65,7 @@ impl Game<'_> {
|
||||
let mut rest = Some(one);
|
||||
for &slot in slots {
|
||||
let Some(one) = rest.take() else { break };
|
||||
rest = self.move_in_item(None, view, one, slot).0;
|
||||
rest = self.move_in_item(None, view, one, slot, Some(face)).0;
|
||||
}
|
||||
rest
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ pub(super) const HEIGHT: f64 = 0.7_f32 as f64;
|
||||
/// `MinecartTNT.handleEntityEvent` answers by calling `primeFuse`.
|
||||
pub(super) const TNT_PRIME_EVENT: i8 = 70;
|
||||
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
|
||||
fn default_display_offset(kind: CartKind) -> f64 {
|
||||
pub(super) fn default_display_offset(kind: CartKind) -> f64 {
|
||||
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
|
||||
}
|
||||
/// `AbstractMinecart.getDisplayOffset`: the saved `DisplayOffset`, else the default.
|
||||
|
||||
@@ -148,6 +148,20 @@ impl Game<'_> {
|
||||
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
|
||||
/// carries on.
|
||||
pub(super) fn tick_sound_engine(&mut self) {
|
||||
if self.remote.is_some() {
|
||||
for entity in self.audio.engine.bound_entities() {
|
||||
let previous = entity.state();
|
||||
if !previous.removed {
|
||||
entity.update(self.remote_sound_entity_state(entity.id as i32).unwrap_or(
|
||||
horizon_client::sound_engine::SoundEntityState {
|
||||
removed: true,
|
||||
..previous
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.pause != self.sound_paused {
|
||||
if self.pause {
|
||||
self.audio.pause();
|
||||
|
||||
@@ -11,13 +11,14 @@ use horizon_assets::{
|
||||
model::{Direction, Material},
|
||||
};
|
||||
use horizon_client::{
|
||||
block_entity_render::{diffuse, diffuse_lights, vertex_light},
|
||||
block_entity_render::{TextCall, diffuse, diffuse_lights, text_vertices, vertex_light},
|
||||
containers::{BlockEntity, ModRevision},
|
||||
fast_hash::FxHashMap,
|
||||
gfx::Cull,
|
||||
hud::Rect,
|
||||
items::ItemStack,
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
};
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
@@ -58,6 +59,76 @@ pub struct BlockEntityDraw {
|
||||
lights: (Vec3, Vec3),
|
||||
vertices: Vec<Vertex>,
|
||||
kept: bool,
|
||||
/// Where each turning part starts in `vertices` (`spin`), and where the
|
||||
/// parts standing still start again (`still`, none).
|
||||
spins: Vec<(usize, Option<Spin>)>,
|
||||
}
|
||||
|
||||
/// A part of a block entity's frame that turns by itself (`BlockEntityDraw::spin`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Spin {
|
||||
axis: Vec3,
|
||||
pivot: Vec3,
|
||||
degrees_per_tick: f32,
|
||||
phase: f32,
|
||||
}
|
||||
|
||||
impl Spin {
|
||||
/// The turn at `game_time` plus `partial`, about the pivot, in the
|
||||
/// block's coordinates. Counted in f64: a world's game time has more
|
||||
/// ticks than an f32 holds exactly.
|
||||
fn turn(&self, game_time: i64, partial: f32) -> Mat4 {
|
||||
let t = game_time as f64 + f64::from(partial);
|
||||
let degrees =
|
||||
(t * f64::from(self.degrees_per_tick) + f64::from(self.phase)).rem_euclid(360.0);
|
||||
Mat4::from_translation(self.pivot)
|
||||
* Mat4::from_quat(Quat::from_axis_angle(
|
||||
self.axis,
|
||||
(degrees as f32).to_radians(),
|
||||
))
|
||||
* Mat4::from_translation(-self.pivot)
|
||||
}
|
||||
}
|
||||
|
||||
/// `vertices`, drawn relative to the block, moved to the camera by `origin`
|
||||
/// and each part of `spins` (starts relative to `vertices`) turned as it is
|
||||
/// at `game_time` plus `partial`, onto `out`.
|
||||
pub(super) fn place_turning(
|
||||
vertices: &[Vertex],
|
||||
spins: &[(usize, Option<Spin>)],
|
||||
game_time: i64,
|
||||
partial: f32,
|
||||
origin: Vec3,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let mut turn: Option<Mat4> = None;
|
||||
let mut next = spins.iter().peekable();
|
||||
for (i, v) in vertices.iter().enumerate() {
|
||||
while let Some((start, spin)) = next.next_if(|(start, _)| *start <= i) {
|
||||
let _ = start;
|
||||
turn = spin.map(|s| s.turn(game_time, partial));
|
||||
}
|
||||
let mut v = *v;
|
||||
if let Some(m) = turn {
|
||||
v.pos = m.transform_point3(Vec3::from_array(v.pos)).to_array();
|
||||
if v.normal[3] != 0 {
|
||||
let n = Vec3::new(
|
||||
f32::from(v.normal[0]),
|
||||
f32::from(v.normal[1]),
|
||||
f32::from(v.normal[2]),
|
||||
);
|
||||
let n = m.transform_vector3(n);
|
||||
v.normal = [
|
||||
n.x.round().clamp(-127.0, 127.0) as i8,
|
||||
n.y.round().clamp(-127.0, 127.0) as i8,
|
||||
n.z.round().clamp(-127.0, 127.0) as i8,
|
||||
v.normal[3],
|
||||
];
|
||||
}
|
||||
}
|
||||
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -83,12 +154,17 @@ impl BlockEntityDraw {
|
||||
lights,
|
||||
vertices,
|
||||
kept: false,
|
||||
spins: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The vertices drawn, with those it was made with first.
|
||||
pub(super) fn into_vertices(self) -> Vec<Vertex> {
|
||||
self.vertices
|
||||
}
|
||||
/// The vertices drawn and where its turning parts start in them (`spin`).
|
||||
pub(super) fn into_turning(self) -> (Vec<Vertex>, Vec<(usize, Option<Spin>)>) {
|
||||
(self.vertices, self.spins)
|
||||
}
|
||||
/// `PoseStack.pushPose`: `pop` comes back to the pose as it is now.
|
||||
pub fn push(&mut self) {
|
||||
let pose = self.pose();
|
||||
@@ -126,13 +202,47 @@ impl BlockEntityDraw {
|
||||
/// its block entity (what the server sent), its light or the resource
|
||||
/// packs change. Call it only when what you drew reads none of
|
||||
/// `partial`, `game_time`, the blocks around it or anything else of
|
||||
/// yours, and moves nothing by itself: an idle machine, not a turning
|
||||
/// shaft. Kept geometry stands still: an enchanted item's glint, and an
|
||||
/// yours, and moves nothing by itself but through `spin`: an idle machine,
|
||||
/// or a shaft turning at a steady speed, not one whose speed changes from
|
||||
/// frame to frame. Kept geometry stands still: an enchanted item's glint, and an
|
||||
/// item drawn with `native_draw_item` whose model reads the time (a clock,
|
||||
/// a compass), stop where they were. API 94.
|
||||
pub fn keep(&mut self) {
|
||||
self.kept = true;
|
||||
}
|
||||
/// What is drawn next turns by itself: `degrees_per_tick` degrees each
|
||||
/// game tick (a shaft at `rpm` turns `rpm * 0.3`, as Create's do), about
|
||||
/// `axis` through `pivot`, both in blocks from the block's corner
|
||||
/// whatever the pose, counterclockwise seen from the axis' positive end,
|
||||
/// `phase` degrees round at game time 0 (to mesh a cog with its
|
||||
/// neighbour's). Draw the part at rest; the game turns it each frame from
|
||||
/// the game time and the partial tick. With `keep` it goes on turning
|
||||
/// with no call of `native_block_entity_submit` until what it was drawn
|
||||
/// with changes: send a new speed in the update tag and the block is
|
||||
/// drawn again (Flywheel's rotating instances, done on the CPU). The
|
||||
/// angle is `game_time * degrees_per_tick + phase`, not a sum of turns:
|
||||
/// with a new speed, pass the phase that keeps the part where it was
|
||||
/// (`old_angle - game_time * new_speed`), or it jumps. Until
|
||||
/// `still` or the next `spin`. A block entity's frame only: a mob's
|
||||
/// (`native_mob_submit`) draws it at rest. API @API:spinning_geometry@.
|
||||
pub fn spin(&mut self, axis: [f32; 3], pivot: [f32; 3], degrees_per_tick: f32, phase: f32) {
|
||||
let spin = Spin {
|
||||
axis: Vec3::from_array(axis).normalize_or(Vec3::Y),
|
||||
pivot: Vec3::from_array(pivot),
|
||||
degrees_per_tick: if degrees_per_tick.is_finite() {
|
||||
degrees_per_tick
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
phase: if phase.is_finite() { phase } else { 0.0 },
|
||||
};
|
||||
self.spins.push((self.vertices.len(), Some(spin)));
|
||||
}
|
||||
/// What is drawn next stands still again, after `spin`.
|
||||
/// API @API:spinning_geometry@.
|
||||
pub fn still(&mut self) {
|
||||
self.spins.push((self.vertices.len(), None));
|
||||
}
|
||||
fn apply(&mut self, m: Mat4) {
|
||||
if let Some(pose) = self.poses.last_mut() {
|
||||
*pose *= m;
|
||||
@@ -195,6 +305,10 @@ struct KeptKey {
|
||||
dim: Dim,
|
||||
state: u32,
|
||||
light: u32,
|
||||
/// `Font::epoch` when it was drawn: a cache reset moves glyphs in the
|
||||
/// atlas, so text a mod kept (`native_draw_text`) is drawn again, as a
|
||||
/// sign's kept text is.
|
||||
glyphs: u64,
|
||||
}
|
||||
|
||||
struct KeptEntry {
|
||||
@@ -206,6 +320,8 @@ struct KeptEntry {
|
||||
used: i64,
|
||||
/// Relative to the block's corner.
|
||||
vertices: Vec<Vertex>,
|
||||
/// Its turning parts (`BlockEntityDraw::spin`), from the start of `vertices`.
|
||||
spins: Vec<(usize, Option<Spin>)>,
|
||||
}
|
||||
|
||||
/// Geometry mods kept (`BlockEntityDraw::keep`), by position. As
|
||||
@@ -250,6 +366,7 @@ impl KeptGeometry {
|
||||
revision: ModRevision,
|
||||
bounds: [f32; 6],
|
||||
vertices: &[Vertex],
|
||||
spins: Vec<(usize, Option<Spin>)>,
|
||||
now: i64,
|
||||
) {
|
||||
let mut kept = self
|
||||
@@ -266,6 +383,7 @@ impl KeptGeometry {
|
||||
bounds,
|
||||
used: now,
|
||||
vertices: kept,
|
||||
spins,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -314,6 +432,7 @@ impl Game<'_> {
|
||||
lights: diffuse_lights(self.dim == Dim::Nether),
|
||||
vertices: std::mem::take(out),
|
||||
kept: false,
|
||||
spins: Vec::new(),
|
||||
};
|
||||
let now = self.world.game_time;
|
||||
let mut kept = self.kept_block_entities.borrow_mut();
|
||||
@@ -335,15 +454,27 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
state,
|
||||
light,
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
};
|
||||
if let Some(entry) = kept.get(pos, key, entity.revision(), now) {
|
||||
if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) {
|
||||
let start = draw.vertices.len();
|
||||
self.mark_block_entity(start, state);
|
||||
draw.vertices.extend(entry.vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
..*v
|
||||
}));
|
||||
if entry.spins.is_empty() {
|
||||
draw.vertices.extend(entry.vertices.iter().map(|v| Vertex {
|
||||
pos: (Vec3::from_array(v.pos) + origin).to_array(),
|
||||
..*v
|
||||
}));
|
||||
} else {
|
||||
place_turning(
|
||||
&entry.vertices,
|
||||
&entry.spins,
|
||||
draw.game_time,
|
||||
draw.partial,
|
||||
origin,
|
||||
&mut draw.vertices,
|
||||
);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -363,7 +494,14 @@ impl Game<'_> {
|
||||
draw.poses.push(Mat4::IDENTITY);
|
||||
draw.light = light;
|
||||
draw.kept = false;
|
||||
draw.spins.clear();
|
||||
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
|
||||
// The turning parts, from the start of this block's vertices.
|
||||
let spins: Vec<(usize, Option<Spin>)> = draw
|
||||
.spins
|
||||
.drain(..)
|
||||
.map(|(at, spin)| (at.saturating_sub(start), spin))
|
||||
.collect();
|
||||
if draw.kept {
|
||||
kept.keep(
|
||||
pos,
|
||||
@@ -371,13 +509,26 @@ impl Game<'_> {
|
||||
entity.revision(),
|
||||
bounds,
|
||||
&draw.vertices[start..],
|
||||
spins.clone(),
|
||||
now,
|
||||
);
|
||||
} else {
|
||||
kept.forget(pos);
|
||||
}
|
||||
for v in &mut draw.vertices[start..] {
|
||||
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
|
||||
if spins.is_empty() {
|
||||
for v in &mut draw.vertices[start..] {
|
||||
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
|
||||
}
|
||||
} else {
|
||||
let drawn = draw.vertices.split_off(start);
|
||||
place_turning(
|
||||
&drawn,
|
||||
&spins,
|
||||
draw.game_time,
|
||||
draw.partial,
|
||||
origin,
|
||||
&mut draw.vertices,
|
||||
);
|
||||
}
|
||||
}
|
||||
*out = draw.vertices;
|
||||
@@ -415,6 +566,51 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Draws `text` under `draw`'s pose as a sign draws its lines
|
||||
/// (`Font.drawInBatch` in a `BlockEntityRenderer`): in the pose's x-y
|
||||
/// plane, one unit a font pixel, y down, its left end at `at`, facing
|
||||
/// +z. Scale the pose first as a sign does (`draw.scale([1.0 / 64.0,
|
||||
/// -1.0 / 64.0, 1.0 / 64.0])` for its letters, 8 pixels high). Each span
|
||||
/// is drawn in its own colour, the others in the 0xRRGGBB `color`;
|
||||
/// `glowing` text is full bright (a glow ink sac's), the rest lit by
|
||||
/// `draw.light`; a nonzero 0xRRGGBB `outline` rings each glyph, as glowing
|
||||
/// sign text's. Returns the width drawn, in font pixels: centre a line
|
||||
/// by starting it at minus half its width. API @API:draw_text@.
|
||||
pub(super) fn native_draw_text(
|
||||
&self,
|
||||
draw: &mut BlockEntityDraw,
|
||||
text: &Text,
|
||||
at: [f32; 2],
|
||||
color: u32,
|
||||
glowing: bool,
|
||||
outline: u32,
|
||||
) -> f32 {
|
||||
let glyph = |c: char| self.gui.atlas.glyph_cell(c);
|
||||
let light = if glowing { 240 | 240 << 8 } else { draw.light };
|
||||
let matrix = draw.pose();
|
||||
let mut x = at[0];
|
||||
for span in &text.spans {
|
||||
let shown = horizon_client::bidi::visual_str(&span.text).into_owned();
|
||||
let width: f32 = shown.chars().map(|c| glyph(c).advance).sum();
|
||||
let call = TextCall {
|
||||
matrix,
|
||||
x,
|
||||
y: at[1],
|
||||
text: shown,
|
||||
color: 0xFF00_0000 | (span.style.color.unwrap_or(color) & 0xFF_FFFF),
|
||||
outline: if outline == 0 {
|
||||
0
|
||||
} else {
|
||||
0xFF00_0000 | (outline & 0xFF_FFFF)
|
||||
},
|
||||
light,
|
||||
};
|
||||
text_vertices(&glyph, &call, &mut draw.vertices);
|
||||
x += width;
|
||||
}
|
||||
x - at[0]
|
||||
}
|
||||
|
||||
/// Draws a box from `from` to `to` (texels of 16 per block, as a block
|
||||
/// model's element) under `draw`'s pose, with the faces given, by
|
||||
/// `Direction` (down, up, north, south, west, east), baked as a model
|
||||
@@ -578,6 +774,7 @@ mod tests {
|
||||
lights: diffuse_lights(false),
|
||||
vertices: Vec::new(),
|
||||
kept: false,
|
||||
spins: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,8 +879,58 @@ mod tests {
|
||||
dim: Dim::Overworld,
|
||||
state: 7,
|
||||
light: 0xF0F0,
|
||||
glyphs: 0,
|
||||
};
|
||||
|
||||
/// A part drawn after `spin` turns about its pivot by the game time and
|
||||
/// partial tick (90 degrees a tick about +y takes +x to -z, by the right
|
||||
/// hand), its normal with it; what is drawn after `still` and before the
|
||||
/// spin stays; all of it is moved by the origin.
|
||||
#[test]
|
||||
fn a_spinning_part_turns_with_the_game_time_and_the_rest_stays() {
|
||||
let mut draw = draw();
|
||||
let at = |x: f32, y: f32, z: f32, normal: [i8; 4]| Vertex {
|
||||
pos: [x, y, z],
|
||||
normal,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
};
|
||||
draw.vertices.push(at(1.0, 0.5, 0.5, [0; 4]));
|
||||
draw.spin([0.0, 1.0, 0.0], [0.5, 0.5, 0.5], 90.0, 0.0);
|
||||
draw.vertices.push(at(1.0, 0.5, 0.5, [127, 0, 0, 127]));
|
||||
draw.still();
|
||||
draw.vertices.push(at(0.0, 0.0, 0.0, [0; 4]));
|
||||
let origin = Vec3::new(10.0, 0.0, 0.0);
|
||||
let close = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
|
||||
// A tick and a half: 135 degrees.
|
||||
let mut out = Vec::new();
|
||||
place_turning(&draw.vertices, &draw.spins, 1, 0.5, origin, &mut out);
|
||||
assert!(close(out[0].pos, [11.0, 0.5, 0.5]), "{:?}", out[0].pos);
|
||||
let h = 0.5 * std::f32::consts::FRAC_1_SQRT_2;
|
||||
assert!(
|
||||
close(out[1].pos, [10.5 - h, 0.5, 0.5 - h]),
|
||||
"{:?}",
|
||||
out[1].pos
|
||||
);
|
||||
assert!(close(out[2].pos, [10.0, 0.0, 0.0]), "{:?}", out[2].pos);
|
||||
// One tick: a quarter turn, +x to -z, the normal along.
|
||||
out.clear();
|
||||
place_turning(&draw.vertices, &draw.spins, 1, 0.0, origin, &mut out);
|
||||
assert!(close(out[1].pos, [10.5, 0.5, 0.0]), "{:?}", out[1].pos);
|
||||
assert_eq!(out[1].normal, [0, 0, -127, 127]);
|
||||
// Four ticks: all the way round, and a world's late game time is no
|
||||
// less exact.
|
||||
out.clear();
|
||||
place_turning(
|
||||
&draw.vertices,
|
||||
&draw.spins,
|
||||
4_000_000_000,
|
||||
0.0,
|
||||
origin,
|
||||
&mut out,
|
||||
);
|
||||
assert!(close(out[1].pos, [11.0, 0.5, 0.5]), "{:?}", out[1].pos);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() {
|
||||
let mut kept = KeptGeometry::default();
|
||||
@@ -694,7 +941,15 @@ mod tests {
|
||||
..Default::default()
|
||||
});
|
||||
let drawn = entity.revision();
|
||||
kept.keep(pos, KEY, drawn, DEFAULT_BOUNDS, &[vertex(0.5)], 0);
|
||||
kept.keep(
|
||||
pos,
|
||||
KEY,
|
||||
drawn,
|
||||
DEFAULT_BOUNDS,
|
||||
&[vertex(0.5)],
|
||||
Vec::new(),
|
||||
0,
|
||||
);
|
||||
let entry = kept.get(pos, KEY, entity.revision(), 1).expect("kept");
|
||||
assert_eq!(entry.vertices.len(), 1);
|
||||
assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]);
|
||||
@@ -713,6 +968,7 @@ mod tests {
|
||||
dim: Dim::Nether,
|
||||
..KEY
|
||||
},
|
||||
KeptKey { glyphs: 1, ..KEY },
|
||||
] {
|
||||
assert!(kept.get(pos, changed, drawn, 1).is_none());
|
||||
}
|
||||
@@ -731,6 +987,7 @@ mod tests {
|
||||
entity.revision(),
|
||||
DEFAULT_BOUNDS,
|
||||
&[vertex(0.25)],
|
||||
Vec::new(),
|
||||
2,
|
||||
);
|
||||
assert!(kept.get(pos, KEY, drawn, 2).is_none());
|
||||
@@ -748,8 +1005,8 @@ mod tests {
|
||||
let mut kept = KeptGeometry::default();
|
||||
let (seen, gone) = ([0, 0, 0], [5, 0, 0]);
|
||||
let m = machine();
|
||||
kept.keep(seen, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.keep(gone, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], 0);
|
||||
kept.keep(seen, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], Vec::new(), 0);
|
||||
kept.keep(gone, KEY, m, DEFAULT_BOUNDS, &[vertex(0.0)], Vec::new(), 0);
|
||||
kept.get(gone, KEY, m, 50);
|
||||
// A sweep at 100 keeps both: each was drawn in the last 100 ticks.
|
||||
kept.sweep(100);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! textures (resource packs included) in its own pass: with `WorldDrawn::block_sprites`, the arrays themselves.
|
||||
use super::Game;
|
||||
use horizon_client::block_sprites::{BlockSprites, Lookup, SpriteRef};
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
@@ -24,6 +26,31 @@ impl Game<'_> {
|
||||
.map(|texture| lookup.texture(texture))
|
||||
.collect()
|
||||
}
|
||||
/// For a client mod drawing a block entity: the texture block state `state`'s model shows on its `side`
|
||||
/// (`block/oak_planks`: vanilla's without their namespace, a mod's with it), by the name `native_draw_cube` and
|
||||
/// `native_draw_quad` take, and that face's
|
||||
/// tint index (-1 for none): a copycat block drawing a box of its material's textures with `native_draw_cube`,
|
||||
/// kept with `BlockEntityDraw::keep`. The face culled against that side comes first, else the first facing it;
|
||||
/// None when the model has no face that way or isn't baked. API @API:block_face_sprite@.
|
||||
pub(super) fn native_block_face_sprite(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
side: Direction,
|
||||
) -> Option<(String, i32)> {
|
||||
let wanted = horizon_assets::model::Direction::ALL[side as usize];
|
||||
let models = self
|
||||
.pack
|
||||
.render
|
||||
.model_catalog
|
||||
.collect_seeded(state, 42)
|
||||
.ok()?;
|
||||
let faces = || models.iter().flat_map(|(_, model)| &model.faces);
|
||||
let face = faces()
|
||||
.find(|f| f.quad.cull == Some(wanted))
|
||||
.or_else(|| faces().find(|f| f.quad.facing == wanted))?;
|
||||
let name = self.pack.layer_names.get(&face.layer)?.clone();
|
||||
Some((name, face.quad.tint_index))
|
||||
}
|
||||
/// For a client mod: changes each time the resources load (a resource pack turned on or off can move every
|
||||
/// sprite): ask `native_block_sprites` again then. API 380.
|
||||
pub(super) fn native_block_sprites_version(&self) -> u64 {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Chunks a native mod keeps loaded (NeoForge's `TicketController`): a train
|
||||
//! running where nobody is, a long contraption, a machine that must keep
|
||||
//! working (docs/native-mods.md, "Chunk tickets"). A held chunk is as a
|
||||
//! `/forceload`ed one, its level stays open, but `/forceload` neither lists
|
||||
//! nor releases it and it isn't saved.
|
||||
use super::Game;
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Holds chunk `chunk` (`[x, z]`, block coordinates `>> 4`) of the level
|
||||
/// in place loaded and ticking for `owner`, or, with `hold` false, lets
|
||||
/// go of it. `owner` names what holds it, under the mod's namespace
|
||||
/// (`create:train/4f0c…`, `create:contraption/12,64,-3`): the chunk stays
|
||||
/// while any owner holds it, as NeoForge's `TicketController.forceChunk`
|
||||
/// with an owner. A held chunk is loaded with the ring around it, its
|
||||
/// blocks and entities tick as a `/forceload`ed one's do, and its level
|
||||
/// stays open with nobody there. Tickets are kept until the owner lets go
|
||||
/// or the server stops, not saved: keep what you hold (a level
|
||||
/// attachment) and hold it again in `native_level_load`. True when the
|
||||
/// set of owners changed; false for an owner without a namespace and off
|
||||
/// a server. API @API:chunk_tickets@.
|
||||
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
let namespaced = owner
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
if !namespaced || !self.native_authoritative() {
|
||||
return false;
|
||||
}
|
||||
let key = (chunk[0], chunk[1]);
|
||||
let level = self.mod_chunk_tickets.entry(self.dim).or_default();
|
||||
let changed = if hold {
|
||||
level.entry(key).or_default().insert(owner.to_owned())
|
||||
} else {
|
||||
let removed = level.get_mut(&key).is_some_and(|o| o.remove(owner));
|
||||
if level.get(&key).is_some_and(|o| o.is_empty()) {
|
||||
level.remove(&key);
|
||||
}
|
||||
removed
|
||||
};
|
||||
if !changed {
|
||||
return false;
|
||||
}
|
||||
if level.contains_key(&key) {
|
||||
if self.streaming.mod_forced.insert(key) {
|
||||
self.streaming.rank_forced(key.0, key.1, key.0, key.1);
|
||||
}
|
||||
} else {
|
||||
self.streaming.mod_forced.remove(&key);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The chunks of the level in place `owner` holds (`native_force_chunk`),
|
||||
/// sorted. API @API:chunk_tickets@.
|
||||
pub(super) fn native_forced_chunks(&self, owner: &str) -> Vec<[i32; 2]> {
|
||||
let mut held: Vec<[i32; 2]> = self
|
||||
.mod_chunk_tickets
|
||||
.get(&self.dim)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(_, owners)| owners.contains(owner))
|
||||
.map(|(&(x, z), _)| [x, z])
|
||||
.collect();
|
||||
held.sort_unstable();
|
||||
held
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use horizon_world::nbt::Tag;
|
||||
|
||||
/// Failure to access a native item component.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum NativeComponentError {
|
||||
pub enum NativeComponentError {
|
||||
UnknownType,
|
||||
InvalidValue(horizon_protocol::Error),
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ use horizon_client::{
|
||||
use serde_json::Value;
|
||||
|
||||
/// One open criterion of a mod's trigger, as the mod's test sees it.
|
||||
pub(crate) struct NativeCriterion<'a> {
|
||||
pub struct NativeCriterion<'a> {
|
||||
/// The criterion's `conditions`, `Null` when it has none. Its `player`
|
||||
/// predicate is the game's to test, as vanilla's `SimpleInstance.player`.
|
||||
pub(crate) conditions: &'a Value,
|
||||
pub conditions: &'a Value,
|
||||
world: &'a dyn adv::World,
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ impl NativeCriterion<'_> {
|
||||
/// and `damage` components, and the `enchantments` and `damage`
|
||||
/// predicates; other components and predicates aren't checked. A missing
|
||||
/// key passes, as an absent `Optional<ItemPredicate>` does.
|
||||
pub(crate) fn item(&self, key: &str, stack: &ItemStack) -> bool {
|
||||
pub fn item(&self, key: &str, stack: &ItemStack) -> bool {
|
||||
adv::item(&self.conditions[key], &item_view(stack), self.world)
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,21 @@ impl Game<'_> {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// `native_trigger_criterion` with its test behind a reference, as a
|
||||
/// trait mod calls it through `dyn Host`: the same players, criteria and
|
||||
/// answer. API @API:host_bridge@.
|
||||
pub(super) fn native_trigger_mod_criterion(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
trigger: &str,
|
||||
test: &dyn Fn(&NativeCriterion<'_>) -> bool,
|
||||
) -> bool {
|
||||
self.native_trigger_criterion(uuid, trigger, test)
|
||||
}
|
||||
|
||||
/// `trigger` for a mod's trigger, for the player in place.
|
||||
fn trigger_mod_criteria(&mut self, trigger: &str, test: &dyn Fn(&NativeCriterion<'_>) -> bool) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for(trigger);
|
||||
self.trigger(trigger, |conditions, world| {
|
||||
passes(conditions, &me, world, test)
|
||||
});
|
||||
@@ -130,6 +142,9 @@ mod tests {
|
||||
fn dimension(&self) -> &str {
|
||||
"minecraft:overworld"
|
||||
}
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
(false, false)
|
||||
}
|
||||
fn biome(&self, _: [i32; 3]) -> String {
|
||||
"minecraft:plains".into()
|
||||
}
|
||||
|
||||
@@ -1028,6 +1028,7 @@ use horizon_client::{
|
||||
use horizon_core::{
|
||||
geometry::Direction,
|
||||
position::BlockPos,
|
||||
random::LegacyRandom,
|
||||
ticks::{ScheduleResult, TickPriority},
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -3144,6 +3145,36 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_block_entity_update_tag(mod_api::mods(), pos, entity, tag);
|
||||
}
|
||||
/// May `stack` go into slot `slot` of the mod block entity at `pos`
|
||||
/// (`entity`, read only), put in through `face` (none when no face is
|
||||
/// asked)? NeoForge's `IItemHandler.isItemValid`, vanilla's
|
||||
/// `Container.canPlaceItem`: a millstone takes only what it has a recipe
|
||||
/// for, so a wrong item can't block it. Asked on the server for every
|
||||
/// item a hopper, a dropper or `native_insert_item` would put in, once
|
||||
/// the block's `ModContainer` lets that slot be filled through that face
|
||||
/// (its `inputs` and `faces`). Set `cancel` to refuse the item there; the
|
||||
/// mover tries the next slot. Not asked for a player's clicks in a menu
|
||||
/// (`native_menu_slot_rules`). Bundles need "hooks": @API:container_accepts@.
|
||||
pub(super) fn native_container_accepts(
|
||||
&self,
|
||||
pos: [i32; 3],
|
||||
entity: &ModBlockEntity,
|
||||
slot: usize,
|
||||
stack: &ItemStack,
|
||||
face: Option<Direction>,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_container_accepts(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
pos,
|
||||
entity,
|
||||
slot,
|
||||
stack,
|
||||
face,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
/// Draw the mod block entity at `pos`, of block `block`
|
||||
/// (`mymod:machine`), on this client: once a frame while it is in view
|
||||
/// (vanilla's `BlockEntityRenderer.submit`: within 64 blocks, its chunk
|
||||
@@ -8521,10 +8552,7 @@ impl Game<'_> {
|
||||
/// `next_float`...). `None` on a client of a server, and for a level not
|
||||
/// open: the Nether and the End open when someone first goes there and
|
||||
/// close after 300 ticks with nobody in them (`close_idle_levels`). API 43.
|
||||
pub(super) fn native_level_random(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
) -> Option<&mut horizon_core::random::LegacyRandom> {
|
||||
pub(super) fn native_level_random(&mut self, dimension: &str) -> Option<&mut LegacyRandom> {
|
||||
if self.serving.is_none() && self.options.dedicated.is_none() {
|
||||
return None;
|
||||
}
|
||||
@@ -10271,7 +10299,7 @@ fn finite(v: f32, lo: f32, hi: f32) -> f32 {
|
||||
/// Read by mods only.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NativeItemEntity {
|
||||
pub struct NativeItemEntity {
|
||||
/// The entity's ID, for `native_set_item_entity`.
|
||||
pub id: u32,
|
||||
pub position: [f64; 3],
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! `Host` forms of documented `Game` helpers that `dyn Host` could not carry:
|
||||
//! the generic `native_with_mod_block_entity`, the level services only the
|
||||
//! level hosts had (`play_sound`, `send_particles`), and what a client mod
|
||||
//! reads of its own player and of the block entities it draws
|
||||
//! (docs/native-mods.md, "Trait mods"). Each goes through the helper or the
|
||||
//! state the game's own code uses, so both answer alike.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, ModBlockEntity},
|
||||
items::ItemStack,
|
||||
particles::ParticleOptions,
|
||||
};
|
||||
use horizon_world::{player_inventory::EquipmentSlot, registry::BlockStateId};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// A copy of the mod block entity at `pos`, to read without changing it:
|
||||
/// a press reading the basin under it, a client mod drawing a machine
|
||||
/// from its neighbour's state. On a server, its saved compound, item
|
||||
/// slots and data slots; on a client, what the server sent of it
|
||||
/// (`native_block_entity_update_tag`, merged into `nbt`), with no item
|
||||
/// slots. None for another kind of block entity, a block with none, and
|
||||
/// on a server for the one ticking now, which is out of the world (as
|
||||
/// for `native_with_mod_block_entity`). API @API:host_bridge@.
|
||||
pub(super) fn native_mod_block_entity(&self, pos: [i32; 3]) -> Option<ModBlockEntity> {
|
||||
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Mod(m)) if !m.id.is_empty() => Some(ModBlockEntity::clone(m)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_with_mod_block_entity` through `dyn Host`: runs `change` on
|
||||
/// the mod block entity at `pos` (a burner heating the basin above it),
|
||||
/// then, as its `setChanged` would, saves its chunk next time and lets
|
||||
/// comparators read it again when its items changed. False, with
|
||||
/// `change` not run, where `native_with_mod_block_entity` finds none.
|
||||
/// Call `native_block_entity_sync` too when what clients draw changed.
|
||||
/// API @API:host_bridge@.
|
||||
pub(super) fn native_change_mod_block_entity(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
change: &mut dyn FnMut(&mut ModBlockEntity),
|
||||
) -> bool {
|
||||
self.native_with_mod_block_entity(pos, |m| change(m))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Plays sound event `sound` at `at` for the players near it in the
|
||||
/// level in place, as a level hook's `LevelWriteHost::play_sound` does
|
||||
/// (`Level.playSound` with no player): a machine's hum from its block
|
||||
/// entity's tick. `source` is the `SoundSource` ordinal (4 for
|
||||
/// `BLOCKS`). True when queued, not heard; false off a server and where
|
||||
/// the level host's `play_sound` refuses. API @API:host_bridge@.
|
||||
pub(super) fn native_level_play_sound(
|
||||
&mut self,
|
||||
sound: &str,
|
||||
at: [f64; 3],
|
||||
source: usize,
|
||||
volume: f32,
|
||||
pitch: f32,
|
||||
) -> bool {
|
||||
if !self.native_authoritative() {
|
||||
return false;
|
||||
}
|
||||
self.with_level(|_, level, _| level.native_play_sound(sound, at, source, volume, pitch))
|
||||
}
|
||||
|
||||
/// `ServerLevel.sendParticles` in the level in place, as a level hook's
|
||||
/// `LevelWriteHost::send_particles` sends them: the players near `at`
|
||||
/// see `count` particles spread by `spread` (a zero count sends one
|
||||
/// moving `speed` along `spread`). `state` is the block of block
|
||||
/// particles. True when accepted; false off a server and where the level
|
||||
/// host's `send_particles` refuses. API @API:host_bridge@.
|
||||
pub(super) fn native_level_send_particles(
|
||||
&mut self,
|
||||
options: &ParticleOptions,
|
||||
state: Option<BlockStateId>,
|
||||
at: [f64; 3],
|
||||
spread: [f64; 3],
|
||||
speed: f64,
|
||||
count: i32,
|
||||
) -> bool {
|
||||
if !self.native_authoritative() {
|
||||
return false;
|
||||
}
|
||||
self.with_level(|_, level, _| {
|
||||
level.native_send_particles(options, state, at, spread, speed, count)
|
||||
})
|
||||
}
|
||||
|
||||
/// The stack in the player's equipment slot `slot`
|
||||
/// (`LivingEntity.getItemBySlot`): on a client the player's own, as
|
||||
/// goggles worn or a tank on the back show their overlay; on a server the
|
||||
/// acting player's. None for an empty slot, and for `Body` and `Saddle`,
|
||||
/// which a player has none of. API @API:host_bridge@.
|
||||
pub(super) fn native_equipment(&self, slot: EquipmentSlot) -> Option<ItemStack> {
|
||||
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,
|
||||
}
|
||||
.filter(|stack| stack.count > 0)
|
||||
}
|
||||
|
||||
/// Whether the player acting now holds sneak (`Player.isShiftKeyDown`,
|
||||
/// what `UseOnContext.isSecondaryUseActive` reads in a use hook): a
|
||||
/// wrench that picks a block up rather than turning it, a crank turned
|
||||
/// backwards. On a server the acting guest's, as their use runs
|
||||
/// (`as_guest` holds sneak for them); on a client the player's own key.
|
||||
/// API @API:host_bridge@.
|
||||
pub(super) fn native_sneaking(&self) -> bool {
|
||||
self.keys.contains(&KeyCode::ShiftLeft)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! What a mod's machine does to the world as a player's hand would, with no
|
||||
//! player: break a block with a tool and keep what drops (a drill, a saw, a
|
||||
//! harvester), show its crack while it works, and use an item as a player
|
||||
//! would (a deployer) (docs/native-mods.md, "Machines").
|
||||
use super::{Game, GuestUse, Hit, InteractTarget, Screen};
|
||||
use horizon_client::{
|
||||
command::args::GameMode,
|
||||
first_person::Hand,
|
||||
items::ItemStack,
|
||||
mod_api::{NativeFakeUse, NativeUseTarget},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::BlockPos};
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// `Player.getStandingEyeHeight`: a fake player's eyes above its feet.
|
||||
const FAKE_EYE: f32 = 1.62;
|
||||
|
||||
// Native mods use these; nothing in Horizon does.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Breaks the block at `pos` as a machine does, with no player: vanilla's
|
||||
/// `Level.destroyBlock(pos, true)` with the loot rolled for `tool`
|
||||
/// (`Block.dropResources(state, level, pos, blockEntity, null, tool)`):
|
||||
/// the break particles and sound, the block's drops with what its block
|
||||
/// entity copies into them (a shulker box's contents), experience for an
|
||||
/// ore as with no tool, then the block's fluid in its place. `tool` picks
|
||||
/// the drops as a player's held item does (silk touch, fortune, shears);
|
||||
/// `None` is an empty hand. The mods' say on drops (`native_loot_drops`)
|
||||
/// applies. With `collect`, the drops are returned and nothing is popped
|
||||
/// (a harvester storing them); without, they pop as item entities and the
|
||||
/// list is empty. Nothing drops where `doTileDrops` is off. None, with
|
||||
/// nothing broken, for air, an unloaded block and off a server.
|
||||
/// API @API:machine_breaking@.
|
||||
pub(super) fn native_machine_destroy_block(
|
||||
&mut self,
|
||||
pos: [i32; 3],
|
||||
tool: Option<&ItemStack>,
|
||||
collect: bool,
|
||||
) -> Option<Vec<ItemStack>> {
|
||||
if !self.native_authoritative() || !self.world.loaded(pos[0], pos[2]) {
|
||||
return None;
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if self.res.behaviours().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let tool_kept = tool.cloned();
|
||||
let mut drops = self.with_level(|b, level, ticks| {
|
||||
level.drop_tool = tool_kept;
|
||||
level.drop_into = Some(Vec::new());
|
||||
b.destroy_block(level, ticks, at, true);
|
||||
level.drop_tool = None;
|
||||
level.drop_into.take().unwrap_or_default()
|
||||
});
|
||||
let _ = self.process_level_events();
|
||||
self.native_block_drops(pos, state, None, tool, None, &mut drops);
|
||||
if collect {
|
||||
return Some(drops);
|
||||
}
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
/// `ServerLevel.destroyBlockProgress`: shows breaker `breaker`'s crack on
|
||||
/// the block at `pos` at stage `progress` (0 to 9) to the players within 64
|
||||
/// blocks, as a zombie breaking a door shows its; any other stage takes the
|
||||
/// crack away. A machine passes an id of its own that no entity has (a
|
||||
/// negative one), the same each time, so a new stage replaces its old one;
|
||||
/// a crack not renewed for 400 ticks fades. Off a server nothing happens.
|
||||
/// API @API:machine_breaking@.
|
||||
pub(super) fn native_set_break_progress(&mut self, breaker: i32, pos: [i32; 3], progress: i32) {
|
||||
if !self.native_authoritative() {
|
||||
return;
|
||||
}
|
||||
self.server_destroy_block_progress(breaker, pos, progress);
|
||||
}
|
||||
|
||||
/// Uses `stack` as a player standing with its eyes at `eye`, looking
|
||||
/// `yaw` and `pitch` degrees (as `Entity.getYRot` and `getXRot`), would:
|
||||
/// NeoForge's `FakePlayer` doing what a deployer does. On a block
|
||||
/// (`NativeUseTarget::Block`) it is `ServerPlayerGameMode.useItemOn`:
|
||||
/// the block reacts first (a lever flips, a cauldron fills), else the
|
||||
/// item is used on it (a block placed, bone meal spread, a bucket
|
||||
/// filled); in the air (`Item`) `useItem` (a bucket picks up the fluid it
|
||||
/// looks at); on an entity (`Entity`) `Player.interactOn` (a sheep
|
||||
/// sheared, a cow milked). With `sneaking` it is a secondary use, as a
|
||||
/// player holding sneak's. The fake player is in survival, holds `stack`
|
||||
/// in its main hand and nothing else, and has no connection: no screen
|
||||
/// it opens stays open, and its stats and advancements count for no
|
||||
/// one. The answer is what it holds and carries afterwards. None, with
|
||||
/// nothing used, off a server and while a player's action is being run
|
||||
/// (from inside a player's hook: run it from a block entity's tick).
|
||||
/// API @API:fake_player@.
|
||||
pub(super) fn native_use_as_player(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
eye: [f64; 3],
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
target: NativeUseTarget,
|
||||
sneaking: bool,
|
||||
) -> Option<NativeFakeUse> {
|
||||
if !self.native_authoritative()
|
||||
|| self.remote.is_some()
|
||||
|| self.player.is_some()
|
||||
|| self.lan_acting.is_some()
|
||||
|| !eye.iter().all(|v| v.is_finite())
|
||||
|| !(yaw.is_finite() && pitch.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let feet = Vec3::new(eye[0], eye[1] - f64::from(FAKE_EYE), eye[2]);
|
||||
let body = horizon_client::motion::PlayerMotion::new(self.motion, feet).ok()?;
|
||||
let slots = self.inventory.len().max(36);
|
||||
let mut inventory = vec![None; slots];
|
||||
inventory[0] = (stack.count > 0).then_some(stack);
|
||||
let keys = if sneaking {
|
||||
[KeyCode::ShiftLeft].into_iter().collect()
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let (forced_hit, guest_use) = match target {
|
||||
NativeUseTarget::Block { pos, face, point } => (
|
||||
Some(Hit {
|
||||
pos,
|
||||
face: face.normal(),
|
||||
point,
|
||||
}),
|
||||
GuestUse::OnBlock(Hand::Main),
|
||||
),
|
||||
NativeUseTarget::Item => (None, GuestUse::Item(Hand::Main)),
|
||||
NativeUseTarget::Entity { id, location } => (
|
||||
None,
|
||||
GuestUse::Entity(Hand::Main, InteractTarget { id, location }),
|
||||
),
|
||||
};
|
||||
// The server's own player state, empty while nobody acts, kept to put
|
||||
// back: the fake player leaves nothing of itself behind.
|
||||
let player = self.player.replace(body);
|
||||
let eye_height = std::mem::replace(&mut self.eye, FAKE_EYE);
|
||||
let look = (
|
||||
std::mem::replace(&mut self.yaw, yaw),
|
||||
std::mem::replace(&mut self.pitch, pitch),
|
||||
);
|
||||
let inventory = std::mem::replace(&mut self.inventory, inventory);
|
||||
let selected = std::mem::replace(&mut self.selected, 0);
|
||||
let armor = std::mem::take(&mut self.armor);
|
||||
let offhand = self.offhand.take();
|
||||
let carried = self.carried.take();
|
||||
let keys = std::mem::replace(&mut self.keys, keys);
|
||||
let screen = std::mem::replace(&mut self.screen, Screen::Playing);
|
||||
let container = self.container.take();
|
||||
let sign_edit = self.sign_edit.take();
|
||||
let book = self.book.take();
|
||||
let command_block = self.command_block_screen.take();
|
||||
let mount_menu = std::mem::take(&mut self.mount_menu);
|
||||
let craft_size = std::mem::replace(&mut self.craft_size, 2);
|
||||
let craft_grid = std::mem::replace(&mut self.craft_grid, vec![None; 9]);
|
||||
let mode = std::mem::replace(&mut self.mode, GameMode::Survival);
|
||||
let creative = std::mem::replace(&mut self.creative, false);
|
||||
let mining = self.mining.take();
|
||||
let using = self.using.take();
|
||||
let use_delay = std::mem::replace(&mut self.use_delay, 0);
|
||||
let cooldowns = std::mem::take(&mut self.cooldowns);
|
||||
let riding = self.riding.take();
|
||||
let sleeping = self.sleeping.take();
|
||||
let reach = (self.block_reach, self.entity_reach);
|
||||
let hit = std::mem::replace(&mut self.forced_hit, forced_hit);
|
||||
let tick_hit = self.tick_hit.take();
|
||||
let used_as = self.guest_use.replace(guest_use);
|
||||
let failed = std::mem::replace(&mut self.use_failed, false);
|
||||
self.block_reach = self.interaction_range("minecraft:block_interaction_range", 4.5, 5.0);
|
||||
self.entity_reach = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
|
||||
|
||||
let consumed = matches!(self.use_item(), Ok(Some(_))) && !self.use_failed;
|
||||
|
||||
let mut left = std::mem::replace(&mut self.inventory, inventory);
|
||||
let held = left
|
||||
.get_mut(0)
|
||||
.and_then(Option::take)
|
||||
.filter(|s| s.count > 0);
|
||||
let other = left
|
||||
.into_iter()
|
||||
.chain([self.offhand.take(), self.carried.take()])
|
||||
.chain(std::mem::take(&mut self.armor))
|
||||
.flatten()
|
||||
.filter(|s| s.count > 0)
|
||||
.collect();
|
||||
self.player = player;
|
||||
self.eye = eye_height;
|
||||
(self.yaw, self.pitch) = look;
|
||||
self.selected = selected;
|
||||
self.armor = armor;
|
||||
self.offhand = offhand;
|
||||
self.carried = carried;
|
||||
self.keys = keys;
|
||||
self.screen = screen;
|
||||
self.container = container;
|
||||
self.sign_edit = sign_edit;
|
||||
self.book = book;
|
||||
self.command_block_screen = command_block;
|
||||
self.mount_menu = mount_menu;
|
||||
self.craft_size = craft_size;
|
||||
self.craft_grid = craft_grid;
|
||||
self.mode = mode;
|
||||
self.creative = creative;
|
||||
self.mining = mining;
|
||||
self.using = using;
|
||||
self.use_delay = use_delay;
|
||||
self.cooldowns = cooldowns;
|
||||
self.riding = riding;
|
||||
self.sleeping = sleeping;
|
||||
(self.block_reach, self.entity_reach) = reach;
|
||||
self.forced_hit = hit;
|
||||
self.tick_hit = tick_hit;
|
||||
self.guest_use = used_as;
|
||||
self.use_failed = failed;
|
||||
Some(NativeFakeUse {
|
||||
consumed,
|
||||
held,
|
||||
other,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! A few blocks drawn in a screen as a small scene seen from an angle: what
|
||||
//! Create's Ponder shows its machines in (docs/native-mods.md, "Scenes in
|
||||
//! screens"). The blocks' models and their mod block entities' drawing are
|
||||
//! projected into the GUI with their own depth, as the smithing screen draws
|
||||
//! its armour stand.
|
||||
use super::Game;
|
||||
use super::native_block_entity_draw::{BlockEntityDraw, place_turning};
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_client::{
|
||||
block_entity_render::diffuse_lights,
|
||||
hud::HudBatch,
|
||||
mesher::Vertex,
|
||||
mod_api::{NativeSceneBlock, NativeSceneView},
|
||||
};
|
||||
use horizon_world::level::data::state_flags;
|
||||
|
||||
/// A quad in the scene's blocks, before it is seen: its corners, UVs in the
|
||||
/// atlas, ABGR colour, and its outward normal.
|
||||
struct SceneQuad {
|
||||
corners: [Vec3; 4],
|
||||
uv: [[f32; 2]; 4],
|
||||
color: u32,
|
||||
normal: Vec3,
|
||||
}
|
||||
|
||||
/// The GUI transform of `view` in the box `rect` (x0, y0, x1, y1): the view's
|
||||
/// centre at the box's middle, a block `scale` pixels across. Unturned, the
|
||||
/// scene is seen from the north looking south, as a player at yaw 0 sees it,
|
||||
/// up the screen's up; `yaw` turns it counterclockwise seen from above, and
|
||||
/// `pitch` tilts its top towards the viewer. The GUI's y runs down and its z
|
||||
/// towards the viewer, so the point reflection keeps the picture unmirrored.
|
||||
fn scene_matrix(rect: [f32; 4], view: NativeSceneView) -> Mat4 {
|
||||
let middle = Vec3::new((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0, 0.0);
|
||||
Mat4::from_translation(middle)
|
||||
* Mat4::from_scale(Vec3::splat(-view.scale))
|
||||
* Mat4::from_rotation_x(-view.pitch.to_radians())
|
||||
* Mat4::from_rotation_y(view.yaw.to_radians())
|
||||
* Mat4::from_translation(-Vec3::from_array(view.center))
|
||||
}
|
||||
|
||||
/// The step a model face's direction points: the model's `Direction` lists
|
||||
/// its sides in the order the game's does.
|
||||
fn step(direction: horizon_assets::model::Direction) -> [i32; 3] {
|
||||
horizon_core::geometry::Direction::ALL[direction as usize].normal()
|
||||
}
|
||||
|
||||
/// How bright a face is with `normal` (world space, unit), as blocks are
|
||||
/// shaded: 1 for the top, 0.5 underneath, 0.8 north and south, 0.6 east and
|
||||
/// west, in between for a slanted face.
|
||||
fn face_shade(normal: Vec3) -> f32 {
|
||||
let up = if normal.y > 0.0 { 1.0 } else { 0.5 };
|
||||
(normal.x * normal.x * 0.6 + normal.y * normal.y * up + normal.z * normal.z * 0.8).min(1.0)
|
||||
}
|
||||
|
||||
/// `color` (ABGR) with its red, green and blue times `shade`.
|
||||
fn shaded(color: u32, shade: f32) -> u32 {
|
||||
let ch = |s: u32| (((color >> s & 255) as f32 * shade) as u32).min(255) << s;
|
||||
(color & 0xFF00_0000) | ch(0) | ch(8) | ch(16)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// Draws `blocks`, a scene of block states at positions of its own (not
|
||||
/// the world's), into the box `rect` (x0, y0, x1, y1, GUI pixels) of a
|
||||
/// screen's `batch`, seen as `view` says: Create's Ponder scenes. Each
|
||||
/// block is drawn with its model, full bright and shaded by face, a face
|
||||
/// against a solid neighbour in the scene left out; a block with an
|
||||
/// `entity` (a mod's) is drawn by `native_block_entity_submit` as in the
|
||||
/// world too, its `spin` parts turning with the game time. What lies
|
||||
/// outside the box is cut off, and the scene has a depth of its own, so
|
||||
/// it never covers the screen's other drawing or is covered by it but in
|
||||
/// the order drawn. Draw it from a screen's drawing hook
|
||||
/// (`native_screen_draw`). A client's call; nothing is drawn on a server.
|
||||
/// API @API:scenes_in_screens@.
|
||||
pub(super) fn native_draw_scene(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
rect: [f32; 4],
|
||||
blocks: &[NativeSceneBlock<'_>],
|
||||
view: NativeSceneView,
|
||||
) {
|
||||
if !self.native_window() || !view.scale.is_finite() || view.scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let occupied: std::collections::HashMap<[i32; 3], _> =
|
||||
blocks.iter().map(|b| (b.pos, b.state)).collect();
|
||||
let solid = |pos: [i32; 3]| {
|
||||
occupied.get(&pos).is_some_and(|&state| {
|
||||
self.res.behaviours().facts(state).flags & state_flags::SOLID_RENDER != 0
|
||||
})
|
||||
};
|
||||
let grass = self.pack.item_renderer.grass_color(0.5, 1.0) as u32;
|
||||
let tables = &self.pack.context.tables;
|
||||
let mut quads = Vec::new();
|
||||
for block in blocks {
|
||||
let at = Vec3::new(
|
||||
block.pos[0] as f32,
|
||||
block.pos[1] as f32,
|
||||
block.pos[2] as f32,
|
||||
);
|
||||
let Ok(models) = self
|
||||
.pack
|
||||
.render
|
||||
.model_catalog
|
||||
.collect_seeded(block.state, 42)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for face in models.iter().flat_map(|(_, model)| &model.faces) {
|
||||
if let Some(cull) = face.quad.cull {
|
||||
let [dx, dy, dz] = step(cull);
|
||||
if solid([block.pos[0] + dx, block.pos[1] + dy, block.pos[2] + dz]) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(sprite) = self
|
||||
.pack
|
||||
.layer_names
|
||||
.get(&face.layer)
|
||||
.and_then(|name| self.gui.atlas.sprites.get(name))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let [nx, ny, nz] = step(face.quad.facing);
|
||||
let normal = Vec3::new(nx as f32, ny as f32, nz as f32);
|
||||
let mut color = shaded(0xFFFF_FFFF, face_shade(normal));
|
||||
if let Some(t) = tables.default_tint(block.state, face.quad.tint_index, grass) {
|
||||
color = horizon_client::hud_layout::multiply(
|
||||
color,
|
||||
horizon_client::mobs::render::abgr(t),
|
||||
);
|
||||
}
|
||||
quads.push(SceneQuad {
|
||||
corners: face.quad.positions.map(|p| at + Vec3::from_array(p)),
|
||||
uv: face.quad.uv.map(|uv| {
|
||||
[
|
||||
sprite.u0 + uv[0] * (sprite.u1 - sprite.u0),
|
||||
sprite.v0 + uv[1] * (sprite.v1 - sprite.v0),
|
||||
]
|
||||
}),
|
||||
color,
|
||||
normal,
|
||||
});
|
||||
}
|
||||
if let Some(entity) = block.entity {
|
||||
let name = &*self.res.facts().get(block.state).name;
|
||||
let mut draw = BlockEntityDraw::posed(
|
||||
self.clock.fraction() as f32,
|
||||
self.world.game_time,
|
||||
240 | 240 << 8,
|
||||
block.pos,
|
||||
Mat4::IDENTITY,
|
||||
diffuse_lights(false),
|
||||
Vec::new(),
|
||||
);
|
||||
self.native_block_entity_submit(block.pos, name, block.state, entity, &mut draw);
|
||||
let (drawn, spins) = draw.into_turning();
|
||||
let mut placed: Vec<Vertex> = Vec::with_capacity(drawn.len());
|
||||
place_turning(
|
||||
&drawn,
|
||||
&spins,
|
||||
self.world.game_time,
|
||||
self.clock.fraction() as f32,
|
||||
at,
|
||||
&mut placed,
|
||||
);
|
||||
for q in placed.as_chunks::<4>().0 {
|
||||
let corners = q.each_ref().map(|v| Vec3::from_array(v.pos));
|
||||
let normal = (corners[1] - corners[0])
|
||||
.cross(corners[2] - corners[0])
|
||||
.normalize_or_zero();
|
||||
quads.push(SceneQuad {
|
||||
corners,
|
||||
uv: q.each_ref().map(|v| v.uv),
|
||||
color: shaded(q[0].color, face_shade(normal)),
|
||||
normal,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let m = scene_matrix(rect, view);
|
||||
let mut seen: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = quads
|
||||
.into_iter()
|
||||
.filter(|q| m.transform_vector3(q.normal).z > 0.0)
|
||||
.map(|q| {
|
||||
let p = q.corners.map(|c| m.transform_point3(c));
|
||||
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
|
||||
(depth, p.map(|v| v.to_array()), q.uv, q.color)
|
||||
})
|
||||
.collect();
|
||||
// Far to near, for the faces that let others show through.
|
||||
seen.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let start = batch.vertices.len();
|
||||
for (_, corners, uv, color) in seen {
|
||||
batch.quad_3d(corners, uv, color);
|
||||
}
|
||||
batch.depth_since(start, rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn view(yaw: f32, pitch: f32) -> NativeSceneView {
|
||||
NativeSceneView {
|
||||
center: [0.5, 0.5, 0.5],
|
||||
yaw,
|
||||
pitch,
|
||||
scale: 20.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Unturned, the scene is seen from the north as a player at yaw 0 sees
|
||||
/// it: the centre in the box's middle, up up the screen, east to the
|
||||
/// viewer's left, south away and its north face towards the viewer.
|
||||
#[test]
|
||||
fn an_unturned_scene_is_seen_from_the_north() {
|
||||
let m = scene_matrix([0.0, 0.0, 100.0, 60.0], view(0.0, 0.0));
|
||||
let at = |p: [f32; 3]| m.transform_point3(Vec3::from_array(p));
|
||||
assert_eq!(at([0.5, 0.5, 0.5]), Vec3::new(50.0, 30.0, 0.0));
|
||||
assert!(at([0.5, 1.5, 0.5]).y < 30.0, "up is up the screen");
|
||||
assert!(at([1.5, 0.5, 0.5]).x < 50.0, "east is on the left");
|
||||
assert!(at([0.5, 0.5, 1.5]).z < 0.0, "south is away");
|
||||
assert!(
|
||||
m.transform_vector3(Vec3::NEG_Z).z > 0.0,
|
||||
"the north face is seen"
|
||||
);
|
||||
assert!(
|
||||
m.transform_vector3(Vec3::Y).z.abs() < 1e-5,
|
||||
"the top is edge on"
|
||||
);
|
||||
}
|
||||
|
||||
/// A tilt shows the top and hides the bottom; a quarter turn brings the
|
||||
/// east face round to the viewer (counterclockwise seen from above: east
|
||||
/// goes to the north, the side the viewer is on).
|
||||
#[test]
|
||||
fn pitch_shows_the_top_and_yaw_turns_the_scene() {
|
||||
let tilted = scene_matrix([0.0, 0.0, 100.0, 100.0], view(0.0, 30.0));
|
||||
assert!(tilted.transform_vector3(Vec3::Y).z > 0.0);
|
||||
assert!(tilted.transform_vector3(Vec3::NEG_Y).z < 0.0);
|
||||
let turned = scene_matrix([0.0, 0.0, 100.0, 100.0], view(90.0, 0.0));
|
||||
assert!(turned.transform_vector3(Vec3::X).z > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_are_shaded_as_blocks_are() {
|
||||
assert_eq!(face_shade(Vec3::Y), 1.0);
|
||||
assert_eq!(face_shade(Vec3::NEG_Y), 0.5);
|
||||
assert!((face_shade(Vec3::Z) - 0.8).abs() < 1e-6);
|
||||
assert!((face_shade(Vec3::X) - 0.6).abs() < 1e-6);
|
||||
assert_eq!(shaded(0xFF80_4020, 0.5), 0xFF40_2010);
|
||||
}
|
||||
}
|
||||
@@ -923,11 +923,23 @@ impl Game<'_> {
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
self.native_register_commands(&mut commands);
|
||||
let (tree, levels, problems) =
|
||||
// Horizon's own moderation commands go in the same tree, so operators' clients complete them
|
||||
// instead of showing them as unknown; the game still runs them itself (`run_guest_command`).
|
||||
let extras = super::tempban::moderation_commands(
|
||||
host.hub.chat_filter_on(),
|
||||
self.server_lists.is_some(),
|
||||
);
|
||||
commands.extend(extras.iter().cloned());
|
||||
let (tree, mut levels, problems) =
|
||||
command_tree(&commands, |name| host.hub.vanilla_command(name));
|
||||
for p in problems {
|
||||
log::warn!("Horizon: a native mod's command was left out: {p}");
|
||||
}
|
||||
levels.retain(|name, _| {
|
||||
!extras
|
||||
.iter()
|
||||
.any(|c| c.usage.split(' ').next() == Some(name.as_str()))
|
||||
});
|
||||
host.hub.set_mod_commands(0, tree);
|
||||
host.native_commands = Some(levels);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use horizon_world::{chunk::BlockState, registry::BlockRegistry};
|
||||
/// Why a server block or tag cannot be read.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum NativeBlockReadError {
|
||||
pub enum NativeBlockReadError {
|
||||
/// The context is stale or the call is outside its server tick hook.
|
||||
InactiveContext,
|
||||
/// Tag queries require an authoritative server.
|
||||
@@ -90,6 +90,20 @@ impl Game<'_> {
|
||||
Ok(items.item_in_tag(tag, item))
|
||||
}
|
||||
|
||||
/// Whether block `block` (`minecraft:oak_log`, as `Host::block` names
|
||||
/// one) is in block tag `tag` (`minecraft:logs`, or a mod's
|
||||
/// `create:fan_transparent`) of the server's merged tags, as
|
||||
/// `BlockState.is(TagKey)`: what `native_server_block_in_tag` answers for
|
||||
/// a state of that block, from any server hook. API @API:host_bridge@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_block_name_in_tag(
|
||||
&self,
|
||||
block: &str,
|
||||
tag: &str,
|
||||
) -> Result<bool, NativeBlockReadError> {
|
||||
Ok(self.native_tag_items()?.block_in_tag(tag, block))
|
||||
}
|
||||
|
||||
/// Read a loaded block in a server tick hook, without loading a chunk.
|
||||
/// The returned name and properties are owned and remain valid after the hook.
|
||||
#[allow(dead_code)]
|
||||
@@ -116,6 +130,36 @@ impl Game<'_> {
|
||||
.ok_or(NativeBlockReadError::UnavailableDimension)?;
|
||||
read_block(world, self.pack.assets.registry, pos)
|
||||
}
|
||||
|
||||
/// The current of the fluid at `pos` in `dimension`
|
||||
/// (`FluidState.getFlow(level, pos)`), normalised: the push an item in it
|
||||
/// gets, a water wheel's speed from the water around it. Zero where there
|
||||
/// is no fluid or it doesn't move; none where the chunk isn't loaded or
|
||||
/// the dimension isn't available (a client has only the level it is in,
|
||||
/// a server also its parked ones), so a caller can keep its last answer.
|
||||
/// API @API:fluid_flow@.
|
||||
#[allow(dead_code)]
|
||||
pub(super) fn native_fluid_flow(&self, dimension: &str, pos: [i32; 3]) -> Option<[f64; 3]> {
|
||||
let dim = match dimension {
|
||||
"minecraft:overworld" => Dim::Overworld,
|
||||
"minecraft:the_nether" => Dim::Nether,
|
||||
"minecraft:the_end" => Dim::End,
|
||||
_ => return None,
|
||||
};
|
||||
let world = if dim == self.dim {
|
||||
&self.world
|
||||
} else {
|
||||
self.levels.parked_world(dim)?
|
||||
};
|
||||
if !world.loaded(pos[0], pos[2]) {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_client::entities::fluid_flow(
|
||||
world,
|
||||
&self.res.motion().fluids,
|
||||
pos,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an existing world state, preserving unloaded and invalid states.
|
||||
|
||||
@@ -1292,15 +1292,21 @@ impl Game<'_> {
|
||||
let uuid = saved_uuid(&self.persist, d.id);
|
||||
out.entry(chunk_of(d.position))
|
||||
.or_default()
|
||||
.push(d.to_nbt(uuid, raw.as_ref()));
|
||||
// The fields kept with it (`remember_tags`: Silent, Tags, a
|
||||
// name...) once it has flown from where it was loaded.
|
||||
.push(with_raw(
|
||||
d.to_nbt(uuid, raw.as_ref()),
|
||||
self.persist.raw.get(&d.id),
|
||||
));
|
||||
}
|
||||
for c in &self.crystals {
|
||||
if keys.contains(&chunk_of(c.position)) {
|
||||
let raw = anon_raw(&self.persist, "minecraft:end_crystal", c.position);
|
||||
let uuid = saved_uuid(&self.persist, c.id);
|
||||
out.entry(chunk_of(c.position))
|
||||
.or_default()
|
||||
.push(c.to_nbt(uuid, raw.as_ref()));
|
||||
out.entry(chunk_of(c.position)).or_default().push(with_raw(
|
||||
c.to_nbt(uuid, raw.as_ref()),
|
||||
self.persist.raw.get(&c.id),
|
||||
));
|
||||
}
|
||||
}
|
||||
for s in &self.ominous_spawners {
|
||||
|
||||
@@ -166,13 +166,14 @@ impl Game<'_> {
|
||||
let now = self.started.elapsed().as_secs_f64();
|
||||
self.pvp.combo.attacked(id, now);
|
||||
}
|
||||
/// The server reports entity `id` hurt; `own` when it is this player.
|
||||
pub(super) fn pvp_hurt(&mut self, id: i32, own: bool) {
|
||||
/// The server reports entity `id` hurt; `own` when it is this player,
|
||||
/// `by_me` whether this player was the damage's direct entity (if named).
|
||||
pub(super) fn pvp_hurt(&mut self, id: i32, own: bool, by_me: Option<bool>) {
|
||||
if own {
|
||||
self.pvp.combo.broken();
|
||||
} else {
|
||||
let now = self.started.elapsed().as_secs_f64();
|
||||
self.pvp.combo.hurt(id, now);
|
||||
self.pvp.combo.hurt(id, by_me, now);
|
||||
}
|
||||
}
|
||||
/// Whether the effects widget replaces vanilla's effect icons (`Hud.extractEffects`).
|
||||
@@ -202,7 +203,8 @@ impl Game<'_> {
|
||||
});
|
||||
// The address is copied only when it changed (`server_address` borrows the whole game).
|
||||
let server = self.server_address().filter(|_| multiplayer);
|
||||
let server = (self.pvp.server.next.as_deref() != server).then(|| server.map(str::to_owned));
|
||||
let server = (hud.server && self.pvp.server.next.as_deref() != server)
|
||||
.then(|| server.map(str::to_owned));
|
||||
let now = self.started.elapsed().as_secs_f64();
|
||||
let atlas = &self.gui.atlas;
|
||||
let width = |s: &str| HudBatch::text_width(atlas, s);
|
||||
|
||||
@@ -167,25 +167,35 @@ fn hurt_sound_for(
|
||||
})
|
||||
}
|
||||
|
||||
/// Position of the entity a `ClientboundSoundEntityPacket` names, if this level
|
||||
/// has it and `EntityBoundSoundInstance.canPlaySound` (`!entity.isSilent()`)
|
||||
/// holds. The local player is not a tracked entity; removed ids leave the
|
||||
/// tracked table.
|
||||
fn entity_position(
|
||||
/// The live entity a bound sound names; absence means that object was removed.
|
||||
fn entity_sound_state(
|
||||
id: i32,
|
||||
player: Option<(i32, [f64; 3])>,
|
||||
player: Option<(i32, [f64; 3], bool)>,
|
||||
mobs: &[Mob],
|
||||
tracked: &HashMap<i32, Tracked>,
|
||||
) -> Option<[f64; 3]> {
|
||||
if let Some((player_id, at)) = player
|
||||
) -> Option<horizon_client::sound_engine::SoundEntityState> {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
if let Some((player_id, position, silent)) = player
|
||||
&& player_id == id
|
||||
{
|
||||
return Some(at);
|
||||
return Some(SoundEntityState {
|
||||
position,
|
||||
silent,
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
if let Some(m) = mobs.iter().find(|m| m.id as i32 == id) {
|
||||
return (!m.silent).then_some([m.position.x, m.position.y, m.position.z]);
|
||||
return Some(SoundEntityState {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
silent: m.silent,
|
||||
removed: m.removed,
|
||||
});
|
||||
}
|
||||
tracked.get(&id).map(|t| t.position)
|
||||
tracked.get(&id).map(|t| SoundEntityState {
|
||||
position: t.position,
|
||||
silent: t.silent,
|
||||
removed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
|
||||
@@ -246,6 +256,7 @@ impl ChunkGenerator for NoTerrain {
|
||||
struct Tracked {
|
||||
kind: String,
|
||||
position: [f64; 3],
|
||||
silent: bool,
|
||||
}
|
||||
/// Smooth an entity toward a new position over three ticks (InterpolationHandler).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -486,6 +497,7 @@ pub(super) struct Remote {
|
||||
pub(super) logged_in: bool,
|
||||
status: String,
|
||||
pub(super) entity_id: i32,
|
||||
local_entity_silent: bool,
|
||||
/// `Minecraft.cameraEntity` when the server put the view on another
|
||||
/// entity (ClientboundSetCameraPacket); None while it is the player.
|
||||
pub(super) camera: Option<i32>,
|
||||
@@ -757,14 +769,28 @@ impl Game<'_> {
|
||||
}),
|
||||
}
|
||||
}
|
||||
/// Position of an entity `ClientboundSoundEntityPacket` names.
|
||||
fn remote_entity_position(&self, id: i32) -> Option<[f64; 3]> {
|
||||
pub(super) fn remote_sound_entity_state(
|
||||
&self,
|
||||
id: i32,
|
||||
) -> Option<horizon_client::sound_engine::SoundEntityState> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let player = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| (r.entity_id, [p.position.x, p.position.y, p.position.z]));
|
||||
entity_position(id, player, &self.mobs, &r.entities)
|
||||
let player = self.player.as_ref().map(|p| {
|
||||
(
|
||||
r.entity_id,
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
r.local_entity_silent,
|
||||
)
|
||||
});
|
||||
let mut state = entity_sound_state(id, player, &self.mobs, &r.entities)?;
|
||||
// Vehicles, projectiles and other simulated objects may have moved since
|
||||
// the last tracking packet, just as the mob interpolation has.
|
||||
if let Some(position) = self
|
||||
.level_entity(id)
|
||||
.and_then(|target| self.target_position(target))
|
||||
{
|
||||
state.position = position;
|
||||
}
|
||||
Some(state)
|
||||
}
|
||||
/// The hurt sound the hurt player's own client plays from
|
||||
/// `ClientboundDamageEventPacket` (`LivingEntity.handleDamageEvent`):
|
||||
@@ -1016,6 +1042,7 @@ impl Game<'_> {
|
||||
reconfiguring: None,
|
||||
logged_in: false,
|
||||
entity_id: 0,
|
||||
local_entity_silent: false,
|
||||
camera: None,
|
||||
use_item: None,
|
||||
dimension: DimensionType::vanilla("minecraft:overworld"),
|
||||
@@ -1202,12 +1229,7 @@ impl Game<'_> {
|
||||
// Out of the server's bed: InBedChatScreen goes with the level.
|
||||
self.sleeping = None;
|
||||
self.sleep_timer = 0;
|
||||
self.mobs.clear();
|
||||
self.clear_remote_objects();
|
||||
self.clear_remote_projectiles();
|
||||
self.dropped.clear();
|
||||
self.particles.clear();
|
||||
self.pickups.clear();
|
||||
self.clear_entities();
|
||||
self.score.board = Default::default();
|
||||
self.tab_list = Default::default();
|
||||
self.border = Default::default();
|
||||
@@ -2607,6 +2629,7 @@ impl Game<'_> {
|
||||
Tracked {
|
||||
kind: kind.to_owned(),
|
||||
position,
|
||||
silent: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2865,6 +2888,7 @@ impl Game<'_> {
|
||||
self.turn_remote_projectile(id, rotation);
|
||||
}
|
||||
fn remove_entity(&mut self, id: i32) {
|
||||
self.audio.remove_sound_entity(id as u32);
|
||||
// Entity.setRemoved detaches every passenger, even when no empty
|
||||
// SetPassengers packet preceded the vehicle's removal. Otherwise
|
||||
// mobs and other players keep their passenger pose after it is gone.
|
||||
@@ -3152,6 +3176,7 @@ impl Game<'_> {
|
||||
r.online_mode = p.online_mode;
|
||||
r.enforces_secure_chat = p.enforces_secure_chat;
|
||||
r.entity_id = p.player_id;
|
||||
r.local_entity_silent = false;
|
||||
// handleLogin: the new LocalPlayer is the camera.
|
||||
r.camera = None;
|
||||
r.logged_in = true;
|
||||
@@ -3179,6 +3204,9 @@ impl Game<'_> {
|
||||
self.remote_chat_login();
|
||||
}
|
||||
C::Respawn(p) => {
|
||||
if let Some(r) = self.remote.as_ref() {
|
||||
self.audio.remove_sound_entity(r.entity_id as u32);
|
||||
}
|
||||
// A new player, without effects (the server sends them
|
||||
// again) and with no item in use.
|
||||
self.potions.effects.clear();
|
||||
@@ -3199,6 +3227,7 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.own_invisible = false;
|
||||
r.own_on_fire = false;
|
||||
r.local_entity_silent = false;
|
||||
}
|
||||
}
|
||||
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
|
||||
@@ -3337,30 +3366,29 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// shortcut: vanilla's `EntityBoundSoundInstance` re-reads the entity's position
|
||||
// every tick and stops with it, so the sound follows the entity; a play at the
|
||||
// packet's arrival does not. Upgrade when the sound engine has a generic
|
||||
// entity-bound instance (its tickable ones are one per mob kind, e.g. `Ticking::Bee`).
|
||||
C::SoundEntity(p) => {
|
||||
if let Some(name) = self.remote_sound_name(&p.sound)
|
||||
&& let Some(at) = self.remote_entity_position(p.id)
|
||||
&& let Some(state) = self.remote_sound_entity_state(p.id)
|
||||
{
|
||||
match usize::try_from(p.source)
|
||||
let source = usize::try_from(p.source)
|
||||
.ok()
|
||||
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
|
||||
{
|
||||
Some(source) => {
|
||||
self.audio
|
||||
.play_on(source, &name, Some(at), p.volume, p.pitch)
|
||||
}
|
||||
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
|
||||
}
|
||||
.unwrap_or_else(|| horizon_client::settings::sound_source(&name));
|
||||
let entity = horizon_client::sound_engine::SoundEntity::new(
|
||||
p.id as u32,
|
||||
state.position,
|
||||
state.silent,
|
||||
);
|
||||
entity.update(state);
|
||||
self.audio
|
||||
.play_entity_seeded(&name, source, p.volume, p.pitch, p.seed, entity);
|
||||
}
|
||||
}
|
||||
C::StopSound(p) => self
|
||||
.audio
|
||||
.stop_sounds(p.sound.as_deref(), p.source.map(|s| s as usize)),
|
||||
C::AddEntity(p) => {
|
||||
self.audio.remove_sound_entity(p.id as u32);
|
||||
let kind = self
|
||||
.remote
|
||||
.as_ref()
|
||||
@@ -3554,6 +3582,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::SetEntityData(p) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
for (index, _, value) in &p.values {
|
||||
if let (4, Value::Bool(silent)) = (index, value) {
|
||||
if p.id == r.entity_id {
|
||||
r.local_entity_silent = *silent;
|
||||
}
|
||||
if let Some(t) = r.entities.get_mut(&p.id) {
|
||||
t.silent = *silent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
|
||||
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
|
||||
// these instead of saved NBT.
|
||||
@@ -4005,8 +4045,9 @@ impl Game<'_> {
|
||||
// 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) => {
|
||||
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
|
||||
self.pvp_hurt(p.id, own);
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id);
|
||||
let own = me == Some(p.id);
|
||||
self.pvp_hurt(p.id, own, p.direct.map(|d| Some(d) == me));
|
||||
if own {
|
||||
let at = self
|
||||
.player
|
||||
@@ -8943,12 +8984,6 @@ impl Game<'_> {
|
||||
/// 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.
|
||||
/// The latency the server's player list gives this player (`PlayerInfo.getLatency`).
|
||||
pub(super) fn own_latency(&self) -> Option<i32> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let me = r.tab_players.get(&r.profile)?;
|
||||
(me.latency >= 0).then_some(me.latency)
|
||||
}
|
||||
fn remote_player_attack(&mut self, mob: usize) {
|
||||
// Java dispatches this through the target's RemotePlayer type. The
|
||||
// level_players map is profile bookkeeping and can lag the entity;
|
||||
@@ -8991,6 +9026,14 @@ impl Game<'_> {
|
||||
}
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
}
|
||||
/// The latency the server's player list gives this player
|
||||
/// (`PlayerInfo.getLatency`). A new entry reads 0 until the server's first
|
||||
/// latency update (every 600 ticks), so 0 shows nothing rather than `0 ms`.
|
||||
pub(super) fn own_latency(&self) -> Option<i32> {
|
||||
let r = self.remote.as_ref()?;
|
||||
let me = r.tab_players.get(&r.profile)?;
|
||||
(me.latency > 0).then_some(me.latency)
|
||||
}
|
||||
|
||||
/// An attribute's value on this client's player, if its map has the attribute.
|
||||
fn own_attribute(&mut self, id: &str) -> Option<f64> {
|
||||
@@ -10300,6 +10343,14 @@ impl horizon_world::level::LevelAccess for DryLevel<'_> {
|
||||
fn is_loaded(&self, pos: horizon_core::position::BlockPos) -> bool {
|
||||
self.base.is_loaded(pos)
|
||||
}
|
||||
/// A block this guess changed has no block entity left, so it reads as a
|
||||
/// missing one; every other position asks the level it stands in for.
|
||||
fn shulker_is_closed(&self, pos: horizon_core::position::BlockPos) -> Option<bool> {
|
||||
if self.changed.contains_key(&pos) {
|
||||
return None;
|
||||
}
|
||||
self.base.shulker_is_closed(pos)
|
||||
}
|
||||
fn min_y(&self) -> i32 {
|
||||
self.base.min_y()
|
||||
}
|
||||
@@ -11290,30 +11341,60 @@ mod tests {
|
||||
super::Tracked {
|
||||
kind: "minecraft:cow".to_owned(),
|
||||
position: [8.5, 64.0, -3.5],
|
||||
silent: false,
|
||||
},
|
||||
);
|
||||
let player = Some((2, [0.5, 70.0, 0.5]));
|
||||
let player = Some((2, [0.5, 70.0, 0.5], false));
|
||||
assert_eq!(
|
||||
super::entity_position(2, player, &[], &tracked),
|
||||
super::entity_sound_state(2, player, &[], &tracked)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
Some([0.5, 70.0, 0.5]),
|
||||
"the local player is named by Remote.entity_id"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_position(7, player, &[], &tracked),
|
||||
super::entity_sound_state(7, player, &[], &tracked)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
Some([8.5, 64.0, -3.5]),
|
||||
"another entity the server spawned"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_position(9, player, &[], &tracked),
|
||||
super::entity_sound_state(9, player, &[], &tracked)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
None,
|
||||
"getEntity is null: handleSoundEntityEvent returns"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_position(2, None, &[], &tracked),
|
||||
super::entity_sound_state(2, None, &[], &tracked)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
None,
|
||||
"a player with no position yet"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_sound_entity_retains_silence_for_players_and_non_mob_targets() {
|
||||
use std::collections::HashMap;
|
||||
let mut tracked = HashMap::new();
|
||||
tracked.insert(
|
||||
7,
|
||||
super::Tracked {
|
||||
kind: "minecraft:arrow".to_owned(),
|
||||
position: [8.5, 64.0, -3.5],
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
let state = super::entity_sound_state(7, None, &[], &tracked).unwrap();
|
||||
assert!(state.silent);
|
||||
assert!(!state.removed);
|
||||
assert_eq!(state.position, [8.5, 64.0, -3.5]);
|
||||
let player =
|
||||
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), &[], &tracked).unwrap();
|
||||
assert!(player.silent);
|
||||
assert!(super::entity_sound_state(9, None, &[], &tracked).is_none());
|
||||
}
|
||||
/// 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.
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, drops, equine, finalize, illusioner,
|
||||
panda, phantom, sniffer, sync, tame, zombie_villager,
|
||||
panda, phantom, raider, sniffer, sync, tame, zombie_villager,
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
@@ -24,6 +24,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
}
|
||||
for (index, _, value) in values {
|
||||
match (*index, value) {
|
||||
// Entity's signed air and frozen counters are synced as-is.
|
||||
(1, Value::Int(air)) => m.air = *air,
|
||||
(7, Value::Int(frozen)) => m.ticks_frozen = *frozen,
|
||||
// Entity.DATA_POSE: a player's or mannequin's box follows it.
|
||||
(6, Value::Id(pose))
|
||||
if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") =>
|
||||
@@ -318,6 +321,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// entities the side heads turn toward in `wither::client_tick`.
|
||||
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
|
||||
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
|
||||
// Raider.IS_CELEBRATING: shared by all six raider types.
|
||||
(species, 16, Value::Bool(celebrating)) if raider::is_raider(species) => {
|
||||
raider::state_mut(m).celebrating = *celebrating;
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
@@ -503,6 +510,7 @@ impl Game<'_> {
|
||||
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
|
||||
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
|
||||
pub(super) fn client_defaults(m: &mut Mob) {
|
||||
m.air = m.max_air_supply();
|
||||
if matches!(m.species(), Species::Wolf | Species::Cat) {
|
||||
m.extra.color = tame::RED;
|
||||
}
|
||||
@@ -521,6 +529,395 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::entity_data::serializer as s;
|
||||
|
||||
fn fixture_mob(kind: &str) -> Mob {
|
||||
use horizon_client::mobs::{Anim, Brain, Flags, MobType, Part};
|
||||
use std::sync::Arc;
|
||||
let species = match kind {
|
||||
"minecraft:cow" => Species::Cow,
|
||||
"minecraft:axolotl" => Species::Axolotl,
|
||||
"minecraft:dolphin" => Species::Dolphin,
|
||||
"minecraft:cod" => Species::Cod,
|
||||
"minecraft:pillager" => Species::Pillager,
|
||||
"minecraft:vindicator" => Species::Vindicator,
|
||||
"minecraft:evoker" => Species::Evoker,
|
||||
"minecraft:illusioner" => Species::Illusioner,
|
||||
"minecraft:witch" => Species::Witch,
|
||||
"minecraft:ravager" => Species::Ravager,
|
||||
"minecraft:zombie" => Species::Zombie,
|
||||
_ => panic!("unknown fixture species"),
|
||||
};
|
||||
// Metadata needs no AI or model. Avoid the resource loader, which also
|
||||
// initializes process-wide item tags used by unrelated mob tests.
|
||||
let kind = Arc::new(MobType {
|
||||
id: kind.into(),
|
||||
species,
|
||||
width: 0.6,
|
||||
height: 1.95,
|
||||
eye_height: 1.5,
|
||||
category: "monster".into(),
|
||||
max_health: 20.0,
|
||||
speed: 0.25,
|
||||
flying_speed: 0.0,
|
||||
attack_damage: 0.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 16.0,
|
||||
step_height: 0.6,
|
||||
armor: 0.0,
|
||||
armor_toughness: 0.0,
|
||||
knockback_resistance: 0.0,
|
||||
jump_strength: 0.42,
|
||||
attributes: Arc::default(),
|
||||
item_stack_limits: Arc::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: 120,
|
||||
flags: Flags::default(),
|
||||
model: Arc::new(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(),
|
||||
}),
|
||||
layers: Default::default(),
|
||||
texture: String::new(),
|
||||
brain: Brain::Passive,
|
||||
anim: Anim::Zombie,
|
||||
sounds: [""; 3],
|
||||
step_sound: None,
|
||||
food: Arc::default(),
|
||||
tempt: Arc::default(),
|
||||
});
|
||||
Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_and_frozen_metadata_reach_the_client() {
|
||||
for kind in ["cow", "cod", "dolphin", "axolotl"] {
|
||||
let mut mob = fixture_mob(&format!("minecraft:{kind}"));
|
||||
super::client_defaults(&mut mob);
|
||||
let max = match kind {
|
||||
"axolotl" => 6000,
|
||||
"dolphin" => 4800,
|
||||
_ => 300,
|
||||
};
|
||||
assert_eq!(mob.air, max, "initial air: {kind}");
|
||||
for (air, frozen) in [(100, -3), (-20, 140), (max, 0)] {
|
||||
super::apply(
|
||||
&mut mob,
|
||||
&[
|
||||
(1, s::INT, Value::Int(air)),
|
||||
(7, s::INT, Value::Int(frozen)),
|
||||
],
|
||||
&Builtin::default(),
|
||||
99,
|
||||
);
|
||||
assert_eq!((mob.air, mob.ticks_frozen), (air, frozen), "{kind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn air_trace(kind: &str, sequence: &[(i32, i32)]) -> Vec<String> {
|
||||
use horizon_client::lan::entity::{Entities, EntityState, Riding, Tracker, Viewer};
|
||||
use horizon_protocol::{buf::Reader, packets::play::Clientbound};
|
||||
let name = format!("minecraft:{kind}");
|
||||
let mut server = fixture_mob(&name);
|
||||
let mut client = fixture_mob(&name);
|
||||
super::client_defaults(&mut client);
|
||||
let mut level = Entities::default();
|
||||
let mut tracker = Tracker::default();
|
||||
let chunks = [(0, 0)].into_iter().collect();
|
||||
let mut state = EntityState {
|
||||
id: 1,
|
||||
uuid: 1,
|
||||
kind: name,
|
||||
needs_sync: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut trace = Vec::new();
|
||||
for &(air, frozen) in sequence {
|
||||
server.air = air;
|
||||
server.ticks_frozen = frozen;
|
||||
state.meta.clear();
|
||||
state.defaults.clear();
|
||||
super::super::lan::mob_freeze_meta(&server, &mut state.meta, &mut state.defaults);
|
||||
let mut packets = Vec::new();
|
||||
level.tick(
|
||||
&[&state],
|
||||
&Riding::default(),
|
||||
&mut [Viewer {
|
||||
entity_id: 99,
|
||||
spectator: false,
|
||||
permission: 0,
|
||||
moved: true,
|
||||
position: [0.0; 3],
|
||||
view_distance: 10,
|
||||
chunks: &chunks,
|
||||
tracker: &mut tracker,
|
||||
out: &mut packets,
|
||||
gone: None,
|
||||
}],
|
||||
|_| Some(100),
|
||||
);
|
||||
let mut sent = Vec::new();
|
||||
for body in packets {
|
||||
let mut reader = Reader::new(&body);
|
||||
let id = reader.varint().unwrap();
|
||||
if let Ok(Clientbound::SetEntityData(data)) = Clientbound::decode(id, reader.rest())
|
||||
{
|
||||
for (index, serializer, value) in &data.values {
|
||||
if matches!(*index, 1 | 7) {
|
||||
assert_eq!(*serializer, s::INT);
|
||||
let Value::Int(value) = value else {
|
||||
panic!("integer metadata");
|
||||
};
|
||||
sent.push((*index, *value));
|
||||
}
|
||||
}
|
||||
super::apply(&mut client, &data.values, &Builtin::default(), 99);
|
||||
}
|
||||
}
|
||||
sent.sort_unstable_by_key(|v| v.0);
|
||||
let sent = if sent.is_empty() {
|
||||
"-".into()
|
||||
} else {
|
||||
sent.iter()
|
||||
.map(|(index, value)| format!("{index}={value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
trace.push(format!("{sent}:{},{}", client.air, client.ticks_frozen));
|
||||
}
|
||||
trace
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_and_frozen_cross_the_tracker_and_wire() {
|
||||
for (kind, max) in [
|
||||
("cow", 300),
|
||||
("cod", 300),
|
||||
("dolphin", 4800),
|
||||
("axolotl", 6000),
|
||||
] {
|
||||
let sequence = [(max, 0), (100, -3), (100, -3), (max, 0), (max, 0)];
|
||||
assert_eq!(
|
||||
air_trace(kind, &sequence),
|
||||
vec![
|
||||
format!("-:{max},0"),
|
||||
"1=100,7=-3:100,-3".into(),
|
||||
"-:100,-3".into(),
|
||||
format!("1={max},7=0:{max},0"),
|
||||
format!("-:{max},0")
|
||||
],
|
||||
"{kind}"
|
||||
);
|
||||
assert_eq!(
|
||||
air_trace(kind, &[(100, -3)]),
|
||||
vec!["1=100,7=-3:100,-3"],
|
||||
"joining while changed: {kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by tools/test_mob_air_parity.py"]
|
||||
fn mob_air_parity_fixture() {
|
||||
use std::io::Write;
|
||||
let input =
|
||||
std::fs::read_to_string(std::env::var("HORIZON_MOB_AIR_INPUT").unwrap()).unwrap();
|
||||
let mut output =
|
||||
std::fs::File::create(std::env::var("HORIZON_MOB_AIR_OUTPUT").unwrap()).unwrap();
|
||||
for line in input.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let kind = fields.next().unwrap();
|
||||
let sequence: Vec<_> = fields
|
||||
.map(|pair| {
|
||||
let (air, frozen) = pair.split_once(',').unwrap();
|
||||
(air.parse().unwrap(), frozen.parse().unwrap())
|
||||
})
|
||||
.collect();
|
||||
writeln!(output, "{}", air_trace(kind, &sequence).join(" ")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raider_celebration_follows_metadata_and_clears() {
|
||||
use horizon_client::mobs::raider;
|
||||
for kind in [
|
||||
"pillager",
|
||||
"vindicator",
|
||||
"evoker",
|
||||
"illusioner",
|
||||
"witch",
|
||||
"ravager",
|
||||
] {
|
||||
let mut m = fixture_mob(&format!("minecraft:{kind}"));
|
||||
for on in [true, false, true, false] {
|
||||
super::apply(
|
||||
&mut m,
|
||||
&[(16, s::BOOLEAN, Value::Bool(on))],
|
||||
&Builtin::default(),
|
||||
99,
|
||||
);
|
||||
assert_eq!(
|
||||
raider::state(&m).is_some_and(|state| state.celebrating),
|
||||
on,
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for kind in ["zombie", "cow"] {
|
||||
let mut m = fixture_mob(&format!("minecraft:{kind}"));
|
||||
super::apply(
|
||||
&mut m,
|
||||
&[(16, s::BOOLEAN, Value::Bool(true))],
|
||||
&Builtin::default(),
|
||||
99,
|
||||
);
|
||||
assert!(raider::state(&m).is_none(), "{kind} is not a raider");
|
||||
}
|
||||
}
|
||||
|
||||
fn celebration_trace(kind: &str, sequence: &[bool]) -> Vec<(Vec<bool>, bool)> {
|
||||
use horizon_client::{
|
||||
lan::entity::{Entities, EntityState, Riding, Tracker, Viewer},
|
||||
mobs::raider,
|
||||
};
|
||||
use horizon_protocol::{buf::Reader, packets::play::Clientbound};
|
||||
let name = format!("minecraft:{kind}");
|
||||
let mut server = fixture_mob(&name);
|
||||
let mut client = fixture_mob(&name);
|
||||
let mut level = Entities::default();
|
||||
let mut tracker = Tracker::default();
|
||||
let chunks = [(0, 0)].into_iter().collect();
|
||||
let mut state = EntityState {
|
||||
id: 1,
|
||||
uuid: 1,
|
||||
kind: name,
|
||||
needs_sync: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut trace = Vec::new();
|
||||
for &on in sequence {
|
||||
raider::state_mut(&mut server).celebrating = on;
|
||||
state.meta.clear();
|
||||
state.defaults.clear();
|
||||
super::super::lan::mob_raider_meta(&server, &mut state.meta, &mut state.defaults);
|
||||
let mut packets = Vec::new();
|
||||
level.tick(
|
||||
&[&state],
|
||||
&Riding::default(),
|
||||
&mut [Viewer {
|
||||
entity_id: 99,
|
||||
spectator: false,
|
||||
permission: 0,
|
||||
moved: true,
|
||||
position: [0.0; 3],
|
||||
view_distance: 10,
|
||||
chunks: &chunks,
|
||||
tracker: &mut tracker,
|
||||
out: &mut packets,
|
||||
gone: None,
|
||||
}],
|
||||
|_| Some(100),
|
||||
);
|
||||
let mut sent = Vec::new();
|
||||
for body in packets {
|
||||
let mut reader = Reader::new(&body);
|
||||
let id = reader.varint().unwrap();
|
||||
if let Ok(Clientbound::SetEntityData(data)) = Clientbound::decode(id, reader.rest())
|
||||
{
|
||||
for (index, serializer, value) in &data.values {
|
||||
if *index == 16 {
|
||||
assert_eq!(*serializer, s::BOOLEAN);
|
||||
let Value::Bool(on) = value else {
|
||||
panic!("boolean metadata");
|
||||
};
|
||||
sent.push(*on);
|
||||
}
|
||||
}
|
||||
super::apply(&mut client, &data.values, &Builtin::default(), 99);
|
||||
}
|
||||
}
|
||||
trace.push((
|
||||
sent,
|
||||
raider::state(&client).is_some_and(|state| state.celebrating),
|
||||
));
|
||||
}
|
||||
trace
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn celebration_crosses_the_tracker_and_wire_without_sticking_or_repeating() {
|
||||
let sequence = [false, true, true, false, false, true, false];
|
||||
let expected = vec![
|
||||
(vec![], false),
|
||||
(vec![true], true),
|
||||
(vec![], true),
|
||||
(vec![false], false),
|
||||
(vec![], false),
|
||||
(vec![true], true),
|
||||
(vec![false], false),
|
||||
];
|
||||
for kind in [
|
||||
"pillager",
|
||||
"vindicator",
|
||||
"evoker",
|
||||
"illusioner",
|
||||
"witch",
|
||||
"ravager",
|
||||
] {
|
||||
assert_eq!(celebration_trace(kind, &sequence), expected, "{kind}");
|
||||
assert_eq!(
|
||||
celebration_trace(kind, &[true]),
|
||||
vec![(vec![true], true)],
|
||||
"joining mid-celebration: {kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by tools/test_raider_celebration_parity.py"]
|
||||
fn raider_celebration_parity_fixture() {
|
||||
use std::io::Write;
|
||||
let input =
|
||||
std::fs::read_to_string(std::env::var("HORIZON_RAIDER_SYNC_INPUT").unwrap()).unwrap();
|
||||
let mut output =
|
||||
std::fs::File::create(std::env::var("HORIZON_RAIDER_SYNC_OUTPUT").unwrap()).unwrap();
|
||||
for line in input.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let kind = fields.next().unwrap();
|
||||
let sequence: Vec<_> = fields
|
||||
.map(|value| match value {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => panic!("invalid celebration flag"),
|
||||
})
|
||||
.collect();
|
||||
let trace = celebration_trace(kind, &sequence);
|
||||
let mut parts = Vec::new();
|
||||
for (sent, state) in trace {
|
||||
let sent = if sent.is_empty() {
|
||||
"-".into()
|
||||
} else {
|
||||
sent.into_iter()
|
||||
.map(|on| i32::from(on).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
parts.push(format!("{sent}:{}", i32::from(state)));
|
||||
}
|
||||
writeln!(output, "{}", parts.join(" ")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn root() -> std::path::PathBuf {
|
||||
|
||||
@@ -269,6 +269,17 @@ impl Game<'_> {
|
||||
(8, Value::Int(v)) => c.hurt_time = *v,
|
||||
(9, Value::Int(v)) => c.hurt_dir = *v,
|
||||
(10, Value::Float(v)) => c.damage = *v,
|
||||
// AbstractMinecart.DATA_ID_CUSTOM_DISPLAY_BLOCK (0, air,
|
||||
// for none) and DATA_ID_DISPLAY_OFFSET.
|
||||
(11, Value::Id(state)) => {
|
||||
c.custom_display = (*state > 0).then(|| {
|
||||
horizon_client::entity_nbt::state_to_nbt(
|
||||
self.pack.assets.registry,
|
||||
horizon_world::registry::BlockStateId(*state as u32),
|
||||
)
|
||||
});
|
||||
}
|
||||
(12, Value::Int(offset)) => c.custom_offset = Some(*offset),
|
||||
(13, Value::Bool(lit)) if c.kind == super::minecarts::CartKind::Furnace => {
|
||||
c.fuel = i32::from(*lit);
|
||||
}
|
||||
|
||||
@@ -243,6 +243,12 @@ impl Game<'_> {
|
||||
let setting = entry.as_ref().and_then(|e| e.accept_textures);
|
||||
state.address = entry.map(|e| e.ip);
|
||||
state.packs.connect(Prompt::for_server(setting));
|
||||
// GetServerDetailsTask.scheduleResourcePackDownload: the Realm's own pack, which the
|
||||
// player agreed to before connecting (`allowServerPacks`, then `pushPack`).
|
||||
if let Some(pack) = horizon_client::realms::take_pending_pack(address) {
|
||||
state.packs.allow();
|
||||
state.packs.push(pack.id, &pack.url, parse_sha1(&pack.hash));
|
||||
}
|
||||
}
|
||||
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
|
||||
pub(super) fn server_packs_disconnect(&mut self) {
|
||||
|
||||
@@ -397,7 +397,10 @@ impl GameLevel<'_> {
|
||||
}
|
||||
/// `Block.dropResources(state, level, pos, blockEntity)`: the block's loot
|
||||
/// (from its random sequence, else the level random), with what its block
|
||||
/// entity copies into it, popped at once.
|
||||
/// entity copies into it, popped at once. While a mod's machine breaks
|
||||
/// the block (`native_machine_destroy_block`), the loot is rolled with its
|
||||
/// `drop_tool` (`dropResources(.., entity, tool)`) and, given `drop_into`,
|
||||
/// kept there instead of popped.
|
||||
pub(super) fn drop_resources_now(&mut self, pos: BlockPos, state: BlockStateId) {
|
||||
let key = (pos.x, pos.y, pos.z);
|
||||
let at = [pos.x, pos.y, pos.z];
|
||||
@@ -410,23 +413,31 @@ impl GameLevel<'_> {
|
||||
self.pending.record_saved(at, &tag);
|
||||
}
|
||||
let dynamic = self.pending.dynamic(at);
|
||||
let tool = self.drop_tool.as_ref();
|
||||
let drops = match self.items.block_loot_sequence(self.registry, state) {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(&sequence, self.seed);
|
||||
self.items
|
||||
.block_drops_with(self.registry, state, None, &dynamic, random)
|
||||
.block_drops_with(self.registry, state, tool, &dynamic, random)
|
||||
}
|
||||
None => self.items.block_drops_with(
|
||||
self.registry,
|
||||
state,
|
||||
None,
|
||||
tool,
|
||||
&dynamic,
|
||||
&mut self.world.random,
|
||||
),
|
||||
};
|
||||
for stack in drops {
|
||||
if &*stack.item != "minecraft:air" && stack.count > 0 {
|
||||
self.pop_resource(at, stack);
|
||||
for mut stack in drops {
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
continue;
|
||||
}
|
||||
match self.drop_into.as_mut() {
|
||||
Some(kept) => {
|
||||
self.pending.attach(at, &mut stack);
|
||||
kept.push(stack);
|
||||
}
|
||||
None => self.pop_resource(at, stack),
|
||||
}
|
||||
}
|
||||
self.pending.clear(at);
|
||||
|
||||
@@ -684,6 +684,18 @@ impl Loading {
|
||||
self.menu_option_events(events);
|
||||
}
|
||||
|
||||
/// The Realms screen's join, once its address came back: queued like a click on a server.
|
||||
pub(super) fn poll_menu_realms(&mut self, event_loop: &dyn EventLoopCtl) {
|
||||
if let Some(action) = self
|
||||
.menu
|
||||
.title
|
||||
.as_mut()
|
||||
.and_then(Title::take_connect_request)
|
||||
{
|
||||
self.menu_action(event_loop, action);
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_action(&mut self, event_loop: &dyn EventLoopCtl, action: Action) {
|
||||
match action {
|
||||
Action::Quit => event_loop.exit(),
|
||||
|
||||
@@ -2957,6 +2957,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Brewing(b)) if b.idle())
|
||||
{
|
||||
self.world.block_entities.sleep(pos);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
//! itself. It is a `banned-players.json` entry with `expires` set, as the chat filter's automatic temporary
|
||||
//! ban makes (game/chat_filter.rs), so the login check, `/banlist` and vanilla's `/pardon` already treat it as
|
||||
//! they treat any ban, and it survives a restart. Operators of level 3 (`/ban`'s level) run it as a player; the
|
||||
//! console takes the same words without the slash. Vanilla has no such command, so it is not in the command
|
||||
//! tree (and has no completions), as the mute commands are not.
|
||||
//! console takes the same words without the slash. `/tempbanlist` lists the bans that end by themselves, with
|
||||
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
|
||||
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
|
||||
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_client::{mod_api::NativeCommand, text::Text};
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::translatable;
|
||||
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
|
||||
@@ -14,8 +15,63 @@ use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date
|
||||
/// The operator level `/tempban` needs, as `/ban`'s.
|
||||
const TEMPBAN_LEVEL: u8 = 3;
|
||||
|
||||
/// The commands of `tempban_command`.
|
||||
pub(super) const TEMPBAN_COMMANDS: [&str; 1] = ["tempban"];
|
||||
/// The commands of `tempban_command`: `/tempbanlist` lists the bans that end by themselves.
|
||||
pub(super) const TEMPBAN_COMMANDS: [&str; 2] = ["tempban", "tempbanlist"];
|
||||
|
||||
/// Horizon's moderation commands in operators' command trees (`command_tree`'s usages, for operators of
|
||||
/// `/ban`'s level): the mute commands when the server filters chat (`chat_filter`), `/tempban` and
|
||||
/// `/tempbanlist` on a dedicated server (`dedicated`), where the game runs them.
|
||||
pub(super) fn moderation_commands(chat_filter: bool, dedicated: bool) -> Vec<NativeCommand> {
|
||||
let mut usages = Vec::new();
|
||||
if chat_filter {
|
||||
usages.extend(super::chat_filter::MUTE_USAGES);
|
||||
}
|
||||
if dedicated {
|
||||
usages.extend([
|
||||
"tempban <player:player> <duration:word> [reason:text]",
|
||||
"tempbanlist",
|
||||
]);
|
||||
}
|
||||
usages
|
||||
.into_iter()
|
||||
.map(|usage| NativeCommand::op(usage, TEMPBAN_LEVEL))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `/tempbanlist`: the bans that end by themselves and haven't yet (`now`), the soonest to end first,
|
||||
/// with who banned, the time left and the reason.
|
||||
fn timed_bans(bans: &[&BanEntry<NameAndId>], now: i64) -> Vec<Reply> {
|
||||
let mut timed: Vec<(&BanEntry<NameAndId>, i64)> = bans
|
||||
.iter()
|
||||
.filter_map(|b| b.expires.filter(|&e| e >= now).map(|e| (*b, e)))
|
||||
.collect();
|
||||
if timed.is_empty() {
|
||||
return vec![Reply::translated("horizon.tempban.list.empty", &[])];
|
||||
}
|
||||
timed.sort_by_key(|&(b, e)| (e, b.user.name.clone()));
|
||||
let mut lines = vec![Reply::translated(
|
||||
"horizon.tempban.list.count",
|
||||
&[Text::literal(timed.len().to_string())],
|
||||
)];
|
||||
for (ban, expires) in timed {
|
||||
lines.push(
|
||||
Reply::translated(
|
||||
"horizon.tempban.list.entry",
|
||||
&[
|
||||
Text::literal(&ban.user.name),
|
||||
Text::literal(&ban.source),
|
||||
Reply::translation(
|
||||
"horizon.moderation.remaining",
|
||||
&[Reply::duration(expires - now)],
|
||||
),
|
||||
Text::literal(format_date(expires)),
|
||||
],
|
||||
)
|
||||
.with_reason(ban.reason.as_deref()),
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// What `/tempban` was asked: the player's name, how long, and the reason, or the line to answer.
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -127,6 +183,13 @@ impl Game<'_> {
|
||||
words: &[&str],
|
||||
by: &str,
|
||||
) -> Vec<Reply> {
|
||||
if words.first() == Some(&"tempbanlist") {
|
||||
let Some(lists) = self.server_lists.clone() else {
|
||||
return vec![Reply::translated("horizon.tempban.dedicated_required", &[])];
|
||||
};
|
||||
let lists = lists.lock().unwrap();
|
||||
return timed_bans(&lists.bans.entries(), now_millis());
|
||||
}
|
||||
match parse(words) {
|
||||
Ok(r) => vec![self.tempban(host, r.name, r.millis, r.reason, by)],
|
||||
Err(line) => vec![line],
|
||||
@@ -404,4 +467,104 @@ mod tests {
|
||||
assert!(after.get(&user(4, "Eve")).is_none());
|
||||
let _ = std::fs::remove_file(&bans.file);
|
||||
}
|
||||
|
||||
fn ban(
|
||||
id: u128,
|
||||
name: &str,
|
||||
by: &str,
|
||||
expires: Option<i64>,
|
||||
reason: Option<&str>,
|
||||
) -> BanEntry<NameAndId> {
|
||||
let mut b = BanEntry::new(
|
||||
user(id, name),
|
||||
Some(by.into()),
|
||||
expires,
|
||||
reason.map(Into::into),
|
||||
);
|
||||
b.created = 0;
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempbanlist_lists_bans_still_running_soonest_first_with_time_left() {
|
||||
let now = 1_800_000_000_000;
|
||||
let hour = 3_600_000;
|
||||
let bans = [
|
||||
ban(1, "Forever", "rdbtclient", None, Some("x-ray")),
|
||||
ban(2, "Ended", "luzW", Some(now - 1), None),
|
||||
ban(
|
||||
3,
|
||||
"Week",
|
||||
"luzW",
|
||||
Some(now + 7 * 24 * hour),
|
||||
Some("griefing"),
|
||||
),
|
||||
ban(4, "Hour", "Server", Some(now + hour), None),
|
||||
];
|
||||
let text = |bans: &[BanEntry<NameAndId>]| -> Vec<String> {
|
||||
timed_bans(&bans.iter().collect::<Vec<_>>(), now)
|
||||
.iter()
|
||||
.map(|l| horizon_protocol::text::plain(&l.tag(), &Default::default()))
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(
|
||||
text(&bans),
|
||||
[
|
||||
"There are 2 temporary ban(s):".to_owned(),
|
||||
format!(
|
||||
"Hour was banned by Server for another 60 minutes (until {})",
|
||||
format_date(now + hour)
|
||||
),
|
||||
format!(
|
||||
"Week was banned by luzW for another 7 days (until {}): griefing",
|
||||
format_date(now + 7 * 24 * hour)
|
||||
),
|
||||
]
|
||||
);
|
||||
// Only permanent or ended bans: nothing to list.
|
||||
assert_eq!(text(&bans[..2]), ["There are no temporary bans"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_commands_reach_only_operators_trees_and_only_where_the_game_runs_them() {
|
||||
let names = |chat_filter, dedicated| {
|
||||
let (tree, levels, problems) = super::super::native_server::command_tree(
|
||||
&moderation_commands(chat_filter, dedicated),
|
||||
|_| false,
|
||||
);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
for level in levels.values() {
|
||||
assert_eq!(*level, 3);
|
||||
}
|
||||
if let Some(children) = tree["children"].as_object() {
|
||||
for (name, node) in children {
|
||||
assert_eq!(
|
||||
node["permissions"],
|
||||
serde_json::json!({"type": "require", "permission": {"level": "admins"}}),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut names: Vec<String> = levels.into_keys().collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
assert_eq!(
|
||||
names(true, true),
|
||||
[
|
||||
"mute",
|
||||
"mutelist",
|
||||
"tempban",
|
||||
"tempbanlist",
|
||||
"tempmute",
|
||||
"unmute"
|
||||
]
|
||||
);
|
||||
assert_eq!(names(false, true), ["tempban", "tempbanlist"]);
|
||||
assert_eq!(
|
||||
names(true, false),
|
||||
["mute", "mutelist", "tempmute", "unmute"]
|
||||
);
|
||||
assert!(names(false, false).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ impl Game<'_> {
|
||||
zombie: &adv::EntityView,
|
||||
villager: &adv::EntityView,
|
||||
) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:cured_zombie_villager");
|
||||
let (zombie, villager) = (zombie.clone(), villager.clone());
|
||||
self.trigger("minecraft:cured_zombie_villager", move |c, w| {
|
||||
let origin = me.position;
|
||||
|
||||
@@ -1632,14 +1632,16 @@ mod tests {
|
||||
.encode(),
|
||||
)
|
||||
.unwrap();
|
||||
c.send(
|
||||
// A server that does not accept transfers turns one away at the handshake and closes
|
||||
// (ServerHandshakePacketListenerImpl.handleIntention), so the hello may find the connection
|
||||
// gone: its disconnect is still there to read.
|
||||
let _ = 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() {
|
||||
|
||||
@@ -2997,6 +2997,14 @@ impl Mob {
|
||||
pub fn species(&self) -> Species {
|
||||
self.kind.species
|
||||
}
|
||||
/// Entity.getMaxAirSupply and the aquatic species' overrides.
|
||||
pub fn max_air_supply(&self) -> i32 {
|
||||
match self.species() {
|
||||
Species::Axolotl => axolotl::MAX_AIR,
|
||||
Species::Dolphin => 4800,
|
||||
_ => 300,
|
||||
}
|
||||
}
|
||||
/// `Mob.getMainArm`: the left arm when the mob is left-handed, a player's
|
||||
/// `mainHand` option for a player mob.
|
||||
pub fn main_arm_left(&self) -> bool {
|
||||
|
||||
@@ -87,7 +87,7 @@ impl SkinModels {
|
||||
frame.clear_draws();
|
||||
}
|
||||
|
||||
fn restart(&mut self, frame: &mut SkinFrame) {
|
||||
pub fn restart(&mut self, frame: &mut SkinFrame) {
|
||||
self.stored.clear();
|
||||
self.items.clear();
|
||||
self.vertices = 0;
|
||||
@@ -507,6 +507,33 @@ mod tests {
|
||||
use crate::gfx::entity_skin::expand;
|
||||
use crate::mobs::{Mob, MobData, MobTypes, render};
|
||||
|
||||
#[test]
|
||||
fn restart_clears_models_and_marks_frame_reset() {
|
||||
let mut models = SkinModels::default();
|
||||
let mut frame = SkinFrame::default();
|
||||
frame.jobs.push(Default::default());
|
||||
frame.parts.push(SkinPart {
|
||||
matrix: [0.0; 16],
|
||||
normal: [0.0; 9],
|
||||
});
|
||||
frame.vertices = 4;
|
||||
frame.outline_jobs.push(Default::default());
|
||||
frame.outline_vertices = 4;
|
||||
assert!(frame.pending());
|
||||
|
||||
models.restart(&mut frame);
|
||||
frame.clear_draws();
|
||||
|
||||
assert_eq!(models.vertices, 0);
|
||||
assert_eq!(frame.base, 0);
|
||||
assert_eq!(frame.vertices, 0);
|
||||
assert_eq!(frame.outline_vertices, 0);
|
||||
assert!(frame.jobs.is_empty());
|
||||
assert!(frame.parts.is_empty());
|
||||
assert!(frame.outline_jobs.is_empty());
|
||||
assert!(frame.reset);
|
||||
}
|
||||
|
||||
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
|
||||
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
|
||||
/// the bit and in the same order, for each mob it takes; the mobs it
|
||||
|
||||
@@ -15,11 +15,13 @@ pub use crate::extras::Corner;
|
||||
pub use crate::extras_hud::Corners;
|
||||
pub use crate::first_person::Hand;
|
||||
pub use crate::fog::{FogData, FogType};
|
||||
pub use crate::game::NativeFluidTank;
|
||||
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
|
||||
pub use crate::game::{
|
||||
ItemEnergyBuffer, NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks,
|
||||
NativeConfig, NativeConfigFile, NativeConfigKind, NativeDataMapType, NativeExperienceOrb,
|
||||
NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit, NativeMapDecoration, NativeMapInfo,
|
||||
ItemEnergyBuffer, NativeBlockEdit, NativeBlockReadError, NativeBlockRun, NativeClock,
|
||||
NativeColumnBlocks, NativeComponentError, NativeConfig, NativeConfigFile, NativeConfigKind,
|
||||
NativeCriterion, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap,
|
||||
NativeHeldItem, NativeHit, NativeItemEntity, NativeMapDecoration, NativeMapInfo,
|
||||
NativeMobRender, NativeModEvent, NativePlayerRender, NativeReloadListener, NativeShake,
|
||||
};
|
||||
pub use crate::gfx::native_render::{
|
||||
@@ -85,6 +87,38 @@ pub struct NativeCamera {
|
||||
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
|
||||
pub type NativeDispensed = (Option<ItemStack>, i32);
|
||||
|
||||
/// What a mod's fake player uses its item on (`Host::use_as_player`), as a
|
||||
/// player's click would.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum NativeUseTarget {
|
||||
/// `ServerPlayerGameMode.useItemOn`: the block at `pos`, clicked on its
|
||||
/// `face` at `point` (world coordinates on that face).
|
||||
Block {
|
||||
pos: [i32; 3],
|
||||
face: Direction,
|
||||
point: [f64; 3],
|
||||
},
|
||||
/// `ServerPlayerGameMode.useItem`: the item alone, in the air.
|
||||
Item,
|
||||
/// `Player.interactOn`: the entity with this id, clicked at `location`
|
||||
/// (from the entity's position), as an interact packet names one.
|
||||
Entity { id: i32, location: [f64; 3] },
|
||||
}
|
||||
|
||||
/// What a mod's fake player did (`Host::use_as_player`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct NativeFakeUse {
|
||||
/// Whether the use did something (`InteractionResult.consumesAction`).
|
||||
pub consumed: bool,
|
||||
/// What its hand holds afterwards: what is left of the stack, or what the
|
||||
/// item became (a filled bucket).
|
||||
pub held: Option<ItemStack>,
|
||||
/// What else ended up in its inventory (a bowl of stew from a stack of
|
||||
/// bowls, a bottle), for the mod to keep or drop.
|
||||
pub other: Vec<ItemStack>,
|
||||
}
|
||||
|
||||
/// Context for a player tuning a note block.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeNoteBlockChange<'a> {
|
||||
@@ -215,6 +249,28 @@ pub struct NativeMinimap {
|
||||
pub round: bool,
|
||||
}
|
||||
|
||||
/// One block of a scene a mod draws in a screen (`Host::draw_scene`): its
|
||||
/// place in the scene, its state, and the block entity drawn with it, if any
|
||||
/// (a mod's, through `native_block_entity_submit` as in the world).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeSceneBlock<'a> {
|
||||
pub pos: [i32; 3],
|
||||
pub state: BlockStateId,
|
||||
pub entity: Option<&'a ModBlockEntity>,
|
||||
}
|
||||
|
||||
/// How a scene in a screen is seen (`Host::draw_scene`): the point at the
|
||||
/// middle of the screen's box, in blocks; the turn about the scene's vertical
|
||||
/// axis and the tilt of its top towards the viewer, in degrees; and how many
|
||||
/// GUI pixels a block is across.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct NativeSceneView {
|
||||
pub center: [f32; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
/// 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; the server's menu does not have a
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,608 @@
|
||||
//! The Realms service client: vanilla's `com.mojang.realmsclient.client.RealmsClient` and its
|
||||
//! `Request`, for the signed-in player's own Microsoft account only. Every request carries
|
||||
//! what `RealmsClient.execute` adds and nothing more: the `sid`, `user` and `version` cookies
|
||||
//! and the three version headers. The access token goes only into the `sid` cookie (and the
|
||||
//! `x-mojangauth` header vanilla adds for the invite-code service); it is never logged, never
|
||||
//! put in an error and never sent anywhere else.
|
||||
use horizon_core::version;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::time::Duration;
|
||||
|
||||
/// `RealmsClient.Environment.PRODUCTION`'s base URL.
|
||||
pub const DEFAULT_REALMS_HOST: &str = "https://pc.realms.minecraft.net";
|
||||
/// `RealmsClient.Environment.PRODUCTION`'s `frontendV2Url` (invite codes).
|
||||
pub const DEFAULT_FRONTEND_V2_HOST: &str = "https://java.frontend.realms.minecraft-services.net";
|
||||
|
||||
/// `Request.DEFAULT_CONNECT_TIMEOUT`.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// `Request.DEFAULT_READ_TIMEOUT`.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
/// `RealmsClient.join`: `Request.get(url, 5000, 30000)`.
|
||||
const JOIN_READ_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Vanilla sets no user agent: `HttpURLConnection` sends the JVM's own, `Java/<version>`.
|
||||
fn user_agent() -> String {
|
||||
format!("Java/{}", version::JAVA_VERSION)
|
||||
}
|
||||
|
||||
/// Gson leaves a field at its default when the JSON has `null`; serde would fail.
|
||||
fn nullable<'de, D: Deserializer<'de>, T: Default + Deserialize<'de>>(d: D) -> Result<T, D::Error> {
|
||||
Ok(Option::<T>::deserialize(d)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// `RealmsServer`: the fields the screens read.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RealmServer {
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub remote_subscription_id: Option<String>,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub name: String,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub motd: String,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub owner: Option<String>,
|
||||
#[serde(rename = "ownerUUID", default)]
|
||||
pub owner_uuid: Option<String>,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub expired: bool,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub expired_trial: bool,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub days_left: i32,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub world_type: String,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub active_slot: i32,
|
||||
#[serde(default)]
|
||||
pub minigame_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub minigame_id: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub minigame_image: Option<String>,
|
||||
}
|
||||
|
||||
impl RealmServer {
|
||||
/// `RealmsServer.State.OPEN` and not expired: the Play button's condition.
|
||||
pub fn is_open(&self) -> bool {
|
||||
self.state.eq_ignore_ascii_case("OPEN") && !self.expired
|
||||
}
|
||||
/// `Minecraft.isLocalPlayer(server.ownerUUID)`.
|
||||
pub fn owned_by(&self, uuid: u128) -> bool {
|
||||
self.owner_uuid
|
||||
.as_deref()
|
||||
.and_then(horizon_client_render::auth::parse_uuid)
|
||||
== Some(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
struct RealmServerList {
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
servers: Vec<RealmServer>,
|
||||
}
|
||||
|
||||
/// `RealmsJoinInformation`: where to connect, and the world's resource pack.
|
||||
#[derive(Clone, Debug, Deserialize, Default, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RealmAddress {
|
||||
#[serde(default)]
|
||||
pub address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub resource_pack_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub resource_pack_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// `PendingInvite`.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PendingInviteDto {
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub invitation_id: String,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub world_name: String,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub world_owner_name: String,
|
||||
#[serde(default)]
|
||||
pub world_owner_uuid: Option<String>,
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
pub date: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Default)]
|
||||
struct PendingInvitesResponse {
|
||||
#[serde(default, deserialize_with = "nullable")]
|
||||
invites: Vec<PendingInviteDto>,
|
||||
}
|
||||
|
||||
/// Why a Realms call failed (`RealmsServiceException` and its `RealmsError`). None of them
|
||||
/// holds a request header: the text a screen shows can't contain the token.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RealmsError {
|
||||
/// `CustomError.connectivityError`: no answer (the transport error, which names the URL).
|
||||
Connectivity(String),
|
||||
/// `AuthenticationError`: a 401, with its `WWW-Authenticate` header.
|
||||
Authentication(String),
|
||||
/// `RetryCallException`: 503 or 277, with the `Retry-After` seconds (5 when missing).
|
||||
Retry(u64),
|
||||
/// `RealmsError.parse`: any other answer, with the body's `errorCode`, `reason` and
|
||||
/// `errorMsg` (or the raw body).
|
||||
Service {
|
||||
status: u16,
|
||||
code: Option<i32>,
|
||||
reason: Option<String>,
|
||||
message: Option<String>,
|
||||
},
|
||||
/// An answer whose JSON doesn't read.
|
||||
Parse,
|
||||
/// `GetServerDetailsTask`'s `TimeoutException`: still busy after 40 tries.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl RealmsError {
|
||||
/// `RealmsError.errorCode`.
|
||||
pub fn code(&self) -> i32 {
|
||||
match self {
|
||||
Self::Connectivity(_) => 500,
|
||||
Self::Authentication(_) => 401,
|
||||
Self::Retry(_) => 503,
|
||||
Self::Service { status, code, .. } => code.unwrap_or(i32::from(*status)),
|
||||
Self::Parse | Self::Timeout => 500,
|
||||
}
|
||||
}
|
||||
/// `RealmsError.errorMessage`, as `RealmsGenericErrorScreen` shows it after the
|
||||
/// `mco.errorMessage.realmsService.realmsError` line.
|
||||
pub fn text(&self) -> String {
|
||||
let no_details = || crate::lang::get("mco.errorMessage.noDetails");
|
||||
let detail = match self {
|
||||
Self::Connectivity(e) => crate::lang::tr(
|
||||
"mco.errorMessage.realmsService.connectivity",
|
||||
&[e as &dyn std::fmt::Display],
|
||||
),
|
||||
Self::Authentication(header) if !header.is_empty() => header.clone(),
|
||||
Self::Authentication(_) => no_details(),
|
||||
Self::Retry(_) => crate::lang::get("mco.errorMessage.retry"),
|
||||
Self::Service { status: 429, .. } => crate::lang::get("mco.errorMessage.serviceBusy"),
|
||||
Self::Service {
|
||||
code,
|
||||
reason,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
let code_key = code.map(|c| format!("mco.errorMessage.{c}"));
|
||||
let reason_key = reason.as_ref().map(|r| format!("mco.errorReason.{r}"));
|
||||
match (code_key, reason_key) {
|
||||
(Some(k), _) if crate::lang::has(&k) => crate::lang::get(&k),
|
||||
(_, Some(k)) if crate::lang::has(&k) => crate::lang::get(&k),
|
||||
_ => message.clone().unwrap_or_else(no_details),
|
||||
}
|
||||
}
|
||||
Self::Parse | Self::Timeout => crate::lang::get("mco.errorMessage.connectionFailure"),
|
||||
};
|
||||
let code = self.code();
|
||||
format!(
|
||||
"{} {detail}",
|
||||
crate::lang::tr(
|
||||
"mco.errorMessage.realmsService.realmsError",
|
||||
&[&code as &dyn std::fmt::Display]
|
||||
)
|
||||
)
|
||||
}
|
||||
/// `RealmsError.parse` of a non-2xx answer.
|
||||
fn from_answer(status: u16, body: &str, html: bool) -> Self {
|
||||
let body = body.trim();
|
||||
let service = |code, reason, message| Self::Service {
|
||||
status,
|
||||
code,
|
||||
reason,
|
||||
message,
|
||||
};
|
||||
if status == 429 || body.is_empty() {
|
||||
return service(None, None, None);
|
||||
}
|
||||
if html {
|
||||
// CustomError.htmlPayload: the text between <body> and </body>.
|
||||
let inner = body
|
||||
.find("<body>")
|
||||
.zip(body.find("</body>"))
|
||||
.filter(|(a, b)| b > a)
|
||||
.map(|(a, b)| body[a + 6..b].trim().to_owned());
|
||||
return service(None, None, inner);
|
||||
}
|
||||
if let Ok(serde_json::Value::Object(o)) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
let text = |k: &str| o.get(k).and_then(|v| v.as_str()).map(str::to_owned);
|
||||
let code = o
|
||||
.get("errorCode")
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.and_then(|c| i32::try_from(c).ok());
|
||||
let (reason, message) = (text("reason"), text("errorMsg"));
|
||||
if code.is_some() || reason.is_some() || message.is_some() {
|
||||
return service(code, reason, message);
|
||||
}
|
||||
}
|
||||
// ErrorWithRawPayload.
|
||||
service(None, None, Some(body.chars().take(256).collect()))
|
||||
}
|
||||
}
|
||||
|
||||
/// The client for one signed-in account.
|
||||
#[derive(Clone)]
|
||||
pub struct RealmsClient {
|
||||
base_url: String,
|
||||
frontend_url: String,
|
||||
player_name: String,
|
||||
player_uuid: u128,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RealmsClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RealmsClient")
|
||||
.field("base_url", &self.base_url)
|
||||
.field("player_name", &self.player_name)
|
||||
.field("access_token", &"<hidden>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RealmsClient {
|
||||
pub fn new(
|
||||
base_url: &str,
|
||||
frontend_url: &str,
|
||||
session: &horizon_client_render::auth::AuthSession,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_owned(),
|
||||
frontend_url: frontend_url.trim_end_matches('/').to_owned(),
|
||||
player_name: session.name.clone(),
|
||||
player_uuid: session.uuid,
|
||||
access_token: session.access_token().to_owned(),
|
||||
}
|
||||
}
|
||||
/// The production service.
|
||||
pub fn production(session: &horizon_client_render::auth::AuthSession) -> Self {
|
||||
Self::new(DEFAULT_REALMS_HOST, DEFAULT_FRONTEND_V2_HOST, session)
|
||||
}
|
||||
|
||||
/// `RealmsClient.execute`'s cookies (`User.getSessionId`, `getName`,
|
||||
/// `WorldVersion.name()`) and `Request.addVersionHeaders`, in that order.
|
||||
pub fn headers(&self) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
(
|
||||
"Cookie",
|
||||
format!(
|
||||
"sid=token:{}:{:032x};user={};version={}",
|
||||
self.access_token,
|
||||
self.player_uuid,
|
||||
self.player_name,
|
||||
version::VANILLA_VERSION
|
||||
),
|
||||
),
|
||||
("Client-Ref", version::VERSION_ID.to_owned()),
|
||||
(
|
||||
"X-NetworkProtocolVersion",
|
||||
version::PROTOCOL_VERSION.to_string(),
|
||||
),
|
||||
("Is-Prerelease", (!version::STABLE).to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn request(&self, method: &str, url: &str, read_timeout: Duration) -> ureq::Request {
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout_connect(CONNECT_TIMEOUT)
|
||||
.timeout_read(read_timeout)
|
||||
.user_agent(&user_agent())
|
||||
// A redirect must not carry the session cookie to another host.
|
||||
.redirects(0)
|
||||
.build();
|
||||
self.headers()
|
||||
.into_iter()
|
||||
.fold(agent.request(method, url), |r, (k, v)| r.set(k, &v))
|
||||
}
|
||||
|
||||
/// `RealmsClient.execute`: the body of a 2xx answer, else the error.
|
||||
fn execute(request: ureq::Request, body: Option<&str>) -> Result<String, RealmsError> {
|
||||
let result = match body {
|
||||
Some(body) => request.send_string(body),
|
||||
None => request.call(),
|
||||
};
|
||||
let response = match result {
|
||||
Ok(response) => response,
|
||||
Err(ureq::Error::Status(_, response)) => response,
|
||||
// The transport error names the URL and the cause, never a header.
|
||||
Err(ureq::Error::Transport(e)) => return Err(RealmsError::Connectivity(e.to_string())),
|
||||
};
|
||||
let status = response.status();
|
||||
if status == 503 || status == 277 {
|
||||
let pause = response
|
||||
.header("Retry-After")
|
||||
.and_then(|s| s.trim().parse().ok())
|
||||
.unwrap_or(5);
|
||||
return Err(RealmsError::Retry(pause));
|
||||
}
|
||||
let auth =
|
||||
(status == 401).then(|| response.header("WWW-Authenticate").unwrap_or("").to_owned());
|
||||
let html = response.content_type().starts_with("text/html");
|
||||
let text = response
|
||||
.into_string()
|
||||
.map_err(|e| RealmsError::Connectivity(e.to_string()))?;
|
||||
if (200..300).contains(&status) {
|
||||
return Ok(text);
|
||||
}
|
||||
if let Some(header) = auth {
|
||||
return Err(RealmsError::Authentication(header));
|
||||
}
|
||||
Err(RealmsError::from_answer(status, &text, html))
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{path}", self.base_url)
|
||||
}
|
||||
|
||||
/// `RealmsClient.listRealms` (a release client: no `listUserWorldsOfType`).
|
||||
pub fn list_worlds(&self) -> Result<Vec<RealmServer>, RealmsError> {
|
||||
let json = Self::execute(self.request("GET", &self.url("worlds"), READ_TIMEOUT), None)?;
|
||||
serde_json::from_str::<RealmServerList>(&json)
|
||||
.map(|l| l.servers)
|
||||
.map_err(|_| RealmsError::Parse)
|
||||
}
|
||||
|
||||
/// `RealmsClient.join`: `worlds/v1/<id>/join/pc`.
|
||||
pub fn join_world(&self, realm_id: i64) -> Result<RealmAddress, RealmsError> {
|
||||
let url = self.url(&format!("worlds/v1/{realm_id}/join/pc"));
|
||||
let json = Self::execute(self.request("GET", &url, JOIN_READ_TIMEOUT), None)?;
|
||||
serde_json::from_str(&json).map_err(|_| RealmsError::Parse)
|
||||
}
|
||||
|
||||
/// `RealmsClient.pendingInvites`.
|
||||
pub fn pending_invites(&self) -> Result<Vec<PendingInviteDto>, RealmsError> {
|
||||
let json = Self::execute(
|
||||
self.request("GET", &self.url("invites/pending"), READ_TIMEOUT),
|
||||
None,
|
||||
)?;
|
||||
serde_json::from_str::<PendingInvitesResponse>(&json)
|
||||
.map(|r| r.invites)
|
||||
.map_err(|_| RealmsError::Parse)
|
||||
}
|
||||
|
||||
/// `RealmsClient.acceptInvitation`.
|
||||
pub fn accept_invite(&self, invitation_id: &str) -> Result<(), RealmsError> {
|
||||
let url = self.url(&format!("invites/accept/{}", path_segment(invitation_id)?));
|
||||
Self::execute(self.request("PUT", &url, READ_TIMEOUT), Some("")).map(drop)
|
||||
}
|
||||
|
||||
/// `RealmsClient.rejectInvitation`.
|
||||
pub fn reject_invite(&self, invitation_id: &str) -> Result<(), RealmsError> {
|
||||
let url = self.url(&format!("invites/reject/{}", path_segment(invitation_id)?));
|
||||
Self::execute(self.request("PUT", &url, READ_TIMEOUT), Some("")).map(drop)
|
||||
}
|
||||
|
||||
/// `RealmsClient.uninviteMyselfFrom`: leaves someone else's Realm.
|
||||
pub fn uninvite_myself(&self, realm_id: i64) -> Result<(), RealmsError> {
|
||||
let url = self.url(&format!("invites/{realm_id}"));
|
||||
Self::execute(self.request("DELETE", &url, READ_TIMEOUT), None).map(drop)
|
||||
}
|
||||
|
||||
/// `RealmsClient.open`.
|
||||
pub fn open_realm(&self, realm_id: i64) -> Result<(), RealmsError> {
|
||||
let url = self.url(&format!("worlds/{realm_id}/open"));
|
||||
Self::execute(self.request("PUT", &url, READ_TIMEOUT), Some("")).map(drop)
|
||||
}
|
||||
|
||||
/// `RealmsClient.close`.
|
||||
pub fn close_realm(&self, realm_id: i64) -> Result<(), RealmsError> {
|
||||
let url = self.url(&format!("worlds/{realm_id}/close"));
|
||||
Self::execute(self.request("PUT", &url, READ_TIMEOUT), Some("")).map(drop)
|
||||
}
|
||||
|
||||
/// `RealmsClient.redeemInviteCode`: the invite-code service, with the token in
|
||||
/// `x-mojangauth` as `frontendV2Request` sets it.
|
||||
pub fn redeem_invite_code(&self, code: &str) -> Result<(), RealmsError> {
|
||||
let code = path_segment(code.trim())?;
|
||||
let url = format!("{}/api/v1.0/invite/codes/use/{code}", self.frontend_url);
|
||||
let request = self
|
||||
.request("POST", &url, READ_TIMEOUT)
|
||||
.set("x-mojangauth", &self.access_token)
|
||||
.set("Accept", "application/json");
|
||||
Self::execute(request, Some("")).map(drop)
|
||||
}
|
||||
}
|
||||
|
||||
/// An id or code that goes into a URL path: vanilla builds those URLs from the server's own
|
||||
/// ids; anything else (a typed code with `/` or `?`) would reach another path.
|
||||
fn path_segment(s: &str) -> Result<&str, RealmsError> {
|
||||
if !s.is_empty()
|
||||
&& s.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(RealmsError::Service {
|
||||
status: 400,
|
||||
code: None,
|
||||
reason: None,
|
||||
message: Some(crate::lang::get("mco.selectServer.joinCode.invalid")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_client_render::auth::AuthSession;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
const TOKEN: &str = "eyJsecret.token.value";
|
||||
const UUID: u128 = 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef;
|
||||
|
||||
/// One HTTP exchange on a local port: the request as the server read it.
|
||||
fn serve_once(answer: &'static str) -> (String, std::thread::JoinHandle<String>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut seen = Vec::new();
|
||||
let mut buf = [0u8; 4096];
|
||||
while !seen.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
let n = stream.read(&mut buf).unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
seen.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
stream.write_all(answer.as_bytes()).unwrap();
|
||||
String::from_utf8_lossy(&seen).into_owned()
|
||||
});
|
||||
(base, handle)
|
||||
}
|
||||
|
||||
fn client(base: &str) -> RealmsClient {
|
||||
RealmsClient::new(base, base, &AuthSession::new("Steve", UUID, TOKEN))
|
||||
}
|
||||
|
||||
fn header<'a>(request: &'a str, name: &str) -> Option<&'a str> {
|
||||
request.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once(':')?;
|
||||
k.eq_ignore_ascii_case(name).then(|| v.trim())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_send_the_session_cookie_only_and_errors_never_hold_the_token() {
|
||||
let (base, server) = serve_once(
|
||||
"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Bearer realm=\"Realms\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
let error = client(&base).list_worlds().unwrap_err();
|
||||
let request = server.join().unwrap();
|
||||
assert!(request.starts_with("GET /worlds HTTP/1.1"), "{request}");
|
||||
// RealmsClient.execute: sid = User.getSessionId (token:<token>:<undashed uuid>), user, version.
|
||||
assert_eq!(
|
||||
header(&request, "Cookie"),
|
||||
Some(
|
||||
"sid=token:eyJsecret.token.value:0123456789abcdef0123456789abcdef;user=Steve;version=26.3"
|
||||
)
|
||||
);
|
||||
assert_eq!(header(&request, "Authorization"), None, "{request}");
|
||||
assert_eq!(header(&request, "Client-Ref"), Some("26.3"));
|
||||
assert_eq!(header(&request, "X-NetworkProtocolVersion"), Some("777"));
|
||||
assert_eq!(header(&request, "Is-Prerelease"), Some("false"));
|
||||
assert_eq!(header(&request, "User-Agent"), Some("Java/25"));
|
||||
// The token appears once in the whole request: in the cookie.
|
||||
assert_eq!(request.matches(TOKEN).count(), 1);
|
||||
assert_eq!(
|
||||
error,
|
||||
RealmsError::Authentication("Bearer realm=\"Realms\"".into())
|
||||
);
|
||||
assert!(!error.text().contains(TOKEN));
|
||||
assert!(!format!("{error:?}").contains(TOKEN));
|
||||
assert!(!format!("{:?}", client(&base)).contains(TOKEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_connection_names_no_header() {
|
||||
// A port nothing listens on: the transport error.
|
||||
let port = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port();
|
||||
let error = client(&format!("http://127.0.0.1:{port}"))
|
||||
.join_world(7)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, RealmsError::Connectivity(_)), "{error:?}");
|
||||
assert!(!error.text().contains(TOKEN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_answers_ask_for_a_retry_after_their_delay() {
|
||||
let (base, server) = serve_once(
|
||||
"HTTP/1.1 503 Service Unavailable\r\nRetry-After: 3\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
assert_eq!(client(&base).join_world(7), Err(RealmsError::Retry(3)));
|
||||
assert!(
|
||||
server
|
||||
.join()
|
||||
.unwrap()
|
||||
.starts_with("GET /worlds/v1/7/join/pc ")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_join_answer_reads_vanillas_field_names() {
|
||||
let (base, server) = serve_once(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"address\":\"1.2.3.4:25565\",\"resourcePackUrl\":\"https://x.test/p.zip\",\"resourcePackHash\":\"abc\",\"sessionRegionData\":null}",
|
||||
);
|
||||
let address = client(&base).join_world(42).unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(
|
||||
address,
|
||||
RealmAddress {
|
||||
address: Some("1.2.3.4:25565".into()),
|
||||
resource_pack_url: Some("https://x.test/p.zip".into()),
|
||||
resource_pack_hash: Some("abc".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invite_code_with_a_path_in_it_is_refused_before_any_request() {
|
||||
// Nothing listens: a request would fail as Connectivity, not as the invalid code.
|
||||
let error = client("http://127.0.0.1:9")
|
||||
.redeem_invite_code("abc/../worlds")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, RealmsError::Service { status: 400, .. }),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_list_reads_camel_case_and_nulls() {
|
||||
let raw = r#"{"servers":[{"id":12345,"remoteSubscriptionId":"sub","name":"Test Realm",
|
||||
"motd":null,"state":"OPEN","owner":"Alex","ownerUUID":"0123456789abcdef0123456789abcdef",
|
||||
"daysLeft":25,"expired":false,"expiredTrial":false,"worldType":"NORMAL","activeSlot":2,
|
||||
"minigameName":null}]}"#;
|
||||
let list: RealmServerList = serde_json::from_str(raw).unwrap();
|
||||
let server = &list.servers[0];
|
||||
assert_eq!(
|
||||
(server.id, server.days_left, server.active_slot),
|
||||
(12345, 25, 2)
|
||||
);
|
||||
assert_eq!(server.motd, "");
|
||||
assert!(server.is_open());
|
||||
assert!(server.owned_by(UUID));
|
||||
assert!(!server.owned_by(UUID + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_bodies_read_as_vanilla_parses_them() {
|
||||
assert_eq!(
|
||||
RealmsError::from_answer(403, r#"{"errorCode":6002,"errorMsg":"tos"}"#, false),
|
||||
RealmsError::Service {
|
||||
status: 403,
|
||||
code: Some(6002),
|
||||
reason: None,
|
||||
message: Some("tos".into())
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
RealmsError::from_answer(500, r#"{"errorCode":6006}"#, false).code(),
|
||||
6006
|
||||
);
|
||||
assert_eq!(
|
||||
RealmsError::from_answer(502, "<html><body> Bad gateway </body></html>", true),
|
||||
RealmsError::Service {
|
||||
status: 502,
|
||||
code: None,
|
||||
reason: None,
|
||||
message: Some("Bad gateway".into())
|
||||
}
|
||||
);
|
||||
assert_eq!(RealmsError::from_answer(404, "", false).code(), 404);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ use std::{
|
||||
pub const VERSION_NAME: &str = horizon_core::VANILLA_VERSION;
|
||||
|
||||
mod gateway;
|
||||
#[cfg(test)]
|
||||
mod loot_tests;
|
||||
|
||||
/// Snapshot live block entities and custom names for a save batch without
|
||||
/// mutating the world. Each map is visited once, retaining its iteration order
|
||||
@@ -526,6 +528,25 @@ impl SaveTables {
|
||||
|
||||
pub use crate::chunk::{ChunkExtras, RolledLoot, SavedProto};
|
||||
|
||||
/// A container was opened: its eager roll is now the container's own inventory.
|
||||
pub fn consume_container_loot(extras: &mut ChunkExtras, pos: (i32, i32, i32)) {
|
||||
let before = extras.rolled_loot.len();
|
||||
extras.rolled_loot.retain(|r| r.pos != pos);
|
||||
if extras.rolled_loot.len() == before {
|
||||
return;
|
||||
}
|
||||
// The source compound participates in the save overlay. Once opened,
|
||||
// even an emptied chest must not inherit its old loot table from there.
|
||||
for (source_pos, source) in &mut extras.block_entity_sources {
|
||||
if *source_pos == pos
|
||||
&& let Tag::Compound(map) = source
|
||||
{
|
||||
map.remove(&"LootTable".into());
|
||||
map.remove(&"LootTableSeed".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_list(ticks: &[SavedTick], name: impl Fn(u32) -> Option<String>) -> Tag {
|
||||
Tag::List(
|
||||
ticks
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (BlockRegistry, Biomes) {
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-opened-loot-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&root).unwrap();
|
||||
let maps = root.join("assets/minecraft/textures/colormap");
|
||||
fs::create_dir_all(&maps).unwrap();
|
||||
// Original one-pixel fixtures: chunk persistence only needs biome names.
|
||||
for name in ["grass", "foliage", "dry_foliage"] {
|
||||
let file = fs::File::create(maps.join(format!("{name}.png"))).unwrap();
|
||||
let mut encoder = png::Encoder::new(file, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&[0, 0, 0])
|
||||
.unwrap();
|
||||
}
|
||||
let folder = root.join("data/minecraft/worldgen/biome");
|
||||
fs::create_dir_all(&folder).unwrap();
|
||||
fs::write(folder.join("plains.json"), "{}").unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![
|
||||
Tag::compound([("id", string("minecraft:air"))]),
|
||||
Tag::compound([("id", string("minecraft:chest"))]),
|
||||
]),
|
||||
),
|
||||
(
|
||||
"defaults",
|
||||
Tag::compound([
|
||||
("minecraft:air", Tag::Int(0)),
|
||||
("minecraft:chest", Tag::Int(1)),
|
||||
]),
|
||||
),
|
||||
]))
|
||||
.unwrap();
|
||||
(registry, biomes)
|
||||
}
|
||||
|
||||
fn reload_loot_case(
|
||||
registry: &BlockRegistry,
|
||||
biomes: &Biomes,
|
||||
table: &str,
|
||||
seed: i64,
|
||||
mode: u8,
|
||||
) -> Chunk {
|
||||
let pos = (1, 64, 2);
|
||||
let mut slots = vec![None; 27];
|
||||
slots[0] = Some(crate::items::ItemStack::new("minecraft:gold_ingot", 2));
|
||||
let source = Tag::compound([
|
||||
("id", string("minecraft:chest")),
|
||||
("x", Tag::Int(pos.0)),
|
||||
("y", Tag::Int(pos.1)),
|
||||
("z", Tag::Int(pos.2)),
|
||||
("LootTable", string(table)),
|
||||
("LootTableSeed", Tag::Long(seed)),
|
||||
("CustomName", string("{\"text\":\"Keep me\"}")),
|
||||
]);
|
||||
let mut extras = ChunkExtras {
|
||||
block_entity_sources: vec![(pos, source)],
|
||||
rolled_loot: vec![RolledLoot {
|
||||
pos,
|
||||
table: table.into(),
|
||||
seed: Some(seed),
|
||||
items: encode_items(&slots),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
// Exactly the production transition used when the player opens the chest.
|
||||
if mode != 0 {
|
||||
consume_container_loot(&mut extras, pos);
|
||||
slots.fill(None);
|
||||
if mode == 2 {
|
||||
slots[0] = Some(crate::items::ItemStack::new("minecraft:gold_ingot", 1));
|
||||
}
|
||||
}
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(pos.0, pos.1, pos.2, 1);
|
||||
chunk
|
||||
.block_entities
|
||||
.push((pos, crate::containers::BlockEntity::Chest(slots)));
|
||||
chunk.saved = Some(Arc::new(extras));
|
||||
let encoded = encode(&chunk, registry, biomes);
|
||||
let bytes = nbt::write_named(&encoded, Limits::default()).unwrap();
|
||||
let (encoded, used) = nbt::read_named(&bytes, Limits::default()).unwrap();
|
||||
assert_eq!(used, bytes.len());
|
||||
decode(&encoded, registry, biomes).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptied_opened_loot_stays_empty_after_chunk_save_and_reload() {
|
||||
let (registry, biomes) = fixture();
|
||||
let loaded = reload_loot_case(®istry, &biomes, "minecraft:chests/ruined_portal", 42, 1);
|
||||
let source = &loaded.saved.as_ref().unwrap().block_entity_sources[0].1;
|
||||
assert!(
|
||||
source.get("LootTable").is_none(),
|
||||
"opened chest keeps a rerollable loot table: {source:?}"
|
||||
);
|
||||
assert!(source.get("LootTableSeed").is_none());
|
||||
assert_eq!(
|
||||
text(source.get("CustomName")).as_deref(),
|
||||
Some("{\"text\":\"Keep me\"}")
|
||||
);
|
||||
let crate::containers::BlockEntity::Chest(slots) = &loaded.block_entities[0].1 else {
|
||||
panic!("chest");
|
||||
};
|
||||
assert!(slots.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consuming_one_table_preserves_unopened_neighbors_and_other_fields() {
|
||||
let pos = (1, 64, 2);
|
||||
let other = (2, 64, 2);
|
||||
let tag = Tag::compound([
|
||||
("LootTable", string("minecraft:chests/ruined_portal")),
|
||||
("LootTableSeed", Tag::Long(42)),
|
||||
("CustomName", string("keep")),
|
||||
]);
|
||||
let rolled = |pos| RolledLoot {
|
||||
pos,
|
||||
table: "minecraft:chests/ruined_portal".into(),
|
||||
seed: Some(42),
|
||||
items: Tag::List(vec![]),
|
||||
};
|
||||
let mut extras = ChunkExtras {
|
||||
block_entity_sources: vec![(pos, tag.clone()), (other, tag.clone())],
|
||||
rolled_loot: vec![rolled(pos), rolled(other)],
|
||||
..Default::default()
|
||||
};
|
||||
consume_container_loot(&mut extras, pos);
|
||||
assert_eq!(extras.rolled_loot, vec![rolled(other)]);
|
||||
assert_eq!(extras.block_entity_sources[1].1, tag);
|
||||
assert_eq!(
|
||||
extras.block_entity_sources[0].1.get("CustomName"),
|
||||
Some(&string("keep"))
|
||||
);
|
||||
let saved = extras.block_entity_sources.clone();
|
||||
consume_container_loot(&mut extras, pos);
|
||||
assert_eq!(extras.block_entity_sources, saved);
|
||||
// No rolled marker means an unopened source is not consumed by accident.
|
||||
extras.rolled_loot.clear();
|
||||
consume_container_loot(&mut extras, other);
|
||||
assert_eq!(extras.block_entity_sources[1].1, tag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by tools/test_opened_loot_parity.py"]
|
||||
fn opened_loot_parity_fixture() {
|
||||
use std::io::Write;
|
||||
let (registry, biomes) = fixture();
|
||||
let input = fs::read_to_string(std::env::var("HORIZON_OPENED_LOOT_INPUT").unwrap()).unwrap();
|
||||
let mut output =
|
||||
fs::File::create(std::env::var("HORIZON_OPENED_LOOT_OUTPUT").unwrap()).unwrap();
|
||||
for line in input.lines() {
|
||||
let fields: Vec<_> = line.split_whitespace().collect();
|
||||
let mode = fields[2].parse().unwrap();
|
||||
let chunk = reload_loot_case(
|
||||
®istry,
|
||||
&biomes,
|
||||
fields[0],
|
||||
fields[1].parse().unwrap(),
|
||||
mode,
|
||||
);
|
||||
let source = &chunk.saved.as_ref().unwrap().block_entity_sources[0].1;
|
||||
let crate::containers::BlockEntity::Chest(slots) = &chunk.block_entities[0].1 else {
|
||||
panic!("chest");
|
||||
};
|
||||
let count = if mode == 0 {
|
||||
"unopened".into()
|
||||
} else {
|
||||
slots
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|s| s.count)
|
||||
.sum::<u32>()
|
||||
.to_string()
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"{} {} {count}",
|
||||
u8::from(source.get("LootTable").is_some()),
|
||||
u8::from(source.get("LootTableSeed").is_some())
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
//! `.mcpr` Replay File Container Specification & I/O
|
||||
//!
|
||||
//! An `.mcpr` file is a ZIP archive containing:
|
||||
//! - `metaData.json`: metadata about the recording session (version, duration, player UUIDs, etc.)
|
||||
//! - `recording.tmcpr`: binary stream of chronological timestamped network packets:
|
||||
//! `[timestamp_ms: u32 big-endian][packet_length: u32 big-endian][packet_data: VarInt(packet_id) + payload]`
|
||||
//! - Optional `markers.json`: timeline markers/chapters.
|
||||
|
||||
use horizon_protocol::buf::{Reader, write_varint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{self, BufReader, BufWriter, Read, Write},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// The most `metaData.json` may inflate to: real files are a few hundred bytes.
|
||||
const MAX_METADATA_BYTES: u64 = 1 << 20;
|
||||
/// The most `recording.tmcpr` may inflate to. The reader keeps the stream in
|
||||
/// memory, so a hostile archive (deflate reaches about 1000:1) must not be able
|
||||
/// to ask for more than this.
|
||||
const MAX_STREAM_BYTES: u64 = 1 << 30;
|
||||
|
||||
/// High-level session metadata stored in `metaData.json`.
|
||||
///
|
||||
/// Only the format fields are required: ReplayMod leaves out `serverName` for
|
||||
/// single player and older format versions lack `protocol`, so those read as
|
||||
/// their defaults rather than rejecting the file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Metadata {
|
||||
#[serde(default)]
|
||||
pub singleplayer: bool,
|
||||
#[serde(default)]
|
||||
pub server_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom_server_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub duration: u32,
|
||||
#[serde(default)]
|
||||
pub date: u64,
|
||||
pub mcversion: String,
|
||||
pub file_format: String,
|
||||
pub file_format_version: u32,
|
||||
/// The network protocol the packet ids belong to; `None` when the file
|
||||
/// doesn't say, which playback has to treat as unknown.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub protocol: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub generator: String,
|
||||
#[serde(default)]
|
||||
pub self_id: i32,
|
||||
#[serde(default)]
|
||||
pub players: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Metadata {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
singleplayer: true,
|
||||
server_name: "Local World".to_owned(),
|
||||
custom_server_name: None,
|
||||
duration: 0,
|
||||
date: 0,
|
||||
mcversion: horizon_core::VANILLA_VERSION.to_owned(),
|
||||
file_format: "MCPR".to_owned(),
|
||||
file_format_version: 14,
|
||||
protocol: Some(horizon_protocol::PROTOCOL_VERSION),
|
||||
generator: "Horizon Studio".to_owned(),
|
||||
self_id: 1,
|
||||
players: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Metadata {
|
||||
/// Construct new metadata with server name and Minecraft version.
|
||||
pub fn new(server_name: String, singleplayer: bool, mcversion: String) -> Self {
|
||||
Self {
|
||||
singleplayer,
|
||||
server_name,
|
||||
mcversion,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw timestamped clientbound network packet from a replay stream.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TimestampedPacket {
|
||||
/// Milliseconds since recording start.
|
||||
pub timestamp_ms: u32,
|
||||
/// Protocol packet ID.
|
||||
pub id: i32,
|
||||
/// Unframed packet payload bytes (excluding the VarInt packet ID).
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TimestampedPacket {
|
||||
pub fn new(timestamp_ms: u32, id: i32, payload: Vec<u8>) -> Self {
|
||||
Self {
|
||||
timestamp_ms,
|
||||
id,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file name beside `dest` that no other writer in this or another process
|
||||
/// picks: `<dest>.<pid>-<nanos>.<suffix>`.
|
||||
fn sibling(dest: &Path, suffix: &str) -> PathBuf {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos());
|
||||
let mut name = dest.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(format!(".{}-{nanos}.{suffix}", std::process::id()));
|
||||
dest.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Writer for generating `.mcpr` replay files.
|
||||
///
|
||||
/// Packets go to a stream file beside the destination
|
||||
/// (`<name>.mcpr.<pid>-<nanos>.tmcpr.tmp`). That file is only removed once
|
||||
/// [`finish`](Self::finish) has put the finished archive in place, so a failed
|
||||
/// finish or a writer dropped without one leaves the packets on disk.
|
||||
pub struct McprWriter {
|
||||
dest_path: PathBuf,
|
||||
temp_tmcpr_path: PathBuf,
|
||||
writer: Option<BufWriter<File>>,
|
||||
metadata: Metadata,
|
||||
packet_count: usize,
|
||||
/// Bytes of whole frames in the stream: what `finish` packages.
|
||||
bytes_written: u64,
|
||||
/// A write failed: the stream may end in a partial frame, and nothing more
|
||||
/// is appended after it.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl McprWriter {
|
||||
/// Create a new replay writer that writes packets to a temporary stream file
|
||||
/// and finalizes into `dest_path` when finished.
|
||||
pub fn new(dest_path: PathBuf, metadata: Metadata) -> io::Result<Self> {
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let temp_tmcpr_path = sibling(&dest_path, "tmcpr.tmp");
|
||||
let file = File::create_new(&temp_tmcpr_path)?;
|
||||
let writer = Some(BufWriter::with_capacity(64 * 1024, file));
|
||||
Ok(Self {
|
||||
dest_path,
|
||||
temp_tmcpr_path,
|
||||
writer,
|
||||
metadata,
|
||||
packet_count: 0,
|
||||
bytes_written: 0,
|
||||
failed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Record a packet at the given elapsed timestamp.
|
||||
///
|
||||
/// After an error the writer refuses further packets; `finish` still
|
||||
/// packages every frame written whole before it.
|
||||
pub fn write_packet(&mut self, timestamp_ms: u32, id: i32, payload: &[u8]) -> io::Result<()> {
|
||||
if self.failed {
|
||||
return Err(io::Error::other("an earlier write failed"));
|
||||
}
|
||||
let writer = self
|
||||
.writer
|
||||
.as_mut()
|
||||
.ok_or_else(|| io::Error::other("writer already closed"))?;
|
||||
let mut frame = Vec::with_capacity(13 + payload.len());
|
||||
frame.extend_from_slice(×tamp_ms.to_be_bytes());
|
||||
frame.extend_from_slice(&[0; 4]);
|
||||
write_varint(&mut frame, id);
|
||||
frame.extend_from_slice(payload);
|
||||
let packet_length = u32::try_from(frame.len() - 8)
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "packet too large"))?;
|
||||
frame[4..8].copy_from_slice(&packet_length.to_be_bytes());
|
||||
|
||||
if let Err(e) = writer.write_all(&frame) {
|
||||
self.failed = true;
|
||||
return Err(e);
|
||||
}
|
||||
self.packet_count += 1;
|
||||
self.bytes_written += frame.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn packet_count(&self) -> usize {
|
||||
self.packet_count
|
||||
}
|
||||
|
||||
pub fn bytes_written(&self) -> u64 {
|
||||
self.bytes_written
|
||||
}
|
||||
|
||||
/// The stream file holding the packets until `finish` succeeds.
|
||||
pub fn stream_path(&self) -> &Path {
|
||||
&self.temp_tmcpr_path
|
||||
}
|
||||
|
||||
/// Complete recording, write `metaData.json`, package into `.mcpr` ZIP,
|
||||
/// and clean up temporary files.
|
||||
///
|
||||
/// The archive is written to a temporary file beside `dest_path`, synced and
|
||||
/// renamed over it, so an existing file at `dest_path` stays whole until the
|
||||
/// new one is complete. On error the stream file is kept and the error names it.
|
||||
pub fn finish(mut self, duration_ms: u32) -> io::Result<PathBuf> {
|
||||
if let Some(mut w) = self.writer.take() {
|
||||
// A failed flush leaves a partial frame at the end at worst: only
|
||||
// `bytes_written` are packaged below.
|
||||
if w.flush().is_err() {
|
||||
self.failed = true;
|
||||
}
|
||||
}
|
||||
self.metadata.duration = duration_ms;
|
||||
|
||||
let part = sibling(&self.dest_path, "mcpr.part");
|
||||
match self.package(&part) {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_file(&self.temp_tmcpr_path);
|
||||
Ok(self.dest_path.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = fs::remove_file(&part);
|
||||
Err(io::Error::new(
|
||||
e.kind(),
|
||||
format!(
|
||||
"could not finish the recording ({e}); its packets are kept in {}",
|
||||
self.temp_tmcpr_path.display()
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn package(&self, part: &Path) -> io::Result<()> {
|
||||
let mut zip = zip::ZipWriter::new(File::create_new(part)?);
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
zip.start_file("metaData.json", options)?;
|
||||
let meta_json = serde_json::to_vec_pretty(&self.metadata)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
zip.write_all(&meta_json)?;
|
||||
|
||||
// Zip64, so a recording past 4 GiB still finishes.
|
||||
zip.start_file("recording.tmcpr", options.large_file(true))?;
|
||||
let mut stream =
|
||||
BufReader::new(File::open(&self.temp_tmcpr_path)?).take(self.bytes_written);
|
||||
if io::copy(&mut stream, &mut zip)? != self.bytes_written {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"the stream file is shorter than what was written",
|
||||
));
|
||||
}
|
||||
|
||||
let file = zip.finish().map_err(io::Error::other)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(part, &self.dest_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McprWriter {
|
||||
fn drop(&mut self) {
|
||||
// An empty stream holds nothing worth keeping; one with packets stays
|
||||
// for a later recovery (see the type's doc).
|
||||
if self.packet_count == 0 && self.temp_tmcpr_path.exists() {
|
||||
self.writer = None;
|
||||
let _ = fs::remove_file(&self.temp_tmcpr_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame at `offset`: `(timestamp_ms, id, payload range, next offset)`, or `None` when it
|
||||
/// isn't whole (a short header, a length past the end, or an id that doesn't
|
||||
/// fit in its length). The index and `next_packet` both read frames through
|
||||
/// this, so they agree on where a damaged stream ends.
|
||||
fn frame_at(data: &[u8], offset: usize) -> Option<(u32, i32, std::ops::Range<usize>, usize)> {
|
||||
let header = data.get(offset..offset.checked_add(8)?)?;
|
||||
let timestamp_ms = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);
|
||||
let length = u32::from_be_bytes([header[4], header[5], header[6], header[7]]) as usize;
|
||||
let start = offset + 8;
|
||||
let end = start.checked_add(length)?;
|
||||
let body = data.get(start..end)?;
|
||||
let mut reader = Reader::new(body);
|
||||
let id = reader.varint().ok()?;
|
||||
let payload_start = end - reader.rest().len();
|
||||
Some((timestamp_ms, id, payload_start..end, end))
|
||||
}
|
||||
|
||||
/// Reader for opening and streaming `.mcpr` replay files.
|
||||
pub struct McprReader {
|
||||
pub metadata: Metadata,
|
||||
tmcpr_data: Vec<u8>,
|
||||
/// Where `next_packet` reads next.
|
||||
position: usize,
|
||||
/// The end of the last whole frame: the stream stops here.
|
||||
end: usize,
|
||||
/// `(timestamp_ms, byte_offset)` of each whole frame. Timestamps never go
|
||||
/// backwards here: one earlier than the frame before it is indexed at that
|
||||
/// frame's time, so seeking stays defined on a damaged file.
|
||||
packet_index: Vec<(u32, usize)>,
|
||||
}
|
||||
|
||||
fn read_entry(archive: &mut zip::ZipArchive<File>, name: &str, max: u64) -> io::Result<Vec<u8>> {
|
||||
let entry = archive
|
||||
.by_name(name)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::NotFound, e))?;
|
||||
let mut bytes = Vec::new();
|
||||
entry.take(max + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("{name} is larger than {max} bytes"),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
impl McprReader {
|
||||
/// Open an `.mcpr` replay archive from disk.
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
let file = File::open(path)?;
|
||||
let mut archive = zip::ZipArchive::new(file)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
let meta_bytes = read_entry(&mut archive, "metaData.json", MAX_METADATA_BYTES)?;
|
||||
let metadata: Metadata = serde_json::from_slice(&meta_bytes)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
if metadata.file_format != "MCPR" {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"not an MCPR recording (fileFormat {:?})",
|
||||
metadata.file_format
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let tmcpr_data = read_entry(&mut archive, "recording.tmcpr", MAX_STREAM_BYTES)?;
|
||||
|
||||
// A recording that stopped mid-write ends in a frame that isn't whole:
|
||||
// indexing and playback both stop before it.
|
||||
let mut packet_index = Vec::new();
|
||||
let mut offset = 0;
|
||||
let mut latest = 0;
|
||||
while let Some((timestamp_ms, _, _, next)) = frame_at(&tmcpr_data, offset) {
|
||||
latest = latest.max(timestamp_ms);
|
||||
packet_index.push((latest, offset));
|
||||
offset = next;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
metadata,
|
||||
tmcpr_data,
|
||||
position: 0,
|
||||
end: offset,
|
||||
packet_index,
|
||||
})
|
||||
}
|
||||
|
||||
/// Total number of packets indexed in the recording.
|
||||
pub fn total_packets(&self) -> usize {
|
||||
self.packet_index.len()
|
||||
}
|
||||
|
||||
/// Read the next timestamped packet from the current position, or `None`
|
||||
/// at the end of the last whole frame.
|
||||
pub fn next_packet(&mut self) -> io::Result<Option<TimestampedPacket>> {
|
||||
if self.position >= self.end {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((timestamp_ms, id, payload, next)) = frame_at(&self.tmcpr_data, self.position)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.position = next;
|
||||
Ok(Some(TimestampedPacket {
|
||||
timestamp_ms,
|
||||
id,
|
||||
payload: self.tmcpr_data[payload].to_vec(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Seek to the first packet of the latest timestamp at or before
|
||||
/// `target_timestamp_ms` (the first packet when the target is earlier than
|
||||
/// all of them), and return that timestamp.
|
||||
///
|
||||
/// Starting at the first packet of a millisecond keeps the packets that
|
||||
/// arrived together, such as a chunk and the entities in it, together.
|
||||
pub fn seek_to_timestamp(&mut self, target_timestamp_ms: u32) -> io::Result<Option<u32>> {
|
||||
let after = self
|
||||
.packet_index
|
||||
.partition_point(|&(ts, _)| ts <= target_timestamp_ms);
|
||||
let chosen = match after.checked_sub(1) {
|
||||
Some(last) => self.packet_index[last].0,
|
||||
None => match self.packet_index.first() {
|
||||
Some(&(ts, _)) => ts,
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
let idx = self.packet_index.partition_point(|&(ts, _)| ts < chosen);
|
||||
self.position = self.packet_index[idx].1;
|
||||
Ok(Some(chosen))
|
||||
}
|
||||
|
||||
/// Reset stream reading to the beginning.
|
||||
pub fn rewind(&mut self) -> io::Result<()> {
|
||||
self.position = 0;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mcpr_roundtrip_metadata_and_packets() {
|
||||
let temp_dir = std::env::temp_dir().join(format!("mcpr_test_{}", std::process::id()));
|
||||
let _ = fs::create_dir_all(&temp_dir);
|
||||
let mcpr_path = temp_dir.join("test_recording.mcpr");
|
||||
|
||||
let meta = Metadata {
|
||||
server_name: "Studio Test Server".to_owned(),
|
||||
players: vec!["Player1".to_owned(), "Player2".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut writer = McprWriter::new(mcpr_path.clone(), meta).unwrap();
|
||||
writer.write_packet(0, 0x01, &[10, 20, 30]).unwrap();
|
||||
writer.write_packet(50, 0x24, &[40, 50]).unwrap();
|
||||
writer
|
||||
.write_packet(100, 0x1A, &[99, 100, 101, 102])
|
||||
.unwrap();
|
||||
writer.write_packet(500, 0x05, &[]).unwrap();
|
||||
|
||||
assert_eq!(writer.packet_count(), 4);
|
||||
let finished_path = writer.finish(550).unwrap();
|
||||
assert_eq!(finished_path, mcpr_path);
|
||||
|
||||
let mut reader = McprReader::open(&mcpr_path).unwrap();
|
||||
assert_eq!(reader.metadata.server_name, "Studio Test Server");
|
||||
assert_eq!(reader.metadata.duration, 550);
|
||||
assert_eq!(reader.metadata.players, vec!["Player1", "Player2"]);
|
||||
assert_eq!(reader.total_packets(), 4);
|
||||
|
||||
let p0 = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!(p0.timestamp_ms, 0);
|
||||
assert_eq!(p0.id, 0x01);
|
||||
assert_eq!(p0.payload, vec![10, 20, 30]);
|
||||
let p1 = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!(p1.timestamp_ms, 50);
|
||||
assert_eq!(p1.id, 0x24);
|
||||
assert_eq!(p1.payload, vec![40, 50]);
|
||||
let p2 = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!(p2.timestamp_ms, 100);
|
||||
assert_eq!(p2.id, 0x1A);
|
||||
assert_eq!(p2.payload, vec![99, 100, 101, 102]);
|
||||
let p3 = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!(p3.timestamp_ms, 500);
|
||||
assert_eq!(p3.id, 0x05);
|
||||
assert!(p3.payload.is_empty());
|
||||
assert!(reader.next_packet().unwrap().is_none());
|
||||
|
||||
let found_ts = reader.seek_to_timestamp(75).unwrap().unwrap();
|
||||
assert_eq!(found_ts, 50);
|
||||
let seeked = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!(seeked.timestamp_ms, 50);
|
||||
assert_eq!(seeked.id, 0x24);
|
||||
|
||||
let _ = fs::remove_file(mcpr_path);
|
||||
let _ = fs::remove_dir(temp_dir);
|
||||
}
|
||||
|
||||
/// A scratch folder for one test, removed when it goes out of scope.
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("mcpr_{name}_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn archive(path: &Path, meta: &[u8], tmcpr: &[u8]) {
|
||||
let mut zip = zip::ZipWriter::new(File::create(path).unwrap());
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
zip.start_file("metaData.json", options).unwrap();
|
||||
zip.write_all(meta).unwrap();
|
||||
zip.start_file("recording.tmcpr", options).unwrap();
|
||||
zip.write_all(tmcpr).unwrap();
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
fn frame(out: &mut Vec<u8>, ts: u32, length: u32, body: &[u8]) {
|
||||
out.extend_from_slice(&ts.to_be_bytes());
|
||||
out.extend_from_slice(&length.to_be_bytes());
|
||||
out.extend_from_slice(body);
|
||||
}
|
||||
|
||||
fn open_stream(scratch: &Scratch, tmcpr: &[u8]) -> McprReader {
|
||||
let path = scratch.0.join("r.mcpr");
|
||||
archive(
|
||||
&path,
|
||||
&serde_json::to_vec(&Metadata::default()).unwrap(),
|
||||
tmcpr,
|
||||
);
|
||||
McprReader::open(&path).unwrap()
|
||||
}
|
||||
|
||||
/// A recording whose last packet was cut short indexes and plays only the
|
||||
/// whole ones, and playback ends cleanly where they end.
|
||||
#[test]
|
||||
fn a_truncated_last_packet_is_not_indexed() {
|
||||
let scratch = Scratch::new("cut");
|
||||
let mut tmcpr = Vec::new();
|
||||
frame(&mut tmcpr, 7, 3, &[0x01, 10, 20]);
|
||||
frame(&mut tmcpr, 9, 100, &[0x02, 1, 2]);
|
||||
let mut reader = open_stream(&scratch, &tmcpr);
|
||||
assert_eq!(reader.total_packets(), 1);
|
||||
assert_eq!(reader.seek_to_timestamp(50).unwrap(), Some(7));
|
||||
let first = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!((first.id, first.payload), (0x01, vec![10, 20]));
|
||||
assert_eq!(reader.next_packet().unwrap(), None);
|
||||
}
|
||||
|
||||
/// A length field far past the end allocates nothing and ends the stream.
|
||||
#[test]
|
||||
fn a_huge_length_field_ends_the_stream() {
|
||||
let scratch = Scratch::new("huge");
|
||||
let mut tmcpr = Vec::new();
|
||||
frame(&mut tmcpr, 0, u32::MAX, &[0x01, 2, 3, 4]);
|
||||
let mut reader = open_stream(&scratch, &tmcpr);
|
||||
assert_eq!(reader.total_packets(), 0);
|
||||
assert_eq!(reader.next_packet().unwrap(), None);
|
||||
assert_eq!(reader.seek_to_timestamp(0).unwrap(), None);
|
||||
}
|
||||
|
||||
/// A zero-length frame has no packet id: it is neither counted nor played,
|
||||
/// and the stream ends before it.
|
||||
#[test]
|
||||
fn a_zero_length_frame_ends_the_stream() {
|
||||
let scratch = Scratch::new("zero");
|
||||
let mut tmcpr = Vec::new();
|
||||
frame(&mut tmcpr, 1, 1, &[0x03]);
|
||||
frame(&mut tmcpr, 2, 0, &[]);
|
||||
frame(&mut tmcpr, 3, 1, &[0x04]);
|
||||
let mut reader = open_stream(&scratch, &tmcpr);
|
||||
assert_eq!(reader.total_packets(), 1);
|
||||
assert_eq!(reader.next_packet().unwrap().unwrap().id, 0x03);
|
||||
assert_eq!(reader.next_packet().unwrap(), None);
|
||||
}
|
||||
|
||||
/// Seeking lands on the first packet of a millisecond, never in the middle
|
||||
/// of packets that share it.
|
||||
#[test]
|
||||
fn seeking_starts_at_the_first_packet_of_a_millisecond() {
|
||||
let scratch = Scratch::new("seek");
|
||||
let mut tmcpr = Vec::new();
|
||||
frame(&mut tmcpr, 50, 1, &[0x00]);
|
||||
for id in 1..=5 {
|
||||
frame(&mut tmcpr, 100, 1, &[id]);
|
||||
}
|
||||
frame(&mut tmcpr, 200, 1, &[0x06]);
|
||||
let mut reader = open_stream(&scratch, &tmcpr);
|
||||
for (target, ts, id) in [(100, 100, 1), (150, 100, 1), (10, 50, 0), (999, 200, 6)] {
|
||||
assert_eq!(
|
||||
reader.seek_to_timestamp(target).unwrap(),
|
||||
Some(ts),
|
||||
"{target}"
|
||||
);
|
||||
assert_eq!(reader.next_packet().unwrap().unwrap().id, id, "{target}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A timestamp that goes backwards is played as stored but indexed at the
|
||||
/// time before it, so seeking still finds the right place.
|
||||
#[test]
|
||||
fn a_backwards_timestamp_does_not_break_seeking() {
|
||||
let scratch = Scratch::new("backwards");
|
||||
let mut tmcpr = Vec::new();
|
||||
frame(&mut tmcpr, 100, 1, &[0x01]);
|
||||
frame(&mut tmcpr, 20, 1, &[0x02]);
|
||||
frame(&mut tmcpr, 300, 1, &[0x03]);
|
||||
let mut reader = open_stream(&scratch, &tmcpr);
|
||||
assert_eq!(reader.seek_to_timestamp(250).unwrap(), Some(100));
|
||||
let first = reader.next_packet().unwrap().unwrap();
|
||||
assert_eq!((first.timestamp_ms, first.id), (100, 0x01));
|
||||
assert_eq!(reader.next_packet().unwrap().unwrap().timestamp_ms, 20);
|
||||
}
|
||||
|
||||
/// Metadata written by other tools may leave out the fields a recording
|
||||
/// can do without; a file that isn't MCPR or has oversized metadata is refused.
|
||||
#[test]
|
||||
fn metadata_reads_minimal_files_and_refuses_bad_ones() {
|
||||
let scratch = Scratch::new("meta");
|
||||
let path = scratch.0.join("m.mcpr");
|
||||
let minimal = br#"{"mcversion":"1.21.4","fileFormat":"MCPR","fileFormatVersion":14,"customServerName":"My World"}"#;
|
||||
archive(&path, minimal, &[]);
|
||||
let reader = McprReader::open(&path).unwrap();
|
||||
assert_eq!(reader.metadata.protocol, None);
|
||||
assert_eq!(reader.metadata.server_name, "");
|
||||
assert_eq!(
|
||||
reader.metadata.custom_server_name.as_deref(),
|
||||
Some("My World")
|
||||
);
|
||||
|
||||
archive(
|
||||
&path,
|
||||
br#"{"mcversion":"1.21.4","fileFormat":"OTHER","fileFormatVersion":1}"#,
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
McprReader::open(&path).err().unwrap().kind(),
|
||||
io::ErrorKind::InvalidData
|
||||
);
|
||||
|
||||
let mut big =
|
||||
br#"{"mcversion":"1.21.4","fileFormat":"MCPR","fileFormatVersion":14,"generator":""#
|
||||
.to_vec();
|
||||
big.resize(big.len() + MAX_METADATA_BYTES as usize, b'a');
|
||||
big.extend_from_slice(br#""}"#);
|
||||
archive(&path, &big, &[]);
|
||||
assert_eq!(
|
||||
McprReader::open(&path).err().unwrap().kind(),
|
||||
io::ErrorKind::InvalidData
|
||||
);
|
||||
}
|
||||
|
||||
/// A finish that can't put the archive in place keeps the packets and says
|
||||
/// where they are; a writer dropped without finishing keeps them too.
|
||||
#[test]
|
||||
fn a_failed_finish_keeps_the_recording() {
|
||||
let scratch = Scratch::new("finish");
|
||||
// A non-empty folder at the destination: the final rename fails.
|
||||
let dest = scratch.0.join("blocked.mcpr");
|
||||
fs::create_dir_all(dest.join("inside")).unwrap();
|
||||
let mut writer = McprWriter::new(dest.clone(), Metadata::default()).unwrap();
|
||||
writer.write_packet(5, 0x07, &[1, 2, 3]).unwrap();
|
||||
let stream = writer.stream_path().to_owned();
|
||||
let err = writer.finish(10).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains(&stream.display().to_string()),
|
||||
"{err}"
|
||||
);
|
||||
assert!(stream.exists());
|
||||
let leftovers: Vec<_> = fs::read_dir(&scratch.0)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.filter(|n| n.ends_with(".part"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "{leftovers:?}");
|
||||
|
||||
let mut kept = McprWriter::new(scratch.0.join("kept.mcpr"), Metadata::default()).unwrap();
|
||||
kept.write_packet(0, 0x01, &[]).unwrap();
|
||||
let kept_stream = kept.stream_path().to_owned();
|
||||
drop(kept);
|
||||
assert!(kept_stream.exists(), "a dropped writer keeps its packets");
|
||||
|
||||
let dropped = McprWriter::new(scratch.0.join("dropped.mcpr"), Metadata::default()).unwrap();
|
||||
let empty_stream = dropped.stream_path().to_owned();
|
||||
drop(dropped);
|
||||
assert!(!empty_stream.exists(), "an empty stream is not kept");
|
||||
}
|
||||
|
||||
/// Finishing over an existing recording replaces it only with a complete one.
|
||||
#[test]
|
||||
fn finishing_replaces_an_existing_recording() {
|
||||
let scratch = Scratch::new("replace");
|
||||
let dest = scratch.0.join("same.mcpr");
|
||||
for id in [0x01, 0x02] {
|
||||
let mut writer = McprWriter::new(dest.clone(), Metadata::default()).unwrap();
|
||||
writer.write_packet(0, id, &[]).unwrap();
|
||||
writer.finish(1).unwrap();
|
||||
}
|
||||
let mut reader = McprReader::open(&dest).unwrap();
|
||||
assert_eq!(reader.next_packet().unwrap().unwrap().id, 0x02);
|
||||
assert_eq!(fs::read_dir(&scratch.0).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Horizon In-Game Video Studio.
|
||||
|
||||
pub mod mcpr;
|
||||
|
||||
pub use mcpr::{McprReader, McprWriter, Metadata, TimestampedPacket};
|
||||
@@ -332,6 +332,7 @@ pub enum Mode {
|
||||
alert: Option<Alert>,
|
||||
},
|
||||
Multiplayer(crate::multiplayer::Screen),
|
||||
Realms(crate::realms::Screen),
|
||||
/// SafetyScreen: the warning before the first Multiplayer click, with its
|
||||
/// "Do not show this screen again" box.
|
||||
MultiplayerWarning {
|
||||
@@ -441,6 +442,8 @@ pub struct Title {
|
||||
erase_check: Cell<[f32; 4]>,
|
||||
/// The folder to open next, once that backup has finished.
|
||||
open_request: Option<String>,
|
||||
/// A Realm's checked address, once the Realms screen's join came back.
|
||||
connect_request: Option<String>,
|
||||
/// EditWorldScreen's and WorldOpenFlows' `SystemToast` and its slide.
|
||||
toast: Option<(crate::toasts::Slide, WorldToast)>,
|
||||
icons: RefCell<std::collections::HashMap<PathBuf, (std::time::SystemTime, Option<Vec<u8>>)>>,
|
||||
@@ -709,6 +712,7 @@ impl Mode {
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "title",
|
||||
Self::Realms(_) => "realms",
|
||||
Self::Select { .. } => "world_select",
|
||||
Self::Create { .. } => "world_create",
|
||||
Self::CreateFlat { .. } => "world_create_flat",
|
||||
@@ -782,6 +786,7 @@ impl Title {
|
||||
optimize: None,
|
||||
erase_check: Cell::new([0.0; 4]),
|
||||
open_request: None,
|
||||
connect_request: None,
|
||||
toast: None,
|
||||
icons: RefCell::new(std::collections::HashMap::new()),
|
||||
splash: choose_splash(&splashes, seed, (today.month(), today.day())),
|
||||
@@ -1206,6 +1211,7 @@ impl Title {
|
||||
}
|
||||
/// Polling is done once per rendered frame so filesystem work never blocks drawing.
|
||||
pub fn poll_backup(&mut self) {
|
||||
self.poll_realms();
|
||||
self.poll_onboarding(std::time::Instant::now());
|
||||
self.poll_account();
|
||||
self.poll_optimize();
|
||||
@@ -1281,6 +1287,23 @@ impl Title {
|
||||
self.toast.is_some()
|
||||
}
|
||||
/// The world a finished backup made before its upgrade opens next.
|
||||
/// The Realms screen's workers: a join that came back connects (`GetServerDetailsTask`
|
||||
/// handing over to `ConnectScreen`).
|
||||
fn poll_realms(&mut self) {
|
||||
if let Mode::Realms(screen) = &mut self.mode
|
||||
&& let crate::realms::Outcome::Connect(address) = screen.poll()
|
||||
{
|
||||
self.connect_request = Some(address);
|
||||
}
|
||||
}
|
||||
/// The server the Realms screen joins once its address came back.
|
||||
pub fn take_connect_request(&mut self) -> Option<Action> {
|
||||
let address = self.connect_request.take()?;
|
||||
Some(Action::Connect {
|
||||
address,
|
||||
lan: false,
|
||||
})
|
||||
}
|
||||
pub fn take_open_request(&mut self) -> Option<String> {
|
||||
self.open_request.take()
|
||||
}
|
||||
@@ -1740,8 +1763,10 @@ impl Title {
|
||||
y + 48.0,
|
||||
200.0,
|
||||
&lang::get_or("menu.online", "Minecraft Realms"),
|
||||
false,
|
||||
0,
|
||||
// TitleScreen.createNormalMenuOptions: Realms is off when
|
||||
// multiplayer is.
|
||||
self.allows_multiplayer,
|
||||
8,
|
||||
),
|
||||
b(
|
||||
cx - 100.0,
|
||||
@@ -2169,6 +2194,15 @@ impl Title {
|
||||
.into_iter()
|
||||
.map(|(x, y, bw, label, enabled, id)| b(x, y, bw, &label, enabled, id))
|
||||
.collect(),
|
||||
Mode::Realms(screen) => screen
|
||||
.buttons(w, h)
|
||||
.into_iter()
|
||||
.map(|(x, y, bw, label, enabled, id, icon)| {
|
||||
let mut btn = b(x, y, bw, &label, enabled, id);
|
||||
btn.icon = icon;
|
||||
btn
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// The screen this mode is, as the narrator reads it: its id and
|
||||
@@ -2358,6 +2392,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
),
|
||||
),
|
||||
),
|
||||
Mode::Realms(_) => ScreenInfo::new("realms", lang::get("menu.online")),
|
||||
Mode::Multiplayer(_) => return None,
|
||||
};
|
||||
Some(info)
|
||||
@@ -3122,6 +3157,15 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
cursor,
|
||||
hide_server_address,
|
||||
),
|
||||
Mode::Realms(screen) => screen.draw(
|
||||
batch,
|
||||
atlas,
|
||||
&mut self.caret.borrow_mut(),
|
||||
self.started,
|
||||
cursor,
|
||||
w,
|
||||
h,
|
||||
),
|
||||
}
|
||||
for b in self.buttons(w, h) {
|
||||
crate::widgets::record(&b.label, [b.x, b.y, b.w, 20.0], b.enabled);
|
||||
@@ -3451,6 +3495,22 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::list(&self.saves));
|
||||
Action::None
|
||||
}
|
||||
(Mode::Main, Some(8)) => {
|
||||
self.mode = Mode::Realms(crate::realms::Screen::new());
|
||||
Action::None
|
||||
}
|
||||
(Mode::Realms(screen), hit) => match screen.click(hit, x, y, (w, h)) {
|
||||
crate::realms::Outcome::Back => {
|
||||
self.mode = Mode::Main;
|
||||
Action::None
|
||||
}
|
||||
crate::realms::Outcome::Connect(address) => Action::Connect {
|
||||
address,
|
||||
lan: false,
|
||||
},
|
||||
crate::realms::Outcome::OpenUri(uri) => Action::OpenUri(uri),
|
||||
crate::realms::Outcome::None => Action::None,
|
||||
},
|
||||
(Mode::MultiplayerWarning { .. }, Some(71)) => {
|
||||
self.mode = Mode::Main;
|
||||
Action::None
|
||||
@@ -3808,6 +3868,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
}
|
||||
match &mut self.mode {
|
||||
Mode::Multiplayer(screen) => screen.focused_text(),
|
||||
Mode::Realms(screen) => screen.focused_text(),
|
||||
mode => caret_field(mode, self.search_focused, &mut self.search),
|
||||
}
|
||||
}
|
||||
@@ -3877,6 +3938,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
.filter(|(id, _)| id == focus)
|
||||
.map(|(_, rect)| rect),
|
||||
Mode::Multiplayer(screen) => screen.focused_box((w, h)),
|
||||
Mode::Realms(screen) => screen.focused_box((w, h)),
|
||||
Mode::CreateBuffet { buffet, .. } if buffet.query_focused => {
|
||||
Some(buffet::search_box(w))
|
||||
}
|
||||
@@ -4018,6 +4080,10 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
}
|
||||
/// Tab navigation through widgets and fields in either forward or reverse order.
|
||||
pub fn tab_dir(&mut self, reverse: bool, size: (f32, f32)) {
|
||||
if let Mode::Realms(screen) = &mut self.mode {
|
||||
screen.tab();
|
||||
return;
|
||||
}
|
||||
if self.rules.get_mut().is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -4519,6 +4585,11 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Mode::Realms(screen) = &mut self.mode
|
||||
&& !screen.escape()
|
||||
{
|
||||
return;
|
||||
}
|
||||
match &self.mode {
|
||||
// OptimizeWorldScreen: `shouldCloseOnEsc` is false; the Backup wait takes no input.
|
||||
Mode::Backup { .. } | Mode::Optimize { .. } => {}
|
||||
@@ -4615,6 +4686,7 @@ fn caret_field<'a>(
|
||||
_ => None,
|
||||
},
|
||||
Mode::Multiplayer(screen) => screen.focused_text(),
|
||||
Mode::Realms(screen) => screen.focused_text(),
|
||||
Mode::CreateFlat { flat, .. } if flat.page == flat::Page::Presets && flat.share_focused => {
|
||||
Some((FLAT_SHARE, &mut flat.share, 1230))
|
||||
}
|
||||
|
||||
@@ -627,9 +627,11 @@ fn classic_navigation_buttons_sound_even_without_a_game_action() {
|
||||
f.title.click(0.0, 0.0, (480.0, 262.0)),
|
||||
(Action::None, false)
|
||||
);
|
||||
assert_eq!(f.click_with_sound("Minecraft Realms"), (Action::None, true));
|
||||
// The Realms screen's Back, as the loaded language names it.
|
||||
assert_eq!(
|
||||
f.click_with_sound("Minecraft Realms"),
|
||||
(Action::None, false)
|
||||
f.click_with_sound(&crate::lang::get("gui.back")),
|
||||
(Action::None, true)
|
||||
);
|
||||
assert_eq!(f.click_with_sound("Singleplayer"), (Action::None, true));
|
||||
assert_eq!(
|
||||
|
||||
@@ -334,9 +334,19 @@ pub struct State<'g, V> {
|
||||
controls: Vec<bool>,
|
||||
errors: Vec<(Sugg, Reason)>,
|
||||
last_cursor: isize,
|
||||
/// Current rule-recursion depth, bounded by `MAX_DEPTH` so a crafted
|
||||
/// deeply nested value (SNBT compound/list or a JSON text component's
|
||||
/// `extra` chain) fails to parse instead of overflowing the stack.
|
||||
depth: usize,
|
||||
/// Context for the rules (e.g. the registries a lookup checks).
|
||||
pub ctx: Option<&'g dyn std::any::Any>,
|
||||
}
|
||||
/// The deepest rule nesting the parser accepts. A level of SNBT nesting costs
|
||||
/// about four rules: 256 takes 63 nested compounds, 36 levels of a text
|
||||
/// component's `extra` list, or 13 containers in an item in a container. It
|
||||
/// stays under the stack overflow of a 2 MiB thread (the server thread's,
|
||||
/// Rust's default) in a debug build, where frames are largest: 384 overflows.
|
||||
const MAX_DEPTH: usize = 256;
|
||||
const UNBOUND: usize = usize::MAX;
|
||||
impl<'g, V: Value> State<'g, V> {
|
||||
pub fn new(grammar: &'g Grammar<V>, input: &'g str, cursor: usize) -> Self {
|
||||
@@ -349,6 +359,7 @@ impl<'g, V: Value> State<'g, V> {
|
||||
controls: Vec::new(),
|
||||
errors: Vec::new(),
|
||||
last_cursor: -1,
|
||||
depth: 0,
|
||||
ctx: None,
|
||||
}
|
||||
}
|
||||
@@ -417,6 +428,10 @@ impl<'g, V: Value> State<'g, V> {
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.depth >= MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
self.depth += 1;
|
||||
let result = match self.grammar.rules[rule].as_ref() {
|
||||
Some(Rule::Term(term, action)) => {
|
||||
let action = action.clone();
|
||||
@@ -432,6 +447,7 @@ impl<'g, V: Value> State<'g, V> {
|
||||
}
|
||||
None => panic!("Unbound rule {}", self.grammar.names[rule]),
|
||||
};
|
||||
self.depth -= 1;
|
||||
let entry = result.clone().map(|v| (v, self.cursor));
|
||||
self.cache.insert((mark, rule), entry);
|
||||
result
|
||||
|
||||
@@ -979,3 +979,53 @@ pub fn rule_in<V: super::packrat::Value>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_fully;
|
||||
|
||||
/// A crafted over-deep value must be rejected, never overflow the stack.
|
||||
#[test]
|
||||
fn deeply_nested_compound_is_rejected_not_overflowed() {
|
||||
let mut deep = String::from("1");
|
||||
for _ in 0..400 {
|
||||
deep = format!("{{a:{deep}}}");
|
||||
}
|
||||
assert!(parse_fully(&deep).is_err());
|
||||
}
|
||||
|
||||
/// The `extra` chain of a JSON text component is the same SNBT shape.
|
||||
#[test]
|
||||
fn deeply_nested_list_is_rejected_not_overflowed() {
|
||||
let mut deep = String::from("\"x\"");
|
||||
for _ in 0..400 {
|
||||
deep = format!("[{deep}]");
|
||||
}
|
||||
assert!(parse_fully(&deep).is_err());
|
||||
}
|
||||
|
||||
/// Shallow, real command values must still parse (the cap is not too low).
|
||||
#[test]
|
||||
fn shallow_values_still_parse() {
|
||||
assert!(parse_fully("{a:{b:{c:1}}}").is_ok());
|
||||
assert!(parse_fully("[[1,2],[3,4]]").is_ok());
|
||||
}
|
||||
|
||||
/// Deep but real values parse: 50 nested compounds, and an item whose
|
||||
/// `container` holds an item with a `container`, ten levels down.
|
||||
#[test]
|
||||
fn deep_real_values_still_parse() {
|
||||
let mut compound = String::from("1");
|
||||
for _ in 0..50 {
|
||||
compound = format!("{{a:{compound}}}");
|
||||
}
|
||||
assert!(parse_fully(&compound).is_ok());
|
||||
let mut item = String::from("{id:\"minecraft:stone\",count:1}");
|
||||
for _ in 0..10 {
|
||||
item = format!(
|
||||
"{{id:\"minecraft:shulker_box\",count:1,components:{{\"minecraft:container\":[{{slot:0,item:{item}}}]}}}}"
|
||||
);
|
||||
}
|
||||
assert!(parse_fully(&item).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -17,20 +17,23 @@ use std::{
|
||||
const REPO: &str = "horizon-rust/horizon";
|
||||
/// The release archives for this computer, most specific first: on Linux the server build (no ALSA or
|
||||
/// udev), else the full game (releases before the server build).
|
||||
const ASSETS: &[&str] = if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
|
||||
&[
|
||||
"horizon-server-linux-x86_64.tar.gz",
|
||||
"horizon-linux-x86_64.tar.gz",
|
||||
]
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
|
||||
&["horizon-macos-arm64.zip"]
|
||||
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
|
||||
&["horizon-macos-x64.zip"]
|
||||
} else if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
|
||||
&["horizon-windows-x64.zip"]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
fn assets_for(os: &str, arch: &str) -> &'static [&'static str] {
|
||||
match (os, arch) {
|
||||
("linux", "x86_64") => &[
|
||||
"horizon-server-linux-x86_64.tar.gz",
|
||||
"horizon-linux-x86_64.tar.gz",
|
||||
],
|
||||
("macos", "aarch64") => &["horizon-macos-arm64.zip"],
|
||||
("macos", "x86_64") => &["horizon-macos-x64.zip"],
|
||||
// A release without an ARM64 build: the x64 one, which Windows runs under emulation.
|
||||
("windows", "aarch64") => &["horizon-windows-arm64.zip", "horizon-windows-x64.zip"],
|
||||
("windows", "x86_64") => &["horizon-windows-x64.zip"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
static ASSETS: std::sync::LazyLock<&'static [&'static str]> =
|
||||
std::sync::LazyLock::new(|| assets_for(std::env::consts::OS, std::env::consts::ARCH));
|
||||
|
||||
fn exe(name: &str) -> String {
|
||||
format!("{name}{}", std::env::consts::EXE_SUFFIX)
|
||||
@@ -820,6 +823,18 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn windows_updates_choose_the_machine_architecture() {
|
||||
assert_eq!(
|
||||
assets_for("windows", "x86_64"),
|
||||
&["horizon-windows-x64.zip"]
|
||||
);
|
||||
assert_eq!(
|
||||
assets_for("windows", "aarch64"),
|
||||
&["horizon-windows-arm64.zip", "horizon-windows-x64.zip"]
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
|
||||
@@ -727,6 +727,12 @@ pub trait LevelAccess {
|
||||
fn has_mod_block_entity(&self, _pos: BlockPos) -> bool {
|
||||
false
|
||||
}
|
||||
/// `ShulkerBoxBlockEntity.isClosed` at `pos`: whether the box's lid is
|
||||
/// still shut. None when no shulker box block entity lives there, or the
|
||||
/// level keeps none: vanilla then reads the box as closed, a full cube.
|
||||
fn shulker_is_closed(&self, _pos: BlockPos) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
/// Load saved block-entity data into the block entity of the block just
|
||||
/// placed at `pos` (`BlockEntity.loadWithComponents`): a bee nest a grown
|
||||
/// tree placed gets its bees. Levels without block entities drop it.
|
||||
|
||||
@@ -170,6 +170,27 @@ impl crate::block_collision::DynamicCollisionWorld for NoBlockEntities {
|
||||
}
|
||||
}
|
||||
|
||||
/// Block-entity lookups for dynamic shapes, read from the level itself: a
|
||||
/// shulker box's lid state decides whether its support shape is the open plate
|
||||
/// or the closed full cube (`ShulkerBoxBlock.getBlockSupportShape`).
|
||||
struct LevelBlockEntities<'a, L: LevelAccess + ?Sized>(&'a L);
|
||||
impl<L: LevelAccess + ?Sized> crate::dynamic_support::DynamicSupportWorld
|
||||
for LevelBlockEntities<'_, L>
|
||||
{
|
||||
type Error = std::convert::Infallible;
|
||||
fn shulker_is_closed(&mut self, position: [i32; 3]) -> Result<Option<bool>, Self::Error> {
|
||||
Ok(self
|
||||
.0
|
||||
.shulker_is_closed(BlockPos::new(position[0], position[1], position[2])))
|
||||
}
|
||||
fn moving_piston_shape(
|
||||
&mut self,
|
||||
_position: [i32; 3],
|
||||
) -> Result<Option<VoxelShape>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
/// Shape queries over `getCollisionShape(level, pos)`, including the
|
||||
/// position-dependent shapes (bamboo, speleothems, shulker boxes).
|
||||
@@ -258,10 +279,39 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
let shape = self.moving_block_shape(pos)?;
|
||||
Some(support.is_supporting(&shape, direction).unwrap_or(false))
|
||||
}
|
||||
/// `ShulkerBoxBlock.getBlockSupportShape` at `pos`: vanilla asks the block
|
||||
/// entity because a shulker box's shape is dynamic, so an open box gives
|
||||
/// support only through its one-pixel plate opposite `facing`, where the
|
||||
/// exported facts see a closed full cube. None for every other block.
|
||||
fn shulker_support(
|
||||
&self,
|
||||
pos: BlockPos,
|
||||
direction: Direction,
|
||||
support: SupportType,
|
||||
) -> Option<bool> {
|
||||
let state = self.state(pos);
|
||||
if !self
|
||||
.b
|
||||
.tags
|
||||
.shulker_boxes
|
||||
.contains(self.b.table.block(state))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let mut world = LevelBlockEntities(&*self.level);
|
||||
self.b
|
||||
.dynamic
|
||||
.get(&state)?
|
||||
.is_face_sturdy(&mut world, [pos.x, pos.y, pos.z], direction, support)
|
||||
.ok()
|
||||
}
|
||||
pub fn sturdy(&self, pos: BlockPos, direction: Direction) -> bool {
|
||||
if let Some(sturdy) = self.moving_block_supports(pos, direction, SupportType::Full) {
|
||||
return sturdy;
|
||||
}
|
||||
if let Some(sturdy) = self.shulker_support(pos, direction, SupportType::Full) {
|
||||
return sturdy;
|
||||
}
|
||||
self.b.is_face_sturdy(self.support_state(pos), direction)
|
||||
}
|
||||
/// `Block.canSupportRigidBlock(level, below)`.
|
||||
@@ -269,6 +319,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
if let Some(rigid) = self.moving_block_supports(pos, Direction::Up, SupportType::Rigid) {
|
||||
return rigid;
|
||||
}
|
||||
if let Some(rigid) = self.shulker_support(pos, Direction::Up, SupportType::Rigid) {
|
||||
return rigid;
|
||||
}
|
||||
self.b
|
||||
.is_face_sturdy_rigid(self.support_state(pos), Direction::Up)
|
||||
}
|
||||
@@ -282,6 +335,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
|
||||
if let Some(center) = self.moving_block_supports(pos, direction, SupportType::Center) {
|
||||
return center;
|
||||
}
|
||||
if let Some(center) = self.shulker_support(pos, direction, SupportType::Center) {
|
||||
return center;
|
||||
}
|
||||
self.b.is_face_sturdy_center(state, direction)
|
||||
}
|
||||
pub fn is_empty_block(&self, pos: BlockPos) -> bool {
|
||||
|
||||
@@ -68,6 +68,9 @@ pub(crate) struct TestLevel {
|
||||
pub openers: HashMap<BlockPos, (i32, i32)>,
|
||||
/// Every position whose container openers were recounted, in order.
|
||||
pub opener_rechecks: Vec<BlockPos>,
|
||||
/// `ShulkerBoxBlockEntity.isClosed` at a position: `true` for a shut lid,
|
||||
/// `false` for an open box. Positions not listed have no block entity.
|
||||
pub shulker_closed: HashMap<BlockPos, bool>,
|
||||
}
|
||||
impl TestLevel {
|
||||
fn new(b: &Behaviours) -> Self {
|
||||
@@ -88,6 +91,7 @@ impl TestLevel {
|
||||
smoke_sources: HashMap::new(),
|
||||
openers: HashMap::new(),
|
||||
opener_rechecks: Vec::new(),
|
||||
shulker_closed: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -96,6 +100,9 @@ impl LevelAccess for TestLevel {
|
||||
self.opener_rechecks.push(pos);
|
||||
self.openers.get(&pos).copied()
|
||||
}
|
||||
fn shulker_is_closed(&self, pos: BlockPos) -> Option<bool> {
|
||||
self.shulker_closed.get(&pos).copied()
|
||||
}
|
||||
fn adjust_has_analog_output(&self, state: BlockStateId, vanilla: bool) -> bool {
|
||||
vanilla || self.analog_states.contains(&state)
|
||||
}
|
||||
@@ -2715,6 +2722,102 @@ fn using_storage_and_interactable_blocks_succeeds() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_shulker_box_supports_only_through_its_base_plate() {
|
||||
let mut w = world!();
|
||||
let pos = BlockPos::new(0, 1, 0);
|
||||
for facing in Direction::ALL {
|
||||
let prop = match facing {
|
||||
Direction::Down => "down",
|
||||
Direction::Up => "up",
|
||||
Direction::North => "north",
|
||||
Direction::South => "south",
|
||||
Direction::West => "west",
|
||||
Direction::East => "east",
|
||||
};
|
||||
w.put(pos, w.id("shulker_box", &[("facing", prop)]));
|
||||
w.level.shulker_closed.remove(&pos);
|
||||
// A shut box, and a box whose block entity the level doesn't keep,
|
||||
// both read as a closed full cube.
|
||||
{
|
||||
let sim = w.b().sim(&mut w.level, &mut w.ticks);
|
||||
assert!(
|
||||
sim.sturdy(pos, Direction::Up),
|
||||
"closed box facing {facing:?}"
|
||||
);
|
||||
assert!(
|
||||
sim.sturdy(pos, Direction::Down),
|
||||
"closed box facing {facing:?}"
|
||||
);
|
||||
assert!(
|
||||
sim.can_support_rigid_block(pos),
|
||||
"closed box facing {facing:?}"
|
||||
);
|
||||
assert!(
|
||||
sim.can_support_center(pos, Direction::Up),
|
||||
"closed box facing {facing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
w.level.shulker_closed.insert(pos, false);
|
||||
let plate = facing.opposite();
|
||||
let sim = w.b().sim(&mut w.level, &mut w.ticks);
|
||||
assert!(
|
||||
sim.sturdy(pos, plate),
|
||||
"open box facing {facing:?} keeps its base plate"
|
||||
);
|
||||
assert!(
|
||||
!sim.sturdy(pos, facing),
|
||||
"open box facing {facing:?} has no support on its lid side"
|
||||
);
|
||||
assert_eq!(
|
||||
sim.can_support_rigid_block(pos),
|
||||
plate == Direction::Up,
|
||||
"open box facing {facing:?}"
|
||||
);
|
||||
assert!(
|
||||
sim.can_support_center(pos, plate),
|
||||
"open box facing {facing:?} base plate"
|
||||
);
|
||||
assert!(
|
||||
!sim.can_support_center(pos, facing),
|
||||
"open box facing {facing:?} lid side"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torch_survives_on_a_box_opening_down_but_not_on_an_open_lid() {
|
||||
let mut w = world!();
|
||||
let box_pos = BlockPos::new(0, 1, 0);
|
||||
let torch_pos = BlockPos::new(0, 2, 0);
|
||||
w.put(torch_pos, w.id("torch", &[]));
|
||||
let torch = w.get(torch_pos);
|
||||
// Facing up, the lid is the top face: open, the torch above loses support.
|
||||
w.put(box_pos, w.id("shulker_box", &[("facing", "up")]));
|
||||
assert!(
|
||||
w.b()
|
||||
.sim(&mut w.level, &mut w.ticks)
|
||||
.can_survive(torch, torch_pos),
|
||||
"a torch stands on a closed box"
|
||||
);
|
||||
w.level.shulker_closed.insert(box_pos, false);
|
||||
assert!(
|
||||
!w.b()
|
||||
.sim(&mut w.level, &mut w.ticks)
|
||||
.can_survive(torch, torch_pos),
|
||||
"an open lid doesn't hold a torch"
|
||||
);
|
||||
// Facing down, the base plate is the top face, so the torch stands.
|
||||
w.put(box_pos, w.id("shulker_box", &[("facing", "down")]));
|
||||
assert!(
|
||||
w.b()
|
||||
.sim(&mut w.level, &mut w.ticks)
|
||||
.can_survive(torch, torch_pos),
|
||||
"the base plate of a box opening down still holds a torch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creative_breaking_piston_head_does_not_drop_piston() {
|
||||
for (piston_name, head_type) in [("piston", "normal"), ("sticky_piston", "sticky")] {
|
||||
|
||||
@@ -19,6 +19,7 @@ Builds are GitHub releases of `horizon-rust/horizon`, one archive per platform:
|
||||
| Platform | Archive |
|
||||
| --- | --- |
|
||||
| Windows x64 | `horizon-windows-x64.zip` |
|
||||
| Windows ARM64 | `horizon-windows-arm64.zip` |
|
||||
| Linux x86_64 | `horizon-linux-x86_64.tar.gz` |
|
||||
| macOS Apple silicon | `horizon-macos-arm64.zip` |
|
||||
| macOS Intel | `horizon-macos-x64.zip` |
|
||||
|
||||
+4
-2
@@ -22,9 +22,11 @@ tunes it. LAN worlds and single player don't filter.
|
||||
## Muting by hand
|
||||
|
||||
Operators of level 3 (`/ban`'s level) mute players themselves; the console takes the same words without the
|
||||
slash. These are Horizon extras, and exist only on servers that filter chat.
|
||||
slash. These are Horizon extras, and exist only on servers that filter chat. They are in the command tree of
|
||||
operators of level 3 and above, so their clients complete them; other players don't see them.
|
||||
|
||||
- `/mute <player> [reason]`: muted until `/unmute`.
|
||||
- `/mute <player> [duration] [reason]`: muted until `/unmute`, or for the duration when the word after the name
|
||||
is one (`/mute Steve 1h spam`).
|
||||
- `/tempmute <player> <duration> [reason]`: muted for a duration such as `30s`, `10m`, `2h`, `1d` or `1w`.
|
||||
- `/mutelist`: every mute in force, the filter's and the operators', with who muted, until when and why.
|
||||
- `/unmute <player>`: lifts either kind.
|
||||
|
||||
+6
-3
@@ -46,13 +46,14 @@ Linux (x86-64) and macOS (Apple Silicon; Intel from the next Nightly):
|
||||
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
|
||||
```
|
||||
|
||||
Windows (x64), in PowerShell:
|
||||
Windows (x64 or ARM64), in PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex
|
||||
```
|
||||
|
||||
The script downloads the current Horizon Nightly for your computer from GitHub (the only build Horizon ships;
|
||||
The script picks the machine's x64 or ARM64 archive, including when PowerShell runs under x64 emulation. It downloads
|
||||
the current Horizon Nightly from GitHub (the only build Horizon ships;
|
||||
`--version` picks another), checks it against the SHA-256 GitHub publishes, and never asks for sudo or administrator rights. It installs:
|
||||
|
||||
- the release in `~/.local/share/horizon/releases/<version>` (Windows: `%LOCALAPPDATA%\Horizon`), with
|
||||
@@ -317,7 +318,9 @@ is online is kicked with the reason and the time the ban ends, and the same mess
|
||||
try to join. A player who is already banned (and whose ban has not ended) is left as they are: `pardon` first to
|
||||
change a ban. The player may be offline if the server has seen them. The console logs it as
|
||||
`[tempban] <operator> banned <player> for <duration>`, and the event stream (`GET /api/v1/events`) gets a `ban`
|
||||
event with `reason`, `by` and `until`. It is not in the command tree, so it has no completions, like `/mute`.
|
||||
event with `reason`, `by` and `until`. `tempbanlist` lists the bans that end by themselves, soonest first, with
|
||||
who banned, the time left, the end and the reason. Both are in operators' command tree (level 3 and above), so
|
||||
their clients complete them, as they do the [mute commands](chat-filter.md#muting-by-hand).
|
||||
|
||||
To generate the world around spawn ahead of the players, Horizon has its own `pregen` command, off until
|
||||
`server.toml` has `horizon-pregen = true` under `[properties]`: `horizon-server cmd pregen start 2500`
|
||||
|
||||
+101
-7
@@ -1733,8 +1733,10 @@ trusting native code is the player's decision, never an agent's.
|
||||
official `rustc`, `cargo` and `rust-std` (and `rust-mingw` on Windows), each pinned by SHA-256, downloaded once
|
||||
from static.rust-lang.org into `<launcher data>/rust/`, with its own Cargo home `<launcher data>/cargo/`. No
|
||||
rustup, nothing on the system; the player's own Rust is never used. It ships for macOS on Apple Silicon, Linux
|
||||
x86-64 and Windows x64; an Intel Mac has none yet, so native builds there stop with "the launcher has none for
|
||||
macos x86_64". Windows uses the `windows-gnu` host, which links without Visual Studio. `rust-mingw` only links, so on Windows the toolchain also brings MinGW-w64
|
||||
x86-64 and Windows x64. The Windows ARM64 launcher has no bundled native-mod compiler yet and stops with
|
||||
"the launcher has none for windows aarch64"; it doesn't build x64 game binaries for an ARM64 install. An Intel
|
||||
Mac has none either, so native builds there stop with "the launcher has none for macos x86_64". Windows uses the
|
||||
`windows-gnu` host, which links without Visual Studio. `rust-mingw` only links, so on Windows the toolchain also brings MinGW-w64
|
||||
(`rust_toolchain::MINGW`: WinLibs' GCC 16.2.0 on MinGW-w64 14.0.0, MSVCRT like `rust-mingw`, one zip pinned by
|
||||
SHA-256, its `mingw64/` without the documentation) on the build's `PATH`: `ring` and `hidapi` compile C,
|
||||
and `windows-sys`' `raw-dylib` imports need `dlltool` and the assembler it runs. The linker is the system's on
|
||||
@@ -2793,6 +2795,22 @@ something happened can time the sound to the distance, such as a blast heard whe
|
||||
`delay = distance / speed`. It answers false on a server and for a value out of range; an event the resources lack
|
||||
plays nothing.
|
||||
|
||||
### Fluid currents
|
||||
|
||||
`native_fluid_flow(dimension, pos)` is `FluidState.getFlow(level, pos)`: the normalised current at a position, the
|
||||
push an item there gets. It is zero for still water and for no fluid, and none where the chunk isn't loaded or the
|
||||
dimension isn't available (a client has only the level it is in), so a water wheel on a chunk edge can keep its last
|
||||
speed rather than stop.
|
||||
|
||||
### A mod's own fluids in tanks
|
||||
|
||||
Tanks (`native_block_entity_fluids`) take a mod's own fluids as they take water and lava: any id in a namespace other
|
||||
than `minecraft` (`create:chocolate`). `native_fluid_insert`, `native_fluid_extract` and `native_move_fluids` move them,
|
||||
and `native_fluid_tanks` reads what a side reaches (`Host::fluid_tanks`). Equal ids merge in a tank; variants of a fluid,
|
||||
a potion's kinds, are ids of their own (`create:potion/healing`). The mod draws its fluid in its block entities. A
|
||||
cauldron takes only water and lava, and the world itself has only vanilla's fluids: no source or flowing blocks of a
|
||||
mod's fluid yet, and no buckets of one.
|
||||
|
||||
### Falling blocks
|
||||
|
||||
`crates/horizon-client/src/game/native_falling.rs`: a helper for server mods, for the level in place, on a server only. It
|
||||
@@ -2827,6 +2845,25 @@ It names the level it means by its `dimension` (`minecraft:overworld`), as `nati
|
||||
that doesn't read or carries NBT (`chest{...}`: refused, set states only), a run naming a palette entry or filter past
|
||||
the end, or more than 65536 filters.
|
||||
|
||||
### Machines
|
||||
|
||||
`crates/horizon-client/src/game/native_machines.rs`: helpers for a mod's machines on a server, in the level in place.
|
||||
These are what Create's drill, saw, harvester and deployer need.
|
||||
|
||||
- `native_machine_destroy_block(pos, tool, collect)` (`Host::destroy_block`) is `Level.destroyBlock(pos, true)` with
|
||||
the loot rolled for `tool` and no player (`Block.dropResources(state, level, pos, blockEntity, null, tool)`).
|
||||
It shows the break particles and plays the sound, and the block entity's contents go into the drops as a player's
|
||||
break puts them. An ore gives experience as with no tool, and the block's fluid takes its place. Mods' loot changes
|
||||
(`native_loot_drops`) apply. With `collect`, the drops are returned instead of popped.
|
||||
- `native_set_break_progress(breaker, pos, progress)` is `ServerLevel.destroyBlockProgress`: a crack, stage 0 to 9, for
|
||||
the players within 64 blocks. Pass a negative id of your own, the same each time; any other stage removes the crack.
|
||||
- `native_use_as_player(stack, eye, yaw, pitch, target, sneaking)` is NeoForge's `FakePlayer`. It uses `stack` on a
|
||||
block (`useItemOn`), in the air (`useItem`) or on an entity (`interactOn`). The fake player is in survival and holds
|
||||
nothing else. It has no connection: nothing it opens stays open, and its stats and advancements count for no one.
|
||||
The answer (`NativeFakeUse`) says whether the use did something, and what the fake player holds and carries
|
||||
afterwards. Call it from a block entity's tick, not from inside a player's hook: while a player acts, it answers
|
||||
`None`.
|
||||
|
||||
### Column reads
|
||||
|
||||
`crates/horizon-client/src/game/native_world_columns.rs`: helpers for server mods, for the level in place, on a server
|
||||
@@ -2846,6 +2883,14 @@ pieces.
|
||||
The tints are the biome's own at the column's top, not blended over the biome blend radius as the game draws them, so
|
||||
near a biome border they can differ from what players see by a few blocks.
|
||||
|
||||
### Chunk tickets
|
||||
|
||||
`crates/horizon-client/src/game/native_chunk_tickets.rs`: `native_force_chunk(owner, [x, z], hold)` holds a chunk of the
|
||||
level in place for `owner` (`create:train/<uuid>`), as NeoForge's `TicketController.forceChunk` does. The chunk stays
|
||||
held while any owner holds it. It is kept as a `/forceload`ed chunk is: it is loaded with the ring around it, it ticks,
|
||||
and its level stays open. `/forceload` neither lists nor releases it. `native_forced_chunks(owner)` lists what an owner
|
||||
holds. Tickets are not saved: keep what you hold (a level attachment) and hold it again in `native_level_load`.
|
||||
|
||||
### Mod messages
|
||||
|
||||
API 40, `crates/horizon-client/src/game/native_net.rs`: bytes between a mod's server half and its client half, on the
|
||||
@@ -2861,6 +2906,15 @@ one custom payload channel `horizon:mod_net`. A vanilla client never gets them,
|
||||
- A player's message reaches the server's hook in the tick after it arrives, when the helpers can reach every player;
|
||||
a message to a player leaves at the end of the tick, from any server hook.
|
||||
|
||||
### Scenes in screens
|
||||
|
||||
`native_draw_scene(batch, rect, blocks, view)` (client, `crates/horizon-client/src/game/native_scene.rs`) draws a small
|
||||
scene into a box of a screen: block states at positions of the scene's own (`NativeSceneBlock`), each with its model and,
|
||||
given a mod block entity, its drawing through `native_block_entity_submit`, `spin` parts included. `NativeSceneView`
|
||||
sets the point at the box's middle, the turn (`yaw`, counterclockwise seen from above; at 0 the scene is seen from the
|
||||
north), the tilt of its top towards the viewer (`pitch`) and the pixels per block (`scale`). The scene has a depth of its
|
||||
own and is cut to the box. Draw it from the screen's drawing hook; Create's Ponder steps through its scenes this way.
|
||||
|
||||
### Chat and commands a client mod sends
|
||||
|
||||
API 404, `crates/horizon-client/src/game/native_net.rs`: `native_send_chat(message)` sends what the player
|
||||
@@ -2905,6 +2959,24 @@ stock client shows as a chest (`generic_9x1` to `generic_9x6`); nothing is insta
|
||||
The menu hooks run with no player's state swapped in; `native_player_stacks` and `native_exchange_items` refuse a
|
||||
player whose state is (inside `native_perform_command`): call them from a menu hook or `native_server_tick`.
|
||||
|
||||
### Text, turning parts and copycats in block entities
|
||||
|
||||
- `native_draw_text(draw, text, at, color, glowing, outline)` draws a `Text` under a block entity's pose, as a sign
|
||||
draws its lines. One unit is one font pixel and y points down, so scale the pose by `[1/64, -1/64, 1/64]` as a sign
|
||||
does. Each span keeps its own colour, and glowing text is full bright. It returns the width, for centring. Only the
|
||||
spans' text and colour are drawn, in the default font: no bold, italic or other font yet. Clamp text read from NBT,
|
||||
since an outlined glyph costs nine quads, and widen `native_block_entity_render_bounding_box` for a long line, which
|
||||
the one-block default bounds cull while it is still on screen.
|
||||
- `BlockEntityDraw::spin(axis, pivot, degrees_per_tick, phase)` makes what is drawn next turn by itself, and `still`
|
||||
ends it. The game works out the angle from the game time each frame (`game_time * degrees_per_tick + phase`), so a
|
||||
new speed needs a new phase to carry on from the current angle. With `keep`, a shaft turning at a steady speed
|
||||
is drawn again with no call of `native_block_entity_submit` until its block entity changes. This stands in for
|
||||
Flywheel's rotating instances, on the CPU.
|
||||
- `native_block_face_sprite(state, side)` names the texture a block state shows on a side, as `native_draw_cube`
|
||||
takes it (`block/oak_planks`: vanilla's without the namespace). With it, a copycat block draws its material's textures on its own shape and keeps the result.
|
||||
- Connected textures (casings, framed glass) need no code. A content mod ships OptiFine CTM rules under
|
||||
`assets/<id>/optifine/ctm/`, and the mesher reads them as it reads a resource pack's.
|
||||
|
||||
### Render hook points
|
||||
|
||||
`crates/horizon-client-render/src/gfx/native_render.rs`: the renderer's own hook points, free functions rather than
|
||||
@@ -2994,6 +3066,13 @@ never ask it. As in vanilla (`AbstractContainerMenu.moveItemStackTo`), a
|
||||
shift-click tops up a slot (not a `results` one) that already holds the same item without asking `may_place`, only `max_stack`: to keep a
|
||||
slot from filling that way, lower `max_stack`.
|
||||
|
||||
### What a mod's container takes
|
||||
|
||||
`native_container_accepts(pos, entity, slot, stack, face, cancel)` is a hook (NeoForge's `IItemHandler.isItemValid`).
|
||||
Hoppers, droppers and `native_insert_item` ask it before they put an item into a slot that the block's `ModContainer`
|
||||
lets them fill. Set `cancel` to refuse the item there: a millstone takes only what it has a recipe for. Players' clicks
|
||||
in a menu are `native_menu_slot_rules`' instead.
|
||||
|
||||
### Shift-click in a mod container
|
||||
|
||||
`native_menu_quick_move(pos, entity, container_slots, slot, stack, handled)` is a mod container's
|
||||
@@ -3394,16 +3473,31 @@ receiver and documentation; a helper without `self` (`config_dir`) takes
|
||||
A helper's `receiver` also exposes it on that handle. Receivers come from public
|
||||
`*Ref` structs in `mod_api.rs`: for example, a public `BlockEntityRef` struct registers
|
||||
`block_entity`. Adding a handle requires no separate generator registration.
|
||||
|
||||
Some helpers reach `Host` in another form, because their own signatures can't
|
||||
go through `dyn Host` (`native_host_bridge.rs`):
|
||||
|
||||
| `Host` method | For | What it does |
|
||||
|---|---|---|
|
||||
| `mod_block_entity(pos)`, `change_mod_block_entity(pos, &mut change)` | `with_mod_block_entity` | Reads or changes a neighbouring mod block entity. On a client, `mod_block_entity` returns what the server sent (`native_block_entity_update_tag`), so a client mod can read another block's data. |
|
||||
| `trigger_criterion(uuid, trigger, &test)` | `trigger_criterion` | The same call, with the test as `&dyn Fn`. |
|
||||
| `play_sound`, `send_particles` | `LevelWriteHost`'s methods | Work in the level in place, from any server hook, such as a block entity's tick. |
|
||||
| `block_name_in_tag(block, tag)` | `server_block_in_tag` | Takes a block name instead of a state. |
|
||||
|
||||
`equipment(slot)` is the player's own equipment on a client and the acting player's on a server.
|
||||
`sneaking()` is `isSecondaryUseActive` in a use hook. `draw_model`, `item_entities`, `level_random`,
|
||||
`server_item_in_tag`, `component`, `set_component` and `remove_component` are the helpers themselves.
|
||||
|
||||
The following documented `Game` helpers are not in the trait yet:
|
||||
|
||||
- `spawn_mob`, `mob`, `mobs`, `block_in_tag`, `item_in_tag`,
|
||||
`server_block_in_tag`, `server_item_in_tag`, `server_block`,
|
||||
`server_block_in_tag`, `server_block`,
|
||||
`set_display`, `sidebar`, `experience_orbs`,
|
||||
`item_entities`, `player`, `panel_recipes`, `recipes`, `component`,
|
||||
`set_component`, `remove_component`, `spawn_falling_block`, `explode`,
|
||||
`fluid_tanks` and `container_slots` use gameplay-private types.
|
||||
`player`, `panel_recipes`, `recipes`, `spawn_falling_block`, `explode`
|
||||
and `container_slots` use gameplay-private types.
|
||||
- `with_mod_block_entity` and `trigger_criterion` are generic or take
|
||||
`impl Trait`, so their signatures cannot be called through `dyn Host`.
|
||||
`impl Trait`, so their signatures cannot be called through `dyn Host`;
|
||||
the forms above stand in for them.
|
||||
|
||||
The following catalog hooks are not in the trait yet:
|
||||
|
||||
|
||||
+4
-3
@@ -60,15 +60,16 @@ mods' boxes. Code: `crates/horizon-client-render/src/pvp_hud.rs` (lines, cache,
|
||||
Cost: a widget keeps its lines (`pvp_hud::Widget`) and rebuilds them only when what it shows changes (a piece
|
||||
takes damage, an effect's second ticks, the count changes); a frame reads a few slots, compares, and adds the
|
||||
box's quads. With nothing changing, a frame allocates nothing.
|
||||
- **Ping** (top left): the latency the server's player list reports for you (the tab list's bars as a number).
|
||||
- **Ping** (top left): the latency the server's player list reports for you (the tab list's bars as a number); nothing until the server's first latency update, up to 30 s after joining.
|
||||
Multiplayer only.
|
||||
- **Coordinates** (top left): block position and the direction faced (`Direction.fromYRot`), as F3 shows them.
|
||||
Hidden whenever F3 is reduced (`reducedDebugInfo`: the option, the game rule or the server), so it never shows
|
||||
what the server hides.
|
||||
- **Server address** (top left): the address you joined (`net::shown_address`: invite codes stay hidden).
|
||||
Multiplayer only.
|
||||
- **Combo counter** (top left): your hits in a row, each counted when the server reports the entity you just
|
||||
attacked hurt (`ClientboundDamageEventPacket` within a second of the swing); back to 0 when you are hurt or
|
||||
- **Combo counter** (top left): your hits in a row, each counted when the server reports an entity hurt with you
|
||||
as the damage's direct entity (`ClientboundDamageEventPacket`: melee and spear hits, not arrows or fire). A
|
||||
report that names no attacker counts once, within a second of a swing at that entity. Back to 0 when you are hurt or
|
||||
3 seconds pass without a hit. It counts what the game already shows (the target's hurt flash).
|
||||
|
||||
## Enchantment levels as numbers (`numericEnchants`, default off)
|
||||
|
||||
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user