Compare commits

..
Author SHA1 Message Date
rdbtCVSandClaude Sonnet 5.5 9fd2e38592 Merge origin/main into split/friends-share-view
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:03:43 +00:00
rdbtCVSandGPT 6.1 Sol 476dabcb08 Merge main into split/friends-share-view
Preserve the ShareView export alongside GetTheGame and combine the Friends documentation updates.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-11 20:28:47 +00:00
Horizon agent ffaaa63836 Merge remote-tracking branch 'origin/main' into tmp/share-view-merge
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 23:45:52 +01:00
Horizon agentandClaude Opus 5.5 2719b22258 Friends reads its code while the screen is drawn, as before
The invite code and the host's events are polled while the Friends screen
is drawn or horizon-ctl friends status asks, as on main: Hooks::share stays
dispatched for the screen's page (#14167 step 2a), which is its first user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:50:04 +01:00
Horizon agentandClaude Opus 5.5 3da374ef18 Play with Friends hosts through Hooks::share and Hooks::lan_closed (spec #14167 step 1)
The engine shows the window's world to crates that share it past the LAN
through horizon_hooks::share::ShareView, ticks them next to social::tick()
(also while paused) and tells them when the world closes to LAN. Only the
window's own player dispatches: never the serving Game or a splitscreen guest.
game/friends.rs keeps its logic but goes through the view, its state in its
own static instead of a Game field, ready to move to horizon-friends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:40:22 +01:00
156 changed files with 1410 additions and 5460 deletions

No files matched your search

+18 -32
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'))) &&
Generated
+1 -2
View File
@@ -1486,7 +1486,6 @@ dependencies = [
"horizon-server-files",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1850,8 +1849,8 @@ dependencies = [
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"horizon-system",
"log",
"regex",
]
+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;
+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()),
+3
View File
@@ -26,6 +26,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
+13 -14
View File
@@ -423,6 +423,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -601,6 +602,8 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -1499,8 +1502,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)
@@ -2898,9 +2901,6 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -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
@@ -5730,7 +5731,6 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
span("tick_friends", || self.tick_friends());
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
span("sleep_status", || self.announce_sleep_status());
@@ -9065,7 +9065,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));
@@ -11093,6 +11093,7 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -11833,8 +11834,6 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
@@ -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 {
-3
View File
@@ -143,9 +143,6 @@ pub(super) struct CombatTracker {
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
/// The arrow hurting the player now pierces (`AbstractArrow.getPierceLevel` above 0):
/// `LivingEntity.applyItemBlocking` lets it past a raised shield (`arrow_hits_player`).
pub(super) piercing_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
+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) {
+32 -24
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;
@@ -3983,7 +3970,6 @@ impl Game<'_> {
) -> bool {
let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive);
let rocket = std::mem::take(&mut self.combat.rocket_hit);
let pierce = std::mem::take(&mut self.combat.piercing_hit);
let melee = self.combat.melee_mob.take();
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -3998,7 +3984,7 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(from, damage_type, pierce);
let blocking = self.player_blocking(from, damage_type, false);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = match known {
@@ -5321,7 +5307,11 @@ impl Game<'_> {
}
let hurt = match who {
Who::Player => {
let hurt = self.arrow_hits_player(amount, p);
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
// AbstractArrow.onHitEntity: a hit that hurt leaves a
// non-piercing arrow stuck in the body.
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
@@ -5585,7 +5575,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 +7138,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 +7642,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();
@@ -319,9 +319,7 @@ impl Game<'_> {
);
let state = self.world.block(origin.x, origin.y, origin.z);
let mut used = Vec::new();
if let Some(op) = case.get("shield_hit") {
used = self.fixture_shield_hit(op)?;
} else if let Some(op) = case.get("special_attack") {
if let Some(op) = case.get("special_attack") {
used = self.fixture_special_attack(op)?;
} else if let Some(op) = case.get("wind_burst") {
let center = [
@@ -499,68 +497,6 @@ impl Game<'_> {
Ok(out)
}
/// Actual player combat and blocking-item side effects, without a window or connection.
fn fixture_shield_hit(&mut self, op: &Value) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().ok_or("shield_hit item")?)?
.ok_or("shield_hit requires an item")?;
let item = stack.item.clone();
self.creative = op["creative"].as_bool().unwrap_or(false);
self.mode = if self.creative {
GameMode::Creative
} else {
GameMode::Survival
};
self.player = Some(PlayerMotion::new(
self.motion,
horizon_core::geometry::Vec3::new(0.0, 0.0, 0.0),
)?);
self.yaw = 0.0;
self.armor = Default::default();
self.offhand = Some(stack);
self.using = Some((super::shield::OFFHAND_SLOT, 100));
self.combat = Default::default();
let before = self.stats.get("used", &item);
let from = horizon_core::geometry::Vec3::new(
0.0,
0.0,
if op["behind"].as_bool().unwrap_or(false) {
-1.0
} else {
1.0
},
);
let mut arrow = horizon_client::projectiles::Projectile::new(
ProjectileKind::Arrow {
base_damage: op["damage"].as_f64().unwrap_or(8.0),
critical: false,
effect: None,
},
from,
None,
);
arrow.velocity = horizon_core::geometry::Vec3::new(0.0, 0.0, 1.0);
arrow.arrow.as_mut().unwrap().pierce = u8::from(op["pierce"].as_bool().unwrap_or(false));
self.projectile_hit(
&arrow,
horizon_client::projectiles::Hit::Entity {
who: horizon_client::mobs::Who::Player,
at: horizon_core::geometry::Vec3::ZERO,
},
)?;
let out = vec![format!(
"shield health={} used={} stack={}",
num(f64::from(self.player.as_ref().unwrap().health.health())),
self.stats.get("used", &item) - before,
self.offhand
.as_ref()
.map_or_else(|| "-".to_owned(), |s| format!("{}:{}", s.count, s.damage))
)];
self.player = None;
self.offhand = None;
self.using = None;
Ok(out)
}
/// A player holding the case's item right-clicks the block at `pos`
/// (`use_item_as_player`'s block steps): the statistics, the result
/// and the held stack, as `VanillaDispenserOracle.use` prints them.
+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| {
+368 -209
View File
@@ -7,10 +7,21 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
//!
//! It sees the game only through `horizon_hooks::share` (the engine's `ShareView`) and
//! `Hooks::lan_closed`, and keeps what it shares in its own static,
//! so it can leave the engine for horizon-friends (spec #14167). The screen and the
//! `horizon-ctl friends` command below still reach it from `Game` until then.
use super::Game;
use horizon_client::{Result, hud::HudBatch};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
share::{LanOptions, ShareView},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
mod screen;
@@ -18,7 +29,7 @@ mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -31,12 +42,52 @@ pub(super) struct Sharing {
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
/// No `Hooks::share` yet: the code and sessions are read while the screen is drawn or
/// `horizon-ctl friends status` asks, as before. The screen's page uses it (#14167 step 2a).
impl Hooks for Friends {
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(world: &dyn ShareView, key: &str, english: &str) -> String {
world.tr(key).unwrap_or_else(|| english.to_owned())
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
@@ -53,101 +104,99 @@ fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
return Err(e.to_string());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
world,
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the screen is drawn and when `friends status` asks.
fn tick(world: &mut dyn ShareView) {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
world,
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -158,7 +207,8 @@ impl Game<'_> {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
world,
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -167,7 +217,8 @@ impl Game<'_> {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
let text = tr(
world,
"horizon.friends.relay_stopped",
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
);
@@ -176,164 +227,272 @@ impl Game<'_> {
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
if let Some(text) = stopped {
world.chat(&text);
}
if announce {
let text = tr(
world,
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
);
world.chat(&text);
}
}
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// What Play with Friends shows.
fn status(world: &dyn ShareView) -> FriendsView {
// Another server, or a splitscreen guest in P1's world: nothing to share.
if !world.can_share() {
return FriendsView::default();
}
let guard = sharing();
let Some(s) = guard.as_ref() else {
return FriendsView {
shown: true,
..FriendsView::default()
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
};
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
world,
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
tick(world);
let view = status(world);
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
}
_ => Err("friends host|stop|status".into()),
}
}
impl Game<'_> {
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
let world = &mut self.share_view();
tick(world);
let view = status(world);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let view = status(&self.share_view());
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
if action == Action::Done {
self.screen = super::Screen::Paused;
return;
}
let world = &mut self.share_view();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
if let Err(e) = start(world) {
let text = tr(
world,
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
}
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Copy => copy(world),
Action::Stop => stop(world),
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
world.open_url(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
Action::Done => {}
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
self.can_share()
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
}
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
self.lan = Some(25565);
Ok(25565)
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!status(&guest).shown);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests show once sharing does.
let view = status(&world);
assert!(view.shown && !view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
}
@@ -13,10 +13,6 @@ pub fn title() -> String {
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
@@ -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,
+2 -2
View File
@@ -1490,7 +1490,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.friends_lan_closed();
self.lan_closed_hooks();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1500,7 +1500,7 @@ impl Game<'_> {
}
return;
}
self.friends_lan_closed();
self.lan_closed_hooks();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
@@ -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,
+72 -14
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;
@@ -1574,7 +1572,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.friends_lan_closed();
self.lan_closed_hooks();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -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::{
@@ -0,0 +1,115 @@
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
use super::{Game, lan::LanSettings};
use horizon_client::text::Text;
use horizon_hooks::share::{LanOptions, ShareView};
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
/// plays P1's world and can't share it; its leaving doesn't close it).
fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err() {
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
if shares(self) {
horizon_hooks::engine::share(&mut World(self));
}
}
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
pub(super) fn lan_closed_hooks(&self) {
if shares(self) {
horizon_hooks::engine::lan_closed();
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
}
}
impl<'g> Game<'g> {
/// The view of this window's world, for the engine's own Friends screen and command until
/// they move to the crate.
#[cfg(feature = "friends")]
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
World(self)
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
fn can_share(&self) -> bool {
self.0.can_share()
}
fn lan_port(&self) -> Option<u16> {
self.0.lan_port()
}
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
self.0
.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: options.guest_commands,
force_game_mode: options.force_game_mode,
})
.map_err(|e| e.to_string())?;
self.0
.lan_port()
.ok_or_else(|| "the world isn't open to LAN".to_owned())
}
fn close_lan(&mut self) {
self.0.lan_close(true);
}
fn players(&self, each: &mut dyn FnMut(&str)) {
let g = &self.0;
if let Some(host) = &g.lan {
for guest in &host.hub.guests {
each(&guest.name);
}
} else if g.integrated.is_some() {
// On the server thread: the other players the server lists.
let me = g.options.player_name();
for name in g.remote_online_names().unwrap_or_default() {
if name != me {
each(&name);
}
}
}
}
fn chat(&mut self, text: &str) {
self.0.system_message(Text::literal(text));
}
fn copy(&mut self, text: &str) {
use horizon_client::clipboard::Clipboard;
self.0.clipboard.set(text);
}
fn open_url(&mut self, url: &str) {
// The system browser, as the folder buttons open their folders.
super::packs::open_uri(url);
}
fn tr(&self, key: &str) -> Option<String> {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
}
}
+9 -26
View File
@@ -21,20 +21,6 @@ mod ram_tests;
pub(super) const OFFHAND_SLOT: usize = 40;
impl Game<'_> {
/// `AbstractArrow.onHitEntity` on the player: a piercing arrow passes item blocking, whoever
/// shot it, because `applyItemBlocking` reads the direct entity's pierce level.
pub(super) fn arrow_hits_player(
&mut self,
amount: f32,
p: &horizon_client::projectiles::Projectile,
) -> bool {
self.combat.piercing_hit = p.arrow.as_ref().is_some_and(|a| a.pierce > 0);
self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
)
}
/// The stack in an inventory slot, the off hand at `OFFHAND_SLOT`.
pub(super) fn stack_in(&self, slot: usize) -> Option<&ItemStack> {
if slot == OFFHAND_SLOT {
@@ -158,7 +144,6 @@ impl Game<'_> {
&& blocked > 0.0
{
let outcome = BlockOutcome {
blocking_item_used: true,
blocked,
fully_blocked: blocked >= damage,
..Default::default()
@@ -208,23 +193,22 @@ impl Game<'_> {
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play(sound, at, 1.0, pitch);
}
if !outcome.blocking_item_used {
return;
}
// BlocksAttacks.hurtBlockingItem: the hit counts the blocking item as
// used, before the blocked-damage gate (vanilla awards it on the
// server even when the item blocks none of the damage).
if self.remote.is_none() {
self.award_stat("used", &stack.item, 1);
}
if outcome.blocked <= 0.0 {
return;
}
// ItemDamageFunction.apply, then hurtAndBreak.
let (threshold, base, factor) = component.item_damage;
let item_damage = ItemDamageFunction {
threshold,
base,
factor,
}
.apply(outcome.blocked);
let item_damage = if outcome.blocked < threshold {
0
} else {
(base + factor * outcome.blocked).floor() as i32
};
if item_damage > 0
&& !self.creative
&& let Some(max) = blocking_item_max_damage(
@@ -258,7 +242,7 @@ impl Game<'_> {
}
}
}
if outcome.blocked <= 0.0 || projectile {
if projectile {
return;
}
let Some(id) = attacker else {
@@ -425,7 +409,6 @@ mod tests {
direct: 1,
};
let outcome = BlockOutcome {
blocking_item_used: true,
hurt: true,
blocked: 5.0,
fully_blocked: true,
@@ -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}");
}
-6
View File
@@ -512,7 +512,6 @@ impl PlayerMotion {
status: self.status.clone(),
blocking,
blocked: 0.0,
blocking_item_used: false,
fully_blocked: false,
block_sound: false,
knocked: false,
@@ -531,7 +530,6 @@ impl PlayerMotion {
let outcome = BlockOutcome {
hurt,
blocked: environment.blocked,
blocking_item_used: environment.blocking_item_used,
fully_blocked: environment.fully_blocked,
sound: environment.block_sound,
knockback: environment.knocked,
@@ -626,7 +624,6 @@ pub(crate) struct Environment<'a> {
/// The raised blocking item, if any, and what it blocked.
pub(crate) blocking: Option<Blocking>,
pub(crate) blocked: f32,
pub(crate) blocking_item_used: bool,
/// What it blocked covers the whole hit, as difficulty scaled it.
pub(crate) fully_blocked: bool,
pub(crate) block_sound: bool,
@@ -707,8 +704,6 @@ pub struct AttackRecord {
/// The outcome of a hurt with a raised blocking item.
#[derive(Clone, Copy, Debug, Default)]
pub struct BlockOutcome {
/// `hurtBlockingItem` ran, including when reductions resolve to zero.
pub blocking_item_used: bool,
pub hurt: bool,
pub blocked: f32,
/// `applyItemBlocking`'s `damageBlocked >= damage`, against the damage
@@ -943,7 +938,6 @@ impl HurtWorld for Environment<'_> {
if damage <= 0.0 || b.bypassed || b.pierce {
return Ok(0.0);
}
self.blocking_item_used = true;
self.blocked = b.rules.resolve(&mut MatchedSets, damage, b.angle)?;
self.fully_blocked = self.blocked >= damage;
Ok(self.blocked)
@@ -17,7 +17,6 @@ pub struct EquipmentHurt {
pending: Option<PendingHurt<()>>,
inner: Option<bool>,
blocked: f32,
blocking_item_used: bool,
fully_blocked: bool,
block_sound: bool,
knocked: bool,
@@ -77,7 +76,6 @@ impl PlayerMotion {
status: self.status.clone(),
blocking: hit.blocking.clone(),
blocked: hit.blocked,
blocking_item_used: hit.blocking_item_used,
fully_blocked: hit.fully_blocked,
block_sound: hit.block_sound,
knocked: hit.knocked,
@@ -94,7 +92,6 @@ impl PlayerMotion {
)?;
let result = step(&mut h.hurt, &mut combat);
hit.blocked = environment.blocked;
hit.blocking_item_used = environment.blocking_item_used;
hit.fully_blocked = environment.fully_blocked;
hit.block_sound = environment.block_sound;
hit.knocked = environment.knocked;
@@ -126,7 +123,6 @@ impl PlayerMotion {
pending: None,
inner: None,
blocked: 0.0,
blocking_item_used: false,
fully_blocked: false,
block_sound: false,
knocked: false,
@@ -223,7 +219,6 @@ impl PlayerMotion {
Ok(BlockOutcome {
hurt,
blocked: hit.blocked,
blocking_item_used: hit.blocking_item_used,
fully_blocked: hit.fully_blocked,
sound: hit.block_sound,
knockback: hit.knocked,
-1
View File
@@ -183,7 +183,6 @@ impl FoodWorld for TickWorld<'_> {
status: Default::default(),
blocking: None,
blocked: 0.0,
blocking_item_used: false,
fully_blocked: false,
block_sound: false,
knocked: false,
-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;
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more