Compare commits

..
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 04c797dcf0 Merge main into split/horizon-ctl-crate
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:37:35 +00:00
rdbtCVSandGPT 6.1 Sol ad777a0249 Merge main into split/horizon-ctl-crate
Resolve the benchmark documentation conflict by preserving the extracted horizon-ctl build target and main's Python 3.11 prerequisite.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:27:05 +00:00
rdbtCVSandGPT 6.1 Sol 3533ab20f5 Merge main into split/horizon-ctl-crate
Preserve both new lockfile packages and combine the official-game and extracted horizon-ctl selections in the Discord harness.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 12:31:40 +00:00
Horizon agent 79cfbfc950 Merge remote-tracking branch 'origin/main' into split/horizon-ctl-crate
# Conflicts:
#	tools/test_friends_integration_parity.py
2026-11-09 20:31:47 +01:00
Horizon agent a82091736e Merge remote-tracking branch 'origin/main' into split/horizon-ctl-crate
# Conflicts:
#	.github/workflows/ci.yml
#	.github/workflows/game-build.yml
#	crates/horizon-client-lib/Cargo.toml
#	tools/ci/build-musl.sh
2026-11-09 18:57:38 +01:00
Horizon agentandClaude Opus 5.5 40637018fa Cargo.lock: the horizon-ctl package
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:38:53 +01:00
Horizon agentandClaude Opus 5.5 c48cdddd66 horizon-ctl: its own platform crate (layer plan M6)
The horizon-ctl binary and its MCP server leave horizon-client-lib for
crates/horizon-ctl, a platform crate on the client lib's control socket.
horizon-client-lib no longer depends on horizon-mcp. Pure move: the
sources are unchanged; build commands now name -p horizon-ctl.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:13:46 +01:00
181 changed files with 892 additions and 4965 deletions

No files matched your search

+19 -33
View File
@@ -1,5 +1,5 @@
name: checks
# verify: every pull request, every merge queue entry and every push to main (runners: see the job). It is the
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
@@ -7,15 +7,11 @@ name: checks
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
# request runs it only when a maintainer adds the run-parity label (.github/workflows/run-parity.yml), so labels
# never start this workflow. See docs/release-flow.md.
# merge_group: the merge queue's commit (main's tip, the pull requests ahead in the queue and this one) runs verify
# before it lands, on GitHub's runners; parity and launcher wait for main's push (docs/release-flow.md#merge-queue).
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
merge_group:
types: [checks_requested]
workflow_dispatch:
permissions:
contents: read
@@ -23,8 +19,7 @@ concurrency:
# A new push to a pull request replaces its running checks. On main, the running check finishes and only the
# newest push waits behind it: GitHub cancels a waiting run when a newer one joins the group. A burst of merges
# queued ~100 main runs on 2026-11-09; the release follows the newest finished main run (docs/release-flow.md).
# Each merge queue entry has a group of its own (its temporary branch), and nothing cancels it: the queue does.
group: checks-${{ github.event.pull_request.number || github.event.merge_group.head_ref || (github.event_name == 'push' && 'main') || github.sha }}
group: checks-${{ github.event.pull_request.number || (github.event_name == 'push' && 'main') || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# Test runs never reach the telemetry server: no live-count ping, no uploads (docs/telemetry.md).
@@ -39,14 +34,11 @@ jobs:
# forks' pull requests and main's pushes (which also save the Rust cache GitHub's pull request runs read), so both
# pools work at once. The repository variable SELF_HOSTED_CI moves them: `all` every job to ours, `light` (the
# desktop paused) or `off` every job to GitHub's. A fork's pull request sees no repository variable, so no switch
# could move it off a machine that is off: it always runs on GitHub's. So does a merge queue's run, whatever
# SELF_HOSTED_CI says: its commit can hold a fork's code, which job-started.sh refuses on ours. On ours the build
# folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are
# GitHub's only.
# could move it off a machine that is off: it always runs on GitHub's. On ours the build folder persists
# (CARGO_TARGET_DIR, outside the checkout), so the apt packages, Python and the Rust cache steps are GitHub's only.
verify:
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
(vars.SELF_HOSTED_CI == 'all' ||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
@@ -60,24 +52,22 @@ jobs:
if: runner.environment == 'github-hosted'
with:
python-version: '3.12'
# A merge queue's run reads its commit against main's tip (merge_group's base and head).
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request' || github.event_name == 'merge_group'
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
# A pull request (or a merge queue entry) adds only its own commits to main, so it reads those; main's pushes
# read every ref's history.
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
- name: Keep game artifacts out of Git
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ] || [ "$EVENT" = merge_group ]; then
if [ "$EVENT" = pull_request ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
@@ -94,22 +84,21 @@ jobs:
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874). A merge queue's entry compares with main's tip
# (merge_group.base_sha), whatever the queue's merge method makes of HEAD^1.
# changes since then would count as the pull request's (#13874).
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || github.event.merge_group.base_sha || '' }}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
@@ -150,13 +139,10 @@ jobs:
cargo +stable test --locked --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26). Not in the merge queue: no check of
# its is required, so the queue wouldn't wait for it, and main's push runs it anyway.
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
if: github.event_name != 'merge_group'
runs-on: >-
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && github.event_name != 'merge_group' &&
(vars.SELF_HOSTED_CI == 'all' ||
${{ vars.SELF_HOSTED_CI != 'off' && vars.SELF_HOSTED_CI != 'light' && (vars.SELF_HOSTED_CI == 'all' ||
github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository &&
(endsWith(format('{0}', github.event.pull_request.number), '0') ||
endsWith(format('{0}', github.event.pull_request.number), '1'))) &&
@@ -239,7 +225,7 @@ jobs:
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-ctl --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-official.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
+1 -1
View File
@@ -173,7 +173,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-ctl --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
+3 -2
View File
@@ -78,7 +78,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-client-lib` | Reusable client systems, the control socket, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-ctl` | `horizon-ctl`, the CLI over the control socket, and its MCP server (platform): `commands.rs` is the command table ([docs/agent-control.md](docs/agent-control.md)) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
@@ -211,7 +212,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Agent control | L: `control.rs` (socket), `crates/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
Generated
+12 -3
View File
@@ -1486,7 +1486,6 @@ dependencies = [
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1557,7 +1556,6 @@ dependencies = [
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1675,6 +1673,17 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-ctl"
version = "0.1.0"
dependencies = [
"horizon-client-lib",
"horizon-core",
"horizon-mcp",
"horizon-threads",
"serde_json",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
@@ -1850,8 +1859,8 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
+2
View File
@@ -13,6 +13,8 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
# horizon-ctl and its MCP server (platform).
"crates/horizon-ctl",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
+117 -3
View File
@@ -1,7 +1,6 @@
//! The machine a report was measured on. CPU, RAM and OS are detected here; the GPU and the display
//! come from the program that rendered (the game knows its adapter, the launcher its window).
#[cfg(any(target_os = "macos", target_os = "linux"))]
use horizon_telemetry::system::command_output as output;
use serde::{Deserialize, Serialize};
@@ -69,12 +68,12 @@ impl Hardware {
os: std::env::consts::OS.to_owned(),
os_version: os_version(),
arch: std::env::consts::ARCH.to_owned(),
cpu: horizon_telemetry::system::cpu(),
cpu: cpu(),
cpu_threads: std::thread::available_parallelism().ok().map(|n| n.get()),
cpu_cores: probes.cores,
cpu_base_mhz: probes.base,
cpu_boost_mhz: probes.boost,
ram_mb: horizon_telemetry::system::ram_bytes().map(|b| b >> 20),
ram_mb: ram_bytes().map(|b| b >> 20),
storage: None,
form: probes.form,
gpu: None,
@@ -110,16 +109,84 @@ fn probes() -> Probes {
}
}
/// The one-minute load average: above about the number of threads the machine was busy.
pub fn load_average() -> Option<f64> {
if cfg!(target_os = "macos") {
// "{ 1.23 1.10 1.00 }"
output("sysctl", &["-n", "vm.loadavg"])?
.trim_start_matches('{')
.split_whitespace()
.next()?
.parse()
.ok()
} else {
std::fs::read_to_string("/proc/loadavg")
.ok()?
.split_whitespace()
.next()?
.parse()
.ok()
}
}
/// `ac` or `battery`; `None` where it can't be read (and on machines without a battery on Linux).
pub fn power_source() -> Option<&'static str> {
horizon_telemetry::system::power()
}
/// Resident memory of a process, in bytes (macOS, Linux).
pub fn rss_bytes(pid: u32) -> Option<u64> {
if cfg!(target_os = "macos") {
return output("ps", &["-o", "rss=", "-p", &pid.to_string()])?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024);
}
let status = std::fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
line.split_whitespace()
.nth(1)?
.parse::<u64>()
.ok()
.map(|kb| kb * 1024)
}
/// CPU time a process has used, user and system, in seconds (macOS, Linux).
pub fn cpu_seconds(pid: u32) -> Option<f64> {
if cfg!(target_os = "macos") {
// "[[dd-]hh:]mm:ss.cc"
let time = output("ps", &["-o", "time=", "-p", &pid.to_string()])?;
let (rest, days) = match time.split_once('-') {
Some((d, rest)) => (rest.to_owned(), d.parse::<f64>().ok()?),
None => (time, 0.0),
};
let seconds = rest.split(':').try_fold(0.0, |acc, part| {
part.parse::<f64>().ok().map(|v| acc * 60.0 + v)
})?;
return Some(days * 86_400.0 + seconds);
}
// utime and stime, fields 14 and 15, in clock ticks (100 per second on Linux).
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields: Vec<&str> = stat.rsplit_once(')')?.1.split_whitespace().collect();
let ticks: f64 = fields.get(11)?.parse::<f64>().ok()? + fields.get(12)?.parse::<f64>().ok()?;
Some(ticks / 100.0)
}
#[cfg(target_os = "macos")]
fn os_version() -> Option<String> {
output("sw_vers", &["-productVersion"])
}
#[cfg(target_os = "macos")]
fn cpu() -> Option<String> {
output("sysctl", &["-n", "machdep.cpu.brand_string"])
}
#[cfg(target_os = "macos")]
fn ram_bytes() -> Option<u64> {
output("sysctl", &["-n", "hw.memsize"])?.parse().ok()
}
#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
let release = std::fs::read_to_string("/etc/os-release").ok()?;
@@ -132,6 +199,22 @@ fn os_version() -> Option<String> {
})
}
#[cfg(target_os = "linux")]
fn cpu() -> Option<String> {
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
horizon_telemetry::system::linux_cpu_name(&info)
}
#[cfg(target_os = "linux")]
fn ram_bytes() -> Option<u64> {
let info = std::fs::read_to_string("/proc/meminfo").ok()?;
let kb = key_value(&info, "MemTotal", ':')?
.trim_end_matches("kB")
.trim();
kb.parse::<u64>().ok().map(|kb| kb * 1024)
}
#[cfg(target_os = "linux")]
fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
text.lines().find_map(|line| {
@@ -145,11 +228,39 @@ fn os_version() -> Option<String> {
horizon_telemetry::system::windows_version()
}
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
fn ram_bytes() -> Option<u64> {
let out = output(
"powershell",
&[
"-NoProfile",
"-Command",
"(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
],
)?;
out.parse().ok()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn os_version() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn cpu() -> Option<String> {
None
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn ram_bytes() -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -163,6 +274,9 @@ mod tests {
assert!(hw.cpu.is_some(), "{hw:?}");
assert!(hw.ram_mb.unwrap() > 256, "{hw:?}");
assert!(hw.os_version.is_some(), "{hw:?}");
assert!(load_average().is_some());
assert!(rss_bytes(std::process::id()).unwrap() > 1 << 20);
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
+1 -3
View File
@@ -13,9 +13,7 @@ use serde::{Deserialize, Serialize};
mod hardware;
pub use hardware::{power_source, Display, Gpu, Hardware};
/// The process probes, from the platform (`horizon-system`).
pub use horizon_telemetry::system::{cpu_seconds, load_average, rss_bytes};
pub use hardware::{cpu_seconds, load_average, power_source, rss_bytes, Display, Gpu, Hardware};
pub const FORMAT: &str = "horizon-benchmark-report";
pub const VERSION: u32 = 1;
-6
View File
@@ -22,8 +22,6 @@ horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -133,10 +131,6 @@ horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
[[bin]]
name = "horizon-ctl"
path = "../horizon-client/src/bin/horizon-ctl/main.rs"
[[example]]
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
+4 -4
View File
@@ -36,7 +36,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_shares: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -292,7 +292,7 @@ fn entity_written_color(in: Out, color: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 && in.overlay != 11u && in.overlay != 12u {
var mark = hdr_entity_mark(in);
if in.overlay == 4u { mark = hdr_scene_alpha(globals.hdr_light.y); }
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_gains), color.a);
return vec4(hdr_written(color.rgb, mark, globals.hdr_settings, globals.hdr_shares), color.a);
}
if FRAME_LINEAR && (in.overlay == 11u || in.overlay == 12u) {
// The cracks scale what is under them by twice their colour (CRUMBLING_BLEND). Vanilla's
@@ -451,7 +451,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
return vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return frame_stored(color);
}
@@ -472,7 +472,7 @@ fn celestial_color(in: Out) -> vec4<f32> {
// or it would come out darker than the sky around it.
let full = y >= globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u];
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_gains), color.a);
return vec4(hdr_written(added, select(1.0, hdr_mark(tier, y), full), globals.hdr_settings, globals.hdr_shares), color.a);
}
// Premultiplied after the decode: the light the texel adds, at its coverage.
if HDR_WRITE_LIGHT { return vec4(frame_stored(color).rgb * color.a, color.a); }
+5 -5
View File
@@ -428,10 +428,10 @@ struct Globals {
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
/// The scene's peak, HDR lighting's gain, world/look and debug; then the tiers' gains
/// Headroom, effective scene gain, world/look and debug, as HDR output's pass has them
/// (`Hdr::draw_params`).
hdr_settings: [f32; 4],
hdr_gains: [f32; 4],
hdr_shares: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -8138,14 +8138,14 @@ impl Renderer {
self.shader_pack_kind().is_some(),
!frame.title,
);
let (hdr_settings, hdr_gains) = self
let (hdr_settings, hdr_shares) = self
.hdr
.as_ref()
.map_or(([0.0; 4], [0.0; 4]), |h| h.draw_params(!frame.title));
self.world_post.baked_light(
hdr_gain,
hdr_settings,
hdr_gains,
hdr_shares,
self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
);
let jitter = self.world_post.jitter(
@@ -8305,7 +8305,7 @@ impl Renderer {
f32::from(u8::from(hdr_gain)),
],
hdr_settings,
hdr_gains,
hdr_shares,
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -33,7 +33,7 @@ struct Uniform {
mode: u32,
pad: [u32; 2],
hdr_settings: [f32; 4],
hdr_gains: [f32; 4],
hdr_shares: [f32; 4],
hdr_light: [f32; 4],
}
@@ -407,7 +407,7 @@ impl CustomSky {
self.written_gain = (globals.hdr_light[3] != 0.0).then(|| {
let mark =
((1.0 - 0.24 * globals.hdr_light[1]).clamp(0.0, 1.0) * 255.0).round() / 255.0;
if globals.hdr_settings[2] > 0.5 {
if globals.hdr_settings[2] > 0.5 && globals.hdr_settings[0] > 1.0 {
(1.0 + (globals.hdr_settings[1] - 1.0) * (1.0 - mark) / 0.24).powf(1.0 / 2.2)
} else {
1.0
@@ -428,7 +428,7 @@ impl CustomSky {
mode: mode as u32,
pad: [0; 2],
hdr_settings: globals.hdr_settings,
hdr_gains: globals.hdr_gains,
hdr_shares: globals.hdr_shares,
hdr_light: globals.hdr_light,
};
let offset = STRIDE * self.frame.len() as u64;
@@ -702,7 +702,7 @@ mod tests {
mode: mode_index as u32,
pad: [0; 2],
hdr_settings: [5.0, 4.0, 1.0, f32::from(u8::from(debug))],
hdr_gains: [1.6, 2.4, 2.8, 4.0],
hdr_shares: [0.2, 0.5, 0.6, 1.0],
hdr_light: [0.8, 0.8, 1.0, f32::from(u8::from(written))],
};
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -10,7 +10,7 @@ struct Layer {
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> layer: Layer;
@@ -54,7 +54,7 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
var color = t.rgb;
if HDR_WRITE_LIGHT && layer.hdr_light.w != 0.0 &&
(layer.mode == ADD || layer.mode == SUBTRACT || layer.mode == REPLACE || layer.mode == ALPHA) {
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains);
color = hdr_written(color, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares);
}
// On a frame of linear light (`FRAME_LINEAR`) the layer's colour is decoded as it is stored.
switch layer.mode {
@@ -84,5 +84,5 @@ fn custom_sky_color(in: Out) -> vec4<f32> {
@fragment fn fs_custom_sky_screen_light(in: Out) -> @location(0) vec4<f32> {
let color = custom_sky_color(in);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_gains), color.a);
return vec4(hdr_written(color.rgb, 1.0 - 0.24 * layer.hdr_light.y, layer.hdr_settings, layer.hdr_shares), color.a);
}
+94 -274
View File
@@ -5,10 +5,6 @@
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
//!
//! On the float frame Horizon's world light is the same on every display (`TIERS`,
//! `SUNLIT_GAIN`, up to `SCENE_PEAK`), and the tone curve alone fits it to the display's
//! peak (docs/design/hdr-linear-light.md, step 6a).
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -103,53 +99,38 @@ struct Layered {
params: wgpu::Buffer,
}
/// How one tier of light sources (`mesher::hdr_tier`) shines: its brightest pixels are
/// multiplied by `gain`, in multiples of paper white, lifted from brightness `start` (none) to
/// `full` (all of it), the texel's own brightness before lighting.
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
/// its brightest pixels, lifted from brightness `start` (none) to `full` (all of it), the
/// texel's own brightness before lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tier {
pub gain: f32,
pub share: f32,
pub start: f32,
pub full: f32,
}
/// Low, incandescent, bright and very bright: faint glowers such as magma, flames and lava,
/// glowstone and campfires, the sun.
/// Low, incandescent, bright and very bright.
pub const TIERS: [Tier; 4] = [
Tier {
gain: 1.6,
share: 0.2,
start: 0.08,
full: 0.09,
},
Tier {
gain: 2.4,
share: 0.5,
start: 0.14,
full: 0.38,
},
Tier {
gain: 2.8,
share: 0.6,
start: 0.14,
full: 0.38,
},
Tier {
gain: 4.0,
share: 1.0,
start: 0.14,
full: 0.38,
},
];
/// The scene's peak, in multiples of paper white: just above the brightest light the world
/// writes (the sun's disc, white at the very bright tier's 4), and what the tone curve maps
/// onto the display's peak; light past it (where the sky adds onto the sun) is held at it. The
/// world is mastered for a 1000-nit display at ITU-R BT.2408's 203-nit reference white, as
/// HDR10 content usually is: there it shows as written, a dimmer display eases its highlights
/// into its peak (BT.2390's EETF), and a brighter one shows it as at 1000.
pub const SCENE_PEAK: f32 = 1000.0 / 203.0;
/// HDR lighting: what the strongest light multiplies a surface by (`hdr_light.wgsl`).
pub const SUNLIT_GAIN: f32 = 3.0;
/// The compatibility frame's tiers (the 16-bit or 8-bit frame, until step 5 removes it): each
/// one's share of the display's headroom, as before one peak, so the look HDR has by default
/// changes once, when the float frame replaces it.
const COMPAT_SHARES: [f32; 4] = [0.2, 0.5, 0.6, 1.0];
/// Which pixels of a light source's sprite may shine (`hdr_stats.wgsl`), as a fraction of
/// the way from its darkest to its brightest opaque pixel: `parts` for one whose bright parts
/// shine (a torch's flame, not its handle), `whole` for one that shines all over
@@ -350,7 +331,7 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
size: 64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
@@ -963,19 +944,16 @@ impl Hdr {
})
}
/// What the world's writers read (`hdr_written`): the scene's peak, HDR lighting's gain,
/// world/look and debug, then the tiers' gains. No part of the display reaches them.
pub(super) fn draw_params(&self, world: bool) -> ([f32; 4], [f32; 4]) {
let p = self.params(world, false);
let sunlit = if self.lighting { SUNLIT_GAIN } else { 1.0 };
([SCENE_PEAK, sunlit, p[2], p[3]], self.tiers.map(|t| t.gain))
([p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap())
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
fn params(&self, world: bool, pq: bool) -> [f32; 16] {
let paper = self.paper_white.max(1.0);
let c = self.calibration;
let mut params = [0.0; 20];
let mut params = [0.0; 16];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -990,17 +968,12 @@ impl Hdr {
} else {
1.0
};
params[8..12].copy_from_slice(&COMPAT_SHARES);
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
}
params[12] = c.map_or(0.0, |c| c.step as f32);
params[13] = c.map_or(0.0, |c| c.nits);
params[14] = c.map_or(0.0, |c| c.band);
// Only Horizon's lifted world is known to end at the scene's peak; elsewhere (HDR
// Lighting off, a pack's own light, the title) the curve's source follows the display.
params[16] = if world && self.lighting {
SCENE_PEAK
} else {
0.0
};
params
}
@@ -1077,9 +1050,8 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// The compatibility frame's HDR lighting (`fs_hdr`): what the strongest light multiplies a
/// surface by, 4, which looked right across biomes; less only where there is little headroom
/// to ease into. The float frame's is `SUNLIT_GAIN`.
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
@@ -1097,8 +1069,7 @@ mod tests {
return;
};
// All 8-bit marks, both easing policies, non-grey and over-white colours, look,
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries. The
// writers take the gain that a share gives.
// debug, headroom and tier shares; half-float neighbours straddle mark boundaries.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ r#"
struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: vec4<f32> }
@@ -1117,20 +1088,19 @@ var<private> params: ReferenceParams;
select(0.0, 1.0, (mode & 32u) != 0u), vec4(0.2, 0.5, 0.6, 1.0));
let color = select(vec3(0.9, 0.4, 0.02), vec3(1.4, 0.8, 0.2), (mode & 64u) != 0u);
let expected = reference_lit(color, mark, shows, eased);
let gains = 1.0 + (params.peak - 1.0) * params.share;
let actual = hdr_mark_light(color, mark, shows, eased,
vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] = select(0u, 1u, any(abs(expected.light - actual.light) > vec3(1e-6)) || expected.lifted != actual.lifted);
// Gain stays above the tone knee until the separate tone curve runs.
let gain = 1.0 + 3.0 * (1.0 - 194.0 / 255.0) / 0.24;
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), gains);
let white = hdr_written(vec3(1.0), 0.76, vec4(5.0, 4.0, 1.0, 0.0), params.share);
errors[i] |= select(0u, 4u, any(abs(pow(white, vec3(2.2)) - vec3(gain)) > vec3(1e-5)));
let boundary = (f32(i % 255u) + 0.5) / 255.0;
for (var step = -1; step <= 1; step++) {
let stored = unpack2x16float(pack2x16float(vec2(boundary + f32(step) / 65536.0))).x;
let quantized = round(saturate(stored) * 255.0) / 255.0;
let want = pow(reference_lit(color, quantized, params.world > 0.5, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), gains);
let want = pow(reference_lit(color, quantized, params.world > 0.5 && params.peak > 1.0, false).light, vec3(1.0 / 2.2));
let got = hdr_written(color, stored, vec4(params.peak, params.scene, params.world, params.debug), params.share);
errors[i] |= select(0u, 2u, any(abs(want - got) > vec3(1e-6)));
}
}
@@ -1349,12 +1319,10 @@ var<private> params: ReferenceParams;
// Independent pre-refactor light arithmetic applied to a compatibility attachment.
// Float targets in these tests only receive the resulting colour and coverage.
// `lift`: the reference's peak, HDR lighting gain, world and debug, then the tiers' shares
// of the headroom up to that peak (`writers_lift`, `display_lift`).
pub(super) fn legacy_light(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
lift: [[f32; 4]; 2],
hdr: &Hdr,
source: &wgpu::Texture,
target: &wgpu::Texture,
) {
@@ -1377,7 +1345,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
@fragment fn fs() -> @location(0) vec4<f32> {
let c = textureLoad(source, vec2(0), 0);
let mark = round(saturate(c.a) * 255.0) / 255.0;
let light = reference_lit(c.rgb, mark, params.world > 0.5, false).light;
let light = reference_lit(c.rgb, mark, params.world > 0.5 && params.peak > 1.0, false).light;
return vec4(pow(light, vec3(1.0 / 2.2)), 1.0);
}
"#)
@@ -1405,9 +1373,10 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
multiview_mask: None,
cache: None,
});
let (settings, shares) = hdr.draw_params(true);
let params = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&lift),
contents: bytemuck::cast_slice(&[settings, shares]),
usage: wgpu::BufferUsages::UNIFORM,
});
let view = source.create_view(&Default::default());
@@ -1435,19 +1404,6 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
);
}
/// The float frame's writers' light as `legacy_light` takes it: each tier's gain as a share
/// of the headroom up to the scene's peak.
pub(super) fn writers_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let (settings, gains) = hdr.draw_params(true);
[settings, gains.map(|g| (g - 1.0) / (settings[0] - 1.0))]
}
/// The compatibility frame's display pass's light (`fs_hdr`) as `legacy_light` takes it.
fn display_lift(hdr: &Hdr) -> [[f32; 4]; 2] {
let p = hdr.params(true, false);
[[p[1], p[7], p[2], p[3]], p[8..12].try_into().unwrap()]
}
#[test]
fn direct_light_matches_the_legacy_non_float_display_below_the_tone_knee() {
let Some((device, queue)) = gpu() else {
@@ -1460,9 +1416,8 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
eprintln!("legacy display GPU skipped: 16-bit UNORM unavailable");
return;
}
// Compare the compatibility display lift with direct colour under the layered HUD:
// the frozen oracle turns the marks into the light that display gives them, and the
// layered display, whose curve is the identity at 1000 nits, must show it the same.
// Compare the compatibility display lift with direct colour under the layered HUD.
// The frozen oracle supplies the writer's light from a non-float attachment.
let shown = |layered: bool, world: [f64; 4]| {
let mut hdr = Hdr::with_debug(
&device,
@@ -1486,14 +1441,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
if world[3] == 1.0 {
fill(&mut encoder, &frame, world);
} else {
// The light the compatibility frame shows: its own lift, not the writers'.
legacy_light(
&device,
&mut encoder,
display_lift(&legacy),
&marked,
&frame,
);
legacy_light(&device, &mut encoder, &hdr, &marked, &frame);
}
hdr.begin(&queue, true);
hdr.tonemap(&mut encoder, None);
@@ -1532,206 +1480,78 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(above[0] > white[0] && above[1] > white[1]);
}
/// The world's writers read nothing of the display: the same gains and the same scene peak
/// at any paper white and peak (docs/design/hdr-linear-light.md, step 6a).
#[test]
fn the_writers_light_is_the_same_on_every_display() {
fn the_tone_curve_eases_light_into_the_peak() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let written = hdr.draw_params(true);
assert_eq!(
written,
(
[super::SCENE_PEAK, super::SUNLIT_GAIN, 1.0, 0.0],
[1.6, 2.4, 2.8, 4.0]
)
);
for (paper, peak) in [
(203.0, 300.0),
(203.0, 600.0),
(203.0, 1500.0),
(400.0, 600.0),
(480.0, 200.0),
(80.0, 10000.0),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
assert_eq!(hdr.draw_params(true), written, "{paper}/{peak}");
assert_eq!(
hdr.params(true, false)[16],
super::SCENE_PEAK,
"{paper}/{peak}"
);
}
// The title, and frames Horizon doesn't light (HDR Lighting off, a pack drawing):
// nothing says where their light ends, so the curve's source follows the display.
assert_eq!(hdr.params(false, false)[16], 0.0);
hdr.lighting = false;
assert_eq!(hdr.draw_params(true).0[1], 1.0);
assert_eq!(hdr.params(true, false)[16], 0.0);
}
/// The tone curve maps the scene's peak S onto the display's peak T with ITU-R BT.2390's
/// EETF, keeping hue, after holding light above S at S. Checkpoints are worked out apart
/// from the shader, in f64 from SMPTE ST 2084 and BT.2390.
#[test]
fn the_tone_curve_maps_the_scene_peak_onto_the_display() {
let Some((device, queue)) = gpu() else {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let scene_peak = super::SCENE_PEAK;
// The frame holding `color` (in gamma, as it stores light), after the curve if `curve`.
let through = |hdr: &mut Hdr, color: [f64; 4], curve: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
if curve {
hdr.tonemap(&mut encoder, None);
}
texel(&device, &queue, encoder, &frame)
};
// `exact`, or within one half-float step: S's own gamma rounds up in half float, to
// light just above S that the curve holds at S, and whether that comes back to the same
// half float depends on the GPU's rounding (one step lower on an Apple M4 Pro).
let unchanged = |hdr: &mut Hdr, color: [f64; 4], exact: bool| {
let (got, stored) = (through(hdr, color, true), through(hdr, color, false));
(0..4).all(|c| {
got[c] == stored[c] || !exact && (got[c] - stored[c]).abs() <= stored[c] / 1024.0
})
};
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let headroom = 1000.0 / 203.0;
// The frame holding `color` (in gamma, as it stores light) after `passes` ran on it.
let after =
|hdr: &mut Hdr, color: [f64; 4], passes: &dyn Fn(&Hdr, &mut wgpu::CommandEncoder)| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
fill(&mut encoder, &frame, color);
hdr.begin(&queue, true);
passes(hdr, &mut encoder);
texel(&device, &queue, encoder, &frame)
};
let tonemap = |hdr: &Hdr, encoder: &mut wgpu::CommandEncoder| hdr.tonemap(encoder, None);
// Linear light in and out, in units of paper white.
let mapped = |hdr: &mut Hdr, light: [f32; 3]| {
let eased = |hdr: &mut Hdr, light: [f32; 3]| {
let [r, g, b] = light.map(|c| f64::from(c.powf(1.0 / 2.2)));
let [r, g, b, a] = through(hdr, [r, g, b, 1.0], true);
let [r, g, b, a] = after(hdr, [r, g, b, 1.0], &tonemap);
assert_eq!(a, 1.0);
[r, g, b].map(|c| c.powf(2.2))
};
let near = |got: f32, want: f32| (got - want).abs() <= 0.005 * want.max(1.0);
// A display at or above the mastering one (T >= S): every stored value up to S comes
// out exactly as it went in, grey or coloured, and light above S is held at S with its
// hue, so 1500 and 10 000 nits show what 1000 does; so do the settings' extremes (Paper
// White 10 to 480 nits, Peak up to 10 000) wherever T reaches S.
for (paper, peak) in [
(203.0, 1000.0),
(203.0, 1500.0),
(203.0, 10000.0),
(480.0, 10000.0),
(10.0, 200.0),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
for i in 0..=64 {
let g = f64::from((scene_peak * i as f32 / 64.0).powf(1.0 / 2.2));
let exact = i < 64;
assert!(unchanged(&mut hdr, [g, g, g, 1.0], exact), "{peak}: {i}");
assert!(
unchanged(&mut hdr, [g, g * 0.6, g * 0.1, 1.0], exact),
"{peak}: {i}"
);
}
for light in [8.0f32, 50.0] {
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
near(r, scene_peak) && near(g * 4.0, r) && b == 0.0,
"{peak}: {light}: {r} {g} {b}"
);
}
}
// Dimmer displays: unchanged up to the knee, then the EETF into T, T at S and past it.
// At 300 nits the knee falls just below white (161 nits): white shows at 198 nits
// where the gates' curve, whose source followed the display, kept it at 203; the
// highlights above it keep more of their steps.
for (paper, peak, knee, checkpoints) in [
(
203.0,
600.0,
2.0,
&[
(2.5, 2.47516),
(3.0, 2.75699),
(4.0, 2.9404),
(scene_peak, 600.0 / 203.0),
(8.0, 600.0 / 203.0),
][..],
),
(
203.0,
300.0,
0.75,
&[
(0.9, 0.89289),
(1.0, 0.97365),
(1.2, 1.10406),
(1.4, 1.20134),
(2.0, 1.36833),
(4.0, 1.47641),
(8.0, 300.0 / 203.0),
],
),
(
400.0,
600.0,
0.8,
&[
(1.0, 0.97973),
(1.2, 1.11501),
(1.5, 1.25607),
(2.0, 1.38818),
(scene_peak, 1.5),
],
),
// No headroom (a peak under paper white): the scene is compressed into white.
(
203.0,
150.0,
0.4,
&[
(1.0, 0.78346),
(1.5, 0.90244),
(2.0, 0.95589),
(4.0, 0.99944),
(scene_peak, 1.0),
],
),
] {
(hdr.paper_white, hdr.peak) = (paper, peak);
for light in [0.0f32, 0.3, knee] {
let g = f64::from(light.powf(1.0 / 2.2));
assert!(
unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true),
"{paper}/{peak}: {light}"
);
}
for &(light, want) in checkpoints {
let [r, ..] = mapped(&mut hdr, [light; 3]);
assert!(near(r, want), "{paper}/{peak}: {light}: {r}, not {want}");
}
// It rises, stays under the peak, and the brightest channel scales the others; a
// half float's step in gamma is 0.1% of the light near white.
let headroom = (peak / paper).max(1.0);
let mut last = 0.0;
for i in 0..40 {
let light = 0.5 * 1.08f32.powi(i);
let [r, g, b] = mapped(&mut hdr, [light, light / 4.0, 0.0]);
assert!(
r >= last * 0.998 && r <= headroom * 1.002,
"{peak}: {light}: {r}"
);
assert!(near(g * 4.0, r) && b == 0.0, "{peak}: {light}: {r} {g} {b}");
last = r;
}
}
// Light Horizon doesn't lift (HDR Lighting off, a 0.2 pack's own) has no known peak:
// the curve's source follows the display, the knee at 60% of its peak and at least
// white, as before one peak. At 300 nits the source's peak is 645 nits.
hdr.lighting = false;
(hdr.paper_white, hdr.peak) = (203.0, 300.0);
let headroom = 300.0 / 203.0;
for light in [0.0f32, 0.5, 0.9, 1.0] {
// Up to the knee (60% of the peak) the light stays exactly as stored.
for light in [0.0f32, 0.5, 1.0, 2.0, 2.9] {
let g = f64::from(light.powf(1.0 / 2.2));
assert!(unchanged(&mut hdr, [g, g * 0.5, 0.0, 1.0], true), "{light}");
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
}
// Past it, BT.2390's EETF in PQ at 203 and 1000 nits, with the source's peak at 2771
// nits (13.7 times paper white), worked out apart from the shader.
for (light, want) in [
(3.5, 3.43791),
(4.0, 3.78351),
(headroom, 4.23556),
(8.0, 4.82043),
(12.0, 4.92462),
(50.0, headroom),
] {
let [r, ..] = eased(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
}
// It rises, stays under the peak, and the brightest channel scales the others: the
// light keeps its hue.
let mut last = 0.0;
for i in 0..40 {
let light = 2.5 * 1.08f32.powi(i);
let [r, g, b] = eased(&mut hdr, [light, light / 4.0, 0.0]);
assert!(r >= last - 0.001 && r <= headroom * 1.002, "{light}: {r}");
assert!(near(g * 4.0, r) && b == 0.0, "{light}: {r} {g} {b}");
last = r;
}
// A display with little headroom, 300 nits at a paper white of 203: the knee sits at
// white, so all that SDR shows stays as it is, and the source's peak is 645 nits.
hdr.peak = 300.0;
let headroom = 300.0 / 203.0;
for light in [0.5f32, 0.9, 1.0] {
let g = f64::from(light.powf(1.0 / 2.2));
let stored = after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &|_, _| {});
assert_eq!(
after(&mut hdr, [g, g * 0.5, 0.0, 1.0], &tonemap),
stored,
"{light}"
);
}
for (light, want) in [
(1.2, 1.16839),
@@ -1740,12 +1560,12 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(2.0, 1.44024),
(4.0, headroom),
] {
let [r, ..] = mapped(&mut hdr, [light; 3]);
let [r, ..] = eased(&mut hdr, [light; 3]);
assert!(near(r, want), "{light}: {r}, not {want}");
}
// With no headroom there, a clip at white, channel by channel.
// No headroom (a peak below paper white): a clip at white, channel by channel.
hdr.peak = 150.0;
let [r, g, b] = mapped(&mut hdr, [1.5, 0.5, 0.2]);
let [r, g, b] = eased(&mut hdr, [1.5, 0.5, 0.2]);
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
@@ -1762,7 +1582,7 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].gain = 3.0;
hdr.tiers[1].share = 0.7;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
@@ -1,6 +1,6 @@
use super::{
Hdr, Output,
tests::{gpu, legacy_light, texel, writers_lift},
tests::{gpu, legacy_light, texel},
};
use wgpu::TextureFormat as F;
use wgpu::util::DeviceExt;
@@ -648,7 +648,7 @@ impl<'a> Draw<'a> {
}
if !written && !self.raw {
let light = hdr.frame(self.device, [1, 1]);
legacy_light(self.device, &mut encoder, writers_lift(hdr), &frame, &light);
legacy_light(self.device, &mut encoder, hdr, &frame, &light);
return texel(self.device, self.queue, encoder, &light);
}
texel(self.device, self.queue, encoder, &frame)
@@ -662,7 +662,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
let mut cases = 0;
for hdr_on in [false, true] {
let globals = super::super::Globals {
@@ -677,7 +677,7 @@ fn float_plain_fragments_keep_rgb_and_write_only_coverage() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, f32::from(u8::from(hdr_on)), 0.0],
hdr_settings: settings,
hdr_gains: gains,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -739,7 +739,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
// Frozen f030d9c80 fragment: the original written branch and its marked fallback.
let source = include_str!("../hdr_light.wgsl").to_owned()
+ include_str!("stars.wgsl")
@@ -750,7 +750,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
@fragment fn reference() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
return vec4(stars.color.rgb * b, 0.175);
}
@@ -763,7 +763,7 @@ fn float_stars_keep_the_original_rgb_with_brightness_as_coverage() {
let mut data = [0.0_f32; 32];
data[16..20].copy_from_slice(&[0.7, 0.8, 0.9, 0.35]);
data[20..24].copy_from_slice(&settings);
data[24..28].copy_from_slice(&gains);
data[24..28].copy_from_slice(&shares);
data[31] = gain;
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
@@ -908,7 +908,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak) = (203.0, 1000.0);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
let mut count = 0;
for fog in [0.0, 0.5, 1.0] {
for visibility in [1.0, 0.4] {
@@ -921,7 +921,7 @@ fn actual_opaque_writers_match_the_legacy_non_float_oracle() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_gains: gains,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for terrain in [true, false] {
@@ -968,7 +968,7 @@ fn actual_translucent_writers_blend_their_own_light() {
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -977,7 +977,7 @@ fn actual_translucent_writers_blend_their_own_light() {
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_gains: gains,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
// Texel alpha 100 is clear glass's kind of texel, below the 0.6 the marked frame's
@@ -1035,14 +1035,14 @@ fn unmarked_entity_users_keep_their_source_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_gains: gains,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 255);
@@ -1079,13 +1079,13 @@ fn additive_texels_below_the_mark_cutoff_keep_their_gain() {
return;
}
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
hdr_windows: hdr.windows(),
hdr_light: [1.0, 1.0, 1.0, 1.0],
hdr_settings: settings,
hdr_gains: gains,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
let draw = Draw::new(&device, &queue, false, &globals, 16);
@@ -1129,7 +1129,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
}))
.unwrap();
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
let (settings, gains) = hdr.draw_params(true);
let (settings, shares) = hdr.draw_params(true);
let globals = super::super::Globals {
fog: [0.0, 100.0, 0.0, 100.0],
fog_ends: [100.0, 100.0, 0.0, 0.0],
@@ -1138,7 +1138,7 @@ fn check_oit_writer(terrain: bool, kind: &str, overlay: u32, tier: u32, alphas:
hdr_windows: hdr.windows(),
hdr_light: [0.8, 0.8, 1.0, 1.0],
hdr_settings: settings,
hdr_gains: gains,
hdr_shares: shares,
..bytemuck::Zeroable::zeroed()
};
for &alpha in alphas {
@@ -2770,8 +2770,6 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
// As the renderer has it under a pack that draws: Horizon lights none of it.
display.lighting = false;
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
@@ -47,7 +47,7 @@ struct Uniform {
clip_from_sky: [[f32; 4]; 4],
color: [f32; 4],
hdr_settings: [f32; 4],
hdr_gains: [f32; 4],
hdr_shares: [f32; 4],
hdr_light: [f32; 4],
}
@@ -184,7 +184,7 @@ impl Stars {
clip_from_sky: clip_from_sky.to_cols_array_2d(),
color: [b; 4],
hdr_settings: globals.hdr_settings,
hdr_gains: globals.hdr_gains,
hdr_shares: globals.hdr_shares,
hdr_light: globals.hdr_light,
};
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&uniform));
@@ -5,7 +5,7 @@ struct Stars {
// starBrightness in every channel (RenderSystem.setShaderColor).
color: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_shares: vec4<f32>,
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> stars: Stars;
@@ -20,7 +20,7 @@ struct Stars {
@fragment fn fs_stars() -> @location(0) vec4<f32> {
let b = stars.color.a;
if HDR_WRITE_LIGHT && stars.hdr_light.w != 0.0 {
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_gains), b);
return vec4(hdr_written(stars.color.rgb * b, 0.175, stars.hdr_settings, stars.hdr_shares), b);
}
if HDR_WRITE_LIGHT { return vec4(frame_stored(stars.color).rgb * b, b); }
return vec4(stars.color.rgb * b, 0.175);
@@ -360,13 +360,13 @@ impl Post {
&mut self,
gain: bool,
settings: [f32; 4],
gains: [f32; 4],
shares: [f32; 4],
windows: [[f32; 4]; 2],
) {
// These uniforms do not change baked colour without gain. Keep compatibility
// history stable when only its final display settings change.
let key = Some(if gain {
(true, settings, gains, windows)
(true, settings, shares, windows)
} else {
(false, [0.0; 4], [0.0; 4], [[0.0; 4]; 2])
});
@@ -887,7 +887,7 @@ mod tests {
post.jitter([32, 32], true, Mat4::IDENTITY, DVec3::ZERO);
assert_eq!(post.taa_this_frame.jitter[2], 0.0);
eprintln!(
"TAA baked-light GPU checked gain, all settings/gains/windows and the first frame after a pack"
"TAA baked-light GPU checked gain, all settings/shares/windows and the first frame after a pack"
);
}
@@ -64,7 +64,7 @@ fn warp_fragment(in: TerrainOut, front: bool) -> Fragment {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fog));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return frame_stored(finished);
}
+26 -50
View File
@@ -20,20 +20,15 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// The compatibility frame's lift (`lit`; the float frame's writers have theirs from
// Hdr::draw_params). HDR lighting: what the strongest light multiplies a surface by, 1 for
// none.
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
scene: f32,
// Per tier, low to very bright (hdr.rs `COMPAT_SHARES`): the share of the headroom
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
calibration: vec4<f32>,
// The scene's peak in units of paper white where Horizon lifts the world (hdr.rs
// `SCENE_PEAK`), else 0: what the tone curve maps onto the display's peak (`tone_scale`).
scene_peak: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@@ -160,7 +155,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
fn lit(f: vec3<f32>, a: f32, shows: bool, eased: bool) -> Lit {
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), (params.peak - 1.0) * params.share);
return hdr_mark_light(f, a, shows, eased, vec4(params.peak, params.scene, params.world, params.debug), params.share);
}
// Calibration: above its controls the test picture, straight in nits, past
@@ -256,52 +251,33 @@ fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
return display(light, pos);
}
// Where the scene's peak is unknown (`tone_scale`), the curve's knee: up to it light stays as it
// is. Where HDR lighting eased into the peak before the curve (`lit`), 60% of it, so bright
// textures keep their detail; at least paper white.
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
// before the curve (`lit`), 60% of it, so bright textures keep their detail; at least paper white.
fn tone_knee(headroom: f32) -> f32 {
return max(0.6 * headroom, 1.0);
}
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper` nits:
// what to scale the light by, so it ends at most `headroom` (T). ITU-R BT.2390's EETF in PQ,
// without the black level lift (`display` does that): the same up to its knee, then a Hermite
// spline reaching T at the source's peak, past which light shows at T. The brightest channel sets
// the scale for all three, so light keeps its hue (lava stays orange) and no channel passes T.
//
// The source is the scene's peak S where Horizon lifts the world (`scene_peak`, hdr.rs
// `SCENE_PEAK`): the world's light is the same on every display, light past S is held at S, and
// where T reaches S that is all, so a display above S shows the world as one at S. Where the
// scene's range is unknown (`scene_peak` 0: a 0.2 pack's own light, HDR Lighting off), the
// source follows the display, as the 0.2 contract has it: the peak that puts the EETF's knee at
// `tone_knee`, PQ's 10 000 nits at most.
fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
let peak = pq(vec3(headroom * paper)).x;
var source: f32;
var e: f32;
var held = 1.0;
if scene_peak > 0.0 {
held = min(1.0, scene_peak / max(top, 1e-6));
if scene_peak <= headroom {
return held;
}
source = pq(vec3(scene_peak * paper)).x;
e = pq(vec3(min(top, scene_peak) * paper)).x / source;
} else {
if top <= tone_knee(headroom) {
return 1.0;
}
source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
e = pq(vec3(top * paper)).x / source;
// The tone curve on light whose brightest channel is `top`, in units of paper white `paper`
// nits: what to scale the light by, so it ends at most `headroom` (above 1). ITU-R BT.2390's
// EETF in PQ, without the black level lift (`display` does that): the same up to its knee, then
// a Hermite spline that reaches the peak level, at the source's peak. That peak is where the knee
// lands at `tone_knee`, PQ's 10 000 nits at most; light past it shows at the peak. The brightest
// channel sets the scale for all three, so light keeps its hue as it eases (lava stays orange)
// and no channel passes the peak.
fn tone_scale(top: f32, headroom: f32, paper: f32) -> f32 {
if top <= tone_knee(headroom) {
return 1.0;
}
let peak = pq(vec3(headroom * paper)).x;
let source = min(3.0 * peak - 2.0 * pq(vec3(tone_knee(headroom) * paper)).x, 1.0);
let e = pq(vec3(top * paper)).x / source;
let max_lum = peak / source;
// At least 0: past 3 to 1 in PQ the knee would leave the source's range.
let ks = max(1.5 * max_lum - 0.5, 0.0);
let ks = 1.5 * max_lum - 0.5;
if e >= 1.0 {
return headroom / top;
}
if e <= ks {
return held;
return 1.0;
}
let t = (e - ks) / (1.0 - ks);
let t2 = t * t;
@@ -311,16 +287,16 @@ fn tone_scale(top: f32, scene_peak: f32, headroom: f32, paper: f32) -> f32 {
}
// Hdr::tonemap, after the post effects and before the camera overlays (docs/design/
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) fitted to the display, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light the curve
// leaves alone stays exactly as stored. With no headroom and no scene peak, a clip at white.
// hdr-linear-light.md, `[tonemap]`): the light (`world` holds the frame) eased into the peak, in
// gamma, where scaling the light by s scales the stored colour by s^(1/2.2). Light up to the
// knee stays exactly as stored. With no headroom, a clip at white.
@fragment fn fs_tonemap(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let g = textureLoad(world, vec2<i32>(pos.xy), 0).rgb;
if params.scene_peak == 0.0 && params.peak <= 1.0 {
if params.peak <= 1.0 {
return vec4(min(g, vec3(1.0)), 1.0);
}
let top = pow(max(max(g.r, max(g.g, g.b)), 0.0), 2.2);
let s = tone_scale(top, params.scene_peak, params.peak, params.paper * 80.0);
let s = tone_scale(top, params.peak, params.paper * 80.0);
return vec4(select(g * pow(s, 1.0 / 2.2), g, s == 1.0), 1.0);
}
@@ -355,7 +331,7 @@ fn layered(at: vec2<i32>) -> vec3<f32> {
}
// Screenshots keep midtones at their drawn brightness, with a shoulder starting at 0.6
// paper white in linear light. Unlike the display curve (which ends at the display's peak),
// paper white in linear light. Unlike the display curve (whose knee is at least white),
// this has an unbounded source range: k + (1-k)*(top-k)/(top+1-2*k) approaches SDR
// white smoothly, with slope 1 at k. One scale keeps the world's linear RGB ratios.
fn screenshot_world(w: vec3<f32>) -> vec3<f32> {
@@ -27,8 +27,7 @@ struct Lit {
lifted: bool,
};
// `lifts`, per tier: how much its fully lifted pixels gain over their own light (their gain - 1).
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, lifts: vec4<f32>) -> Lit {
fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4<f32>, shares: vec4<f32>) -> Lit {
var lifted = false;
// Light over 1 (a float frame's) shows only where the world does and carries
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
@@ -61,7 +60,7 @@ fn hdr_mark_light(f: vec3<f32>, a: f32, shows: bool, eased: bool, settings: vec4
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
var gain = 1.0 + lifts[tier] * lift;
var gain = 1.0 + (settings.x - 1.0) * shares[tier] * lift;
if eased {
gain = min(gain, settings.x / max(top, 1e-4));
}
@@ -89,8 +88,7 @@ fn hdr_quantized_mark(mark: f32) -> f32 {
return round(saturate(mark) * 255.0) / 255.0;
}
// The world's writers' light, from the tiers' `gains` (Hdr::draw_params): the same on every
// display; the tone curve fits it to the display afterwards (fs_tonemap).
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, gains: vec4<f32>) -> vec3<f32> {
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), settings.z > 0.5, false, settings, gains - vec4(1.0)).light, vec3(1.0 / 2.2));
fn hdr_written(color: vec3<f32>, mark: f32, settings: vec4<f32>, shares: vec4<f32>) -> vec3<f32> {
let shows = settings.z > 0.5 && settings.x > 1.0;
return pow(hdr_mark_light(max(color, vec3(0.0)), hdr_quantized_mark(mark), shows, false, settings, shares).light, vec3(1.0 / 2.2));
}
@@ -24,7 +24,7 @@ fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
var color = oit_cloud_color(in, front, true);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_scene_alpha(globals.hdr_light.y),
globals.hdr_settings, globals.hdr_gains), color.a);
globals.hdr_settings, globals.hdr_shares), color.a);
}
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
@@ -62,7 +62,7 @@
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
var color = celestial_color(in);
if HDR_WRITE_LIGHT && globals.hdr_light.w != 0.0 {
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_gains), color.a);
color = vec4(hdr_written(color.rgb, hdr_entity_mark(in), globals.hdr_settings, globals.hdr_shares), color.a);
}
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
}
@@ -41,7 +41,7 @@ fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
globals.hdr_light.y, f * globals.fog_color.a));
// Convert the surface before weighting; its mark never belongs to the light behind it.
let fogged = mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_gains) * weighted.a, weighted.a);
return vec4(hdr_written(fogged, mark, globals.hdr_settings, globals.hdr_shares) * weighted.a, weighted.a);
}
return vec4(rgb, weighted.a);
}
@@ -1100,9 +1100,6 @@ impl PackScreen {
0,
);
self.search.draw(&mut out, m, blink_ms, None);
if self.browser.is_none() && self.search.contains(f64::from(mouse.0), f64::from(mouse.1)) {
crate::widgets::text_cursor(true, true);
}
for side in 0..2 {
self.draw_list(&mut out, m, side, mouse);
}
@@ -1305,9 +1302,6 @@ impl PackScreen {
field.x = qx;
field.y = qy;
field.draw(out, m, 0, None);
if field.contains(f64::from(mouse.0), f64::from(mouse.1)) {
crate::widgets::text_cursor(true, true);
}
let (sbx, sby) = self.browser_search_button();
d::button(
out,
@@ -310,9 +310,6 @@ pub(crate) fn search_field(
let frame = [x, y, clear[0] - x, h];
search.frame = Some(frame);
search.draw(batch, atlas, None);
if ui::hovered(cursor, x, y, frame[2], h) {
crate::widgets::text_cursor(true, true);
}
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
@@ -43,7 +43,7 @@ struct Globals {
// w: apply the no-pack HDR gain; HDR_WRITE_LIGHT independently keeps float alpha as coverage.
hdr_light: vec4<f32>,
hdr_settings: vec4<f32>,
hdr_gains: vec4<f32>,
hdr_shares: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -788,7 +788,7 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
fn solid_light(finished: vec4<f32>) -> vec4<f32> {
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_gains), 1.0);
return vec4(hdr_written(finished.rgb, finished.a, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return frame_stored(vec4(finished.rgb, 1.0));
}
@@ -870,7 +870,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let mark = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged));
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_gains), finished.a);
return vec4(hdr_written(finished.rgb, mark, globals.hdr_settings, globals.hdr_shares), finished.a);
}
return frame_stored(finished);
}
@@ -1076,7 +1076,7 @@ fn sky_finished(color: vec3<f32>) -> vec4<f32> {
let mark = 1.0 - 0.24 * globals.hdr_light.y;
if HDR_WRITE_LIGHT {
if globals.hdr_light.w != 0.0 {
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_gains), 1.0);
return vec4(hdr_written(color, mark, globals.hdr_settings, globals.hdr_shares), 1.0);
}
return frame_stored(vec4(color, 1.0));
}
@@ -13,8 +13,6 @@ pub enum Cursor {
#[default]
Arrow,
PointingHand,
/// `CursorTypes.IBEAM`: over a text field that takes input.
Ibeam,
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
@@ -57,18 +55,6 @@ pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
}
}
/// `EditBox.handleCursor`: a hovered text field shows the I-beam, or the
/// forbidden sign when it takes no input.
pub fn text_cursor(hovered: bool, editable: bool) {
if hovered {
request_cursor(if editable {
Cursor::Ibeam
} else {
Cursor::NotAllowed
});
}
}
/// The cursor the frame asked for, and back to the arrow for the next one
/// (the window shows the arrow unless a widget asks).
pub fn take_cursor() -> Cursor {
@@ -443,16 +429,6 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
mod cursor_tests {
use super::*;
#[test]
fn a_hovered_text_field_asks_for_the_i_beam() {
let _ = take_cursor();
text_cursor(false, true);
assert_eq!(take_cursor(), Cursor::Arrow);
text_cursor(true, true);
assert_eq!(take_cursor(), Cursor::Ibeam);
text_cursor(true, false);
assert_eq!(take_cursor(), Cursor::NotAllowed);
}
#[test]
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
let _ = take_cursor();
-2
View File
@@ -73,8 +73,6 @@ horizon-theme = { path = "../horizon-theme" }
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
# The machine and process probes (the dedicated server's performance report).
horizon-system = { path = "../horizon-system" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
@@ -40,17 +40,6 @@ pub fn carries_record(stack: &crate::items::ItemStack) -> bool {
.is_some_and(|data| data.get("RecordItem").is_some())
}
/// `LecternBlock.getStateForPlacement`: the item's `minecraft:block_entity_data`
/// carries a `Book` (`TypedEntityData.contains` checks the key only), so the
/// placed lectern's `has_book` state says so. Vanilla reads the item's data,
/// not the block entity the placement just filled.
pub fn carries_book(stack: &crate::items::ItemStack) -> bool {
stack
.data()
.and_then(|d| d.other(BLOCK_ENTITY_DATA))
.is_some_and(|data| data.get("Book").is_some())
}
/// `BlockItem.shouldPrintOpWarning` for a player with game master permission:
/// the item carries data of an op-only type.
pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
-2
View File
@@ -139,8 +139,6 @@ pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod telemetry;
#[cfg(test)]
pub(crate) mod test_fixtures;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
+19 -54
View File
@@ -84,7 +84,6 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub mod nameplate;
mod obstacles;
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
pub use obstacles::ObstacleGrid;
@@ -1290,41 +1289,8 @@ pub struct ExperienceOrb {
/// `ExperienceOrb.followingPlayer`: the followed player's entity id, kept
/// until they are gone or further than 8 blocks. Never saved.
pub following: Option<u32>,
/// `Entity.fallDistance` (`fall_distance` in its save).
pub fall_distance: f64,
}
/// An orb's `Entity.move` this tick, for the fall bookkeeping its caller
/// finishes (the long-move cast, `checkFallDamage`'s HIT_GROUND).
#[derive(Clone, Copy, Debug)]
pub struct OrbMove {
pub from: Vec3,
pub moved: Vec3,
/// `Entity.isInWater` from this tick's fluid step.
pub in_water: bool,
}
impl ExperienceOrb {
/// A fresh orb (`new ExperienceOrb(level, pos, motion, value)`): full
/// health, no age, fall or followed player, not yet ticked.
pub fn new(id: u32, position: Vec3, velocity: Vec3, value: i32) -> Self {
Self {
rotation: [0.0, 0.0],
join: Default::default(),
id,
position,
previous: position,
velocity,
value,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
no_gravity: false,
following: None,
fall_distance: 0.0,
}
}
/// `ExperienceOrb.hurtServer`: the damage comes off the health as an
/// `int`; an orb at 0 or below is discarded. True when it is gone.
pub fn hurt(&mut self, damage: f32) -> bool {
@@ -1448,7 +1414,7 @@ impl ExperienceOrb {
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
let (stays, _) = self.tick_among(
self.tick_among(
world,
facts,
collision,
@@ -1459,12 +1425,10 @@ impl ExperienceOrb {
fluids,
on_sound,
&[],
);
stays
)
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
/// Also gives the move it made, whose fall bookkeeping the caller finishes.
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
@@ -1478,12 +1442,12 @@ impl ExperienceOrb {
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> (bool, OrbMove) {
) -> bool {
self.ticks += 1;
self.previous = self.position;
// `Entity.baseTick`'s fluid step, whose `updateFluidOnEyes` answer the
// water branch below reads: the orb's eye, not its feet, decides.
let (eye_in_water, in_water) = self.update_fluids(world, fluids);
let eye_in_water = self.update_fluids(world, fluids);
let block = world.block(
self.position.x.floor() as i32,
self.position.y.floor() as i32,
@@ -1550,7 +1514,6 @@ impl ExperienceOrb {
let moved = scene
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
let from = self.position;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -1598,12 +1561,7 @@ impl ExperienceOrb {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
let step = OrbMove {
from,
moved,
in_water,
};
(!discarded && self.age < 6000, step)
!discarded && self.age < 6000
}
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
/// server, through the orb's existing hurt/discard policy, playing the generic
@@ -2062,8 +2020,6 @@ pub struct ArmorStand {
/// (`getAttribute`) on the stand's first server tick, and a save that lists
/// it loads it back, so a stand summoned this tick has none yet.
pub speed_instance: bool,
/// `NAME_TAG_DISTANCE` and `BELOW_NAME_DISTANCE` from server attribute updates.
pub nameplate: crate::entities::nameplate::NameplateDistances,
}
/// The fluid a stand travels in (`LivingEntity.travelInFluid`: water first,
/// then lava), from the heights `Entity::push_by_fluid` measured.
@@ -2147,6 +2103,20 @@ impl ArmorStand {
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// The `Pose` compound's keys.
pub const POSE_KEYS: [&'static str; 6] =
@@ -2303,7 +2273,6 @@ impl ArmorStand {
enchantment_equipment: Default::default(),
support: Default::default(),
speed_instance: false,
nameplate: Default::default(),
}
}
/// `Entity.getOnPos(0.2)` (`getOnPosLegacy`): the supporting block's
@@ -3377,7 +3346,6 @@ mod world_border_tests {
None,
);
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4789,7 +4757,6 @@ mod orb_underwater_tests {
.iter()
.map(|(v, _)| {
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -4825,7 +4792,6 @@ mod render_light_tests {
fn orb(y: f64) -> ExperienceOrb {
ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -5028,7 +4994,6 @@ mod orb_lava_tests {
fn sample_orb(at: Vec3, health: i32) -> ExperienceOrb {
ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
+7 -18
View File
@@ -159,28 +159,19 @@ impl ItemEntity {
}
impl ExperienceOrb {
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
/// orb's box and pushes its velocity by any water or lava current; water
/// resets its fall, and `Entity.baseTick` halves it in lava (`isInLava`
/// is false on the orb's first tick, `Entity.firstTick`). Answers
/// `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads, `0.425`
/// above the orb's feet, and `isInWater`.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (bool, bool) {
let (water, lava, eye) = update_entity_fluids(
/// orb's box and pushes its velocity by any water or lava current.
/// Answers `isEyeInFluid(FluidTags.WATER)` for the eye the scan reads,
/// `0.425` above the orb's feet.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
update_entity_fluids(
self.bounds(),
self.position,
self.position.y + f64::from(0.425f32),
&mut self.velocity,
world,
fluids,
);
if water > 0.0 {
self.fall_distance = 0.0;
}
// `tick_among` counts this tick first: `tickCount` is 1 on the first.
if lava > 0.0 && self.ticks > 1 {
self.fall_distance *= 0.5;
}
(eye, water > 0.0)
)
.2
}
}
impl ArmorStand {
@@ -480,7 +471,6 @@ mod tests {
}
let at = Vec3::new(20.5, 4.0, 20.5);
let orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -572,7 +562,6 @@ mod tests {
}
let at = Vec3::new(20.5, 4.6, 20.5);
let mut orb = ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -1,150 +0,0 @@
//! The nameplate attributes carried by armor stands and mobs.
use super::ArmorStand;
use crate::items::ItemStack;
use crate::mobs::Mob;
/// The two synced attributes that control name tags and below-name lines.
/// `None` means the server has not sent a sync for that attribute yet.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct NameplateDistances {
/// `minecraft:name_tag_distance` from the server's attribute updates.
pub name_tag: Option<f64>,
/// `minecraft:below_name_distance` from the server's attribute updates.
pub below_name: Option<f64>,
}
impl NameplateDistances {
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`: the
/// default is 64 and vanilla's maximum is 512.
pub fn name_tag_distance(&self) -> f64 {
name_tag_distance_of(self.name_tag)
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`: the
/// default is 10 and vanilla's maximum is 512.
pub fn below_name_distance(&self) -> f64 {
below_name_distance_of(self.below_name)
}
}
impl Mob {
/// Stores both distances, from a model's own live attributes.
pub fn set_nameplate(&mut self, distances: NameplateDistances) {
let attributes = self.extra.attributes.get_or_insert_with(Default::default);
attributes.name_tag_distance = distances.name_tag;
attributes.below_name_distance = distances.below_name;
}
/// Stores `NAME_TAG_DISTANCE` from a server attribute update.
pub fn set_name_tag_distance(&mut self, value: f64) {
self.extra
.attributes
.get_or_insert_with(Default::default)
.name_tag_distance = Some(value);
}
/// Stores `BELOW_NAME_DISTANCE` from a server attribute update.
pub fn set_below_name_distance(&mut self, value: f64) {
self.extra
.attributes
.get_or_insert_with(Default::default)
.below_name_distance = Some(value);
}
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
pub fn name_tag_distance(&self) -> f64 {
name_tag_distance_of(
self.extra
.attributes
.as_ref()
.and_then(|attributes| attributes.name_tag_distance),
)
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
pub fn below_name_distance(&self) -> f64 {
below_name_distance_of(
self.extra
.attributes
.as_ref()
.and_then(|attributes| attributes.below_name_distance),
)
}
}
impl ArmorStand {
/// `NameTagItem.interactLivingEntity`: a custom name handles the use;
/// only a living target changes. Stack consumption belongs to the caller.
pub fn name_with_tag(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:name_tag" {
return false;
}
let Some(name) = stack.data().and_then(|data| data.custom_name.as_ref()) else {
return false;
};
if self.health > 0.0 {
self.custom_name = Some(crate::component::name_text(name));
}
true
}
/// `LivingEntity.getAttributeValue(Attributes.NAME_TAG_DISTANCE)`.
pub fn name_tag_distance(&self) -> f64 {
self.nameplate.name_tag_distance()
}
/// `LivingEntity.getAttributeValue(Attributes.BELOW_NAME_DISTANCE)`.
pub fn below_name_distance(&self) -> f64 {
self.nameplate.below_name_distance()
}
}
/// The vanilla default (64) and maximum (512) for `NAME_TAG_DISTANCE`.
pub fn name_tag_distance_of(value: Option<f64>) -> f64 {
value
.filter(|v| v.is_finite())
.unwrap_or(64.0)
.clamp(0.0, 512.0)
}
/// The vanilla default (10) and maximum (512) for `BELOW_NAME_DISTANCE`.
pub fn below_name_distance_of(value: Option<f64>) -> f64 {
value
.filter(|v| v.is_finite())
.unwrap_or(10.0)
.clamp(0.0, 512.0)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
#[test]
fn nameplate_distances_use_vanilla_defaults_and_clamp() {
assert_eq!(name_tag_distance_of(None), 64.0);
assert_eq!(name_tag_distance_of(Some(5.0)), 5.0);
assert_eq!(name_tag_distance_of(Some(600.0)), 512.0);
assert_eq!(name_tag_distance_of(Some(-4.0)), 0.0);
assert_eq!(below_name_distance_of(None), 10.0);
assert_eq!(below_name_distance_of(Some(2.0)), 2.0);
assert_eq!(below_name_distance_of(Some(-1.0)), 0.0);
}
#[test]
fn armor_stand_nameplate_values_override_defaults_and_clamp() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert_eq!(stand.name_tag_distance(), 64.0);
assert_eq!(stand.below_name_distance(), 10.0);
stand.nameplate = NameplateDistances {
name_tag: Some(5.0),
below_name: Some(2.0),
};
assert_eq!(
(stand.name_tag_distance(), stand.below_name_distance()),
(5.0, 2.0)
);
stand.nameplate.name_tag = Some(600.0);
assert_eq!(stand.name_tag_distance(), 512.0);
}
}
+8 -90
View File
@@ -2360,8 +2360,8 @@ pub fn mob_from_nbt_with_random(
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
crate::mobs::tame::restore_sitting(&mut m, flag(tag, "Sitting"));
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
}
if m.species() == crate::mobs::Species::Panda {
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
@@ -2386,8 +2386,8 @@ pub fn mob_from_nbt_with_random(
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
let legacy = |c: i64| u8::try_from(c as i8).ok().filter(|c| *c < 16).unwrap_or(0);
m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, legacy);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
}
if matches!(
m.species(),
@@ -2640,7 +2640,7 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) as i8 % 6).unsigned_abs();
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
@@ -2901,7 +2901,7 @@ pub fn entity_base_tag(
motion,
rotation,
on_ground: raw.is_some_and(|t| flag(t, "OnGround")),
fall_distance: raw.map_or(0.0, |t| ground_from_nbt(t).1),
fall_distance: 0.0,
}
.tag(raw)
}
@@ -3037,7 +3037,6 @@ pub fn arrow_to_nbt(
}
for (key, value) in [
("Fire", Tag::Short(a.fire_ticks as i16)),
("Air", Tag::Short(p.air_supply as i16)),
("life", Tag::Short(a.life as i16)),
("shake", Tag::Byte(a.shake as i8)),
("inGround", Tag::Byte(i8::from(a.in_ground))),
@@ -3116,7 +3115,6 @@ pub fn arrow_from_nbt(
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
p.on_ground = flag(tag, "OnGround");
projectile_fields::load_arrow_entity_fields(&mut p, tag);
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -3136,6 +3134,8 @@ pub fn arrow_from_nbt(
_ => false,
})
.cloned();
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
a.spectral = spectral;
// `SpectralArrow.readAdditionalSaveData`: absent Duration defaults to 200.
if spectral {
@@ -4162,30 +4162,6 @@ mod tests {
.is_none()
);
}
/// `Shulker` reads `AttachFace` as a byte before
/// `Direction.from3DDataValue` (`abs(data % 6)`): an int is narrowed
/// first, so 258 is byte 2, not 258 % 6 = 0, and 250 is byte -6, face 0.
#[test]
fn a_shulkers_attach_face_is_narrowed_to_a_byte_first() {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:shulker".into();
ty.species = crate::mobs::Species::Shulker;
let types = [("minecraft:shulker".into(), kind)].into();
let face = |saved: Tag| {
let raw = Tag::compound([
("id", string("minecraft:shulker")),
("Pos", doubles(&[4.5, 220.0, 4.5])),
("AttachFace", saved),
]);
mob_from_nbt(&raw, &types, 1, None).unwrap().extra.variant3
};
assert_eq!(face(Tag::Byte(3)), 3);
assert_eq!(face(Tag::Byte(-2)), 2);
assert_eq!(face(Tag::Int(258)), 2);
assert_eq!(face(Tag::Int(250)), 0);
}
use super::*;
use crate::items::ItemStack;
@@ -4233,33 +4209,6 @@ mod tests {
assert_eq!(tag.get("UUID"), Some(&Tag::IntArray(vec![9, 9, 9, 9])));
}
/// An entity that never moves (an `AreaEffectCloud`) saves the
/// `fall_distance` `Entity.load` read, and 0 when it was made in play.
#[test]
fn a_base_tag_keeps_the_loaded_fall_distance() {
let raw = horizon_world::nbt::parse_snbt(
"{id:\"minecraft:area_effect_cloud\",Pos:[1d,200d,1d],fall_distance:5.0d}",
)
.unwrap()
.0;
let fall = |raw: Option<&super::Tag>| {
let tag = super::entity_base_tag(
"minecraft:area_effect_cloud",
super::Vec3::new(1.0, 200.0, 1.0),
super::Vec3::ZERO,
[0.0, 0.0],
[1; 4],
raw,
);
match tag.get("fall_distance") {
Some(super::Tag::Double(bits)) => f64::from_bits(*bits),
other => panic!("no fall_distance: {other:?}"),
}
};
assert_eq!(fall(Some(&raw)), 5.0);
assert_eq!(fall(None), 0.0);
}
#[test]
fn empty_saved_tags_are_omitted_without_changing_raw_or_nonempty_tags() {
let base = Base {
@@ -4590,35 +4539,6 @@ mod tests {
assert_eq!(m.extra.head.as_deref(), Some("minecraft:iron_helmet"));
}
/// `Wolf` and `Cat` read `CollarColor` with `DyeColor.LEGACY_ID_CODEC`: a
/// byte through `DyeColor.byId`, whose out-of-range answer is the first
/// colour, white (`OutOfBoundsStrategy.ZERO`). Nothing wraps.
#[test]
fn a_collar_colour_outside_the_sixteen_reads_white() {
for (species, id) in [
(crate::mobs::Species::Wolf, "minecraft:wolf"),
(crate::mobs::Species::Cat, "minecraft:cat"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = id.into();
ty.species = species;
let types = [(id.into(), kind)].into();
let colour = |saved: Option<Tag>| {
let mut fields = vec![("id", string(id)), ("Pos", doubles(&[4.5, 220.0, 4.5]))];
fields.extend(saved.map(|c| ("CollarColor", c)));
let m = mob_from_nbt(&Tag::compound(fields), &types, 1, None).unwrap();
m.extra.color
};
assert_eq!(colour(Some(Tag::Byte(11))), 11, "{id}");
assert_eq!(colour(Some(Tag::Byte(17))), 0, "{id}: not 17 & 15");
assert_eq!(colour(Some(Tag::Byte(-1))), 0, "{id}: not 15");
// Codec.BYTE narrows an int first: 259 is byte 3.
assert_eq!(colour(Some(Tag::Int(259))), 3, "{id}");
assert_eq!(colour(None), crate::mobs::tame::RED, "{id}");
}
}
fn fused_creeper(fuse: Option<i16>) -> (Mob, Tag) {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
@@ -4862,7 +4782,6 @@ mod tests {
no_gravity: true,
on_ground: true,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: TRIDENT_HIT_GROUND,
item: &trident,
},
@@ -5868,7 +5787,6 @@ mod tests {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: TRIDENT_HIT_GROUND,
item: &trident,
},
+2 -5
View File
@@ -1,7 +1,5 @@
//! Experience orbs: `ExperienceOrb` save data.
use super::{
Base, ExperienceOrb, Tag, flag, float, int_value, motion, number, position, put, rotation,
};
use super::{Base, ExperienceOrb, Tag, flag, int_value, motion, number, position, put, rotation};
/// `ExperienceOrb`: Health, Age, Value, Count.
pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
@@ -12,7 +10,7 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag {
motion: o.velocity,
rotation: o.rotation,
on_ground: o.on_ground,
fall_distance: o.fall_distance,
fall_distance: 0.0,
};
let mut tag = base.tag(None);
for (key, value) in [
@@ -37,7 +35,6 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec<ExperienceOrb> {
};
let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32;
let orb = ExperienceOrb {
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
rotation: rotation(tag),
join: Default::default(),
no_gravity: flag(tag, "NoGravity"),
@@ -45,17 +45,3 @@ pub(super) fn load_projectile_flags_and_entity_fields(p: &mut Projectile, tag: &
p.no_gravity = flag(tag, "NoGravity");
load_projectile_entity_fields(p, tag);
}
/// `Entity.load` for an arrow: fire and fall distance live on its arrow state,
/// air on the projectile and defaults to the 300 of `getMaxAirSupply`.
pub(super) fn load_arrow_entity_fields(p: &mut Projectile, tag: &Tag) {
p.air_supply = tag.get("Air").and_then(int_value).unwrap_or(300);
if let Some(a) = p.arrow.as_mut() {
a.fire_ticks = i32::from(tag.get("Fire").and_then(int_value).unwrap_or(0) as i16);
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
}
}
#[cfg(test)]
#[path = "projectile_fields_tests.rs"]
mod tests;
@@ -1,17 +0,0 @@
use crate::entity_nbt::{arrow_from_nbt, arrow_to_nbt};
use horizon_world::nbt::{Tag, parse_snbt};
/// `Entity.load` reads `Air` and `saveWithoutId` writes it back.
#[test]
fn arrow_keeps_its_saved_air() {
let parse = |text: &str| parse_snbt(text).unwrap().0;
let raw = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d],Air:32767s,Fire:7s}");
let (arrow, _) = arrow_from_nbt(&raw, None, |_| None).unwrap();
assert_eq!(arrow.air_supply, 32767);
let saved = arrow_to_nbt(&arrow, [197, 0, 0, 1], None, None, Some(&raw)).unwrap();
assert_eq!(saved.get("Air"), Some(&Tag::Short(32767)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(7)));
let absent = parse("{id:\"minecraft:arrow\",Pos:[1d,200d,1d]}");
let (fresh, _) = arrow_from_nbt(&absent, None, |_| None).unwrap();
assert_eq!(fresh.air_supply, 300);
}
@@ -1,6 +1,6 @@
//! Thrown tridents: `AbstractArrow`, `Projectile` and `ThrownTrident` save data.
use super::{
Base, Pickup, Tag, Vec3, flag, float, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
Base, Pickup, Tag, Vec3, flag, item_from_nbt, item_to_nbt, motion, number, owner_uuid,
position, put, rotation, string, text,
};
@@ -22,8 +22,6 @@ pub struct TridentSave<'a> {
/// `Entity.onGround`: a trident moves by `setPos`, so it keeps the loaded one.
pub on_ground: bool,
pub fire_ticks: i32,
/// `Entity.fallDistance`.
pub fall_distance: f64,
/// `AbstractArrow.soundEvent`: the sound of its next block hit.
pub sound_event: &'a str,
pub item: &'a crate::items::ItemStack,
@@ -40,7 +38,7 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
motion: t.motion,
rotation: [t.yaw, t.pitch],
on_ground: t.on_ground,
fall_distance: t.fall_distance,
fall_distance: 0.0,
}
.tag(raw);
// Projectile.addAdditionalSaveData omits LeftOwner until it is true,
@@ -97,8 +95,6 @@ pub struct TridentNbt {
pub no_gravity: bool,
pub on_ground: bool,
pub fire_ticks: i32,
/// `Entity.load`: `getDoubleOr("fall_distance", 0.0)`.
pub fall_distance: f64,
/// `SoundEvent`, or the trident's default when missing or no identifier.
pub sound_event: String,
pub item: crate::items::ItemStack,
@@ -124,7 +120,6 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
no_gravity: flag(tag, "NoGravity"),
on_ground: flag(tag, "OnGround"),
fire_ticks: number(tag.get("Fire")).unwrap_or(0) as i16 as i32,
fall_distance: float(tag.get("fall_distance")).unwrap_or(0.0),
sound_event: text(tag.get("SoundEvent"))
.and_then(|s| horizon_client_core::identifier::normalize_id(&s))
.unwrap_or_else(|| TRIDENT_HIT_GROUND.to_owned()),
+9 -8
View File
@@ -1499,8 +1499,8 @@ impl LevelAccess for GameLevel<'_> {
signal
};
}
if let Some(signal) = books::lectern_signal(self.world, self.registry, state, pos) {
return signal;
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal * i32::from(books::has_book_state(self.registry, state));
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
@@ -3357,10 +3357,11 @@ impl Game<'_> {
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
// Minecraft.pick: from the camera entity's eyes along its view
// (LivingEntity.getViewYRot: the head's yaw).
if let Some(e) = self.camera_entity() {
let [x, y, z] = e.position;
let eye = [x, y + f64::from(e.eye_height), z];
return Some((eye, pick_view_vector(e.y_rot, e.x_rot)));
if let Some(m) = self.camera_mob() {
return Some((
[m.position.x, m.eye_y(), m.position.z],
pick_view_vector(m.head_yaw, m.head_pitch),
));
}
Some((self.eye_position()?, pick_view_vector(self.yaw, self.pitch)))
}
@@ -3403,7 +3404,7 @@ impl Game<'_> {
Some(b)
}
fn outline_context_kind(&self) -> horizon_world::collision_context::EntityContextKind {
if self.camera_entity().is_some() {
if self.camera_mob().is_some() {
horizon_world::collision_context::EntityContextKind::Living
} else {
horizon_world::collision_context::EntityContextKind::Player
@@ -9065,7 +9066,7 @@ impl Game<'_> {
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
// A spectated entity's view is not the local player's yaw and pitch
// (`Camera.setup` reads the camera entity), so it is kept too.
let spectating = self.camera_entity().is_some();
let spectating = self.camera_mob().is_some();
self.posed_rotation = view
.filter(|posed| spectating || Some(*posed) != vanilla)
.map(|(_, yaw, pitch)| (yaw, pitch));
@@ -533,8 +533,7 @@ impl Game<'_> {
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, a) in self.armor_stands.iter().enumerate() {
// Minecraft.pick never hits the camera entity it looks from.
if a.marker || self.remote_camera_id() == Some(a.id) {
if a.marker {
continue;
}
let b = a.bounds();
@@ -1438,11 +1437,7 @@ impl Game<'_> {
) {
// The stands' models posed on the GPU path (`mobs::skin`), when it runs this frame.
let skinning = self.skinning();
for a in self
.armor_stands
.iter()
.filter(|a| !self.hides_as_camera(a.id))
{
for a in &self.armor_stands {
// ArmorStand.shouldRenderAtSqrDistance: 256 times the box's mean
// edge (4 for a marker's empty box), whatever the entity distance.
// LivingEntityRenderer grows the culling box by 0.5 for a dragon head.
+45 -93
View File
@@ -5,7 +5,6 @@
//! with a redstone pulse, the comparator output and taking the book).
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
use horizon_client::{
block_entity_item,
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
@@ -19,7 +18,8 @@ use horizon_client::{
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::{level::flags as level_flags, nbt::Tag, registry::BlockStateId};
use horizon_world::level::flags as level_flags;
use horizon_world::nbt::Tag;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -914,32 +914,6 @@ impl Game<'_> {
self.set_lectern_powered(key, false);
}
}
/// `LecternBlock.getStateForPlacement`: a lectern placed from an item
/// whose `block_entity_data` holds a `Book`, by a placer who may use
/// game-master blocks, starts with `has_book` on. It runs before the
/// item's `block_state` component, which vanilla applies afterwards and
/// which may set `has_book` back. Returns the state to go on with.
pub(super) fn lectern_placed(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
game_master: bool,
) -> horizon_client::Result<BlockStateId> {
if !game_master
|| !block_entity_item::carries_book(stack)
|| &*self.res.facts().get(state).name != "minecraft:lectern"
{
return Ok(state);
}
match self.with_props(state, &[("has_book", "true")]) {
Some(new) if new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `LecternBlock.useItemOn`: a book goes onto an empty lectern.
pub(super) fn use_lectern(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let key = (pos[0], pos[1], pos[2]);
@@ -1712,25 +1686,20 @@ impl Game<'_> {
}
}
}
/// `LecternBlock.getAnalogOutputSignal` and
/// `LecternBlockEntity.getRedstoneSignal`: the state's `has_book` gates the
/// answer, and an entity with no book reads 14 (a page count of 0 is a page
/// progress of 1.0 with no book to add).
pub(super) fn lectern_signal(
world: &horizon_client::world::World,
registry: &horizon_world::registry::BlockRegistry,
state: BlockStateId,
pos: BlockPos,
) -> Option<i32> {
let s = registry.state(state)?;
if s.name != "minecraft:lectern" {
return None;
}
if s.properties.get("has_book").is_none_or(|v| v != "true") {
return Some(0);
}
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
pub(super) fn has_book_state(
registry: &'static horizon_world::registry::BlockRegistry,
state: horizon_world::registry::BlockStateId,
) -> bool {
registry
.state(state)
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
}
/// `LecternBlockEntity.getRedstoneSignal`.
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(14, |b| {
Some(BlockEntity::Lectern(l)) => Some(l.book.as_ref().map_or(0, |b| {
let data = b.data();
let count = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.len()))
@@ -1840,10 +1809,6 @@ mod lectern_books_tag_tests {
}
}
#[cfg(test)]
#[path = "lectern_tests.rs"]
mod lectern_tests;
#[cfg(test)]
mod lectern_signal_tests {
use super::super::native_machines::tests::server_game;
@@ -1857,26 +1822,19 @@ mod lectern_signal_tests {
use horizon_world::level::LevelAccess;
use horizon_world::registry::BlockStateId;
fn signal(
game: &mut super::Game<'_>,
has_book: bool,
book: Option<ItemStack>,
page: i32,
) -> i32 {
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
let key = (8, 64, 8);
let registry = game.pack.assets.registry;
let default = registry.default_state("minecraft:lectern").unwrap();
let mut state = registry.state(default).unwrap().clone();
state
.properties
.insert("has_book".into(), has_book.to_string());
let state: BlockStateId = registry.id(&state).unwrap();
let state: BlockStateId = game
.pack
.assets
.registry
.default_state("minecraft:lectern")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world
.block_entities
.insert(key, BlockEntity::Lectern(Lectern { book, page }));
let pos = BlockPos::new(key.0, key.1, key.2);
lectern_signal(&game.world, registry, state, pos).unwrap()
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
}
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
@@ -1928,19 +1886,30 @@ mod lectern_signal_tests {
);
}
/// `LecternBlock.getAnalogOutputSignal` asks for the state's `HAS_BOOK`
/// first, so a lectern without it reads 0 whatever its block entity holds;
/// with it, `LecternBlockEntity.getRedstoneSignal` is `floor(page /
/// (pageCount - 1) * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a
/// book-content component, so a stack that only sits in the lectern (a
/// non-book item a command left in `Book`) reads 14, not the book's 15.
/// An entity with no book at all reads 14 too: a page count of 0 is a page
/// progress of 1.0 with no book to add.
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
/// component, so a stack that only sits in the lectern (a non-book item a
/// command left in `Book`) reads 14, not the book's 15.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_lecterns_signal_adds_one_only_for_a_book() {
let mut game = server_game();
let writable = Some(ItemStack::new("minecraft:writable_book", 1));
assert_eq!(signal(&mut game, None, 0), 0, "no book");
assert_eq!(
signal(&mut game, Some(ItemStack::new("minecraft:stone", 1)), 0),
14,
"a non-book stack has no book content"
);
// `writable_book_content` is a writable book's default component.
assert_eq!(
signal(
&mut game,
Some(ItemStack::new("minecraft:writable_book", 1)),
0
),
15,
"a writable book is a book"
);
let mut written = ItemStack::new("minecraft:written_book", 1);
written.extra = Some(Box::new(StackData {
written: Some(WrittenBook {
@@ -1951,25 +1920,8 @@ mod lectern_signal_tests {
}),
..Default::default()
}));
// `has_book=false`: no signal, whatever the block entity holds.
assert_eq!(signal(&mut game, false, None, 0), 0);
assert_eq!(
signal(&mut game, false, writable.clone(), 0),
0,
"state denies"
);
// `has_book=true`, an entity with no book: page progress 1.0.
assert_eq!(signal(&mut game, true, None, 0), 14, "no book");
let stone = Some(ItemStack::new("minecraft:stone", 1));
assert_eq!(signal(&mut game, true, stone, 0), 14, "no book content");
// `writable_book_content` is a writable book's default component.
assert_eq!(signal(&mut game, true, writable, 0), 15, "a book");
assert_eq!(
signal(&mut game, true, Some(written.clone()), 0),
1,
"page 0"
);
assert_eq!(signal(&mut game, true, Some(written), 1), 15, "last page");
assert_eq!(signal(&mut game, Some(written.clone()), 0), 1, "page 0");
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
}
}
impl BookScreen {
+17 -8
View File
@@ -2613,14 +2613,23 @@ impl Game<'_> {
// `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck like any orb spawn.
let at = self.unstuck_orb(pos);
self.next_entity += 1;
let at = Vec3::new(at[0], at[1], at[2]);
let orb = horizon_client::entities::ExperienceOrb::new(
self.next_entity,
at,
Vec3::ZERO,
0,
);
self.orbs.push(orb);
self.orbs.push(horizon_client::entities::ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: self.next_entity,
position: Vec3::new(at[0], at[1], at[2]),
previous: Vec3::new(at[0], at[1], at[2]),
velocity: Vec3::new(0.0, 0.0, 0.0),
value: 0,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
following: None,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
@@ -646,7 +646,6 @@ impl Game<'_> {
orb.count = new.count;
orb.health = new.health;
orb.no_gravity = new.no_gravity;
orb.fall_distance = new.fall_distance;
// Entity.load reads portalCooldown and leaves the portal
// process as it is.
if let Some(portal) = self.portals.entities.get_mut(&id) {
@@ -1039,7 +1039,14 @@ impl Game<'_> {
.unwrap_or(Tag::Compound(Default::default())),
v => horizon_client::scoreboard::json_to_tag(v),
};
set_custom_data(&mut stack, &tag);
edit_components(&mut stack, |c| {
let mut data = match c.get(&"minecraft:custom_data".into()) {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
merge_compound(&mut data, &tag);
c.insert("minecraft:custom_data".into(), data);
});
}
"minecraft:set_components" => {
let patch = m["components"].clone();
@@ -1570,22 +1577,6 @@ fn edit_components(stack: &mut ItemStack, f: impl FnOnce(&mut horizon_world::nbt
stack.extra = extra;
stack.tidy();
}
/// `SetCustomDataFunction.run`: `CustomData.update(stack, tag -> tag.merge(value))`.
fn set_custom_data(stack: &mut ItemStack, value: &Tag) {
edit_components(stack, |c| {
let mut data = match c.get(&"minecraft:custom_data".into()) {
Some(t @ Tag::Compound(_)) => t.clone(),
_ => Tag::Compound(Default::default()),
};
merge_compound(&mut data, value);
// Vanilla CustomData.update removes the component when the result is empty.
if matches!(&data, Tag::Compound(m) if m.is_empty()) {
c.remove(&"minecraft:custom_data".into());
} else {
c.insert("minecraft:custom_data".into(), data);
}
});
}
/// `CompoundTag.merge`: nested compounds merge, other values replace.
fn merge_compound(into: &mut Tag, from: &Tag) {
let (Tag::Compound(a), Tag::Compound(b)) = (into, from) else {
@@ -2824,16 +2815,3 @@ mod registered_slot_source_preservation_tests {
}
}
}
#[cfg(test)]
mod set_custom_data_tests {
use super::*;
#[test]
fn merging_an_empty_tag_into_a_stack_without_custom_data_adds_no_component() {
let mut stack = ItemStack::new("minecraft:stick", 1);
set_custom_data(&mut stack, &Tag::Compound(Default::default()));
let saved = horizon_client::item_data::encode_components(&stack);
assert!(saved.is_none_or(|c| c.get("minecraft:custom_data").is_none()));
}
}
+3 -7
View File
@@ -52,15 +52,12 @@ impl Game<'_> {
consumable: &ItemConsumable,
stack: &ItemStack,
) {
// Consumable.onConsume runs on the stack's own component.
let consumable = horizon_client::item_sync::stack_consumable(stack, consumable);
let consumable = &*consumable;
self.emit_consume(consumable, stack, 16);
// ServerPlayer: the used statistic and the consume_item trigger.
self.award_stat("used", &stack.item, 1);
self.trigger_item("minecraft:consume_item", stack);
// FoodProperties.onConsume, the player's one ConsumableListener.
if let Some((nutrition, saturation)) = horizon_client::item_sync::food(stack, info.food) {
if let Some((nutrition, saturation)) = info.food {
let at = self.feet();
let pitch = self.world.random.triangle_float(1.0, 0.4);
self.audio.play(&consumable.sound, at, 1.0, pitch);
@@ -99,11 +96,10 @@ impl Game<'_> {
// with infinite materials, the hand when emptied, else the inventory
// (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop.
self.consume_held();
if let Some(remainder) =
horizon_client::item_sync::use_remainder(stack, info.use_remainder.clone())
if let Some(remainder) = &info.use_remainder
&& !self.creative
{
let remainder = ItemStack::new(&remainder, 1);
let remainder = ItemStack::new(remainder, 1);
if self.held().is_none() {
*self.held_mut() = Some(remainder);
} else if let Some(rest) = self.give(remainder) {
+26 -21
View File
@@ -3,8 +3,6 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod bred_draws;
mod effect_instance;
use effect_instance::mob_effect_instance;
mod mob_geometry;
mod mob_group;
mod mob_islands;
@@ -2312,9 +2310,10 @@ impl Game<'_> {
self.post_game_event(event, at, ctx);
}
// A mob's own `gameEvent(..)` calls: Armadillo.rollUp/rollOut's
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, a fox's
// DRINK, the shulker's lid, an evoker's vexes (ENTITY_PLACE),
// Enderman.teleport's TELEPORT, onEquipItem's EQUIP/UNEQUIP.
// ENTITY_ACTION, Creeper.tick's PRIME_FUSE, Mob.ate's EAT, the
// shulker's lid (Shulker.setRawPeekAmount), an evoker's vexes
// (ENTITY_PLACE), Enderman.teleport's TELEPORT,
// LivingEntity.onEquipItem's EQUIP/UNEQUIP.
MobEvent::GameEvent {
event, at, source, ..
} if matches!(
@@ -2322,7 +2321,6 @@ impl Game<'_> {
"minecraft:entity_action"
| "minecraft:prime_fuse"
| "minecraft:eat"
| "minecraft:drink"
| "minecraft:container_open"
| "minecraft:container_close"
| "minecraft:entity_place"
@@ -2534,13 +2532,6 @@ impl Game<'_> {
});
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
let portal_proof = species == Species::SulfurCube
&& source_mob.is_some_and(|id| {
self.portals
.entities
.get(&id)
.is_some_and(|p| p.cooldown > 0)
});
self.combat.explosion_cause = match species {
Species::Creeper => Some("minecraft:creeper"),
Species::Wither => Some("minecraft:wither"),
@@ -2570,11 +2561,7 @@ impl Game<'_> {
Species::Creeper | Species::Wither => {
self.with_hive_emptying_blast(|g| g.explode(at, radius, source))
}
Species::SulfurCube => self.explode_sulfur_cube(
at,
radius,
source.with_portal_proof(portal_proof),
),
Species::SulfurCube => self.explode_sulfur_cube(at, radius, source),
_ => self.explode(at, radius, source),
};
self.combat.explosion_source = None;
@@ -5585,7 +5572,7 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob_raw("minecraft:chicken", atp, true)
&& let Some(c) = self.mobs.iter_mut().find(|m| m.id == id)
{
(c.extra.age, c.extra.variant) = (-24000, p.egg_variant.unwrap_or(0));
c.extra.age = -24000;
}
}
}
@@ -7148,12 +7135,12 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
// Projectile.shootFromRotation adds getKnownMovement: what the thrower last moved.
// Projectile.shootFromRotation adds getKnownMovement: what the thrower
// last moved, not a velocity the server kept for a guest.
let on_ground = p.ground.on_ground;
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.set_egg_item(stack);
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
@@ -7652,6 +7639,24 @@ fn knock_back(
);
}
/// The instance a mob effect gives the player; `None` for a creative or
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
fn mob_effect_instance(
effect: &'static str,
ticks: i32,
amplifier: i32,
ambient: bool,
immune: bool,
) -> Option<horizon_client::effects::EffectInstance> {
if immune && !ambient {
return None;
}
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
instance.ambient = ambient;
Some(instance)
}
#[cfg(test)]
mod local_sound_event_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
@@ -1,17 +0,0 @@
/// The instance a mob effect gives the player; `None` for a creative or
/// spectator player (`immune`) unless it is ambient (the nautilus's Breath).
pub(super) fn mob_effect_instance(
effect: &'static str,
ticks: i32,
amplifier: i32,
ambient: bool,
immune: bool,
) -> Option<horizon_client::effects::EffectInstance> {
if immune && !ambient {
return None;
}
let id = effect.strip_prefix("minecraft:").unwrap_or(effect);
let mut instance = horizon_client::effects::EffectInstance::new(id, ticks, amplifier);
instance.ambient = ambient;
Some(instance)
}
@@ -58,7 +58,7 @@ impl Recording {
Self {
seconds,
started: Instant::now(),
cpu_s: horizon_system::cpu_seconds(std::process::id()),
cpu_s: horizon_bench::cpu_seconds(std::process::id()),
spans_were_on,
ticks: Vec::with_capacity(seconds as usize * 20),
sampled_ticks: 0,
@@ -363,7 +363,7 @@ impl Game<'_> {
let costs = tick_profile::take();
let mob_samples = costs.iter().map(|c| c.mob_ticks).sum();
let jobs = job_stats::take();
let cpu = horizon_system::cpu_seconds(std::process::id())
let cpu = horizon_bench::cpu_seconds(std::process::id())
.zip(r.cpu_s)
.map(|(end, start)| round((end - start) / window_s.max(1e-9)));
let players = self.lan.as_ref().map_or(0, |h| h.hub.guests.len());
@@ -382,7 +382,7 @@ impl Game<'_> {
"overhead": overhead(&r, mob_samples, span_ns, mob_ns),
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_system::rss_bytes(std::process::id()).map(|b| b >> 20) },
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"process": process_limits(),
"cpu_cores_used": cpu,
"config": {
@@ -395,12 +395,9 @@ impl Game<'_> {
// The machine's description runs commands: off the tick.
std::thread::spawn(move || {
let mut report = report;
let hw = horizon_bench::Hardware::detect();
report["machine"] = json!({
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"cpu": horizon_system::cpu(),
"cpu_threads": std::thread::available_parallelism().ok().map(|n| n.get()),
"ram_mb": horizon_system::ram_bytes().map(|b| b >> 20),
"os": hw.os, "arch": hw.arch, "cpu": hw.cpu, "cpu_threads": hw.cpu_threads, "ram_mb": hw.ram_mb,
});
match write_report(Path::new("reports"), &report, &text) {
Ok(path) => log::info!(
+8 -43
View File
@@ -37,10 +37,6 @@ pub(super) fn aim_deflect(
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
// `WindCharge.deflect`: refused while its `noDeflectTicks` last.
if !p.deflectable() {
return;
}
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
@@ -187,27 +183,6 @@ impl Game<'_> {
let dir = view_vector(self.yaw, self.pitch);
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
// `WindCharge.deflect` refuses while its `noDeflectTicks` last: the
// blow goes on as an attack that hurts nothing.
if p.deflectable() {
Self::turn_by_player(p, dir, guest, owner_uuid);
}
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
/// `AIM_DEFLECT` by a player's blow: along their view, theirs now.
fn turn_by_player(
p: &mut Projectile,
dir: [f64; 3],
guest: Option<u32>,
owner_uuid: Option<[i32; 4]>,
) {
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
@@ -221,6 +196,14 @@ impl Game<'_> {
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
}
}
@@ -262,7 +245,6 @@ mod tests {
assert_eq!(b.max_position().y, 66.0);
}
/// A wind charge past its first five ticks (`noDeflectTicks` run out).
fn wind_charge() -> Projectile {
let mut p = Projectile::new(
ProjectileKind::WindCharge,
@@ -270,26 +252,9 @@ mod tests {
Some(Who::Mob(7)),
);
p.velocity = Vec3::new(0.0, 1.5, 0.0);
p.no_deflect_ticks = 0;
p
}
#[test]
fn a_fresh_wind_charge_is_not_turned() {
let mut p = wind_charge();
p.no_deflect_ticks = 5;
aim_deflect(
&mut p,
Some(Vec3::new(0.0, -1.0, 0.0)),
Some(Who::Player),
Some([4; 4]),
Some(3),
);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, Some(Who::Mob(7)));
assert!(!p.needs_sync);
}
#[test]
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
+1 -2
View File
@@ -909,7 +909,7 @@ impl Game<'_> {
};
let (Some(slot), true) = (
info.equip_slot.as_deref().and_then(Slot::from_name),
horizon_client::item_sync::dispensable(stack, info.equip_dispensable),
info.equip_dispensable,
) else {
return false;
};
@@ -1240,7 +1240,6 @@ impl Game<'_> {
_ => ProjectileKind::Snowball,
};
let mut shot = Projectile::new(kind, at, None);
shot.set_egg_item(&one);
// ArrowItem/SpectralArrowItem.asProjectile: dispensed arrows
// may be picked up.
if let Some(a) = shot.arrow.as_mut() {
+4 -3
View File
@@ -381,7 +381,7 @@ pub(super) struct Dragon {
pub id: u32,
pub position: Vec3,
pub previous: Vec3,
pub(super) velocity: Vec3,
velocity: Vec3,
pub yaw: f32,
pub previous_yaw: f32,
yaw_accel: f32,
@@ -869,14 +869,14 @@ impl Dragon {
/// `ServerExplosion.hurtEntities` knockback on the dragon itself:
/// `pushFromExplosion` adds `(1 - dist) * exposure` along the blast to
/// its eye to the motion (`Entity.push`); the parts are not pushed.
pub(super) fn whole_box(&self) -> Aabb {
fn whole_box(&self) -> Aabb {
let p = self.position;
Aabb::new(
Vec3::new(p.x - 8.0, p.y, p.z - 8.0),
Vec3::new(p.x + 8.0, p.y + 8.0, p.z + 8.0),
)
}
pub(super) fn explosion_knockback(
fn explosion_knockback(
&self,
center: [f64; 3],
radius: f32,
@@ -5831,6 +5831,7 @@ mod dragon_fireball_spawn_tests {
);
}
}
#[cfg(test)]
#[path = "thorns_tests.rs"]
mod thorns_tests;
-1
View File
@@ -2274,7 +2274,6 @@ mod set_rotation_tests {
#[test]
fn set_rotation_keeps_yaw_and_clamps_pitch_on_kinds_that_had_none() {
let mut orb = ExperienceOrb {
fall_distance: 0.0,
join: Default::default(),
no_gravity: false,
id: 0,
+4 -20
View File
@@ -109,7 +109,6 @@ pub(super) enum BlockInteraction {
#[derive(Clone, Copy, Default)]
pub(super) struct ExplosionSource {
silent: bool,
pub(super) portal_proof: bool,
/// The entity that exploded and its kind (`Explosion.getDirectSourceEntity`), when known.
entity: Option<(u32, &'static str)>,
}
@@ -118,10 +117,6 @@ impl ExplosionSource {
self.silent = silent;
self
}
pub(super) fn with_portal_proof(mut self, portal_proof: bool) -> Self {
self.portal_proof = portal_proof;
self
}
pub(super) fn with_entity(mut self, id: u32, kind: &'static str) -> Self {
self.entity = Some((id, kind));
self
@@ -422,15 +417,10 @@ impl Game<'_> {
pos: [i32; 3],
resists: Option<([i32; 3], f32)>,
cap: Option<f32>,
portal_proof: bool,
wither_immune: Option<&std::collections::HashSet<String, S>>,
) -> (f32, bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
// USED_PORTAL_DAMAGE_CALCULATOR returns no resistance and never explodes a portal.
if portal_proof && &*f.name == "minecraft:nether_portal" {
return (0.0, true);
}
let fluid = if f.fluid != Fluid::None {
100.0f32
} else {
@@ -463,7 +453,6 @@ impl Game<'_> {
center: [f64; 3],
radius: f32,
resists: Option<([i32; 3], f32)>,
portal_proof: bool,
) -> Vec<[i32; 3]> {
let (min_y, max_y) = self.with_level(|_, level, _| {
use horizon_world::level::LevelAccess;
@@ -512,13 +501,8 @@ impl Game<'_> {
let (taken, spared, again) = match last {
Some((at, taken, spared)) if at == pos => (taken, spared, true),
_ => {
let (taken, spared) = self.blast_step(
pos,
resists,
cap,
portal_proof,
wither_immune.as_ref(),
);
let (taken, spared) =
self.blast_step(pos, resists, cap, wither_immune.as_ref());
last = Some((pos, taken, spared));
(taken, spared, false)
}
@@ -1269,7 +1253,7 @@ impl Game<'_> {
// Explosion.explode: the blast is heard.
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.exploded_positions(center, radius, resists, source.portal_proof);
let mut targets = self.exploded_positions(center, radius, resists);
let dimension = self.dim.id();
self.native_explosion_detonate(dimension, center, source.entity, &mut targets);
let pushed =
@@ -1726,7 +1710,7 @@ mod tests {
game.world.random.set_seed(7);
let expected = exploded_positions_stepwise(&mut game, center, radius, resists);
game.world.random.set_seed(7);
let got = game.exploded_positions(center, radius, resists, false);
let got = game.exploded_positions(center, radius, resists);
assert!(expected.len() > 20, "{radius}: {}", expected.len());
assert_eq!(got, expected, "radius {radius}, {resists:?}");
}
+1 -3
View File
@@ -756,9 +756,7 @@ impl Game<'_> {
.filter_map(|(i, s)| Some((i, s?)))
.filter(|(_, s)| counts(s));
let (first, stack) = found.next()?;
let total = found
.map(|(_, s)| s.count)
.fold(stack.count, u32::saturating_add);
let total = stack.count + found.map(|(_, s)| s.count).sum::<u32>();
(ranged || total > 1).then(|| (first, stack.item.clone(), total))
}
/// One widget's box in `corner`: icons in a column, texts beside them.
+6 -6
View File
@@ -95,12 +95,12 @@ impl Game<'_> {
valid_fov_modifier(modifier, base)
});
let target = self.zoomed_fov_target(target);
// A server's spectator camera on a mob or an armor stand: its eye
// height. Its modifier stays 1 (another player's own modifier is not
// modelled here); the zoom extra still narrows it.
if let Some(e) = self.camera_entity() {
self.view
.tick(Some(e.eye_height), self.zoomed_fov_target(1.0));
// A server's spectator camera on a mob: its eye height. Its modifier
// stays 1 (another player's own modifier is not modelled here); the
// zoom extra still narrows it.
if let Some(m) = self.camera_mob() {
let eye = m.eye_height() as f32;
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
return;
}
let eye = self.player.as_ref().map(|p| {
@@ -499,7 +499,6 @@ impl Game<'_> {
}
self.next_entity += 1;
self.orbs.push(ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -73,9 +73,6 @@ impl Game<'_> {
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
_ if let Some(consumable) = self.consumable(stack) => consumable.ticks(),
_ if let Some(ticks) = patched_ticks => ticks,
_ if let Some(seconds) = horizon_client::item_sync::consumable_seconds(stack) => {
(seconds * 20.0) as i32
}
_ => match info {
Some(i) if let Some(c) = &i.consumable => c.ticks(),
_ if patched_blocks => 72_000,
@@ -3477,7 +3477,13 @@ impl Game<'_> {
if host.hub.guests[gi].spectator() {
return;
}
if self.guest_attack_stops(host, gi, id) {
// handleAttack: a target inside the world border, then
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
// mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& (!self.inside_border(&target)
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
{
return;
}
// Native mods, where Player.attack starts: a canceled blow does
@@ -6214,7 +6220,6 @@ fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>
[range(at[0]), range(at[2])]
}
mod attack_checks;
mod dig_stop;
use dig_stop::{StopDig, stop_dig};
@@ -1,333 +0,0 @@
//! `ServerGamePacketListenerImpl.handleAttack`'s checks between the target's lookup and
//! `Player.attack`: the world border, the attack range, and the targets no player may attack.
use super::*;
use horizon_client::projectiles::ProjectileKind;
use horizon_protocol::{packets::configuration::UpdateTags, text::translatable};
impl Game<'_> {
/// Whether a guest's attack on entity `id` ends before `Player.attack` (handleAttack).
/// The guest who attacks an `ItemEntity`, an `ExperienceOrb`, themselves or an
/// `AbstractArrow` that is not `isAttackable` is disconnected with
/// `multiplayer.disconnect.invalid_entity_attacked`.
pub(super) fn guest_attack_stops(&mut self, host: &mut LanHost, gi: usize, id: i32) -> bool {
let conn = host.hub.guests[gi].conn;
// handleAttack: a target inside the world border, then
// `isWithinAttackRange(mainHandItem, box, 3.0)` before anything else, players,
// mobs, objects and fireballs alike.
if let Some(target) = self.guest_target_box(host, gi, id)
&& (!self.inside_border(&target)
|| self.as_guest(host, conn, |g| g.within_attack_range(&target)) != Some(true))
{
return true;
}
let Some(target) = self.refused_target(host, gi, id) else {
return false;
};
// The same checks for these targets, then `PIERCING_WEAPON` in the main hand
// returns before the kick; handleAttack ignores a player whose client has not
// loaded (hasClientLoaded) before all of them.
if !host.hub.guests[gi].client_loaded()
|| !self.inside_border(&target)
|| self.as_guest(host, conn, |g| {
g.within_attack_range(&target) && !g.holds_piercing_weapon()
}) != Some(true)
{
return true;
}
let name = host.hub.guests[gi].name.clone();
host.hub.kick(
conn,
translatable("multiplayer.disconnect.invalid_entity_attacked", &[]),
);
log::warn!("Player {name} tried to attack an invalid entity");
true
}
/// The box of entity `id` when handleAttack refuses it as a target: an `ItemEntity`, an
/// `ExperienceOrb`, the attacking player, or an `AbstractArrow` (an arrow, a spectral
/// arrow, a trident) that is not `isAttackable`. Only for a guest in the level in place,
/// whose entities these are.
fn refused_target(&self, host: &LanHost, gi: usize, id: i32) -> Option<Aabb> {
let g = &host.hub.guests[gi];
if !host.hub.here(g) {
return None;
}
if g.entity_id == id {
return Some(g.bounds());
}
let id = u32::try_from(id).ok().filter(|&id| id != 0)?;
if let Some(item) = self.dropped.iter().find(|i| i.id == id) {
return Some(item.bounds());
}
if let Some(orb) = self.orbs.iter().find(|o| o.id == id) {
return Some(orb.bounds());
}
let sync = host.hub.lan.shared.sync();
let refused =
|kind: &str| !arrow_attackable(&sync.tags, sync.id("minecraft:entity_type", kind));
if let Some(p) = self.creatures.projectiles.iter().find(|p| {
p.id == id
&& matches!(
p.kind,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident
)
}) {
return refused(p.entity_type()).then(|| p.bounds());
}
// A thrown trident: `EntityType.TRIDENT`, 0.5 x 0.5.
let t = self.tridents.iter().find(|t| t.id == id)?;
let p = t.position;
refused("minecraft:trident").then(|| {
Aabb::new(
Vec3::new(p.x - 0.25, p.y, p.z - 0.25),
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
)
})
}
/// The main-hand stack has `minecraft:piercing_weapon` (a spear's).
fn holds_piercing_weapon(&self) -> bool {
self.held().is_some_and(|stack| {
self.pack
.item_renderer
.models
.components_view(&horizon_client::item_render::stack_view(stack))
.contains_key("minecraft:piercing_weapon")
})
}
}
/// `AbstractArrow.isAttackable`: the arrow's type (`type_id`) is in
/// `#minecraft:redirectable_projectile`, as the server sent its tags to the players, a world's
/// data packs included. A type or a tag missing from the data counts as attackable, so no
/// player is kicked over data the server does not have.
fn arrow_attackable(tags: &UpdateTags, type_id: Option<i32>) -> bool {
let Some(type_id) = type_id else {
return true;
};
let tag = tags
.registries
.iter()
.find(|r| r.registry == "minecraft:entity_type")
.and_then(|r| {
r.tags
.iter()
.find(|t| t.name == "minecraft:redirectable_projectile")
});
tag.is_none_or(|t| t.ids.contains(&type_id))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::configuration::{
ClientInformation, RegistryTags, TagEntry, UpdateTags,
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn tags(redirectable: &[i32]) -> UpdateTags {
UpdateTags {
registries: vec![RegistryTags {
registry: "minecraft:entity_type".into(),
tags: vec![TagEntry {
name: "minecraft:redirectable_projectile".into(),
ids: redirectable.to_vec(),
}],
}],
}
}
/// `AbstractArrow.isAttackable` reads the tag the players were sent: a data pack that
/// puts arrows in `#minecraft:redirectable_projectile` makes them attackable, and a
/// vanilla client then picks and attacks them, which must not kick.
#[test]
fn an_arrow_is_attackable_only_in_the_sent_redirectable_tag() {
assert!(!arrow_attackable(&tags(&[30, 40]), Some(5)));
assert!(arrow_attackable(&tags(&[5, 30, 40]), Some(5)));
assert!(arrow_attackable(&tags(&[30]), None), "an unknown type");
assert!(
arrow_attackable(&UpdateTags { registries: vec![] }, Some(5)),
"no entity type tags"
);
}
fn disconnects(rx: &mpsc::Receiver<Out>) -> Vec<horizon_world::nbt::Tag> {
rx.try_iter()
.filter_map(|out| match out {
Out::Disconnect(reason) => Some(reason),
_ => None,
})
.collect()
}
/// handleAttack disconnects a player who attacks a dropped item, an experience orb,
/// themselves, an arrow or a thrown trident (`invalid_entity_attacked`); a pig is
/// attacked as before, and an item out of reach, or attacked with a spear in hand,
/// ends the attack without a kick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn attacking_an_item_an_orb_oneself_or_an_arrow_kicks_the_player() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-attack-checks-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
game.lan_host(
crate::game::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "attack-checks-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let summoned = |game: &mut Game<'_>, command: &str| {
let before = game.next_entity;
assert!(game.run_command(command), "{command}");
game.lan_assign_ids();
assert!(game.next_entity > before, "{command}");
game.next_entity as i32
};
let pig = summoned(
&mut game,
"summon pig 6.5 200 4.5 {NoAI:1b,NoGravity:1b,PersistenceRequired:1b}",
);
let item = summoned(
&mut game,
"summon item 4.5 200 6 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
);
let far_item = summoned(
&mut game,
"summon item 4.5 200 12.5 {NoGravity:1b,Item:{id:\"minecraft:stone\",count:1}}",
);
let orb = summoned(
&mut game,
"summon experience_orb 3 200 4.5 {NoGravity:1b,Value:1s}",
);
let arrow = summoned(&mut game, "summon arrow 4.5 200.5 3 {NoGravity:1b}");
let trident = summoned(&mut game, "summon trident 5.5 200.5 3 {NoGravity:1b}");
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 332,
name: "AttackGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
game.next_entity as i32 + 1,
[4.5, 200.0, 4.5],
2,
);
game.next_entity += 1;
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.load_timeout = 0;
let me = guest.entity_id;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let attack = |game: &mut Game<'_>, host: &mut LanHost, id: i32| {
game.lan_guest_packet(host, 2, S::Attack(play::Attack { id }));
};
let kicked = vec![translatable(
"multiplayer.disconnect.invalid_entity_attacked",
&[],
)];
let hp = |g: &Game<'_>| g.mobs.iter().find(|m| m.id as i32 == pig).unwrap().health;
let full = hp(&game);
attack(&mut game, &mut host, pig);
assert_eq!(disconnects(&rx), vec![], "a pig is a target");
assert!(hp(&game) < full, "the pig is not hurt: {full} health left");
attack(&mut game, &mut host, far_item);
assert_eq!(disconnects(&rx), vec![], "an item out of reach");
host.hub.guests[0].inventory[0] = Some(ItemStack::new("minecraft:iron_spear", 1));
host.hub.guests[0].selected = 0;
attack(&mut game, &mut host, item);
assert_eq!(disconnects(&rx), vec![], "a spear in hand");
host.hub.guests[0].inventory[0] = None;
for (what, id) in [
("an item", item),
("an experience orb", orb),
("themselves", me),
("an arrow", arrow),
("a thrown trident", trident),
] {
attack(&mut game, &mut host, id);
assert_eq!(disconnects(&rx), kicked, "{what}");
}
}
}
+6 -42
View File
@@ -163,13 +163,8 @@ impl WorldOptions {
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
/// shows there too; a remote world has no such box.
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
if remote || !ui::hovered(cursor, port.x, port.y, port.width, port.height) {
return None;
}
Some(match port_error(&port.value) {
Some(key) => Text::translate(key, &[Text::literal("1024"), Text::literal("65535")]),
None => Text::translate("lanServer.port", &[]),
})
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
.then(|| Text::translate("lanServer.port", &[]))
}
fn snapshot(&self) -> Snapshot {
Snapshot {
@@ -561,18 +556,6 @@ impl Restrictions {
}
}
/// `WorldOptionsScreen.tryParsePort`'s invalid value: not a number in
/// `PORT_LOWER_BOUND..=PORT_HIGHER_BOUND`. A number another process holds
/// (`HttpUtil.isPortAvailable`) is not checked: the port map records that gap.
fn port_error(value: &str) -> Option<&'static str> {
if value.is_empty() {
return None;
}
match value.parse::<u32>() {
Ok(port) if (1024..=65535).contains(&port) => None,
_ => Some("lanServer.port.invalid"),
}
}
impl Game<'_> {
pub(super) fn remote_world_options_permission(&mut self, permission: bool) {
if let Some(o) = self.world_options.as_mut() {
@@ -1036,8 +1019,7 @@ impl Game<'_> {
}
// The widgets are drawn; the port box's tooltip goes over them.
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
// Tooltip.splitTooltip: font.split(message, 170).
ui::tooltip(batch, atlas, &atlas.font.split(&tip, 170.0), cursor);
ui::tooltip(batch, atlas, &[tip], cursor);
}
}
@@ -1936,29 +1918,11 @@ mod tests {
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
/// label; `setPortError` turns that into the reason a number is rejected.
/// A remote world (`o.remote`) has no port box to hover.
/// label; a remote world (`o.remote`) has no port box to hover.
#[test]
fn the_port_box_explains_itself_on_hover() {
let mut port = EditBox::new(158.0, 100.0, 40.0, 20.0);
let tip = |port: &EditBox| {
WorldOptions::port_tooltip(port, (159.0, 101.0), false).map(|t| t.plain())
};
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
// Empty takes the suggestion, a number in range is fine.
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"));
for bad in ["0", "80", "70000", "abc", "-1"] {
port.set_value(bad);
assert_eq!(
tip(&port).as_deref(),
Some("lanServer.port.invalid"),
"{bad}"
);
}
for good in ["1024", "25565", "65535"] {
port.set_value(good);
assert_eq!(tip(&port).as_deref(), Some("lanServer.port"), "{good}");
}
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
@@ -1,146 +0,0 @@
//! Lectern placement and the `block_change` game event of a book going on or
//! off (`LecternBlock.getStateForPlacement`, `LecternBlock.placeBook` and
//! `LecternBlockEntity.onBookItemRemove`).
use super::super::native_machines::tests::server_game;
use super::Game;
use crate::game::{Hit, sculk::TEST_POSTED_EVENTS};
use horizon_client::{
containers::{BlockEntity, Lectern},
items::ItemStack,
mobs::Who,
};
/// A lectern at `pos`: the state's `has_book`, and the entity's book.
fn lectern(game: &mut Game<'_>, pos: [i32; 3], has_book: bool, book: Option<ItemStack>) {
let registry = game.pack.assets.registry;
let default = registry.default_state("minecraft:lectern").unwrap();
let mut state = registry.state(default).unwrap().clone();
state
.properties
.insert("has_book".into(), has_book.to_string());
let state = registry.id(&state).unwrap();
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
game.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Lectern(Lectern { book, page: 0 }),
);
}
/// The lectern item `/give` hands out with a written book in its
/// `block_entity_data`.
fn lectern_item() -> ItemStack {
let text = r#"{"minecraft:block_entity_data":{id:"minecraft:lectern",Book:{id:"minecraft:written_book",count:1,components:{"minecraft:written_book_content":{title:"t",author:"a",pages:["p1","p2"]}}}}}"#;
let components = horizon_world::nbt::parse_snbt(text).expect("snbt").0;
let (damage, extra) = horizon_client::item_data::decode_components(Some(&components));
ItemStack {
damage,
extra,
..ItemStack::new("minecraft:lectern", 1)
}
}
fn click(pos: [i32; 3]) -> Hit {
Hit {
pos,
face: [0, 1, 0],
point: [f64::from(pos[0]) + 0.5, f64::from(pos[1]), 0.5],
}
}
/// The `has_book` of the lectern state at `pos`, and what a comparator beside
/// it reads (`LecternBlock.getAnalogOutputSignal`).
fn state_at(game: &Game<'_>, pos: [i32; 3]) -> (String, i32) {
let registry = game.pack.assets.registry;
let state = game.world.block(pos[0], pos[1], pos[2]);
let has_book = registry
.state(state)
.unwrap()
.properties
.get("has_book")
.cloned()
.unwrap_or_default();
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let comparator = super::lectern_signal(&game.world, registry, state, at).expect("a lectern");
(has_book, comparator)
}
/// `LecternBlock.getStateForPlacement`: the state's `has_book` comes from the
/// item's `block_entity_data.Book` for a placer who may use game-master blocks
/// (`minecraft:lectern` is in `BlockEntityTypes.OP_ONLY_CUSTOM_DATA`, so the
/// block entity only takes the book for one too), and not for anyone else.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_placed_lectern_takes_has_book_from_its_item() {
let mut game = server_game();
game.set_mode_id(1);
game.ui.registries.permission = 2;
*game.held_mut() = Some(lectern_item());
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
assert!(
matches!(game.world.block_entities.get(&(8, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_some())
);
// A two-page book on page 0: `floor(0 / 1 * 14) + 1`.
assert_eq!(state_at(&game, [8, 64, 8]), ("true".into(), 1));
// The same item from a placer who may not use game-master blocks gains
// neither the state nor the block entity book, so the comparator reads 0.
game.ui.registries.permission = 0;
*game.held_mut() = Some(lectern_item());
assert!(game.use_hand(Some(&click([10, 64, 8]))).unwrap());
assert!(
matches!(game.world.block_entities.get(&(10, 64, 8)), Some(BlockEntity::Lectern(l)) if l.book.is_none())
);
assert_eq!(state_at(&game, [10, 64, 8]), ("false".into(), 0));
}
fn record_events(record: bool) {
TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = record.then(Vec::new));
}
/// The `block_change` game events posted since `record_events(true)`, with
/// their position and source entity.
fn block_changes() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
TEST_POSTED_EVENTS.with(|e| {
let events = e.borrow();
let changes = events.as_ref().unwrap().iter();
changes
.filter(|(e, _, _)| *e == "minecraft:block_change")
.copied()
.collect()
})
}
/// `LecternBlock.placeBook` -> `resetBookState` with the player as the source
/// entity, and `LecternBlockEntity.onBookItemRemove` -> `resetBookState(null,
/// ...)`: one `block_change` at the lectern each way, and `has_book` follows.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_book_going_on_or_off_posts_one_block_change() {
let mut game = server_game();
let key = (8, 64, 8);
lectern(&mut game, [8, 64, 8], false, None);
*game.held_mut() = Some(ItemStack::new("minecraft:written_book", 1));
record_events(true);
assert!(game.use_hand(Some(&click([8, 64, 8]))).unwrap());
assert_eq!(
block_changes(),
[(
"minecraft:block_change",
[8.5, 64.5, 8.5],
Some(Who::Player)
)]
);
assert_eq!(state_at(&game, [8, 64, 8]).0, "true");
// Taking it back off through the lectern's menu.
record_events(true);
let mut menu = game.lectern_menu(key).expect("the lectern's menu");
menu.set_slot(&game.menu_env(), 0, None);
game.lectern_menu_done(key, &menu);
assert_eq!(
block_changes(),
[("minecraft:block_change", [8.5, 64.5, 8.5], None)]
);
assert_eq!(state_at(&game, [8, 64, 8]).0, "false");
record_events(false);
}
+2 -24
View File
@@ -19,7 +19,6 @@ use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::level::Ticks;
pub(super) struct Bolt {
/// The entity id a server's players know it by (0 until it has one).
@@ -66,13 +65,6 @@ fn in_viewer_box(bolt: [f64; 3], p: [f64; 3]) -> bool {
&& p[1] >= bolt[1] - 15.0
&& p[1] <= bolt[1] + 21.0
}
/// `ServerLevel.tickThunder` -> `Level.getBlockRandomPos(minX, 0, minZ, 15)`:
/// picks the strike column from the level's separate `randValue` LCG.
fn thunder_strike_column(ticks: &mut Ticks, cx: i32, cz: i32) -> (i32, i32) {
let r = ticks.block_random_pos(cx * 16, 0, cz * 16, 15);
(r.x, r.z)
}
impl Game<'_> {
/// `ServerChunkCache.tickSpawningChunk`: one `tickThunder` roll per
/// spawning chunk (within 128 blocks of a player) that is in entity
@@ -98,7 +90,8 @@ impl Game<'_> {
continue;
}
// getBlockRandomPos(minX, 0, minZ, 15)
let (x, z) = thunder_strike_column(&mut self.block_ticks, cx, cz);
let r = self.world.random.next_int();
let (x, z) = (cx * 16 + (r & 15), cz * 16 + (r >> 16 & 15));
let Some(pos) = self.lightning_target(x, z) else {
continue;
};
@@ -886,21 +879,6 @@ mod tests {
assert!(!in_viewer_box(bolt, [15.5, 64.0, 0.0]));
}
/// `ServerLevel.tickThunder` picks its strike column from `Level.randValue`
/// (`Ticks::block_random_pos`), using bits `2..=5` for X and `10..=13` for Z
/// rather than drawing from `Level.random`.
#[test]
fn thunder_strike_column_uses_block_random_pos_lcg_bits() {
let mut ticks = Ticks::new(12345);
let mut expected_lcg = 12345i32;
for (cx, cz) in [(0, 0), (-3, 5), (12, -8)] {
expected_lcg = expected_lcg.wrapping_mul(3).wrapping_add(1_013_904_223);
let r = expected_lcg >> 2;
let expected = (cx * 16 + (r & 15), cz * 16 + ((r >> 8) & 15));
assert_eq!(thunder_strike_column(&mut ticks, cx, cz), expected);
}
}
/// `Pig.thunderHit`: striking a pig with lightning converts it to a zombified
/// piglin while preserving age (#7510).
#[test]
@@ -122,61 +122,6 @@ fn a_rocket_posts_its_shot_once_and_a_silent_one_launches_quietly() {
assert_eq!(game.audio.heard.get(launch), Some(&1));
});
}
/// #14440: an orb's move adds its drop to the fall outside water; landing
/// after a fall posts HIT_GROUND and forgets the fall (`Entity.checkFallDamage`);
/// in water the drop adds nothing, so a landing posts nothing.
#[test]
#[ignore = "prepared resources; actual registered load, Game tick and posted events"]
fn a_landing_orb_posts_hit_ground_after_a_fall() {
use horizon_client::entities::OrbMove;
with_game(|game| {
assert!(game.run_command("summon experience_orb 6.5 200 10.5 {Value:1,fall_distance:2d}"));
let i = game.orbs.len() - 1;
let from = Vec3::new(6.5, 200.5, 10.5);
let down = Vec3::new(0.0, -0.5, 0.0);
start_events();
// In the air: the drop adds to the fall.
game.orbs[i].on_ground = false;
game.orb_moved(
i,
OrbMove {
from,
moved: down,
in_water: false,
},
);
assert_eq!(game.orbs[i].fall_distance, 2.5);
// On the ground: HIT_GROUND, then the fall is gone.
game.orbs[i].on_ground = true;
game.orb_moved(
i,
OrbMove {
from,
moved: down,
in_water: false,
},
);
assert_eq!(game.orbs[i].fall_distance, 0.0);
// In water with no fall: no drop, so no landing event.
game.orb_moved(
i,
OrbMove {
from,
moved: down,
in_water: true,
},
);
let events: Vec<_> = crate::game::sculk::TEST_POSTED_EVENTS.with(|v| {
std::mem::take(v.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|e| e.0 == "minecraft:hit_ground")
.collect()
});
stop_events();
assert_eq!(events.len(), 1);
assert_eq!(game.orbs[i].fall_distance, 0.0);
});
}
/// #14318: an angled rocket falling onto stone builds up its fall and posts
/// HIT_GROUND when it lands (`Entity.checkFallDamage`); one falling into water
/// splashes once (`Entity.doWaterSplashEffect`: the sound and SPLASH) and lands
+1 -14
View File
@@ -146,10 +146,6 @@ pub(super) struct Minecart {
pub push: Vec3,
/// TNT minecarts: ticks until the explosion, -1 when not primed.
pub fuse: i32,
/// TNT minecarts: `explosionPowerBase` and `explosionSpeedFactor`, the
/// blast's power at rest and its gain with the cart's speed.
pub explosion_power: f32,
pub explosion_speed_factor: f32,
/// Hopper minecarts: an unpowered activator rail enables them.
pub enabled: bool,
/// Chest minecarts: the loot table rolled into `items` on load, saved
@@ -366,8 +362,6 @@ impl Minecart {
fuel: 0,
push: Vec3::ZERO,
fuse: -1,
explosion_power: DEFAULT_EXPLOSION_POWER,
explosion_speed_factor: DEFAULT_EXPLOSION_SPEED_FACTOR,
enabled: true,
loot: None,
first_tick: true,
@@ -1553,9 +1547,7 @@ impl Game<'_> {
}
let speed = speed_sqr.sqrt().min(5.0);
let r = c.random.next_double();
// MinecartTNT.explode: the base power plus the speed's share.
let power = (f64::from(c.explosion_power)
+ f64::from(c.explosion_speed_factor) * r * 1.5 * speed) as f32;
let power = (4.0 + r * 1.5 * speed) as f32;
let at = [c.position.x, c.position.y, c.position.z];
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
@@ -2928,11 +2920,6 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// `MinecartTNT.DEFAULT_EXPLOSION_POWER_BASE` and `DEFAULT_EXPLOSION_SPEED_FACTOR`.
const DEFAULT_EXPLOSION_POWER: f32 = 4.0;
const DEFAULT_EXPLOSION_SPEED_FACTOR: f32 = 1.0;
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -32,8 +32,6 @@ impl Minecart {
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
map.remove(&JavaString::from("explosion_power"));
map.remove(&JavaString::from("explosion_speed_factor"));
crate::game::boats::put_ticks_frozen(map, self.ticks_frozen);
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
@@ -64,26 +62,7 @@ impl Minecart {
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
// MinecartTNT.addAdditionalSaveData: the powers only off their defaults.
CartKind::Tnt => {
put("fuse", Tag::Int(self.fuse));
for (key, value, default) in [
(
"explosion_power",
self.explosion_power,
DEFAULT_EXPLOSION_POWER,
),
(
"explosion_speed_factor",
self.explosion_speed_factor,
DEFAULT_EXPLOSION_SPEED_FACTOR,
),
] {
if value != default {
put(key, Tag::Float(value.to_bits()));
}
}
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
@@ -172,21 +151,6 @@ impl Minecart {
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
if kind == CartKind::Tnt {
// MinecartTNT.readAdditionalSaveData: getFloatOr, kept to 0..128.
let float = |k: &str, default: f32| match tag.get(k) {
Some(Tag::Float(v)) => f32::from_bits(*v),
Some(Tag::Double(v)) => f64::from_bits(*v) as f32,
Some(Tag::Long(v)) => *v as f32,
Some(other) => {
horizon_client_core::nbt_path::int_value(other).map_or(default, |v| v as f32)
}
None => default,
};
c.explosion_power = float("explosion_power", DEFAULT_EXPLOSION_POWER).clamp(0.0, 128.0);
c.explosion_speed_factor =
float("explosion_speed_factor", DEFAULT_EXPLOSION_SPEED_FACTOR).clamp(0.0, 128.0);
}
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
+16 -34
View File
@@ -1,7 +1,7 @@
//! Custom names: `NameTagItem.interactLivingEntity` and the name tag
//! (`EntityRenderer.submitNameTag`): above the head, facing the camera,
//! on a translucent black background, shown when the name is visible or the
//! mob is under the crosshair within its name-tag distance (64 by default), as its team's name tag
//! mob is under the crosshair within 64 blocks, as its team's name tag
//! visibility allows, in the team's colours, with its score in the
//! objective displayed below names. Other players' names (on a LAN host
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows,
@@ -260,12 +260,10 @@ impl Game<'_> {
}
None => return None,
};
// The tag's per-entity distance (vanilla's default is 64) is
// measured to the entity: copy the name only for a mob the frame
// can show it on.
// The tag's 64 block distance is measured to the entity: copy the
// name only for a mob the frame can show it on.
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
let max = m.name_tag_distance();
(at.distance_squared(camera) < max * max).then_some((m, name, false))
(at.distance_squared(camera) < 64.0 * 64.0).then_some((m, name, false))
});
let own = self.own_name_tag().map(|(m, _)| m.id);
let players = players
@@ -287,7 +285,7 @@ impl Game<'_> {
}
// EntityAttachment.NAME_TAG: 0.5 above the box of the current pose.
let p = name_tag_at(m, alpha);
// The name tag distance is measured to the entity.
// The name tag distance (64) is measured to the entity.
let distance_sqr =
glam::DVec3::new(m.position.x, m.position.y, m.position.z).distance_squared(camera);
// Match the model's renderer-specific box, not the tag anchor: the
@@ -299,7 +297,6 @@ impl Game<'_> {
distance_sqr,
(lo - camera - inflate).as_vec3(),
(hi - camera + inflate).as_vec3(),
m.name_tag_distance(),
) {
continue;
}
@@ -340,13 +337,12 @@ impl Game<'_> {
// MobRenderer.shouldShowName: a visible name, or the mob under the
// crosshair; players (LivingEntityRenderer) need neither.
// `ClientMannequin.belowNameDisplay`: the description is `scoreText`, drawn
// within the below-name distance (10 by default) whether the name
// shows or not.
// within the below-name distance (10) whether the name shows or not.
let description = (m.species() == horizon_client::mobs::Species::Mannequin
&& distance_sqr < m.below_name_distance().powi(2))
.then(|| m.extra.description.component())
.flatten()
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
&& distance_sqr < 10.0 * 10.0)
.then(|| m.extra.description.component())
.flatten()
.map(|tag| horizon_client::entity_nbt::name_text(&tag).resolve_legacy());
let is_own = Some(m.id) == own;
let name_shown = sb::name_shown(team, viewer, view)
&& (!is_own || !view.is_vehicle)
@@ -404,14 +400,14 @@ impl Game<'_> {
.unwrap_or(shown);
let mut lines = Vec::new();
// EntityRenderer.extractNameTags: the score shows within the
// per-entity below-name distance (10 by default), under the name.
// below-name distance (10), under the name.
let mut lift = 0.0;
if m.species() == horizon_client::mobs::Species::Mannequin {
if let Some(text) = description {
lines.push((text, 0.0));
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
} else if distance_sqr < m.below_name_distance().powi(2)
} else if distance_sqr < 10.0 * 10.0
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score.resolve_legacy(), 0.0));
@@ -470,7 +466,6 @@ impl Game<'_> {
distance_sqr,
(origin - half).as_vec3(),
(origin + glam::DVec3::new(half.x, height + grow, half.z)).as_vec3(),
a.name_tag_distance(),
) {
continue;
}
@@ -512,7 +507,7 @@ impl Game<'_> {
let mut lines = Vec::new();
let mut lift = 0.0;
let holder = self.persist.uuids.get(&a.id).map(|u| uuid_text(*u));
if distance_sqr < a.below_name_distance().powi(2)
if distance_sqr < 10.0 * 10.0
&& let Some(score) = holder.and_then(|h| self.below_name_text(&h))
{
lines.push((score.resolve_legacy(), 0.0));
@@ -834,10 +829,8 @@ fn name_tag_in_view(
distance_sqr: f64,
min: glam::Vec3,
max: glam::Vec3,
max_distance: f64,
) -> bool {
distance_sqr < max_distance * max_distance
&& frustum.is_none_or(|view| view.intersects(min, max))
distance_sqr < 64.0 * 64.0 && frustum.is_none_or(|view| view.intersects(min, max))
}
fn first_mob_indices(mobs: &[Mob]) -> std::collections::HashMap<u32, usize> {
@@ -1152,8 +1145,7 @@ mod tests {
Some(&View),
181.0,
glam::Vec3::new(-0.8, 8.5, -10.8),
glam::Vec3::new(0.8, 11.3, -9.2),
64.0
glam::Vec3::new(0.8, 11.3, -9.2)
));
for (offset, distance, expected) in [
([0.0, 0.0, -10.0], 100.0, true),
@@ -1169,21 +1161,11 @@ mod tests {
Some(&View),
distance,
glam::Vec3::from_array(offset) - glam::Vec3::splat(0.5),
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5),
64.0
glam::Vec3::from_array(offset) + glam::Vec3::splat(0.5)
),
expected
);
}
// A server-synced `minecraft:name_tag_distance` of 5 hides a tag
// six blocks away even though the vanilla default reaches 64.
assert!(!name_tag_in_view(
Some(&View),
36.0,
glam::Vec3::splat(-0.5),
glam::Vec3::splat(0.5),
5.0
));
}
#[test]
@@ -702,19 +702,7 @@ impl Game<'_> {
});
self.portals.before = before;
self.portals.entities.retain(|id, _| present.contains(id));
// SulfurCube.tickFuse runs before Entity.handlePortal. A cube removed
// there must neither decrement its cooldown nor travel before its blast.
let removed_before_portal: std::collections::HashSet<u32> = self
.mobs
.iter()
.filter(|_| !vehicles)
.filter(|m| horizon_client::mobs::sulfur_cube::removes_before_portal_tick(m))
.map(|m| m.id)
.collect();
for (id, allowed, delay) in ids {
if removed_before_portal.contains(&id) {
continue;
}
let Some(state) = self.portals.entities.get_mut(&id) else {
continue;
};
+1 -26
View File
@@ -258,7 +258,7 @@ impl Game<'_> {
.get_or_insert_with(random_source::entity)
.next_float()
};
let (stays, step) = {
let stays = {
let audio = &mut self.audio;
let mut on_sound = |pos: [f64; 3], pitch: f32| {
audio.play("minecraft:entity.generic.burn", Some(pos), 0.4, pitch);
@@ -280,7 +280,6 @@ impl Game<'_> {
// orb that is done is removed.
let snow = (o.previous, o.position, o.bounds(), o.velocity);
self.entity_powder_snow_particles(snow.0, snow.1, snow.2, snow.3, false);
self.orb_moved(i, step);
if !stays {
self.orbs.remove(i);
}
@@ -305,30 +304,6 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// `Entity.move`'s fall bookkeeping after orb `i`'s move: a move of a
/// block or more casts for a reset block or water, `checkFallDamage` adds
/// the drop outside water and, on the ground after a fall, posts
/// HIT_GROUND, then forgets it. `Block.fallOn` (farmland's random draw,
/// honey's slide) is not run for an orb yet, nor for the other entities
/// that are not living.
pub(super) fn orb_moved(&mut self, i: usize, step: horizon_client::entities::OrbMove) {
let o = &self.orbs[i];
let mut fall = self.vehicle_fall_reset(o.fall_distance, step.from, step.moved);
if !step.in_water && step.moved.y < 0.0 {
fall -= f64::from(step.moved.y as f32);
}
if o.on_ground {
if fall > 0.0 {
let (position, bounds) = (o.position, o.bounds());
self.post_landing_event(position, bounds, false);
}
fall = 0.0;
}
self.orbs[i].fall_distance = fall;
}
}
#[cfg(test)]
mod tests {
use super::{find_join, first_join};
-99
View File
@@ -44,10 +44,6 @@ impl Game<'_> {
.iter()
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
if let Some(j) = taken {
let before = self.dropped[j].clone();
// `Panda.pickUpItem`'s `onItemPickup` comes first: the
// advancement of whoever threw it.
self.mob_item_pickup_trigger(i, &before);
let e = self.remove_dropped(j);
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
@@ -191,98 +187,3 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod pickup_tests {
use super::*;
use crate::game::native_machines::tests::server_game;
use horizon_client::advancements as adv;
use horizon_client::motion::PlayerMotion;
use horizon_world::lazy_json::LazyJson;
const ADV: &str = "horizon:test/panda_pickup";
const TRIGGER: &str = "minecraft:thrown_item_picked_up_by_entity";
const THROWER: [i32; 4] = [413, 0, 0, 1];
/// An advancement on the pickup trigger, as a data pack would add one: no
/// vanilla advancement tests it, so the tree needs one of its own.
fn install_pickup_advancement(game: &mut Game<'_>) {
let mut tree = std::sync::Arc::new(adv::Tree::default());
let one = std::sync::Arc::get_mut(&mut tree).unwrap();
one.list.push(adv::Advancement {
id: ADV.into(),
parent: None,
display: None,
criteria: vec![adv::Criterion {
name: "pickup".into(),
trigger: TRIGGER.into(),
conditions: LazyJson::new(&serde_json::json!({})),
}],
requirements: vec![vec!["pickup".into()]],
rewards: Default::default(),
});
one.by_id.insert(ADV.into(), 0);
one.by_trigger.insert(TRIGGER.into(), vec![(0, 0)]);
game.adv.tree = tree;
}
/// `Panda.pickUpItem` calls `onItemPickup`, which fires
/// `CriteriaTriggers.THROWN_ITEM_PICKED_UP_BY_ENTITY` for whoever threw the
/// bamboo, as the fox's pickup does (`LivingEntity.onItemPickup`).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_panda_that_takes_a_thrown_item_credits_the_thrower() {
let mut game = server_game();
game.options.dedicated = None;
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(4.5, 100.0, 4.5)).unwrap());
game.level.player_uuid = Some(THROWER);
game.level.health = 20.0;
game.creative = false;
// A world of its own under the temporary folder, never the cwd.
game.world_root =
std::env::temp_dir().join(format!("horizon-panda-pickup-world-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&game.world_root);
game.build_registries();
game.set_allow_commands(true);
install_pickup_advancement(&mut game);
// `DistanceManager` keeps a chunk entity-ticking only while its whole
// 5x5 neighbourhood is resident.
for cx in -2..=2 {
for cz in -2..=2 {
game.world.chunks.insert(
(cx, cz),
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
);
}
}
for command in [
"forceload add 0 0",
"tick freeze",
"fill 0 99 0 15 99 15 stone",
"summon minecraft:panda 4.5 100 6.5 {UUID:[I;9,9,9,9],PersistenceRequired:1b,CanPickUpLoot:1b}",
"summon minecraft:item 4.5 100 6.5 {Item:{id:\"minecraft:bamboo\",count:1},Thrower:[I;413,0,0,1],PickupDelay:0s}",
] {
assert!(game.run_command(command), "{command}");
}
let id = game.mob_by_uuid([9, 9, 9, 9]).unwrap();
let holding = |g: &Game<'_>| {
g.mobs
.iter()
.any(|m| m.id == id && m.extra.main_hand.is_some())
};
for _ in 0..20 {
if holding(&game) {
break;
}
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
assert!(holding(&game), "the panda took the bamboo");
assert!(
game.adv.progress.has(ADV, "pickup"),
"the thrower's advancement: {:?}",
game.adv.progress.criteria
);
let _ = std::fs::remove_dir_all(&game.world_root);
}
}
+6 -70
View File
@@ -6,7 +6,7 @@ use super::Game;
use horizon_client::mobs::{Species, riders};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
random::RandomSource,
};
use std::collections::HashMap;
@@ -50,12 +50,6 @@ fn mount_rolls(species: Species, baby: bool, natural: bool) -> MountRolls {
}
}
/// `SkeletonTrapGoal.tick`: each additional trap horse's horizontal push draws
/// two `triangle(0.0, 1.1485)` offsets from the original trap horse's random.
fn trap_horse_scatter(random: &mut LegacyRandom) -> (f64, f64) {
(random.triangle(0.0, 1.1485), random.triangle(0.0, 1.1485))
}
impl Game<'_> {
/// `Entity.startRiding` for two mobs.
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
@@ -706,21 +700,18 @@ impl Game<'_> {
let Some(other) = self.spawn_mob("minecraft:skeleton_horse", at, true) else {
continue;
};
// SkeletonTrapGoal.createHorse: scatter is drawn after the rider.
let (dx, dz) = (
self.world.random.triangle(0.0, 1.1485),
self.world.random.triangle(0.0, 1.1485),
);
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
h.invulnerable_time = TRAP_INVULNERABLE;
h.extra.tame = true;
h.extra.age = 0;
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
}
if let Some(rider) = self.trap_skeleton(at) {
self.mount_mob(rider, other);
let Some(horse) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let (dx, dz) = trap_horse_scatter(&mut horse.random);
if let Some(h) = self.mobs.iter_mut().find(|m| m.id == other) {
h.velocity = Vec3::new(h.velocity.x + dx, h.velocity.y, h.velocity.z + dz);
}
}
}
}
@@ -777,59 +768,4 @@ mod tests {
assert!(!r(Species::Drowned, true).nautilus);
assert!(!r(Species::Zombie, false).camel_husk);
}
#[test]
fn trap_horse_scatter_draws_two_triangles_from_original_horse_random() {
let mut horse_random = LegacyRandom::new(0x5DEECE66D);
let mut expected = horse_random.clone();
for _ in 0..3 {
let (dx, dz) = trap_horse_scatter(&mut horse_random);
assert_eq!(dx, expected.triangle(0.0, 1.1485));
assert_eq!(dz, expected.triangle(0.0, 1.1485));
}
assert_eq!(horse_random.next_long(), expected.next_long());
}
/// `SkeletonTrapGoal.tick`: springing a skeleton trap draws the three
/// additional horses' horizontal velocity scatter from the original trap
/// horse's own `random` after each rider is created.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn spring_skeleton_trap_draws_scatter_from_trap_horse_after_each_rider() {
use horizon_client::chunk::Chunk;
use std::sync::Arc;
let mut game = super::super::native_machines::tests::server_game();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
let at = [8.5, 64.0, 8.5];
let trap_id = game
.spawn_mob_raw("minecraft:skeleton_horse", at, false)
.unwrap();
let mut expected_horse_rng = LegacyRandom::new(777);
if let Some(horse) = game.mobs.iter_mut().find(|m| m.id == trap_id) {
horse.extra.trap = true;
horse.random = horizon_core::random::EntityRandom::own(expected_horse_rng.clone());
}
game.spring_skeleton_trap(trap_id);
let expected_pushes: Vec<(f64, f64)> = (0..3)
.map(|_| trap_horse_scatter(&mut expected_horse_rng))
.collect();
let horse = game.mobs.iter_mut().find(|m| m.id == trap_id).unwrap();
assert!(!horse.extra.trap);
assert_eq!(horse.random.next_long(), expected_horse_rng.next_long());
let extra_horses: Vec<&horizon_client::mobs::Mob> = game
.mobs
.iter()
.filter(|m| m.species() == Species::SkeletonHorse && m.id != trap_id)
.collect();
assert_eq!(extra_horses.len(), 3);
for (h, (dx, dz)) in extra_horses.iter().zip(expected_pushes) {
assert_eq!((h.velocity.x, h.velocity.z), (dx, dz));
assert!(h.extra.rider.is_some());
}
}
}
+8 -16
View File
@@ -9,7 +9,6 @@ use horizon_client::{
collisions::SceneCollisions,
};
use horizon_client::{
entities::nameplate::NameplateDistances,
hud::HudBatch,
mobs::{Anim, Brain, Mob, MobType},
mouse::Rotation,
@@ -290,9 +289,9 @@ impl Game<'_> {
}
// Minecraft.cameraEntity: a server's spectator camera on a mob or
// another player.
let (entity, descending, entity_kind) = match self.camera_entity() {
Some(e) => (
e,
let (entity, descending, entity_kind) = match self.camera_mob() {
Some(m) => (
super::remote_camera::mob_camera_entity(m),
false,
horizon_world::collision_context::EntityContextKind::Living,
),
@@ -461,17 +460,11 @@ impl Game<'_> {
/// Keep the player's model in step with the player (`LivingEntity.tick`
/// body turning, `WalkAnimationState`, the swing and the pose).
pub(super) fn tick_player_model(&mut self) {
let (scale, nameplate) = self.player.as_mut().map_or_else(
|| (1.0, NameplateDistances::default()),
|p| {
let scale = p.attribute("minecraft:scale").unwrap_or(1.0) as f32;
let nameplate = NameplateDistances {
name_tag: p.attribute("minecraft:name_tag_distance").ok(),
below_name: p.attribute("minecraft:below_name_distance").ok(),
};
(scale, nameplate)
},
);
let scale = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:scale").ok())
.unwrap_or(1.0) as f32;
let Some(p) = self.player.as_ref() else {
self.player_model = None;
return;
@@ -534,7 +527,6 @@ impl Game<'_> {
};
m.extra.ints[1] = bed;
m.extra.model_scale = scale;
m.set_nameplate(nameplate);
// The crouch flag is still last tick's here.
let (from, at) =
player_model_positions(previous_position, position, m.extra.crouching, crouching);
@@ -176,7 +176,6 @@ mod tests {
fn orb_at(x: f64, y: f64, z: f64) -> Taken {
let at = horizon_core::geometry::Vec3::new(x, y, z);
Taken::Orb(Box::new(ExperienceOrb {
fall_distance: 0.0,
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
@@ -338,7 +338,6 @@ impl Game<'_> {
window.set_cursor(match effective {
Cursor::Arrow => winit::window::CursorIcon::Default,
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::Ibeam => winit::window::CursorIcon::Text,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
Cursor::ResizeEw => winit::window::CursorIcon::EwResize,
+71 -13
View File
@@ -64,8 +64,6 @@ use winit::{event::MouseButton, keyboard::KeyCode};
mod abilities;
#[path = "remote_poll.rs"]
mod poll;
#[path = "remote_attributes.rs"]
mod remote_attributes;
#[path = "remote_tables.rs"]
mod tables;
@@ -2808,8 +2806,23 @@ impl Game<'_> {
if kind == "minecraft:experience_orb" {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
self.orbs
.push(ExperienceOrb::new(id as u32, at, Vec3::ZERO, 1));
self.orbs.push(ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: id as u32,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
air: 300,
following: None,
});
}
if let Some(t) = mob_type {
let mut mob = Mob::new(id as u32, t, at, head);
@@ -3245,6 +3258,60 @@ impl Game<'_> {
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob, each value from
/// `AttributeInstance.calculateValue`: its maximum health (the hearts drawn
/// over its mount), and its `minecraft:scale`, which sizes its box
/// (`LivingEntity.getDimensions`) and model (`LivingEntityRenderState.scale`)
/// as the local player's does: NPC plugins make their fake players bigger
/// with it, datapacks scale any mob.
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let catalog = &self.motion.attributes;
let value = |name: &str| {
let attribute = catalog.id(name)?;
let s = values
.iter()
.find(|s| s.attribute == attribute.registry_id())?;
Some(
catalog
.attribute(attribute)
.definition
.sanitize(snapshot_value(s)),
)
};
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
let (speed, jump) = (
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
// its own height (`getSpeed`, `getCustomJump`).
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
if let Some(speed) = speed {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MovementSpeed,
speed,
);
}
if let Some(jump) = jump {
m.extra.vector[0] = jump;
}
}
if let Some(max_health) = max_health {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MaxHealth,
max_health,
);
}
if let Some(scale) = scale {
m.extra.model_scale = scale as f32;
}
}
pub(super) fn handle_packet(&mut self, packet: C) -> Result<()> {
self.native_client_packet(&packet);
match packet {
@@ -4498,16 +4565,7 @@ impl Game<'_> {
// HUD draws and the health it may hold), reach, speed and so on.
// Then the received health again, which the old maximum clamped.
C::UpdateAttributes(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => {
let nameplate = self.remote_nameplate_values(&p.values);
self.apply_own_attributes(p.values);
if let Some(m) = self.player_model.as_mut() {
if let Some(value) = nameplate.name_tag {
m.set_name_tag_distance(value);
}
if let Some(value) = nameplate.below_name {
m.set_below_name_distance(value);
}
}
self.apply_remote_health();
}
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
@@ -1,89 +0,0 @@
//! `ClientPacketListener.handleUpdateAttributes` for entity attributes that
//! affect the client-side nameplate.
use super::{Game, snapshot_value};
use horizon_client::entities::nameplate::NameplateDistances;
use horizon_protocol::packets::play;
impl Game<'_> {
/// Computes one synced attribute using the catalog's vanilla range.
fn remote_attribute_value(
&self,
name: &str,
values: &[play::AttributeSnapshot],
) -> Option<f64> {
let attribute = self.motion.attributes.id(name)?;
let snapshot = values
.iter()
.find(|snapshot| snapshot.attribute == attribute.registry_id())?;
Some(
self.motion
.attributes
.attribute(attribute)
.definition
.sanitize(snapshot_value(snapshot)),
)
}
/// The two attributes read by `LivingEntityRenderer` and
/// `EntityRenderer.extractNameTags`.
pub(super) fn remote_nameplate_values(
&self,
values: &[play::AttributeSnapshot],
) -> NameplateDistances {
NameplateDistances {
name_tag: self.remote_attribute_value("minecraft:name_tag_distance", values),
below_name: self.remote_attribute_value("minecraft:below_name_distance", values),
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob. The same
/// packet can target a player, mob or armor stand.
pub(super) fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let max_health = self.remote_attribute_value("minecraft:max_health", values);
let scale = self.remote_attribute_value("minecraft:scale", values);
let speed = self.remote_attribute_value("minecraft:movement_speed", values);
let jump = self.remote_attribute_value("minecraft:jump_strength", values);
let nameplate = self.remote_nameplate_values(values);
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id as u32) {
if let Some(value) = nameplate.name_tag {
a.nameplate.name_tag = Some(value);
}
if let Some(value) = nameplate.below_name {
a.nameplate.below_name = Some(value);
}
}
return;
};
// A horse its rider's client moves goes at its own speed and jumps
// its own height (`getSpeed`, `getCustomJump`).
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
if let Some(speed) = speed {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MovementSpeed,
speed,
);
}
if let Some(jump) = jump {
m.extra.vector[0] = jump;
}
}
if let Some(max_health) = max_health {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MaxHealth,
max_health,
);
}
if let Some(scale) = scale {
m.extra.model_scale = scale as f32;
}
if let Some(value) = nameplate.name_tag {
m.set_name_tag_distance(value);
}
if let Some(value) = nameplate.below_name {
m.set_below_name_distance(value);
}
}
}
@@ -7,7 +7,6 @@
use super::Game;
use horizon_client::{
camera::{CameraEntity, LivingCamera},
entities::ArmorStand,
mobs::Mob,
};
@@ -54,35 +53,6 @@ pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity {
}
}
/// `Camera.alignWithEntity`'s view of an armor stand, the usual camera of a
/// server's cutscene or menu scene. `ArmorStand.setYHeadRot` keeps its head
/// on `yRot`, so its view turns with its yaw; its eyes are its dimensions'
/// (`getDimensionsMarker`): none for a marker, `BABY_DIMENSIONS`' 0.9875 for
/// a small stand, the type's 1.7775 otherwise.
pub(super) fn stand_camera_entity(a: &ArmorStand) -> CameraEntity {
let eye_height = if a.marker {
0.0
} else {
a.probe_dimensions().2 as f32
};
CameraEntity {
position: [a.position.x, a.position.y, a.position.z],
previous: [a.previous.x, a.previous.y, a.previous.z],
x_rot: a.pitch,
y_rot: a.yaw,
x_rot_o: a.pitch,
y_rot_o: a.previous_yaw,
eye_height,
living: Some(LivingCamera {
scale: 1.0,
camera_distance: 4.0,
sleeping: None,
dead: None,
}),
mount: None,
}
}
/// `GameRenderer.checkEntityPostEffect`: the chain a spectated creeper, spider
/// or enderman puts on the screen.
pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
@@ -98,9 +68,7 @@ pub(super) fn entity_post_effect(kind: &str) -> Option<&'static str> {
impl Game<'_> {
/// `ClientPacketListener.handleSetCamera`.
pub(super) fn remote_set_camera(&mut self, camera_id: i32) {
let id = camera_id as u32;
let in_level =
self.mobs.iter().any(|m| m.id == id) || self.armor_stands.iter().any(|a| a.id == id);
let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32);
let player = self
.remote
.as_ref()
@@ -134,18 +102,6 @@ impl Game<'_> {
let id = self.remote_camera_id()?;
self.mobs.iter().find(|m| m.id == id)
}
/// The view of the entity a server attached the camera to, while the
/// level still holds it: a mob or an armor stand.
pub(super) fn camera_entity(&self) -> Option<CameraEntity> {
if let Some(m) = self.camera_mob() {
return Some(mob_camera_entity(m));
}
let id = self.remote_camera_id()?;
self.armor_stands
.iter()
.find(|a| a.id == id)
.map(stand_camera_entity)
}
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
/// `EntityHitResult`): the nearest mob or object before any block.
pub(super) fn spectator_click_target(&self) -> Option<i32> {
@@ -166,28 +122,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{camera_after_packet, entity_post_effect, stand_camera_entity};
use horizon_client::entities::ArmorStand;
use horizon_core::geometry::Vec3;
/// A server's camera on an armor stand looks out of the stand's eyes
/// (EntityType.ARMOR_STAND `eyeHeight(1.7775F)`, `BABY_DIMENSIONS`
/// 0.9875F for a small one, nothing for a marker), along its yaw.
#[test]
fn an_armor_stand_holds_the_camera_at_its_eyes() {
let mut stand = ArmorStand::new(7, Vec3::new(10.5, 64.0, -3.5), 90.0);
stand.yaw = 135.0;
stand.pitch = -20.0;
let view = stand_camera_entity(&stand);
assert_eq!(view.position, [10.5, 64.0, -3.5]);
assert_eq!(view.eye_height, 1.7775f32);
assert_eq!((view.y_rot_o, view.y_rot), (90.0, 135.0));
assert_eq!(view.x_rot, -20.0);
stand.small = true;
assert_eq!(stand_camera_entity(&stand).eye_height, 0.9875f32);
stand.marker = true;
assert_eq!(stand_camera_entity(&stand).eye_height, 0.0);
}
use super::{camera_after_packet, entity_post_effect};
/// Only a creeper, spider or enderman has a spectator chain.
#[test]
+1 -1
View File
@@ -20,7 +20,7 @@ use super::input_trace::{InputOrigin, TraceEvent, TraceKind};
use super::{Game, Screen};
use horizon_client::{Result, gamepad::Button, keybinds, text::Text, title::world_management};
use horizon_core::geometry::Vec3;
use horizon_system::scrub::Scrubber;
use horizon_telemetry::scrub::Scrubber;
use serde::Deserialize;
use serde_json::{Value, json};
use std::{
@@ -296,32 +296,6 @@ impl Game<'_> {
self.open_text_edit(key, front);
}
}
/// Whether `SignBlock.useWithoutItem` on the sign at `pos` consumes the
/// click. Vanilla answers: a waxed sign (the failed-interaction sound), a
/// side whose lines carry a click action (`executeClickCommandsIfPresent`,
/// which runs it), and a player who may build (the text editor). Every
/// other sign answers PASS, so `ServerPlayerGameMode.useItemOn` goes on to
/// `ItemStack.useOn` and an adventure-mode `can_place_on` stack places
/// against it. Vanilla also passes while another player is editing the
/// sign and when its text has styled lines; neither is modelled here.
pub(super) fn sign_consumes_click(&self, pos: [i32; 3]) -> bool {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
if sign.waxed || self.may_build() {
return true;
}
let side = if self.facing_sign_front(key) {
&sign.front
} else {
&sign.back
};
side.click
.iter()
.flatten()
.any(|event| click_action(event).is_some())
}
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
/// honeycomb): the item changes the side the player faces and is used up.
/// Whether it did; a sign it does not apply to goes on to the empty hand.
@@ -1,150 +0,0 @@
//! `SignBlock.useWithoutItem`: the click it passes goes on to the item
//! (`ServerPlayerGameMode.useItemOn`'s `useWithoutItem` then `ItemStack.useOn`),
//! so an adventure player's `can_place_on` block places against the sign.
use horizon_client::{
command::args::GameMode, containers::BlockEntity, items::ItemStack, mobs::Who, signs::SignData,
};
use horizon_world::nbt::Tag;
type Posted = (&'static str, [f64; 3], Option<Who>);
/// A server's game with nobody playing, or `None` without prepared resources.
fn game() -> Option<crate::game::Game<'static>> {
std::env::var_os("HORIZON_TEST_RESOURCES")?;
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.set_allow_commands(true);
game.player = None;
Some(game)
}
/// `stack` with a `can_place_on` component naming `block`.
fn held(name: &str, count: u32, block: &str) -> ItemStack {
let mut stack = ItemStack::new(name, count);
stack.data_mut().other.push((
"minecraft:can_place_on".into(),
Tag::compound([("blocks", Tag::List(vec![Tag::String(block.into())]))]),
));
stack
}
/// An adventure player (`mayBuild` false) clicks the south side of the sign
/// at [2, 4, 3] with `stack` in hand, as `Minecraft.startUseItem` does; the
/// game events the click posted.
fn click_sign(game: &mut crate::game::Game<'_>, sign: SignData, stack: ItemStack) -> Vec<Posted> {
let registry = game.pack.assets.registry;
let sign_state = registry.default_state("minecraft:oak_sign").unwrap();
game.world.set(2, 4, 3, sign_state).unwrap();
game.world.set(2, 4, 4, game.res.facts().air).unwrap();
game.world
.block_entities
.insert((2, 4, 3), BlockEntity::Sign(sign));
game.set_mode(GameMode::Adventure);
*game.held_mut() = Some(stack);
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
let hit = crate::game::Hit {
pos: [2, 4, 3],
face: [0, 0, 1],
point: [2.5, 4.5, 3.9],
};
game.with_player_actor(|g| g.use_hand(Some(&hit))).unwrap();
crate::game::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap())
}
/// `SignBlock.useWithoutItem` answers PASS for a sign with no click action to
/// run, that is not waxed and that a player who may not build aims at, so
/// ServerPlayerGameMode.useItemOn goes on to `ItemStack.useOn`: a
/// `can_place_on` block naming the sign places against it.
#[test]
fn an_adventure_click_on_a_sign_places_a_can_place_on_block() {
let Some(mut game) = game() else {
return;
};
let planks = game
.pack
.assets
.registry
.default_state("minecraft:oak_planks")
.unwrap();
let events = click_sign(
&mut game,
SignData::default(),
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
);
assert_eq!(game.world.block(2, 4, 4), planks, "placed against the sign");
assert_eq!(game.held().map(|s| s.count), Some(1), "one plank is used");
assert_eq!(
events,
vec![("minecraft:block_place", [2.5, 4.5, 4.5], Some(Who::Player))]
);
// `can_place_on` names another block: nothing places and nothing is used.
let events = click_sign(
&mut game,
SignData::default(),
held("minecraft:oak_planks", 2, "minecraft:stone"),
);
assert_ne!(game.world.block(2, 4, 4), planks, "nothing places");
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
assert!(events.is_empty(), "{events:?}");
}
/// The signs whose `useWithoutItem` consumes the click keep it, so the
/// `can_place_on` block is never placed: a waxed sign (its failed-interaction
/// sound) and a sign whose side runs a click command.
#[test]
fn a_waxed_sign_or_a_click_command_keeps_the_adventure_click() {
let Some(mut game) = game() else {
return;
};
let planks = game
.pack
.assets
.registry
.default_state("minecraft:oak_planks")
.unwrap();
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
let waxed = SignData {
waxed: true,
..SignData::default()
};
click_sign(
&mut game,
waxed,
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
);
assert_ne!(game.world.block(2, 4, 4), planks, "a waxed sign keeps it");
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
// A `run_command` line runs, and the click ends there.
let mut command = SignData {
allow_op_features: true,
..SignData::default()
};
command.front.click[0] = Some(Tag::compound([
("action", Tag::String("run_command".into())),
(
"command",
Tag::String("setblock 5 4 3 minecraft:stone".into()),
),
]));
let events = click_sign(
&mut game,
command,
held("minecraft:oak_planks", 2, "minecraft:oak_sign"),
);
assert_eq!(game.world.block(5, 4, 3), stone, "the click command ran");
assert_ne!(game.world.block(2, 4, 4), planks, "the command keeps it");
assert_eq!(game.held().map(|s| s.count), Some(2), "nothing is used");
assert!(
!events.iter().any(|e| e.0 == "minecraft:block_place"),
"{events:?}"
);
}
@@ -43,7 +43,6 @@ impl Game<'_> {
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
let (knockback, _) = self.extra_knockback("minecraft:ender_dragon", scale);
self.attack_effects(area, None, false, super::mace::Spared::Nothing, None);
self.post_attack_dragon_burst();
self.special_attack_slowdown(knockback);
self.special_attack_exhaustion();
}
-70
View File
@@ -1362,76 +1362,6 @@ mod tests {
Some(game)
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn portal_blast_sulfur_cube_uses_only_positive_cooldown() {
use horizon_client::mobs::Mob;
use horizon_client::nether_portal::PortalState;
let mut game = server_game().expect("set HORIZON_TEST_RESOURCES");
let portal = game
.pack
.assets
.registry
.default_state("minecraft:nether_portal")
.unwrap();
for (cooldown, silent) in [
(1, false),
(1, true),
(0, false),
(0, true),
(-1, false),
(-1, true),
] {
game.world.set(8, 64, 8, portal).unwrap();
let kind = game
.res
.mob_types()
.get("minecraft:sulfur_cube")
.unwrap()
.clone();
let mut cube = Mob::new(9123, kind, Vec3::new(8.5, 64.0, 8.5), 0.0);
cube.silent = silent;
cube.extra.body = Some("minecraft:tnt".into());
cube.extra.counter2 = 2; // fuse 1: explodes on this tick.
game.mobs.push(cube);
game.portals.entities.insert(
9123,
PortalState {
cooldown,
process: None,
},
);
// The actual level tick orders the portal pass before the mob pass.
game.audio.heard.clear();
game.handle_entity_portals();
game.tick_creatures().unwrap();
assert_eq!(
game.world.block(8, 64, 8) == portal,
cooldown > 0,
"cooldown {cooldown}, Silent={silent}"
);
assert_eq!(
game.audio
.heard
.get("minecraft:entity.generic.explode")
.copied()
.unwrap_or(0),
u32::from(!silent),
"cooldown {cooldown}, Silent={silent}"
);
}
// An unrelated native/sourceless blast never inherits the cube's calculator.
game.world.set(8, 64, 8, portal).unwrap();
game.server_explode(
[8.5, 64.0625, 8.5],
4.0,
super::super::explosion::BlockInteraction::Destroy,
false,
)
.unwrap();
assert_ne!(game.world.block(8, 64, 8), portal);
}
#[test]
fn loaded_tnt_fuse_uses_numeric_short_and_saves_without_raw() {
use horizon_world::nbt::Tag;
-31
View File
@@ -54,8 +54,6 @@ pub(super) struct ThrownTrident {
pub(super) on_ground: bool,
/// `Entity.remainingFireTicks`; on clients, the synchronized on-fire flag.
pub(super) fire_ticks: i32,
/// `Entity.fallDistance`: kept as loaded; water resets it.
fall_distance: f64,
/// `AbstractArrow.soundEvent`: the trident's own until it first hits a block.
sound_event: String,
/// Water membership from the preceding `baseTick` (not updated while stuck).
@@ -131,7 +129,6 @@ impl ThrownTrident {
self.no_gravity = n.no_gravity;
self.on_ground = n.on_ground;
self.fire_ticks = n.fire_ticks;
self.fall_distance = n.fall_distance;
self.sound_event = n.sound_event;
}
/// `AbstractArrow.addAdditionalSaveData` + `DealtDamage`.
@@ -158,7 +155,6 @@ impl ThrownTrident {
no_gravity: self.no_gravity,
on_ground: self.on_ground,
fire_ticks: self.fire_ticks,
fall_distance: self.fall_distance,
sound_event: &self.sound_event,
item: &self.stack,
},
@@ -205,7 +201,6 @@ impl ThrownTrident {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
was_touching_water: false,
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
@@ -625,7 +620,6 @@ impl Game<'_> {
no_gravity: false,
on_ground: false,
fire_ticks: 0,
fall_distance: 0.0,
sound_event: horizon_client::entity_nbt::TRIDENT_HIT_GROUND.to_owned(),
was_touching_water: false,
random: horizon_core::random::LegacyRandom::new(i64::from(id)),
@@ -1229,10 +1223,6 @@ impl Game<'_> {
t.has_been_shot = true;
let rain = self.arrow_rain();
t.was_touching_water = self.arrow_scene(&rain).in_water(t.position);
// Entity.updateInWaterStateAndDoFluidPushing: resetFallDistance in water.
if t.was_touching_water {
t.fall_distance = 0.0;
}
if t.fire_ticks > 0 {
t.fire_ticks -= 1;
}
@@ -1524,27 +1514,6 @@ mod tests {
assert_eq!(back.last_state, t.last_state);
}
/// `Entity.saveWithoutId` writes the `fall_distance` `Entity.load` read:
/// a trident keeps the one it was loaded with.
#[test]
fn a_trident_keeps_its_fall_distance_across_a_reload() {
use horizon_world::nbt::Tag;
let registry = stone_registry();
let fall = |tag: &Tag| match tag.get("fall_distance") {
Some(Tag::Double(bits)) => Some(f64::from_bits(*bits)),
_ => None,
};
let fresh = ThrownTrident::remote(1, Vec3::ZERO, 0.0, 0.0);
assert_eq!(fall(&fresh.to_nbt([1; 4], &registry, None)), Some(0.0));
let tag = Tag::compound([
("Pos", Tag::List(vec![Tag::Double(0); 3])),
("fall_distance", Tag::Double(5.0_f64.to_bits())),
]);
let loaded = horizon_client::entity_nbt::trident_from_nbt(&tag).unwrap();
let back = ThrownTrident::from_nbt(loaded, &tag, &registry);
assert_eq!(fall(&back.to_nbt([1; 4], &registry, None)), Some(5.0));
}
/// `AbstractArrow.startFalling` on `new Random(42)`: its first three
/// `nextFloat() * 0.2F` draws (computed from Java's LCG) scale x, y, z.
#[test]
+2 -19
View File
@@ -127,10 +127,6 @@ impl Game<'_> {
self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some());
let pots_flower = self
.with_level(|b, _, _| b.potted_flower_state(cake, item.as_deref()).is_some());
// Read before the use runs: a sign's click commands execute in
// `process_level_events` and may change or remove the sign, but
// vanilla keeps the click consumed (`executedClickCommand`).
let sign_consumes = self.sign_consumes_click(hit.pos);
let (result, branch) = self.with_level(|b, level, ticks| {
b.use_item_on_branch(
level,
@@ -182,15 +178,7 @@ impl Game<'_> {
_ => {}
}
}
// SignBlock.useWithoutItem answers PASS for a sign that is
// not waxed, has no click action to run and that nobody may
// build on: ServerPlayerGameMode.useItemOn goes on to
// ItemStack.useOn, so an adventure-mode `can_place_on` stack
// still places against the sign.
let sign_passes = branch == horizon_world::level::UseBranch::WithoutItem
&& self.res.facts().get(cake).sign_form().is_some()
&& !sign_consumes;
if result.consumes_action() && !sign_passes {
if result.consumes_action() {
// ServerPlayerGameMode.useItemOn: ITEM_USED_ON_BLOCK with
// the stack before the use, or DEFAULT_BLOCK_USE.
match branch {
@@ -473,8 +461,6 @@ impl Game<'_> {
return Ok(true);
};
let target = [pos.x, pos.y, pos.z];
let game_master = self.can_use_game_master_blocks();
let state = self.lectern_placed(target, state, &stack, game_master)?;
let state = self.apply_item_block_state(target, state, &stack)?;
let key = (target[0], target[1], target[2]);
if !self.world.block_entities.stateful_entity(state.0)
@@ -483,6 +469,7 @@ impl Game<'_> {
self.world.block_entities.insert(key, entity);
}
if self.world.block_entities.get(&key).is_some() {
let game_master = self.can_use_game_master_blocks();
self.apply_placed_item(target, &stack, game_master);
// Block.setPlacedBy.
match self.world.block_entities.get(&key) {
@@ -685,10 +672,6 @@ impl Game<'_> {
}
}
#[cfg(test)]
#[path = "sign_use_tests.rs"]
mod sign_use_tests;
#[cfg(test)]
#[path = "sign_chain_tests.rs"]
mod sign_chain_tests;
+2 -7
View File
@@ -11,15 +11,10 @@ impl Game<'_> {
let Some(stack) = self.held().cloned() else {
return false;
};
// Consumable.canConsume asks the stack's own `food`.
let consumable = self.consumable(&stack);
if consumable.is_none()
|| (horizon_client::item_sync::food(&stack, info.food).is_some()
&& (self.player.is_none()
|| !self.can_eat(
&stack,
horizon_client::item_sync::can_always_eat(&stack, info.can_always_eat),
)))
|| (info.food.is_some()
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
{
return false;
}
-49
View File
@@ -92,50 +92,6 @@ fn triggers(name: &str) -> bool {
|| n == "bell"
}
impl Game<'_> {
/// `Player.itemAttackInteraction` applies trigger-only held-item effects
/// after an EnderDragonPart is successfully hit.
pub(super) fn post_attack_dragon_burst(&mut self) {
if let Some(held) = self.held().cloned() {
self.post_attack_bursts(&held, "minecraft:ender_dragon");
}
}
/// `ServerExplosion.hurtEntities` knockback for a trigger-only blast such
/// as Wind Burst. The dragon is not in the normal entity lists, but its
/// body still receives the blast impulse (without taking damage).
pub(super) fn wind_burst_knockback(
&mut self,
center: [f64; 3],
radius: f32,
multiplier: f32,
blocks: &[[i32; 3]],
) {
let Some(d) = self.dragon.as_ref() else {
return;
};
let whole = d.whole_box();
let (lo, hi) = (whole.min_position(), whole.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let Some((id, mut push)) = self
.dragon
.as_ref()
.and_then(|d| Some((d.id, d.explosion_knockback(center, radius, exposure)?)))
else {
return;
};
for component in &mut push {
*component *= f64::from(multiplier);
}
self.native_explosion_knockback(self.dim.id(), center, blocks, Some(id), &mut push);
if let Some(d) = self.dragon.as_mut() {
d.velocity = Vec3::new(
d.velocity.x + push[0],
d.velocity.y + push[1],
d.velocity.z + push[2],
);
}
}
/// `WindChargeItem.use`.
pub(super) fn throw_wind_charge(&mut self) -> Result<bool> {
let (Some(eye), Some(p)) = (self.eye_position(), self.player.as_ref()) else {
@@ -616,7 +572,6 @@ impl Game<'_> {
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.wind_positions(center, radius);
let pushed = self.wind_knockback(center, radius, multiplier, breeze, wind_charge, &targets);
self.wind_burst_knockback(center, radius, multiplier, &targets);
let block_count = targets.len();
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
if !breeze || self.ui.rules.bool("mob_griefing") {
@@ -785,10 +740,6 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
#[path = "wind_dragon_tests.rs"]
mod wind_dragon_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1,88 +0,0 @@
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn end_game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
assert_eq!(
game.enter_command_level(crate::game::Dim::End),
Some(crate::game::Dim::Overworld)
);
assert!(game.dimension_type().has_ender_dragon_fight, "the End");
for x in -1..2 {
for z in -1..2 {
game.world
.chunks
.insert((x, z), Arc::new(Chunk::empty(x, z)));
}
}
assert!(game.run_command("forceload add 0 0"));
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_wind_burst_pushes_the_dragon_without_damage() {
let mut game = end_game();
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
.expect("spawned dragon");
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.0, false, false)
.unwrap();
let dragon = game.dragon.as_ref().expect("dragon");
assert_eq!(dragon.health, 200.0);
assert!(
dragon.velocity.y > 0.0,
"Wind Burst did not push the dragon"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_wind_burst_scales_the_dragons_push_by_its_multiplier() {
let mut game = end_game();
game.spawn_dragon_spawned([0.0, 70.0, 0.0])
.expect("spawned dragon");
game.dragon.as_mut().expect("dragon").velocity = Vec3::ZERO;
game.wind_burst([0.0, 66.0, 0.0], 5.0, 1.22, false, false)
.unwrap();
let push = game.dragon.as_ref().expect("dragon").velocity.y;
assert!(push > 0.0, "Wind Burst did not push the dragon");
assert!(push > 0.6, "Wind Burst multiplier was not applied: {push}");
}
-323
View File
@@ -143,147 +143,6 @@ pub fn patched_use(stack: &ItemStack) -> (Option<i32>, bool) {
out
}
/// `FoodProperties.canAlwaysEat` from the stack's `food` component.
pub fn can_always_eat(stack: &ItemStack, default: bool) -> bool {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
return false;
}
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
return horizon_world::datafix::dynamic::as_bool(food.get("can_always_eat"), false);
}
default
}
/// `Consumable.consumeSeconds` from the stack's `consumable` component.
pub fn consumable_seconds(stack: &ItemStack) -> Option<f32> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
data.and_then(|d| d.other("minecraft:consumable"))
.map(|consumable| {
horizon_world::datafix::dynamic::as_float(consumable.get("consume_seconds"), 1.6)
})
}
/// `UseRemainder` from the stack's `use_remainder` component.
pub fn use_remainder(stack: &ItemStack, default: Option<String>) -> Option<String> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:use_remainder").is_some()) {
return None;
}
if let Some(remainder) = data.and_then(|d| d.other("minecraft:use_remainder")) {
return match remainder.get("id") {
Some(Tag::String(id)) => Some(id.to_string_lossy()),
_ => None,
};
}
default
}
/// `Equippable.dispensable` from the stack's `equippable` component.
pub fn dispensable(stack: &ItemStack, default: bool) -> bool {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
return false;
}
if let Some(equippable) = data.and_then(|d| d.other("minecraft:equippable")) {
return horizon_world::datafix::dynamic::as_bool(equippable.get("dispensable"), true);
}
default
}
/// `FoodProperties.nutrition` and `saturation` from the stack's `food`
/// component, else the item's.
pub fn food(stack: &ItemStack, default: Option<(u32, f32)>) -> Option<(u32, f32)> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:food").is_some()) {
return None;
}
if let Some(food) = data.and_then(|d| d.other("minecraft:food")) {
return Some((
horizon_world::datafix::dynamic::as_int(food.get("nutrition"), 0).max(0) as u32,
horizon_world::datafix::dynamic::as_float(food.get("saturation"), 0.0),
));
}
default
}
/// `Consumable.animation` from the stack's `consumable` component.
pub fn consumable_animation(stack: &ItemStack) -> Option<horizon_world::consumable::UseAnimation> {
use horizon_world::consumable::UseAnimation;
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
let Some(Tag::String(animation)) = data
.and_then(|d| d.other("minecraft:consumable"))
.and_then(|c| c.get("animation"))
else {
return None;
};
// ItemUseAnimation.CODEC's names.
Some(match &*animation.to_string_lossy() {
"none" => UseAnimation::None,
"drink" => UseAnimation::Drink,
"block" => UseAnimation::Block,
"bow" => UseAnimation::Bow,
"trident" => UseAnimation::Trident,
"crossbow" => UseAnimation::Crossbow,
"spyglass" => UseAnimation::Spyglass,
"toot_horn" => UseAnimation::TootHorn,
"brush" => UseAnimation::Brush,
"bundle" => UseAnimation::Bundle,
"spear" => UseAnimation::Spear,
_ => UseAnimation::Eat,
})
}
/// `Consumable.sound` from the stack's `consumable` component.
pub fn consumable_sound(stack: &ItemStack) -> Option<String> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
let sound = data
.and_then(|d| d.other("minecraft:consumable"))
.and_then(|c| c.get("sound"))?;
match sound {
Tag::String(id) => Some(id.to_string_lossy()),
// SoundEvent.DIRECT_CODEC: `{sound_id, range}`.
Tag::Compound(_) => match sound.get("sound_id") {
Some(Tag::String(id)) => Some(id.to_string_lossy()),
_ => None,
},
_ => None,
}
}
/// `Consumable.hasConsumeParticles` from the stack's `consumable` component.
pub fn consumable_particles(stack: &ItemStack) -> Option<bool> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some()) {
return None;
}
data.and_then(|d| d.other("minecraft:consumable"))
.map(|c| horizon_world::datafix::dynamic::as_bool(c.get("has_consume_particles"), true))
}
/// The `consumable` component a use reads: the stack's own when it has one
/// (its time, animation, sound and particles, with `Consumable.CODEC`'s
/// defaults for what it leaves out), else the item's. The item's effects stay:
/// a stack's `on_consume_effects` are not read.
pub fn stack_consumable<'a, E: Clone>(
stack: &ItemStack,
item: &'a horizon_world::consumable::Consumable<String, E>,
) -> std::borrow::Cow<'a, horizon_world::consumable::Consumable<String, E>> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:consumable").is_some())
|| !data.is_some_and(|d| d.other("minecraft:consumable").is_some())
{
return std::borrow::Cow::Borrowed(item);
}
let mut c = item.clone();
c.seconds = consumable_seconds(stack).unwrap_or(1.6);
c.animation =
consumable_animation(stack).unwrap_or(horizon_world::consumable::UseAnimation::Eat);
c.sound = consumable_sound(stack).unwrap_or_else(|| "minecraft:entity.generic.eat".into());
c.particles = consumable_particles(stack).unwrap_or(true);
std::borrow::Cow::Owned(c)
}
/// The parts of a stack's `equippable` component (`Equippable`) the client
/// draws with.
#[derive(Clone, Debug, PartialEq)]
@@ -1568,188 +1427,6 @@ mod tests {
assert_eq!(net_c.effects, effects);
}
#[test]
fn food_can_always_eat_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:food",
Some(Tag::compound([("can_always_eat", Tag::Byte(1))])),
);
assert!(can_always_eat(&stack, false));
assert!(!can_always_eat(
&ItemStack::new("minecraft:apple", 1),
false
));
stack.data_mut().set_other(
"!minecraft:food",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!can_always_eat(&stack, true));
}
#[test]
fn consumable_seconds_come_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([(
"consume_seconds",
Tag::Float(0.5f32.to_bits()),
)])),
);
assert_eq!(consumable_seconds(&stack), Some(0.5));
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_seconds(&stack), None);
}
#[test]
fn use_remainder_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:mushroom_stew", 1);
stack.data_mut().set_other(
"minecraft:use_remainder",
Some(Tag::compound([(
"id",
Tag::String("minecraft:bowl".into()),
)])),
);
assert_eq!(
use_remainder(&stack, None).as_deref(),
Some("minecraft:bowl")
);
stack.data_mut().set_other(
"!minecraft:use_remainder",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(
use_remainder(&stack, Some("minecraft:glass_bottle".into())),
None
);
}
#[test]
fn dispensable_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:golden_helmet", 1);
stack.data_mut().set_other(
"minecraft:equippable",
Some(Tag::compound([
("slot", Tag::String("head".into())),
("dispensable", Tag::Byte(0)),
])),
);
assert!(!dispensable(&stack, true));
stack.data_mut().set_other(
"!minecraft:equippable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert!(!dispensable(&stack, true));
}
#[test]
fn food_nutrition_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:food",
Some(Tag::compound([
("nutrition", Tag::Int(9)),
("saturation", Tag::Float(4.5f32.to_bits())),
])),
);
assert_eq!(food(&stack, Some((4, 2.4))), Some((9, 4.5)));
stack.data_mut().set_other(
"!minecraft:food",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(food(&stack, Some((4, 2.4))), None);
}
#[test]
fn consumable_animation_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([("animation", Tag::String("drink".into()))])),
);
assert_eq!(
consumable_animation(&stack),
Some(horizon_world::consumable::UseAnimation::Drink)
);
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_animation(&stack), None);
}
#[test]
fn consumable_sound_comes_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([(
"sound",
Tag::String("minecraft:entity.generic.drink".into()),
)])),
);
assert_eq!(
consumable_sound(&stack).as_deref(),
Some("minecraft:entity.generic.drink")
);
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_sound(&stack), None);
}
#[test]
fn consumable_particles_come_from_the_stack() {
let mut stack = ItemStack::new("minecraft:apple", 1);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([("has_consume_particles", Tag::Byte(0))])),
);
assert_eq!(consumable_particles(&stack), Some(false));
stack.data_mut().set_other(
"!minecraft:consumable",
Some(Tag::compound(Vec::<(&str, Tag)>::new())),
);
assert_eq!(consumable_particles(&stack), None);
}
/// A stack's own `consumable` replaces the item's: what it leaves out
/// takes the codec's defaults, not the item's values.
#[test]
fn a_stacks_consumable_takes_the_codecs_defaults() {
use horizon_world::consumable::{Consumable, UseAnimation};
let honey: Consumable<String> = Consumable {
seconds: 2.0,
animation: UseAnimation::Drink,
sound: "minecraft:item.honey_bottle.drink".into(),
particles: false,
effects: vec![],
};
let mut stack = ItemStack::new("minecraft:honey_bottle", 1);
assert_eq!(*stack_consumable(&stack, &honey), honey);
stack.data_mut().set_other(
"minecraft:consumable",
Some(Tag::compound([(
"sound",
Tag::compound([(
"sound_id",
Tag::String("minecraft:entity.generic.drink".into()),
)]),
)])),
);
let c = stack_consumable(&stack, &honey);
assert_eq!(c.seconds, 1.6);
assert_eq!(c.animation, UseAnimation::Eat);
assert_eq!(c.sound, "minecraft:entity.generic.drink");
assert!(c.particles);
}
#[test]
fn merchant_water_cost_survives_the_offer_packet() {
use horizon_protocol::{
@@ -1,45 +0,0 @@
//! The converted equipment of one guest. A slot keeps the stack it was converted from; the
//! stack is converted again only when the slot holds a different one, compared in full.
use crate::items::ItemStack;
use horizon_protocol::item::Stack;
/// Equipment slots: held, offhand, then the armor from head to feet.
pub const SLOTS: usize = 6;
/// The wire slot of each equipment slot, in the order the equipment is listed. Armor is stored
/// head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
const WIRE_SLOTS: [u8; SLOTS] = [0, 1, 5, 4, 3, 2];
#[derive(Default)]
struct Slot {
source: Option<ItemStack>,
converted: Option<Stack>,
}
#[derive(Default)]
pub struct EquipmentCache {
slots: [Slot; SLOTS],
}
impl EquipmentCache {
/// Whether `source` differs from the stack slot `index` was last converted from.
pub fn is_stale(&self, index: usize, source: Option<&ItemStack>) -> bool {
self.slots[index].source.as_ref() != source
}
/// Records the stack slot `index` was converted from, and the conversion.
pub fn store(&mut self, index: usize, source: Option<ItemStack>, converted: Option<Stack>) {
self.slots[index] = Slot { source, converted };
}
/// `(wire slot, stack)` for each equipment slot that holds an item.
pub fn equipment(&self) -> Vec<(u8, Option<Stack>)> {
WIRE_SLOTS
.iter()
.zip(&self.slots)
.filter(|(_, slot)| slot.source.is_some())
.map(|(&wire, slot)| (wire, slot.converted.clone()))
.collect()
}
}
-3
View File
@@ -301,8 +301,6 @@ pub struct Guest {
/// with `armor` so each player compares against their own snapshot.
pub location_armor: [Option<ItemStack>; 4],
pub offhand: Option<ItemStack>,
/// The converted held, offhand and armor stacks, kept while they do not change.
pub equipment: super::equipment_cache::EquipmentCache,
pub selected: usize,
pub carried: Option<ItemStack>,
/// The inventory menu's crafting grid (2x2) and its result
@@ -559,7 +557,6 @@ impl Guest {
armor: [None, None, None, None],
location_armor: [None, None, None, None],
offhand: None,
equipment: Default::default(),
selected: 0,
carried: None,
craft_grid: Default::default(),
+30 -24
View File
@@ -9,7 +9,6 @@ use super::{
chunks::ChunkEncoder,
data::SyncData,
entity::{Entities, EntityState, Viewer},
equipment_cache,
guest::{Guest, SentEffect, stack_to_network},
};
use crate::{
@@ -3280,19 +3279,28 @@ impl Hub {
}
}
/// The equipment of a placed guest; only the stacks that changed since the last call are converted.
fn guest_equipment(&mut self, i: usize) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
for index in 0..equipment_cache::SLOTS {
let g = &self.guests[i];
let source = equipment_source(g, index);
if !g.equipment.is_stale(index, source) {
continue;
fn equipment_of(
&mut self,
main: &Option<ItemStack>,
off: &Option<ItemStack>,
armor: [&Option<ItemStack>; 4],
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
// Armor is stored head..feet; the wire slots are feet(2) legs(3) chest(4) head(5).
let mut out = Vec::new();
let slots = [
(0u8, main),
(1, off),
(5, armor[0]),
(4, armor[1]),
(3, armor[2]),
(2, armor[3]),
];
for (slot, item) in slots {
if item.is_some() {
out.push((slot, self.stack(item)));
}
let source = source.cloned();
let converted = self.stack(&source);
self.guests[i].equipment.store(index, source, converted);
}
self.guests[i].equipment.equipment()
out
}
/// Whether an entity is tracked by the current dimension's level.
@@ -3371,7 +3379,16 @@ impl Hub {
let guest_states: Vec<_> = placed
.into_iter()
.map(|i| {
let equipment = self.guest_equipment(i);
let (main, off, armor) = {
let g = &self.guests[i];
(
g.inventory.get(g.selected).cloned().flatten(),
g.offhand.clone(),
g.armor.clone(),
)
};
let equipment =
self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]);
let g = &self.guests[i];
let mut state = self.player_state(
g.entity_id,
@@ -3735,8 +3752,6 @@ impl Hub {
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
for g in &mut self.guests {
// Converted under the old registries: convert again under the new ones.
g.equipment = Default::default();
let mut granted = Vec::new();
// PlayerAdvancements.reload: isFirstPacket again, the progress
// read against the new tree; a player whose advancements were
@@ -5251,15 +5266,6 @@ fn listed_properties(
}
}
/// The stack in equipment slot `index` (`equipment_cache::SLOTS`): held, offhand, armor head..feet.
fn equipment_source(g: &Guest, index: usize) -> Option<&ItemStack> {
match index {
0 => g.inventory.get(g.selected)?.as_ref(),
1 => g.offhand.as_ref(),
_ => g.armor.get(index - 2)?.as_ref(),
}
}
#[cfg(test)]
#[path = "hub_tests.rs"]
mod tests;
@@ -1381,57 +1381,6 @@ fn memory_connection_first_view_sends_every_ready_column() {
);
}
/// The equipment as it was before the cache: every equipped slot converted in turn.
fn equipment_converted_every_time(
hub: &mut Hub,
) -> Vec<(u8, Option<horizon_protocol::item::Stack>)> {
let g = &hub.guests[0];
let sources = [
(0u8, g.inventory.get(g.selected).cloned().flatten()),
(1, g.offhand.clone()),
(5, g.armor[0].clone()),
(4, g.armor[1].clone()),
(3, g.armor[2].clone()),
(2, g.armor[3].clone()),
];
let mut out = Vec::new();
for (slot, item) in sources {
if item.is_some() {
out.push((slot, hub.stack(&item)));
}
}
out
}
#[test]
fn equipment_is_converted_again_only_when_a_stack_changes() {
use crate::items::ItemStack;
let stack = |item: &str, count: u32| ItemStack {
item: item.into(),
count,
damage: 0,
extra: None,
};
let (mut hub, _rx) = hub_with_a_guest();
hub.guests[0].inventory[0] = Some(stack("minecraft:stone", 1));
hub.guests[0].offhand = Some(stack("minecraft:shield", 1));
hub.guests[0].armor[0] = Some(stack("minecraft:iron_helmet", 1));
let first = equipment_converted_every_time(&mut hub);
for _ in 0..3 {
assert_eq!(hub.guest_equipment(0), first);
}
hub.guests[0].inventory[0] = Some(stack("minecraft:stone", 3));
let changed = equipment_converted_every_time(&mut hub);
assert_ne!(changed, first);
assert_eq!(hub.guest_equipment(0), changed);
hub.guests[0].offhand = None;
let cleared = equipment_converted_every_time(&mut hub);
assert_eq!(cleared.len(), changed.len() - 1);
assert_eq!(hub.guest_equipment(0), cleared);
}
/// `ServerGamePacketListenerImpl.handleAcceptTeleportPacket`: an accept whose id is
/// the last teleport's (`awaitingTeleport`) while none is awaited any more
/// (`awaitingPositionFromClient` is null) disconnects with
-1
View File
@@ -22,7 +22,6 @@ pub mod containers;
pub mod data;
pub mod discovery;
pub mod entity;
pub mod equipment_cache;
pub mod far;
pub mod forwarding;
pub mod guest;
+22 -9
View File
@@ -708,7 +708,12 @@ fn sync(body: &mut PlayerMotion, server: &PlayerMotion) {
mod tests {
use super::record::Record;
use super::*;
use crate::{motion::MotionWorld, world::World};
use crate::{
assets::Assets,
motion::{MotionResources, MotionWorld},
world::World,
};
use std::path::Path;
/// Keys for each tick: 20 walking, 12 sprint-jumping, 12 strafing while turning, 6 still.
fn script() -> Vec<(u8, f32)> {
@@ -815,9 +820,17 @@ mod tests {
cheat: impl Fn(usize, Vec3, Vec3) -> Vec3,
push: Option<Push>,
) -> Option<Vec<Step>> {
let fixture = crate::test_fixtures::vanilla()?;
let (assets, binding, resources) = (&fixture.assets, &fixture.collision, &fixture.motion);
let mut scene = World::test_scene(assets, &fixture.render, 2).unwrap();
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return None;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
// A clean stone floor with its top at y 5, and a slab on the walk to step up on.
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let air = assets.registry.default_state("minecraft:air").unwrap();
@@ -839,9 +852,9 @@ mod tests {
scene.set(at[0], at[1], at[2], state).unwrap();
}
let start = run.start;
let mut client = PlayerMotion::new(resources, start).unwrap();
let mut client = PlayerMotion::new(&resources, start).unwrap();
client.client_side = true;
let mut server = PlayerMotion::new(resources, start).unwrap();
let mut server = PlayerMotion::new(&resources, start).unwrap();
(client.glider, server.glider) = (run.glider, run.glider);
client.pitch = run.pitch;
let mut world = |player: &mut PlayerMotion, f: &mut dyn FnMut(&mut MotionWorld)| {
@@ -849,9 +862,9 @@ mod tests {
entities: &[],
player,
scene: &mut scene,
assets,
collision_binding: binding,
resources,
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
})
};
+11 -11
View File
@@ -4053,7 +4053,7 @@ impl Mob {
// `setHealth(1.0F)`, then `DeathProtection.applyEffects`.
self.health = 1.0;
for effect in &effects {
self.apply_consume_effect(effect);
self.apply_death_effect(effect);
}
let at = [self.position.x, self.position.y, self.position.z];
self.outbox.push(MobEvent::EntityEvent {
@@ -4063,9 +4063,9 @@ impl Mob {
});
true
}
/// One `ConsumeEffect` of a totem or a fox's food on the mob. Teleporting,
/// sounds and a mod's own kinds are not applied here.
pub(crate) fn apply_consume_effect(&mut self, effect: &crate::items::ConsumeEffect) {
/// One `ConsumeEffect` of a `death_protection` component on the mob.
/// Teleporting, sounds and a mod's own kinds are not applied to mobs.
fn apply_death_effect(&mut self, effect: &crate::items::ConsumeEffect) {
use crate::items::ConsumeEffect;
match effect {
// `LivingEntity.removeAllEffects` -> `onEffectsRemoved` ->
@@ -6416,7 +6416,7 @@ mod hidden_speed_mob_tick_tests {
assert_eq!(to.extra.effect_fallbacks, m.extra.effect_fallbacks);
to.extra.effect_fallbacks[0].duration = 222;
assert_eq!(m.extra.effect_fallbacks[0].duration, 300);
m.apply_consume_effect(&crate::items::ConsumeEffect::RemoveEffects(vec![
m.apply_death_effect(&crate::items::ConsumeEffect::RemoveEffects(vec![
"minecraft:speed".into(),
]));
assert_eq!(m.extra.effects, [("minecraft:night_vision", 400, 0)]);
@@ -6425,7 +6425,7 @@ mod hidden_speed_mob_tick_tests {
assert!(m.extra.effect_fallbacks.is_empty());
m.add_effect("speed", 5, 1);
assert_eq!(m.extra.effect_fallbacks[0].duration, 20);
m.apply_consume_effect(&crate::items::ConsumeEffect::ClearAllEffects);
m.apply_death_effect(&crate::items::ConsumeEffect::ClearAllEffects);
assert!(m.extra.effects.is_empty() && m.extra.effect_fallbacks.is_empty());
// The basic add has default incoming flags, while preserving its
// existing active flag policy; a newly retained weaker node gets them.
@@ -6448,7 +6448,7 @@ mod hidden_speed_mob_tick_tests {
m.add_effect("health_boost", 200, 1);
assert_eq!(m.attrs.max_health, 18.0);
m.health = 18.0;
m.apply_consume_effect(&crate::items::ConsumeEffect::ClearAllEffects);
m.apply_death_effect(&crate::items::ConsumeEffect::ClearAllEffects);
assert_eq!(m.attrs.max_health, 10.0);
assert_eq!(m.health, 10.0);
}
@@ -6480,7 +6480,7 @@ mod hidden_speed_mob_tick_tests {
m0.add_effect("absorption", 600, 0);
assert_eq!(armor::max_absorption(&m0), 4.0);
assert_eq!(m0.absorption, 4.0);
m0.apply_consume_effect(&crate::items::ConsumeEffect::ClearAllEffects);
m0.apply_death_effect(&crate::items::ConsumeEffect::ClearAllEffects);
assert_eq!(
m0.absorption, 0.0,
"base 0 mob loses absorption on ClearAllEffects"
@@ -6497,7 +6497,7 @@ mod hidden_speed_mob_tick_tests {
m20.add_effect("absorption", 600, 0);
assert_eq!(armor::max_absorption(&m20), 24.0);
assert_eq!(m20.absorption, 4.0);
m20.apply_consume_effect(&crate::items::ConsumeEffect::ClearAllEffects);
m20.apply_death_effect(&crate::items::ConsumeEffect::ClearAllEffects);
assert_eq!(
m20.absorption, 4.0,
"base 20 mob keeps absorption on ClearAllEffects"
@@ -6514,7 +6514,7 @@ mod hidden_speed_mob_tick_tests {
m20_clamp.extra.armor_attributes = Some(Box::new(attrs3));
m20_clamp.add_effect("absorption", 600, 0);
m20_clamp.absorption = 24.0;
m20_clamp.apply_consume_effect(&crate::items::ConsumeEffect::RemoveEffects(vec![
m20_clamp.apply_death_effect(&crate::items::ConsumeEffect::RemoveEffects(vec![
"minecraft:absorption".into(),
]));
assert_eq!(
@@ -6528,7 +6528,7 @@ mod hidden_speed_mob_tick_tests {
let mut m0_remove = Mob::new(4, kind, Vec3::ZERO, 0.0);
m0_remove.add_effect("absorption", 600, 0);
assert_eq!(m0_remove.absorption, 4.0);
m0_remove.apply_consume_effect(&crate::items::ConsumeEffect::RemoveEffects(vec![
m0_remove.apply_death_effect(&crate::items::ConsumeEffect::RemoveEffects(vec![
"minecraft:absorption".into(),
]));
assert_eq!(
+37 -83
View File
@@ -1,9 +1,7 @@
//! The per-tick mob update in vanilla order (`Entity.baseTick`,
//! `LivingEntity.baseTick/tick/aiStep`, `Mob.serverAiStep`) and the
//! dispatch to each species family's goals and special behaviour.
pub(crate) use freezing::{can_freeze, in_powder_snow};
mod feeding;
mod freezing;
pub use feeding::takes_food;
@@ -1397,6 +1395,43 @@ fn touches_lava(m: &Mob, w: &MobWorld) -> bool {
}
false
}
pub(super) fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool {
let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (min, max) = (b.min_position(), b.max_position());
for x in min.x.floor() as i32..=max.x.floor() as i32 {
for y in min.y.floor() as i32..=max.y.floor() as i32 {
for z in min.z.floor() as i32..=max.z.floor() as i32 {
if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:powder_snow" {
return true;
}
}
}
}
false
}
/// `LivingEntity.canFreeze`: not a `#freeze_immune_entity_types` mob and no
/// worn piece in `#freeze_immune_wearables`.
fn can_freeze(m: &Mob, w: &MobWorld) -> bool {
if w.data
.entity_has("minecraft:freeze_immune_entity_types", &m.kind.id)
{
return false;
}
let immune = w
.items
.and_then(|i| i.item_tags.get("minecraft:freeze_immune_wearables"));
// A wolf's armor (its body slot) is `#freeze_immune_wearables`.
![
&m.extra.body,
&m.extra.head,
&m.extra.chest,
&m.extra.legs,
&m.extra.feet,
]
.into_iter()
.flatten()
.any(|item| immune.is_some_and(|t| t.contains(&**item)))
}
/// `Mob.playAmbientSound` on demand: a spawn egg or a mob bucket just placed `m`.
pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
play_ambient(m, w, events);
@@ -5707,87 +5742,6 @@ mod nautilus_noai_air_tests {
}
}
#[cfg(test)]
mod freeze_tests {
use super::*;
use std::sync::Arc;
/// `LivingEntity.aiStep`'s freezing, through `freeze_tick`: powder snow
/// builds a mob's frozen ticks towards the 140 that freeze it, and at 140 a
/// tick that falls on the 40-tick damage interval hurts it by 1. A skeleton
/// answers `canFreeze` false, so it takes neither, and so does the stray (by
/// tag), while the bogged, parched, wither skeleton and a zombie freeze.
#[test]
fn a_skeleton_never_freezes() {
crate::mobs::world::with_built_test_world(
&[],
|world, registry| {
let stone = registry.default_state("minecraft:stone").unwrap();
let snow = registry.default_state("minecraft:powder_snow").unwrap();
for cx in -1..1 {
for cz in -1..1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -4..4 {
for z in -4..4 {
world.set(x, 63, z, stone).unwrap();
}
}
// The mob stands in this block.
world.set(0, 64, 0, snow).unwrap();
},
|w, types| {
let frozen_with = |id: &str, ticks_frozen: i32, body: Option<&str>| {
let kind = Arc::new(types.types[id].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.ticks_frozen = ticks_frozen;
m.extra.body = body.map(Into::into);
// The freeze damage falls on a tick that is a multiple of 40.
m.age = 40;
let start = m.health;
let mut events = Vec::new();
freeze_tick(&mut m, w, &mut events);
(m.ticks_frozen, start - m.health)
};
let frozen = |id: &str, ticks_frozen: i32| frozen_with(id, ticks_frozen, None);
// Powder snow builds no frozen ticks on a skeleton, and one
// already frozen melts two ticks a tick instead of taking the
// damage a fully frozen mob takes at 140.
for skeleton in ["minecraft:skeleton", "minecraft:stray"] {
assert_eq!(frozen(skeleton, 0), (0, 0.0), "{skeleton}");
assert_eq!(frozen(skeleton, 140), (138, 0.0), "{skeleton} frozen");
}
// The bogged, parched and wither skeleton extend AbstractSkeleton,
// not Skeleton, and freeze like a zombie.
for sibling in [
"minecraft:bogged",
"minecraft:parched",
"minecraft:wither_skeleton",
] {
assert_eq!(frozen(sibling, 0), (1, 0.0), "{sibling}");
assert_eq!(frozen(sibling, 139), (140, 1.0), "{sibling} frozen");
}
// A zombie in the same block freezes, and at 140 takes the 1.
assert_eq!(frozen("minecraft:zombie", 139), (140, 1.0));
// SulfurCube.canFreeze: a cube carrying a block in its body does
// not freeze; without one it freezes and takes the 1 like a zombie.
assert_eq!(
frozen_with("minecraft:sulfur_cube", 0, Some("minecraft:stone")),
(0, 0.0)
);
assert_eq!(
frozen_with("minecraft:sulfur_cube", 140, Some("minecraft:stone")),
(138, 0.0)
);
assert_eq!(frozen("minecraft:sulfur_cube", 0), (1, 0.0));
assert_eq!(frozen("minecraft:sulfur_cube", 139), (140, 1.0));
},
);
}
}
#[cfg(test)]
mod happy_ghast_ambient_tests {
use super::*;
@@ -1,55 +0,0 @@
//! `LivingEntity.canFreeze` and the powder-snow test behind it.
use super::{Mob, MobWorld};
use horizon_core::geometry::Vec3;
pub(crate) fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool {
let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (min, max) = (b.min_position(), b.max_position());
for x in min.x.floor() as i32..=max.x.floor() as i32 {
for y in min.y.floor() as i32..=max.y.floor() as i32 {
for z in min.z.floor() as i32..=max.z.floor() as i32 {
if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:powder_snow" {
return true;
}
}
}
}
false
}
/// `LivingEntity.canFreeze`: not a `#freeze_immune_entity_types` mob and no
/// worn piece in `#freeze_immune_wearables`.
pub(crate) fn can_freeze(m: &Mob, w: &MobWorld) -> bool {
// Skeleton.canFreeze: a skeleton never freezes, so powder snow builds no
// frozen ticks on it and deals it no freeze damage. Only `Skeleton`
// overrides it: the bogged, parched and wither skeleton extend
// `AbstractSkeleton` and freeze; the stray is immune through the
// `#freeze_immune_entity_types` tag below.
if m.species() == super::Species::Skeleton {
return false;
}
// SulfurCube.canFreeze: a cube carrying a block in its body does not freeze
// either; without one it takes the tags below like any other mob.
if m.species() == super::Species::SulfurCube && crate::mobs::sulfur_cube::has_body(m) {
return false;
}
if w.data
.entity_has("minecraft:freeze_immune_entity_types", &m.kind.id)
{
return false;
}
let immune = w
.items
.and_then(|i| i.item_tags.get("minecraft:freeze_immune_wearables"));
// A wolf's armor (its body slot) is `#freeze_immune_wearables`.
![
&m.extra.body,
&m.extra.head,
&m.extra.chest,
&m.extra.legs,
&m.extra.feet,
]
.into_iter()
.flatten()
.any(|item| immune.is_some_and(|t| t.contains(&**item)))
}
@@ -487,11 +487,6 @@ pub struct Modifier {
pub struct Attributes {
pub bases: Vec<(Attr, f64)>,
pub modifiers: Vec<Modifier>,
/// `NAME_TAG_DISTANCE` from a server attribute update: the client reads
/// it for nameplates and a save never writes it.
pub name_tag_distance: Option<f64>,
/// `BELOW_NAME_DISTANCE` from a server attribute update.
pub below_name_distance: Option<f64>,
/// The transient minecraft:baby speed modifier was taken away
/// (`/attribute ... modifier remove`) until the next setBaby. Not
/// saved: a load runs setBaby again.
+17 -475
View File
@@ -1,20 +1,18 @@
//! Foxes (`net.minecraft.world.entity.animal.fox.Fox`): the goal list, the
//! flags byte (sleeping, sitting, crouching, interested, pouncing,
//! faceplanted), stalking and pouncing, napping in the shade by day, seeking
//! shelter, perching, eating berries and the food held in the mouth: its whole
//! `Consumable.onConsume` (the item's effects, its sounds and the eat game
//! event), so a chorus fruit teleports it and a golden apple feeds it, and
//! defending trusted entities from whoever hurt them.
//! shelter, perching, eating berries and the food held in the mouth (with the
//! item's on-consumed effects: a chorus fruit teleports it).
//! Defending trusted players is not ported yet.
//! The goal that walks to a dropped item is here; the game side takes the
//! items (`Fox.pickUpItem`, `game/foxes.rs`). A totem in the mouth is a
//! `death_protection` item like any other held one (`Mob::damage`).
use super::{
Mob, MobEvent, MobWorld, Seen, Species, Who,
Mob, MobEvent, MobWorld, Seen, Species,
goals::{
Avoid, AvoidEntity, Breed, ClimbOnTopOfPowderSnow, Cx, Float, FollowParent, Goal, JUMP,
LOOK, LeapAtTarget, LookAtPlayer, MOVE, MeleeAttack, MoveToBlock, NearestTarget, Panic,
Prey, Stroll, TARGET, TargetState, goal_clone, hide_pos, reduced, target_continue,
within_home,
Prey, Stroll, goal_clone, hide_pos, reduced,
},
living::wrap,
raider::heightmap_pos,
@@ -24,13 +22,11 @@ use super::{
use horizon_core::{
geometry::{Axis, Vec3},
jmath,
random::{LegacyRandom, RandomSource},
random::RandomSource,
};
use horizon_items::items::ConsumeEffect;
use horizon_world::consumable::EmissionWorld;
use horizon_world::registry::BlockStateId;
use horizon_world::teleport_consume::TeleportConsume;
use std::convert::Infallible;
// Fox.DATA_FLAGS_ID, kept in `extra.ints[0]` (and sent to clients as is).
pub const SITTING: i32 = 1;
@@ -41,7 +37,7 @@ pub const SLEEPING: i32 = 32;
pub const FACEPLANTED: i32 = 64;
pub const DEFENDING: i32 = 128;
/// `Fox.ticksSinceEaten`.
pub(crate) const TICKS_SINCE_EATEN: usize = 1;
const TICKS_SINCE_EATEN: usize = 1;
/// `Fox.interestedAngle` and `crouchAmount` in `anim.v`, the previous tick's
/// values in `anim.vo`.
const INTERESTED_ANGLE: usize = 0;
@@ -123,122 +119,6 @@ pub fn trust_breeders(
pub fn can_move(m: &Mob) -> bool {
!flag(m, SLEEPING | SITTING | FACEPLANTED)
}
/// `Fox.setTarget`: dropping the target ends the defence.
fn set_target(m: &mut Mob, target: Option<Who>) {
if target.is_none() && flag(m, DEFENDING) {
set_flag(m, DEFENDING, false);
}
m.ai.target = target;
}
/// `EntityReference.getEntity`: the entity a trusted UUID names, in the level.
fn trusted_who(w: &MobWorld, uuid: [i32; 4]) -> Option<Who> {
if w.player.as_ref().is_some_and(|p| p.uuid == uuid) {
Some(Who::Player)
} else {
w.who_by_uuid(uuid)
}
}
/// `LivingEntity.getLastHurtByMob` of a trusted entity and its timestamp (0
/// when nothing hurt it).
fn hurt_by(w: &MobWorld, who: Who) -> (Option<Who>, i64) {
match who {
// `PlayerView.hurt_by` keeps the 100 ticks `LivingEntity.tick` keeps it.
Who::Player => w
.player
.as_ref()
.and_then(|p| p.hurt_by)
.map_or((None, 0), |(by, at)| (Some(by), at)),
Who::Mob(id) => w
.seen(id)
.and_then(|s| s.hurt_by)
.map_or((None, 0), |(by, at)| (Some(by), i64::from(at))),
}
}
/// `TargetGoal.canAttack` for the entity that hurt a trusted one:
/// `TargetingConditions.forCombat().range(followRange)` with the goal's
/// selector (`TRUSTED_TARGET_SELECTOR`, and not a trusted entity itself).
fn can_defend(m: &mut Mob, w: &MobWorld, who: Who) -> bool {
let Some(t) = w.target_info(who) else {
return false;
};
let range = m.attrs.follow_range;
let trusted = who == Who::Player && w.player.as_ref().is_some_and(|p| trusts(m, p.uuid));
t.alive
&& t.can_be_seen_as_enemy
&& !(t.is_player && t.creative)
&& !trusted
&& !m.spares_target(w, who)
&& m.ai
.home
.is_none_or(|(c, r)| within_home(c, r, block_pos(t.position)))
&& m.distance_sqr(t.position) <= range * range
&& m.sees(w, who)
}
/// `Fox.DefendTrustedTargetGoal`: take as target whoever last hurt a trusted
/// entity, growling and raising `DEFENDING` while the goal runs.
#[derive(Clone, Debug, Default)]
struct DefendTrusted {
/// `trustedLastHurtBy`: the attacker `start` targets.
attacker: Option<Who>,
/// `timestamp`: the trusted entity's hurt timestamp last defended.
timestamp: i64,
/// The timestamp `canUse` read, which `start` stores in the same tick.
hurt_at: i64,
/// `TargetGoal`'s caches; the goal never needs sight (`mustSee` false).
st: TargetState,
}
impl Goal for DefendTrusted {
goal_clone!();
fn name(&self) -> &'static str {
"DefendTrustedTargetGoal"
}
fn flags(&self) -> u8 {
TARGET
}
/// `DefendTrustedTargetGoal.canUse`: the first trusted entity the level
/// still knows decides, as vanilla returns from inside its loop, and only
/// a new hurt of it starts the goal.
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// `NearestAttackableTargetGoal`'s random interval.
if m.random.next_int_bound(reduced(10)).unwrap_or(1) != 0 {
return false;
}
// `getTrustedEntities`: `DATA_TRUSTED_ID_0` first, then the second.
for uuid in m.extra.trusted.into_iter().flatten() {
let Some(trusted) = trusted_who(cx.w, uuid) else {
continue;
};
let (by, at) = hurt_by(cx.w, trusted);
self.attacker = by;
self.hurt_at = at;
return at != self.timestamp && by.is_some_and(|b| can_defend(m, cx.w, b));
}
false
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// `TargetGoal.canContinueToUse`.
target_continue(&mut self.st, false, m, cx)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
set_target(m, self.attacker);
self.timestamp = self.hurt_at;
// `Entity.playSound(FOX_AGGRO, 1.0, 1.0)`: at its feet, unless silent.
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.fox.aggro", at, 1.0, 1.0));
}
set_flag(m, DEFENDING, true);
set_flag(m, SLEEPING, false); // wakeUp
// `TargetGoal.start`: the unseen and reach caches start over.
self.st = TargetState::default();
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
set_target(m, None);
self.attacker = None;
}
}
/// `FoxLookControl.resetXRotOnTick`: stalking and pouncing keep their pitch.
pub fn resets_x_rot(m: &Mob) -> bool {
!flag(m, POUNCING | CROUCHING | INTERESTED | FACEPLANTED)
@@ -287,14 +167,11 @@ pub fn register(m: &mut Mob) {
);
g.add(
4,
AvoidEntity::new(Avoid::Species(vec![Species::Wolf]), 8.0, 1.6, 1.4)
.seen_when(|s| !s.tame)
.when(|m| !flag(m, DEFENDING)),
AvoidEntity::new(Avoid::Species(vec![Species::Wolf]), 8.0, 1.6, 1.4).seen_when(|s| !s.tame),
);
g.add(
4,
AvoidEntity::new(Avoid::Species(vec![Species::PolarBear]), 8.0, 1.6, 1.4)
.when(|m| !flag(m, DEFENDING)),
AvoidEntity::new(Avoid::Species(vec![Species::PolarBear]), 8.0, 1.6, 1.4),
);
g.add(5, StalkPrey);
g.add(6, Pounce);
@@ -327,8 +204,6 @@ pub fn register(m: &mut Mob) {
let turtle = || NearestTarget::new(Prey::Filter(baby_turtle_on_land), false).interval(10);
let fish = || NearestTarget::new(Prey::Filter(schooling_fish), false).interval(20);
let t = &mut m.ai.targets;
// `setTargetGoals`: what last hurt a trusted entity, before the prey.
t.add(3, DefendTrusted::default());
t.add(4, land().gate(red));
t.add(4, turtle().gate(red));
t.add(6, fish().gate(red));
@@ -452,10 +327,8 @@ fn eats(w: &MobWorld, item: &str) -> bool {
.is_some_and(|i| i.food.is_some() && i.consumable.is_some())
}
/// `Consumable.onConsume` for a fox that finishes the item in its mouth
/// (`Fox.aiStep`'s `ItemStack.finishUsingItem`): the consumable's crumbs and
/// sound, the food's own sound, its `on_consume_effects` and the eat game
/// event, in vanilla's order.
/// `Consumable.onConsume`'s `on_consume_effects` when a fox finishes an item
/// (`Item.finishUsingItem`): the teleport that a chorus fruit gives it.
fn consume(w: &MobWorld, m: &mut Mob, events: &mut Vec<MobEvent>, item: &str) {
let Some(consumable) = w
.items
@@ -464,88 +337,11 @@ fn consume(w: &MobWorld, m: &mut Mob, events: &mut Vec<MobEvent>, item: &str) {
else {
return;
};
let stack = crate::items::ItemStack::new(item, 1);
// `emitParticlesAndSounds(random, user, stack, 16)`.
let _ = consumable.emit(&mut Eater { m, events }, (), stack, 16);
// `FoodProperties.onConsume`, the food component's `ConsumableListener`: the
// consumable's sound again, at full volume. A fox is no player, so it eats
// no food and does not burp; `Level.playSound` is no `Entity.playSound`, so
// a silent fox's food is still heard.
let pitch = m.random.triangle_float(1.0, 0.4);
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::SoundId(consumable.sound.clone(), at, 1.0, pitch));
for effect in &consumable.effects {
match effect {
ConsumeEffect::TeleportRandomly(teleport) => teleport_randomly(w, m, events, teleport),
// The rest reach the fox as `LivingEntity.addEffect` adds them.
other => m.apply_consume_effect(other),
if let ConsumeEffect::TeleportRandomly(teleport) = effect {
teleport_randomly(w, m, events, teleport);
}
}
// `user.gameEvent(animation == DRINK ? GameEvent.DRINK : GameEvent.EAT)` at
// its own position, itself the source.
events.push(MobEvent::GameEvent {
event: if consumable.animation == horizon_world::consumable::UseAnimation::Drink {
"minecraft:drink"
} else {
"minecraft:eat"
},
at: [m.position.x, m.position.y, m.position.z],
source: m.id,
state: BlockStateId(0),
});
}
/// The fox as `Consumable`'s user: its own random, and its own ears.
struct Eater<'a> {
m: &'a mut Mob,
events: &'a mut Vec<MobEvent>,
}
impl EmissionWorld for Eater<'_> {
type Error = Infallible;
type Stack = crate::items::ItemStack;
type Sound = String;
type Random = ();
type Source = LegacyRandom;
fn random_source(&mut self, (): ()) -> &mut LegacyRandom {
&mut self.m.random
}
/// `LivingEntity.spawnItemParticles` hands each of the 16 crumbs to
/// `Level.addParticle`, which a server does nothing with: the four draws a
/// crumb costs still come off the fox's random, as vanilla spends them.
fn spawn_item_particles(
&mut self,
_: crate::items::ItemStack,
count: i32,
) -> Result<(), Infallible> {
let m = &mut *self.m;
let (at, eye) = ([m.position.x, m.position.y, m.position.z], m.eye_y());
let (pitch, yaw) = (m.head_pitch, m.yaw);
crate::particles::item_use_particles(
&mut m.random,
pitch,
yaw,
at,
eye,
count.max(0) as u32,
|_, _| {},
);
Ok(())
}
fn overrides_consume_sound(&self) -> bool {
false
}
fn consume_sound(&mut self, _: crate::items::ItemStack) -> Result<String, Infallible> {
unreachable!("the consumable plays its own sound")
}
/// `Entity.playSound`: at the fox's feet, and nothing from a silent fox.
fn play_sound(&mut self, sound: String, volume: f32, pitch: f32) -> Result<(), Infallible> {
if !self.m.silent {
let at = [self.m.position.x, self.m.position.y, self.m.position.z];
self.events
.push(MobEvent::SoundId(sound, at, volume, pitch));
}
Ok(())
}
}
/// `TeleportRandomlyConsumeEffect.apply` for a fox: 16 draws of a point in a
@@ -1123,14 +919,15 @@ impl Goal for Pounce {
) == "minecraft:snow"
{
m.head_pitch = 60.0;
set_target(m, None);
m.ai.target = None;
set_flag(m, FACEPLANTED, true);
}
}
}
/// `Fox.SeekShelterGoal`: by day, or in a thunderstorm, leave open sky for
/// somewhere the sky does not see; a fox standing in a village stays put.
/// somewhere the sky does not see. Villages are not told apart yet (vanilla
/// stays put in one).
#[derive(Clone, Debug)]
struct SeekShelter {
speed: f64,
@@ -1174,12 +971,7 @@ impl Goal for SeekShelter {
false
} else {
self.interval = 100;
// `ServerLevel.isVillage(pos)`: a section of a village POI or its
// neighbour's, where the fox keeps strolling in the sun.
cx.w.daylight
&& sees_sky
&& sections_to_village(&village_sections(m, cx.w), section_of(at)) > 1
&& self.set_wanted_pos(m, cx.w)
cx.w.daylight && sees_sky && self.set_wanted_pos(m, cx.w)
}
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
@@ -1637,92 +1429,6 @@ mod consume_tests {
.any(|e| matches!(e, MobEvent::EntityEvent { event: 46, .. })),
"entity event 46: {events:?}"
);
// `Consumable.onConsume` ends with `user.gameEvent(EAT)`, at the
// fox's new position and from the fox itself.
let eat = events.iter().position(
|e| matches!(e, MobEvent::GameEvent { event, .. } if *event == "minecraft:eat"),
);
let Some(eat) = eat else {
panic!("the eat game event: {events:?}");
};
assert!(
matches!(&events[eat], MobEvent::GameEvent { at, source: 1, .. } if *at == [m.position.x, m.position.y, m.position.z]),
"at its feet, from itself: {:?}",
events[eat]
);
},
);
// Without the vanilla data under .local/horizon-client (CI's unit
// tests) there is no fox to feed: the test has nothing to check.
let _ = ran;
}
/// `Consumable.onConsume` runs the whole of a golden apple's consume, not
/// just its effects: the two eat sounds (the consumable's, then the food
/// component's own), `ApplyStatusEffectsConsumeEffect`'s regeneration and
/// absorption, and `GameEvent.EAT` once they are in place. Vanilla draws the
/// crumbs of `spawnItemParticles` but hands them to `Level.addParticle`,
/// which a server does nothing with.
#[test]
fn a_golden_apple_feeds_the_fox_and_posts_the_eat_event() {
let ran = with_built_test_world(
&[],
|_, _| {},
|w, types| {
let kind = std::sync::Arc::new(types.types["minecraft:fox"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.on_ground = true;
m.extra.set_main_hand(Some("minecraft:golden_apple".into()));
m.extra.ints[TICKS_SINCE_EATEN] = 601;
let mut events = Vec::new();
ai_step(&mut m, w, &mut events);
// `ApplyStatusEffectsConsumeEffect`: regeneration II for 100
// ticks, then absorption for 2400.
assert!(
m.extra
.effects
.contains(&("minecraft:regeneration", 100, 1)),
"{:?}",
m.extra.effects
);
assert!(
m.extra.effects.contains(&("minecraft:absorption", 2400, 0)),
"{:?}",
m.extra.effects
);
assert_eq!(m.absorption, 4.0, "a level of absorption is four halves");
let eat_sounds: Vec<_> = events
.iter()
.enumerate()
.filter(|(_, e)| {
matches!(e, MobEvent::SoundId(name, ..) if name == "minecraft:entity.generic.eat")
})
.collect();
assert_eq!(eat_sounds.len(), 2, "{events:?}");
let MobEvent::SoundId(_, at, volume, pitch) = eat_sounds[1].1 else {
unreachable!()
};
assert_eq!(*volume, 1.0, "the food's own sound is at full volume");
assert_eq!(*at, [0.5, 64.0, 0.5]);
// `Mth.randomBetween`/`triangle(1.0, 0.4)`: 0.6 to 1.4.
assert!((0.6..=1.4).contains(pitch), "its pitch is drawn: {pitch}");
assert_eq!(m.extra.main_hand, None, "the apple was eaten");
assert_eq!(m.extra.ints[TICKS_SINCE_EATEN], 0);
let eat = events.iter().position(
|e| matches!(e, MobEvent::GameEvent { event, .. } if *event == "minecraft:eat"),
);
let Some(eat) = eat else {
panic!("the eat game event: {events:?}");
};
assert!(
eat > eat_sounds[1].0,
"the event comes after the sounds, as vanilla's last step"
);
assert!(
matches!(&events[eat], MobEvent::GameEvent { at, source: 1, .. } if *at == [0.5, 64.0, 0.5]),
"at its feet, from itself: {:?}",
events[eat]
);
},
);
// Without the vanilla data under .local/horizon-client (CI's unit
@@ -1835,167 +1541,3 @@ mod consume_tests {
}
}
}
#[cfg(test)]
mod seek_shelter_tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use crate::mobs::world::with_built_test_world;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// A village bell at (5, 1, 5), and the section the fox stands in lit as
/// shade everywhere but its own block: sky above the fox, a roof beside it.
fn village_with_shade(
world: &mut crate::world::World,
registry: &horizon_world::registry::BlockRegistry,
) {
for cx in -1..=1 {
for cz in -1..=1 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
let bell = registry.default_state("minecraft:bell").unwrap();
world.set(5, 1, 5, bell).unwrap();
let chunk = Arc::make_mut(world.chunks.get_mut(&(0, 0)).unwrap());
// Section 4 is y 0..16: the one the fox stands in.
let section = Arc::make_mut(&mut chunk.sections[4]);
let lit = crate::chunk::index(0, 1, 0);
section.light = crate::chunk::Light::Full(Box::new(std::array::from_fn(|i| {
if i == lit { 0xF0 } else { 0 }
})));
}
/// A villager of a village whose bell is at (5, 1, 5): the fox's section.
fn villager_of_the_bell() -> Seen {
let mut kind = (*kind()).clone();
kind.species = Species::Villager;
let mut seen = Mob::new(2, Arc::new(kind), Vec3::new(5.5, 1.0, 5.5), 0.0).seen(None);
seen.meeting = Some([5, 1, 5]);
seen
}
/// `SeekShelterGoal.canUse` for a fox under open sky, its 100-tick wait
/// over, `others` being the only villagers there are.
fn seeks_shelter(others: &[Seen]) -> Option<bool> {
let mut out = None;
let ran = with_built_test_world(others, village_with_shade, |w, types| {
let kind = Arc::new(types.types["minecraft:fox"].clone());
let mut fox = Mob::new(1, kind, Vec3::new(0.5, 1.0, 0.5), 0.0);
let mut goal = SeekShelter::new(1.25);
goal.interval = 0;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
out = Some(goal.can_use(&mut fox, &mut cx));
});
let _ = ran;
out
}
/// `Fox.SeekShelterGoal.canUse`: under open sky by day a fox looks for
/// shade, but not while it stands in a village (`ServerLevel.isVillage`):
/// there it stays out in the sun.
#[test]
fn a_village_fox_does_not_look_for_shade() {
let (Some(open), Some(village)) =
(seeks_shelter(&[]), seeks_shelter(&[villager_of_the_bell()]))
else {
// No prepared resources under .local/horizon-client (CI).
return;
};
assert!(open, "outside a village the fox looks for shade");
assert!(!village, "inside a village it stays in the open");
}
}
#[cfg(test)]
mod defend_tests {
use super::*;
use crate::mobs::sensor_scan_tests::kind;
use crate::mobs::world::{test_player, with_built_test_world};
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// The player the fox trusts, and the zombie that just hit it.
const TRUSTED: [i32; 4] = [3, 1, 4, 1];
const ZOMBIE: u32 = 9;
fn zombie() -> Seen {
let mut kind = (*kind()).clone();
kind.species = Species::Zombie;
Mob::new(ZOMBIE, Arc::new(kind), Vec3::new(0.5, 64.0, 3.5), 0.0).seen(None)
}
/// `Fox.DefendTrustedTargetGoal`: a fox whose trusted player was hit takes
/// that attacker as its target, growls (`FOX_AGGRO`), sets `DEFENDING` and
/// drops the flag when the goal stops.
#[test]
fn a_fox_defends_the_player_it_trusts() {
let ran = with_built_test_world(
&[zombie()],
|_, _| {},
|w, types| {
let mut world = w.clone();
let mut player = test_player(Vec3::new(0.5, 64.0, 1.5));
player.uuid = TRUSTED;
// `getLastHurtByMob` of the trusted player, and when.
player.hurt_by = Some((Who::Mob(ZOMBIE), world.game_time));
world.player = Some(player);
let kind = Arc::new(types.types["minecraft:fox"].clone());
let mut fox = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
add_trusted_entity(&mut fox, TRUSTED);
register(&mut fox);
let mut targets = std::mem::take(&mut fox.ai.targets);
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
// `NearestAttackableTargetGoal`'s random interval: one try in five,
// so tick until the goal takes its turn.
let mut started = false;
for _ in 0..40 {
cx.events.clear();
targets.tick(&mut fox, &mut cx);
if targets.is_running("DefendTrustedTargetGoal") {
started = true;
break;
}
}
assert!(started, "the fox took the defend goal");
assert_eq!(
fox.ai.target,
Some(Who::Mob(ZOMBIE)),
"it targets the attacker"
);
assert!(flag(&fox, DEFENDING), "and is defending");
assert!(
cx.events.iter().any(|e| matches!(
e,
MobEvent::Sound("entity.fox.aggro", at, 1.0, 1.0)
if *at == [0.5, 64.0, 0.5]
)),
"with a growl at its feet: {:?}",
cx.events
);
// Out of the follow range the goal stops (`TargetGoal.stop`), and
// `Fox.setTarget(null)` ends the defence.
fox.position = Vec3::new(0.5, 64.0, 400.0);
targets.tick(&mut fox, &mut cx);
fox.ai.targets = targets;
assert!(!flag(&fox, DEFENDING), "the stopped goal ends the defence");
assert_eq!(fox.ai.target, None, "and drops the target");
},
);
let _ = ran;
}
}
@@ -626,16 +626,6 @@ pub fn fuse(m: &Mob) -> i32 {
pub fn primed(m: &Mob) -> bool {
fuse(m) >= 0
}
/// `SulfurCube.tick`: tickFuse removes the cube before super.tick can handle
/// its portal. This happens even when the TNT explosion gamerule is off.
pub fn removes_before_portal_tick(m: &Mob) -> bool {
if m.species() != super::Species::SulfurCube {
return false;
}
let fuse = fuse(m);
let next_fuse = if fuse > 0 { fuse - 1 } else { fuse };
next_fuse == 0 && archetype(m).is_some_and(|a| a.explosion.is_some())
}
/// `hurtServer`'s priming by a hit whose direct entity is a burning
/// `AbstractArrow` (an arrow or a trident): a cube with a block that can
/// explode lights its fuse, the long one.
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more