Merge f9f1b5abfc into pvp/hud-info

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-05 00:48:27 +01:00
commit 3842a7aee2
232 files changed
+10221 -773

No files matched your search

+2 -1
View File
@@ -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]
+83
View File
@@ -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).
+23 -3
View File
@@ -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
+37 -5
View File
@@ -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:
+12
View File
@@ -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)" \
+1 -1
View File
@@ -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).
+117 -11
View File
@@ -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"));
+4
View File
@@ -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();
+66 -23
View File
@@ -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))
@@ -11695,27 +11711,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]
@@ -11723,28 +11761,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 {
+1 -1
View File
@@ -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/...`).
@@ -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);
}
}
+49 -7
View File
@@ -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);
+79
View File
@@ -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 —
+2
View File
@@ -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;
+1 -1
View File
@@ -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"))
+27
View File
@@ -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`).
+95 -3
View File
@@ -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
@@ -11611,6 +11651,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).
@@ -12798,7 +12867,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();
@@ -12825,6 +12894,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) {
@@ -18868,6 +18950,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() {
@@ -19331,6 +19421,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,
@@ -21401,6 +21492,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();
+53 -38
View File
@@ -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| {
+1
View File
@@ -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
+37 -1
View File
@@ -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)))?;
+1 -1
View File
@@ -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));
}
+11 -21
View File
@@ -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)));
}
+60 -2
View File
@@ -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));
+138 -12
View File
@@ -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;
+36 -1
View File
@@ -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`.
+1 -1
View File
@@ -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;
+82 -11
View File
@@ -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)));
}
}
+25 -2
View File
@@ -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());
}
}
+1 -1
View File
@@ -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| {
+30 -4
View File
@@ -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
}
+1 -1
View File
@@ -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.
+14
View File
@@ -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()
}
+33 -5
View File
@@ -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.
+10 -4
View File
@@ -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 {
+120 -42
View File
@@ -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.
@@ -10303,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()
}
@@ -11293,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) {
+17 -6
View File
@@ -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(())
}
}
+168 -5
View File
@@ -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;
+5 -3
View File
@@ -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() {
+8
View File
@@ -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 {
+28 -1
View File
@@ -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
+59 -3
View File
@@ -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
+608
View File
@@ -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);
}
}
+21
View File
@@ -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(&registry, &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(
&registry,
&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();
}
}
+690
View File
@@ -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(&timestamp_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);
}
}
+5
View File
@@ -0,0 +1,5 @@
//! Horizon In-Game Video Studio.
pub mod mcpr;
pub use mcpr::{McprReader, McprWriter, Metadata, TimestampedPacket};
+74 -2
View File
@@ -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))
}
+4 -2
View File
@@ -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!(
+16
View File
@@ -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
+50
View File
@@ -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());
}
}
+29 -14
View File
@@ -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!(
+6
View File
@@ -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.
+56
View File
@@ -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 {
+103
View File
@@ -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")] {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+13 -6
View File
@@ -273,15 +273,19 @@ the notes.
| --- | --- | --- |
| `horizon-linux-x86_64.tar.gz` | GitHub `ubuntu-22.04` (an older glibc, so it runs on any recent distribution) | the standard loader and libraries only (`tools/check_imports.py`) |
| `horizon-server-linux-x86_64.tar.gz` | the same Linux job, after the client archive: `horizon-server`, `horizon-prepare` and the game built with `--no-default-features` as `horizon-engine` (docs/server.md) | as above, and neither `libasound.so.2` nor `libudev.so.1` (`tools/check_imports.py --server`) |
| `horizon-windows-x64.zip` | GitHub `windows-latest`, C runtime linked in | system DLLs only (`tools/check_imports.py`) |
| `horizon-windows-x64.zip` | GitHub `windows-latest`, C runtime linked in | system DLLs only (`tools/check_imports.py`); PE machine is x64 |
| `horizon-windows-arm64.zip` | GitHub `windows-11-vs2026-arm`, built for `aarch64-pc-windows-msvc`, C runtime linked in | system DLLs only (`tools/check_imports.py`); PE machine is ARM64 and binaries start on the native runner |
| `horizon-macos-arm64.zip` | The Mac mini's runner (`vars.MAC_RUNNER=macos-latest` sends it to GitHub) | system libraries only (`otool -L`); a 1500-frame offscreen run of a new world (about 12 s; 300 frames end before the world has loaded) with resources prepared from the runner's own Minecraft (`--offscreen --world smoke --frames 1500 --smoke-report`: frames drawn, chunks generated and meshed, a player, no error) |
| `horizon-macos-x64.zip` | The same runner, cross-built for `x86_64-apple-darwin` (#1161) | as `horizon-macos-arm64.zip`, every program and the 1500-frame run under Rosetta 2; only the arm64 build's `smoke-test.json` is published |
Each builds `horizon-client`, `horizon-ctl` and `horizon-prepare` in release mode, checks all three exist, starts
each one (`--help`, `--version`), packages the folder with `cargo run -p xtask -- package` and archives it. The
Each builds `horizon-client`, `horizon-ctl`, `horizon-prepare` and `horizon-server` in release mode, checks they
exist, verifies Windows PE architectures, checks the `--help` and `--version` entry points, packages the folder with
`cargo run -p xtask -- package` and archives it. The
GitHub jobs keep their Cargo caches (`Swatinem/rust-cache`); the Mac mini keeps its `target/`. Only the Mac
runs the game: GitHub's runners have neither a GPU worth the name nor the game files.
Building Windows ARM64 locally: [Windows ARM64](windows-arm64.md).
### Notes
`tools/ci/nightly.py pick` writes them: what reached main since the previous Nightly (`### New`, label `feature`,
@@ -388,11 +392,14 @@ and by hand; pushes to `launcher` and `launcher-*` branches only build.
`HORIZON_LAUNCHER_TAG`, which `launcher/src/backend.rs` reads with `option_env!` (falling back to `Cargo.toml`'s),
so `--version`, the window, the User-Agent and the self-update comparison all use them. Each build checks
`--version` says it.
- Linux on GitHub `ubuntu-22.04`, Windows on `windows-latest`, macOS on the Mac mini's runner; each runs the
launcher's tests, checks its libraries, and installs the latest game release with the new launcher.
- Linux on GitHub `ubuntu-22.04`, Windows x64 on `windows-latest`, Windows ARM64 on `windows-11-vs2026-arm`, and
macOS on the Mac mini's runner; each runs the launcher's tests and checks its libraries. The smoke test installs
the newest game build for that platform; Intel macOS skips only the install step while no matching build is
available yet, and Windows ARM64 installs the x64 build (under emulation) from a Nightly without an ARM64 one.
- Published with `--latest=false`: the repository's latest release stays a game release. Notes list the pull
requests under New and Fixed; Kira posts launcher releases without pinging.
- Windows also publishes `horizon-launcher-setup.exe`, the launcher's binary under another name: with no
- Windows also publishes `horizon-launcher-setup.exe` (x64) and `horizon-launcher-setup-arm64.exe`, the launcher's
binary under another name: with no
arguments it opens the per-user setup (`launcher/src/installer.rs`) instead of Home, and
`horizon-launcher.exe uninstall` removes it again. Both files are in the release's signed `SHA256SUMS`.
+1
View File
@@ -75,6 +75,7 @@ Installs made before 0.2 run old code that compares tags number by number, so to
| --- | --- | --- |
| Launcher 0.16.0 and older, updating itself | Looks only at `launcher-v` tags and takes one above its own version. Never sees `launcher@`. | **The bridge**: one last `launcher-v0.17.0`, built with the new code. Old launchers take it as usual; it knows it is a `launcher-v` release and takes `[email protected]` next. Two updates, both by themselves. |
| Launcher 0.16.0 and older, installing games | Sorts `v0.2.0` below every dated release and below the versions it has installed, so it never offers it. | Once the launcher has updated itself (above), it offers `v0.2.0` like any update. Until then a player can install `v0.2.0` by hand on the Versions page (it is listed last). |
| Launcher that can't replace itself: opened from its disk image, a macOS app not moved out of Downloads (macOS runs it from a read-only copy), a `.zip` opened in Explorer without extracting it, a folder it can't write (#9287) | Finds every update and fails to put it in place at each check. Launchers released before #9287 say so only on Settings' Launcher line ("the update failed: …"), so the player sees nothing and stays on that version (launcher 0.2.0 seen on 2026-11-04 with 0.2.40 out). Newer ones say "A new version is available" with **Download again**. | Download the launcher again once from playhorizon.dev and put it where it can write: Applications on macOS, an extracted folder or `horizon-launcher-setup.exe` on Windows. It updates itself from then on. Nothing in a release can reach such a copy: it fails before it runs anything new. |
| Launcher that never updates itself (`HORIZON_LAUNCHER_NO_SELF_UPDATE`, a package) | As above, without the bridge. | Update the package, or install `v0.2.0` by hand. |
| `horizon-server` from before the bridge | Reads GitHub's *latest* release and installs it only if its tag compares higher: `v0.2.0` never does. `--version <tag>` skips the comparison. A server never updates itself: its supervisor only logs a notice every 6 hours, and the running process keeps its old code until the service restarts. | **The game bridge**: the last dated release, built with the new code, made stable (*latest*) before `v0.2.0` is published. `v0.2.0` then spends at least 6 hours as a beta, so *latest* stays the bridge for that long (the longer it stays a beta, the more servers are caught). An owner who runs `horizon-server update` in that time gets the bridge, and the command runs the new code at once, so the next `horizon-server update` takes `v0.2.0` once it is stable. The log notice only changes after the service restarts. An owner on old code after that time is told the server is up to date, and runs `horizon-server update --version v0.2.0` once. 0.2.0's notes say so. |
| Servers the launcher hosts (Servers page) | Set up from the launcher's installed versions. | Follow the launcher. |
+52
View File
@@ -0,0 +1,52 @@
# Windows ARM64
Horizon builds the game and launcher as native Windows ARM64 executables for
`aarch64-pc-windows-msvc`. GitHub Actions builds and checks them on a Windows ARM64 runner.
## Build from source
Use Windows 11 on ARM, or cross-compile from Windows x64. Install stable Rust, the
`aarch64-pc-windows-msvc` Rust target, Visual Studio C++ build tools for ARM64, and a Windows SDK
with ARM64 libraries. Install LLVM/Clang and put `clang.exe` on `PATH`; the `ring` dependency uses
Clang to compile its ARM64 code. The MSVC target also needs the Visual C++ linker and Windows import
libraries.
To run `horizon-prepare compile-exporters`, install JDK 25 and Python 3.10+; that command compiles
the Java exporters used by the game.
```powershell
rustup target add aarch64-pc-windows-msvc
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
```
The launcher has a separate workspace:
```powershell
cd launcher
cargo test --release --locked --target aarch64-pc-windows-msvc --lib
cargo build --release --locked --target aarch64-pc-windows-msvc
```
## Releases and limitations
The game archive, `horizon-windows-arm64.zip`, is attached to the [Nightly release](https://github.com/horizon-rust/horizon/releases/tag/nightly)
and listed in its signed `SHA256SUMS`. The launcher archive, `horizon-launcher-windows-arm64.zip`,
and `horizon-launcher-setup-arm64.exe` are attached to the [launcher releases](https://github.com/horizon-rust/horizon/releases)
and listed in their signed `SHA256SUMS`.
Each launcher keeps its own architecture: an x64 launcher on an ARM64 PC (under emulation) updates
itself from the x64 archive and installs the x64 game, so an update never moves a working setup to
another build. `horizon-launcher-setup-arm64.exe` installs the ARM64 launcher, which installs
`horizon-windows-arm64.zip` and falls back to `horizon-windows-x64.zip` (under emulation) for a release
without an ARM64 build. Native-mod builds need the x64 launcher for now: the bundled Windows compiler is
x64-only, and the ARM64 launcher reports that no matching compiler is available instead of building x64
binaries.
On Windows ARM64, `Graphics API: Auto` tries DirectX 12 before Vulkan following a device-loss observed
in testing. Vulkan remains selectable in settings.
The `Windows ARM64 game and launcher` check in `ci.yml` runs only by hand
(`gh workflow run checks --ref <branch>`); the Nightly and launcher releases build these archives on
every run.
Loaded 100 of 232 files, more files were not shown because too many files have changed in this diff. Show more