mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #7273
Co-Authored-By: Claude Opus 5.5 <[email protected]> # Conflicts: # crates/horizon-client/src/game.rs # tools/test_server_entities_parity.py
This commit is contained in:
commit
60aa0ec37f
543 files changed
+44533
-5206
No files matched your search
@@ -96,8 +96,9 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
|
||||
# recompiling the workspace with a different dependency graph.
|
||||
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
cargo +stable test --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
|
||||
|
||||
@@ -185,6 +185,15 @@ jobs:
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
|
||||
fi
|
||||
done
|
||||
- name: Baseline x86-64 CPUs only
|
||||
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
|
||||
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
|
||||
if: runner.os != 'macOS'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustup component add llvm-tools
|
||||
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
|
||||
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
|
||||
- name: Every program starts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
+2
-1
@@ -96,7 +96,8 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
|
||||
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
|
||||
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
|
||||
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
|
||||
enough), then claim it. `/unclaim` gives it back.
|
||||
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
|
||||
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
|
||||
|
||||
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
|
||||
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
|
||||
|
||||
Generated
+1
-2
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
@@ -2113,8 +2114,6 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
[[package]]
|
||||
name = "libmimalloc-sys"
|
||||
version = "0.1.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cty",
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
|
||||
members = [
|
||||
"crates/horizon-core",
|
||||
"crates/horizon-dualsense",
|
||||
@@ -67,6 +67,10 @@ large_enum_variant = "allow"
|
||||
wgpu-hal = { path = "third_party/wgpu-hal" }
|
||||
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
|
||||
naga = { path = "third_party/naga" }
|
||||
# libmimalloc-sys 0.1.49 whose mimalloc v3 counts bits without POPCNT when MSVC builds it: the
|
||||
# game died before main on CPUs without it (Core 2, #7264; THIRD_PARTY_NOTICES.md). Drop this and
|
||||
# the folder once a libmimalloc-sys release has microsoft/mimalloc#1291's fix.
|
||||
libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
|
||||
|
||||
# Line tables keep file:line in backtraces; full debug info made every
|
||||
# example and test binary hundreds of megabytes and slow to link.
|
||||
|
||||
@@ -14,6 +14,15 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
|
||||
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
|
||||
|
||||
## libmimalloc-sys and mimalloc
|
||||
|
||||
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
|
||||
0.1.49 by Octavian Oncescu, with the [mimalloc](https://github.com/microsoft/mimalloc) v3 sources it
|
||||
bundles (Copyright (c) 2018-2025 Microsoft Corporation, Daan Leijen), both under the MIT license
|
||||
(`LICENSE.txt` and `c_src/mimalloc/v3/LICENSE` in that folder). It holds only the v3 sources. One
|
||||
change: `mi_popcount` in `include/mimalloc/bits.h` uses mimalloc's portable bit count on MSVC
|
||||
x86/x64 instead of the POPCNT instruction, which CPUs before Nehalem lack (#7264).
|
||||
|
||||
## Pixelify Sans
|
||||
|
||||
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
|
||||
|
||||
@@ -688,4 +688,15 @@ mod tests {
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_text_preserves_legacy_and_decodes_components() {
|
||||
let legacy = Tag::String("§6Golden Name".into());
|
||||
let text = name_text(&legacy);
|
||||
assert_eq!(text.plain(), "Golden Name");
|
||||
|
||||
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
|
||||
let text = name_text(&comp);
|
||||
assert_eq!(text.plain(), "Custom Name");
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(".png"))
|
||||
.map(|rel| format!("{namespace}:{rel}")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
|
||||
@@ -573,6 +573,7 @@ mod tests {
|
||||
default: || 0,
|
||||
codec: None,
|
||||
copy_on_death: false,
|
||||
sync: false,
|
||||
};
|
||||
let p = (1, 64, 2);
|
||||
let mut entities = level();
|
||||
|
||||
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
|
||||
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CellIter {
|
||||
radius: i32,
|
||||
ring: i32,
|
||||
x: i32,
|
||||
pending_z: Option<i32>,
|
||||
}
|
||||
|
||||
impl CellIter {
|
||||
pub fn new(radius: i32) -> Self {
|
||||
Self {
|
||||
radius,
|
||||
ring: 0,
|
||||
x: 0,
|
||||
pending_z: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for CellIter {
|
||||
type Item = (i32, i32);
|
||||
|
||||
#[inline]
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if let Some(z) = self.pending_z.take() {
|
||||
let x = self.x;
|
||||
self.x += 1;
|
||||
return Some((x, z));
|
||||
}
|
||||
let r2 = self.radius * self.radius;
|
||||
while self.ring <= 2 * self.radius {
|
||||
let min_abs = self.ring - self.radius;
|
||||
while self.x <= self.ring {
|
||||
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
|
||||
self.x = min_abs;
|
||||
}
|
||||
let x = self.x;
|
||||
let z = self.ring - x.abs();
|
||||
if z <= self.radius && x * x + z * z <= r2 {
|
||||
if z != 0 {
|
||||
self.pending_z = Some(z);
|
||||
return Some((x, -z));
|
||||
} else {
|
||||
self.x += 1;
|
||||
return Some((x, 0));
|
||||
}
|
||||
}
|
||||
self.x += 1;
|
||||
}
|
||||
self.ring += 1;
|
||||
self.x = -self.ring;
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
|
||||
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
|
||||
(0..=2 * radius).flat_map(move |ring| {
|
||||
(-ring..=ring).flat_map(move |x| {
|
||||
let z = ring - x.abs();
|
||||
let visible = z <= radius && x * x + z * z <= radius * radius;
|
||||
(visible && z != 0)
|
||||
.then_some((x, -z))
|
||||
.into_iter()
|
||||
.chain(visible.then_some((x, z)))
|
||||
})
|
||||
})
|
||||
pub fn cells(radius: i32) -> CellIter {
|
||||
CellIter::new(radius)
|
||||
}
|
||||
|
||||
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
|
||||
@@ -107,10 +154,16 @@ pub(crate) struct Uniform {
|
||||
colors: [[f32; 4]; 4],
|
||||
}
|
||||
|
||||
/// How far the clouds have drifted, in ticks: the game time within one trip across the cloud
|
||||
/// texture (`width` cells of 400 ticks), plus the partial tick (`CloudRenderer.prepare`).
|
||||
pub fn drift(time: i64, partial_tick: f64, width: u32) -> f64 {
|
||||
time.rem_euclid(i64::from(width) * 400) as f64 + partial_tick
|
||||
}
|
||||
|
||||
impl Input<'_> {
|
||||
fn state(&self) -> (MeshKey, Uniform) {
|
||||
let (w, h, _) = self.texture;
|
||||
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
|
||||
let offset = drift(self.time, self.partial_tick, *w);
|
||||
let mut x = self.camera.x + offset * 0.030_000_001;
|
||||
let mut z = self.camera.z + 3.96;
|
||||
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
|
||||
@@ -150,9 +203,121 @@ impl Input<'_> {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mesh {
|
||||
key: Option<MeshKey>,
|
||||
cells: Vec<(i32, i32)>,
|
||||
cached_radius: Option<i32>,
|
||||
pub vertices: Vec<crate::mesher::Vertex>,
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
p: [[f32; 3]; 4],
|
||||
shade: u32,
|
||||
normal: [i8; 4],
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
for pos in p {
|
||||
vertices.push(crate::mesher::Vertex {
|
||||
normal,
|
||||
pos,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_face(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
direction: usize,
|
||||
inside: bool,
|
||||
x0: f32,
|
||||
z0: f32,
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
let x1 = x0 + 12.0;
|
||||
let z1 = z0 + 12.0;
|
||||
const Y0: f32 = 0.0;
|
||||
const Y1: f32 = 4.0;
|
||||
let (p, shade, normal) = match direction {
|
||||
0 => (
|
||||
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
|
||||
1,
|
||||
[0, -127, 0, 0],
|
||||
),
|
||||
1 => (
|
||||
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
|
||||
0,
|
||||
[0, 127, 0, 0],
|
||||
),
|
||||
2 => (
|
||||
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
|
||||
2,
|
||||
[0, 0, -127, 0],
|
||||
),
|
||||
3 => (
|
||||
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
|
||||
2,
|
||||
[0, 0, 127, 0],
|
||||
),
|
||||
4 => (
|
||||
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
|
||||
3,
|
||||
[-127, 0, 0, 0],
|
||||
),
|
||||
_ => (
|
||||
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
|
||||
3,
|
||||
[127, 0, 0, 0],
|
||||
),
|
||||
};
|
||||
if inside {
|
||||
push_quad(
|
||||
vertices,
|
||||
[p[3], p[2], p[1], p[0]],
|
||||
shade,
|
||||
normal.map(|c| -c),
|
||||
uv,
|
||||
);
|
||||
} else {
|
||||
push_quad(vertices, p, shade, normal, uv);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_faces(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
mask: u16,
|
||||
x0: f32,
|
||||
z0: f32,
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
for d in [1, 0, 2, 3, 4, 5] {
|
||||
if mask & (1 << d) != 0 {
|
||||
emit_face(vertices, d, false, x0, z0, uv);
|
||||
}
|
||||
}
|
||||
if mask >> 6 != 0 {
|
||||
for d in 0..6 {
|
||||
if mask & (1 << (d + 6)) != 0 {
|
||||
emit_face(vertices, d, true, x0, z0, uv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
|
||||
let x1 = x0 + 12.0;
|
||||
let z1 = z0 + 12.0;
|
||||
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
|
||||
push_quad(vertices, p, 0, [0; 4], uv);
|
||||
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Returns true only when vertices must be uploaded. Sub-cell motion and
|
||||
/// color changes update only the uniform, as in CloudRenderer.prepare.
|
||||
@@ -161,80 +326,39 @@ impl Mesh {
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return (false, uniform);
|
||||
}
|
||||
if self.cached_radius != Some(key.radius) {
|
||||
self.cells = self::cells(key.radius).collect();
|
||||
self.cached_radius = Some(key.radius);
|
||||
}
|
||||
self.vertices.clear();
|
||||
let (w, h, cells) = input.texture;
|
||||
let (w, h) = (i64::from(*w), i64::from(*h));
|
||||
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
|
||||
|
||||
let (w, h, tex_cells) = input.texture;
|
||||
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
|
||||
let bottom = uniform.offset[1];
|
||||
let uv = input.uv;
|
||||
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
|
||||
for (dx, dz) in self::cells(radius) {
|
||||
let (dx, dz) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx, cell_z + dz);
|
||||
|
||||
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
|
||||
let filled = |cx: i64, cz: i64| {
|
||||
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
|
||||
};
|
||||
|
||||
for &(dx, dz) in &self.cells {
|
||||
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
|
||||
if !filled(cx, cz) {
|
||||
continue;
|
||||
}
|
||||
let x0 = dx as f32 * 12.0;
|
||||
let z0 = dz as f32 * 12.0;
|
||||
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
|
||||
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
|
||||
// `shade`.
|
||||
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
|
||||
for v in p {
|
||||
// Overlay 4: clouds (unlit, faded by the cloud fog end).
|
||||
self.vertices.push(crate::mesher::Vertex {
|
||||
normal,
|
||||
pos: v,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
if input.fast {
|
||||
for p in flat_cell(x0, z0, 0.0) {
|
||||
quad(p, 0, [0; 4]);
|
||||
}
|
||||
emit_flat_cell(&mut self.vertices, x0, z0, uv);
|
||||
continue;
|
||||
}
|
||||
let quads = [
|
||||
(
|
||||
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
|
||||
1,
|
||||
[0, -127, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
|
||||
0,
|
||||
[0, 127, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
|
||||
2,
|
||||
[0, 0, -127, 0],
|
||||
),
|
||||
(
|
||||
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
|
||||
2,
|
||||
[0, 0, 127, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
|
||||
3,
|
||||
[-127, 0, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
|
||||
3,
|
||||
[127, 0, 0, 0],
|
||||
),
|
||||
];
|
||||
|
||||
let mask = face_mask(
|
||||
dx as i32,
|
||||
dz as i32,
|
||||
dx,
|
||||
dz,
|
||||
bottom,
|
||||
[
|
||||
!filled(cx, cz - 1),
|
||||
@@ -243,15 +367,9 @@ impl Mesh {
|
||||
!filled(cx + 1, cz),
|
||||
],
|
||||
);
|
||||
for (direction, inside) in faces(mask) {
|
||||
let (p, shade, n) = quads[usize::from(direction)];
|
||||
if inside {
|
||||
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
|
||||
} else {
|
||||
quad(p, shade, n);
|
||||
}
|
||||
}
|
||||
emit_faces(&mut self.vertices, mask, x0, z0, uv);
|
||||
}
|
||||
|
||||
self.key = Some(key);
|
||||
(true, uniform)
|
||||
}
|
||||
|
||||
@@ -182,12 +182,24 @@ where
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = CollisionContext::position(bounds.min_position().y);
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// [`Self::resolve`] with the entity's captured collision context.
|
||||
pub fn resolve_with_context(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
context: CollisionContext<'_>,
|
||||
) -> Result<Vec3> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -223,23 +235,12 @@ impl SceneCollisions<'_, '_> {
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
@@ -326,6 +327,88 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
|
||||
/// building one: no block that can collide in its inner cells, and no block that
|
||||
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
|
||||
/// the scan decides). The same cells, blocks and section flags the scan reads.
|
||||
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
|
||||
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
|
||||
/// its cell; a global palette, or a section outside the column, may.
|
||||
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
type Error = io::Error;
|
||||
/// The resident column, looked up once per chunk the scan enters.
|
||||
@@ -339,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
Ok(self.section_reaches_out(chunk, section_y))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
@@ -818,3 +895,88 @@ mod shulker_collision_tests {
|
||||
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
|
||||
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
|
||||
/// reaching into the cell above) and the scene's stone: wherever the cells say no
|
||||
/// block can collide, the full resolution returns the movement, to the bit; the rest
|
||||
/// is left to it. Needs the prepared resources.
|
||||
#[test]
|
||||
fn cells_without_collision_leave_the_movement_as_resolving_does() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
// Torches, and a fence at the bottom of a section above air.
|
||||
for x in 17..30 {
|
||||
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
|
||||
}
|
||||
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
|
||||
let scene = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
|
||||
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
|
||||
assert!(scene.cells_without_collision(torches));
|
||||
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
|
||||
assert!(scene.cells_without_collision(over_floor));
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut skipped, mut resolved) = (0, 0);
|
||||
for i in 0..4000 {
|
||||
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
|
||||
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
|
||||
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
|
||||
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
|
||||
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
|
||||
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
|
||||
let d = Vec3::new(
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
);
|
||||
let fast = scene.cells_without_collision(bb.expand_towards(d));
|
||||
let mut full = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
let moved = full.resolve(bb, d, 0.0, false).unwrap();
|
||||
if fast {
|
||||
skipped += 1;
|
||||
assert_eq!(
|
||||
moved.components().map(f64::to_bits),
|
||||
d.components().map(f64::to_bits),
|
||||
"{bb:?} by {d:?}"
|
||||
);
|
||||
} else {
|
||||
resolved += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
skipped > 500 && resolved > 500,
|
||||
"{skipped} skipped, {resolved} resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,53 @@ thread_local! {
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// The native mod hook this thread is inside, for the panic hook to name.
|
||||
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// A call into one hook of a native mod.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct NativeModCall {
|
||||
/// The mod's `[mod] id`, None in a build that didn't name its mods.
|
||||
pub id: Option<&'static str>,
|
||||
/// Its place in dispatch order, which names it then.
|
||||
pub index: usize,
|
||||
pub hook: &'static str,
|
||||
}
|
||||
|
||||
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
|
||||
/// unwinds. A hook called from a hook is named by the inner call.
|
||||
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
|
||||
NativeModGuard {
|
||||
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts back the call that `enter_native_mod` found.
|
||||
#[must_use]
|
||||
pub struct NativeModGuard {
|
||||
outer: Option<NativeModCall>,
|
||||
}
|
||||
|
||||
impl Drop for NativeModGuard {
|
||||
fn drop(&mut self) {
|
||||
IN_NATIVE_MOD.with(|c| c.set(self.outer));
|
||||
}
|
||||
}
|
||||
|
||||
/// `message`, with the native mod hook this thread is inside, if any.
|
||||
fn attributed(message: String) -> String {
|
||||
match IN_NATIVE_MOD.with(Cell::get) {
|
||||
Some(NativeModCall {
|
||||
id: Some(id), hook, ..
|
||||
}) => format!("{message}\nin native mod {id}, hook {hook}"),
|
||||
Some(NativeModCall {
|
||||
id: None,
|
||||
index,
|
||||
hook,
|
||||
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
|
||||
None => message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
@@ -418,10 +465,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
attributed(match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
|
||||
@@ -537,7 +584,8 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
|
||||
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
|
||||
/// panic inside a native mod's hook names the mod and the hook.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
@@ -676,6 +724,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_a_native_mods_hook_names_it() {
|
||||
let outer = enter_native_mod(NativeModCall {
|
||||
id: None,
|
||||
index: 1,
|
||||
hook: "render",
|
||||
});
|
||||
let inner = enter_native_mod(NativeModCall {
|
||||
id: Some("b"),
|
||||
index: 0,
|
||||
hook: "tick",
|
||||
});
|
||||
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
|
||||
drop(inner);
|
||||
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
|
||||
drop(outer);
|
||||
assert_eq!(attributed("boom".into()), "boom");
|
||||
}
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
|
||||
@@ -787,17 +854,37 @@ mod tests {
|
||||
#[test]
|
||||
fn a_panic_becomes_a_report() {
|
||||
// A real panic through a hook, as the game's would be.
|
||||
let caught = std::sync::Arc::new(Mutex::new(None));
|
||||
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = caught.clone();
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if panic_message(info).contains("the test crash") {
|
||||
*sink.lock().unwrap() = Some(panic_report(info, "client"));
|
||||
sink.lock().unwrap().push(panic_report(info, "client"));
|
||||
}
|
||||
}));
|
||||
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
|
||||
// The same panic inside a native mod's hook, which the hook sees before the unwind.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _guard = enter_native_mod(NativeModCall {
|
||||
id: Some("a"),
|
||||
index: 0,
|
||||
hook: "tick",
|
||||
});
|
||||
panic!("the test crash")
|
||||
});
|
||||
std::panic::set_hook(previous);
|
||||
let report = caught.lock().unwrap().take().expect("the hook ran");
|
||||
// A caught panic leaves the thread as it found it.
|
||||
assert_eq!(attributed("boom".into()), "boom");
|
||||
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
|
||||
assert_eq!(reports.len(), 2, "the hook ran for both panics");
|
||||
let in_mod = reports.pop().unwrap();
|
||||
assert!(
|
||||
in_mod.error.ends_with("\nin native mod a, hook tick"),
|
||||
"{}",
|
||||
in_mod.error
|
||||
);
|
||||
let report = reports.pop().unwrap();
|
||||
assert!(!report.error.contains("native mod"));
|
||||
assert!(report.error.contains("the test crash"));
|
||||
assert!(report.error.contains("crash.rs"));
|
||||
// The first frame is the test's own code, not the panic machinery.
|
||||
|
||||
@@ -147,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
var back = c;
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
|
||||
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
|
||||
@@ -23,7 +23,12 @@ use horizon_optifine::fusion::{
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
num::NonZeroU16,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
@@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
for path in mcmeta_files(&textures) {
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
|
||||
/// from the folders (linked folders too: mods' content links into the packs' tree).
|
||||
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
|
||||
return files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| textures.join(file))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut dirs = vec![textures.to_owned()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1447,13 @@ fn instance_descriptor(
|
||||
}
|
||||
descriptor.backends = backends.with_env();
|
||||
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
|
||||
// FXC spends about 2.6 s on every start compiling the pipelines (DXC about
|
||||
// 1 s); what it compiled once is kept in the game folder and read back
|
||||
// after that (third_party/wgpu-hal/src/dx12/disk_cache.rs).
|
||||
#[cfg(windows)]
|
||||
if dx12 {
|
||||
wgpu::hal::dx12::disk_cache::set_file(dx12_shader_cache(&crate::logging::game_home()));
|
||||
}
|
||||
if !cfg!(debug_assertions) && !dx12 {
|
||||
descriptor
|
||||
.flags
|
||||
@@ -1460,6 +1467,11 @@ fn instance_descriptor(
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
/// Where DX12 keeps the shaders it compiled, inside a game folder.
|
||||
pub fn dx12_shader_cache(home: &std::path::Path) -> std::path::PathBuf {
|
||||
home.join("cache").join("dx12-shaders.bin")
|
||||
}
|
||||
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
|
||||
@@ -4805,11 +4817,13 @@ impl Renderer {
|
||||
) -> impl Iterator<Item = (&'a ([i32; 3], u32), bool)> + 'a {
|
||||
let fade = fade_millis(fade_seconds);
|
||||
let now = Instant::now();
|
||||
// No section uploaded within the fade: all are whole, none is looked up.
|
||||
let all = !self.sections_fading_in(fade, now);
|
||||
let min_y = self.layout.min_y();
|
||||
self.block_entities
|
||||
.iter()
|
||||
.filter(move |(key, _)| {
|
||||
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
all || chunk_visibility(self.section_elapsed_ms(**key, now), fade)
|
||||
>= CHUNK_VISIBILITY_THRESHOLD
|
||||
})
|
||||
.filter_map(move |(key, blocks)| {
|
||||
@@ -5320,6 +5334,14 @@ impl Renderer {
|
||||
&& self.native.is_none()
|
||||
}
|
||||
|
||||
/// Whether the eyes render type's layers may be posed on the GPU too
|
||||
/// (`mobs::skin::SkinModels::eyes`): only with improved transparency off,
|
||||
/// where they are ordinary entity quads. On, `EntityBatch` sorts them into
|
||||
/// its stages by the CPU vertices' `OIT_ENTITY` tag.
|
||||
pub fn skins_eyes(&self) -> bool {
|
||||
self.skins_entities() && !self.improved_transparency
|
||||
}
|
||||
|
||||
pub fn frame_generation_share(&self) -> Option<f32> {
|
||||
self.frame_gen.made_share
|
||||
}
|
||||
@@ -5339,7 +5361,8 @@ impl Renderer {
|
||||
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
|
||||
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
|
||||
/// (its block's light), three values a layer.
|
||||
/// Returns the programs that run and the notes of loading them.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
@@ -6211,8 +6234,9 @@ impl Renderer {
|
||||
self.config.height.max(1) as f32,
|
||||
frame.depth_far,
|
||||
));
|
||||
let hand_view_proj = hand_projection * frame.bob * view;
|
||||
let hand_globals = Globals {
|
||||
view_proj: (hand_projection * frame.bob * view).to_cols_array_2d(),
|
||||
view_proj: hand_view_proj.to_cols_array_2d(),
|
||||
..globals
|
||||
};
|
||||
self.uploads.buffer(
|
||||
@@ -6990,7 +7014,11 @@ impl Renderer {
|
||||
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
|
||||
let see_through_quads = (frame.see_through.len() / 4) as u32;
|
||||
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
|
||||
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
|
||||
// Glowing entities' silhouettes posed on the GPU go last, past every
|
||||
// material (the outline pass draws them from there).
|
||||
let outline_base = posed + all_entities.len() as u32;
|
||||
let outlined = skin.map_or(0, |s| s.outline_vertices);
|
||||
let entity_vertices = u64::from(outline_base) + u64::from(outlined);
|
||||
if entity_vertices > self.entity_capacity {
|
||||
self.entity_capacity = entity_vertices.next_power_of_two();
|
||||
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
@@ -7016,6 +7044,7 @@ impl Renderer {
|
||||
skin,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
outline_base,
|
||||
);
|
||||
}
|
||||
let static_sections = frame.static_block_entities;
|
||||
@@ -7142,6 +7171,7 @@ impl Renderer {
|
||||
&mut self.uploads,
|
||||
&mut encoder,
|
||||
frame.glowing,
|
||||
(outline_base, outlined / 4),
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
if let Some((rotation, brightness)) = frame.stars {
|
||||
@@ -7173,7 +7203,8 @@ impl Renderer {
|
||||
lightning::QUADS
|
||||
})
|
||||
.max(weather_quads)
|
||||
.max(frame.glowing.len() as u32 / 4);
|
||||
.max(frame.glowing.len() as u32 / 4)
|
||||
.max(outlined / 4);
|
||||
if max_quads > self.index_quads {
|
||||
self.index_quads = max_quads.next_power_of_two();
|
||||
self.indices = Self::quad_indices(&self.device, self.index_quads);
|
||||
@@ -8727,6 +8758,8 @@ impl Renderer {
|
||||
first_crosshair: debug_depth + debug_top,
|
||||
world_depth: false,
|
||||
real: false,
|
||||
hand_drawn: !pack_draws_hand,
|
||||
hand_view_proj,
|
||||
};
|
||||
self.draw_overlay(&mut encoder, frame, &generated_view, generated, &overlay);
|
||||
}
|
||||
@@ -8769,6 +8802,8 @@ impl Renderer {
|
||||
first_crosshair: debug_depth + debug_top,
|
||||
world_depth: scaled.is_none(),
|
||||
real: true,
|
||||
hand_drawn: !pack_draws_hand,
|
||||
hand_view_proj,
|
||||
};
|
||||
let readback =
|
||||
self.draw_overlay(&mut encoder, frame, &view_target, &target_texture, &overlay);
|
||||
@@ -9056,12 +9091,30 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A native mod's turn after the hand, on the real frame only.
|
||||
if overlay.real && !frame.title {
|
||||
native_render::native_hand_drawn(&mut native_render::HandDrawn {
|
||||
device: &self.device,
|
||||
queue: &self.queue,
|
||||
encoder: &mut *encoder,
|
||||
color: target_texture,
|
||||
color_view: view_target,
|
||||
color_format: self.config.format.remove_srgb_suffix(),
|
||||
size: [self.config.width, self.config.height],
|
||||
hand: if overlay.hand_drawn { frame.hand } else { &[] },
|
||||
view_proj: overlay.hand_view_proj,
|
||||
camera: frame.camera,
|
||||
atlas: self.hud.atlas_texture.as_ref(),
|
||||
quad_indices: &self.indices,
|
||||
});
|
||||
}
|
||||
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
|
||||
self.entity_outline.draw(
|
||||
encoder,
|
||||
view_target,
|
||||
&self.entity_group,
|
||||
&self.indices,
|
||||
&self.entity_buffer,
|
||||
timing.as_mut(),
|
||||
);
|
||||
// The world icon grabs here: after the level and the hand, before
|
||||
@@ -9592,6 +9645,11 @@ struct Overlay {
|
||||
world_depth: bool,
|
||||
/// The real frame, not frame generation's made one: the world icon grabs it.
|
||||
real: bool,
|
||||
/// The game drew the first-person hand, in its own pass or at the end of the world's
|
||||
/// (`hand_in_world`), not a shader pack.
|
||||
hand_drawn: bool,
|
||||
/// Camera-relative world to clip space for the hand: its own field of view and the bob.
|
||||
hand_view_proj: Mat4,
|
||||
}
|
||||
|
||||
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
|
||||
|
||||
@@ -506,12 +506,17 @@ impl Arena {
|
||||
by_page[page].push((start, Self::span(vertices).min(self.page_limit)));
|
||||
}
|
||||
for (page, ranges) in by_page.into_iter().enumerate() {
|
||||
if !ranges.is_empty() {
|
||||
self.pages[page]
|
||||
.as_mut()
|
||||
.expect("live arena page")
|
||||
.free
|
||||
.give_all(ranges);
|
||||
if ranges.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let page = self.pages[page].as_mut().expect("live arena page");
|
||||
// Every range of the page given back (a far teleport empties the arena): one
|
||||
// free range, without sorting tens of thousands into the list.
|
||||
let given: u64 = ranges.iter().map(|r| r.1).sum();
|
||||
if page.free_vertices() + given == page.capacity {
|
||||
page.free = FreeList::new(page.capacity);
|
||||
} else {
|
||||
page.free.give_all(ranges);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,6 +751,41 @@ mod tests {
|
||||
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
|
||||
}
|
||||
|
||||
/// Released together, every range of a page leaves it one free range (a far teleport
|
||||
/// empties the arena at once), while a page keeping some ranges frees only the others.
|
||||
#[test]
|
||||
fn ranges_released_at_once_free_exactly_their_vertices() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::new(&device, 4, GRAIN, false);
|
||||
arena.page_limit = 4 * GRAIN;
|
||||
let ranges: Vec<(u32, u64)> = (0..8)
|
||||
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
|
||||
.collect();
|
||||
let (first, second) = (ranges[0].0, ranges[7].0);
|
||||
assert_ne!(first, second);
|
||||
// All four of the first page, the middle two of the second.
|
||||
let released: Vec<(u32, u64, u64)> = ranges
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(i, _)| i < 4 || i == 5 || i == 6)
|
||||
.map(|(_, &(page, start))| (page, start, GRAIN))
|
||||
.collect();
|
||||
arena.release_all(released);
|
||||
let free = |page: u32| -> Vec<(u64, u64)> {
|
||||
let free = &arena.pages[page as usize].as_ref().unwrap().free.0;
|
||||
free.iter().map(|(&start, &len)| (start, len)).collect()
|
||||
};
|
||||
assert_eq!(free(first), vec![(0, 4 * GRAIN)]);
|
||||
assert_eq!(free(second), vec![(ranges[5].1, 2 * GRAIN)]);
|
||||
// The emptied page takes a whole page's range again, without growing.
|
||||
let pages = arena.pages.iter().flatten().count();
|
||||
assert_eq!(arena.alloc_existing(4 * GRAIN), Some((first, 0)));
|
||||
assert_eq!(arena.pages.iter().flatten().count(), pages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -29,6 +29,8 @@ pub(super) struct EntityOutline {
|
||||
vertices: wgpu::Buffer,
|
||||
capacity: u64,
|
||||
quads: u32,
|
||||
/// The silhouettes posed on the GPU: first vertex in the entity buffer, quads.
|
||||
posed: (u32, u32),
|
||||
targets: Option<Targets>,
|
||||
}
|
||||
|
||||
@@ -159,6 +161,7 @@ impl EntityOutline {
|
||||
vertices: Self::vertex_buffer(device, 256),
|
||||
capacity: 256,
|
||||
quads: 0,
|
||||
posed: (0, 0),
|
||||
targets: None,
|
||||
}
|
||||
}
|
||||
@@ -175,16 +178,20 @@ impl EntityOutline {
|
||||
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
|
||||
/// when there are any) and sizes the targets like the window. A frame with
|
||||
/// none gives the targets' video memory back until an entity glows again.
|
||||
/// `posed`: where the silhouettes posed on the GPU start in the entity
|
||||
/// buffer (`entity_skin`'s outline jobs), and how many quads they are.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
uploads: &mut super::upload::Uploads,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
silhouettes: &[Vertex],
|
||||
posed: (u32, u32),
|
||||
size: [u32; 2],
|
||||
) {
|
||||
self.quads = (silhouettes.len() / 4) as u32;
|
||||
if self.quads == 0 {
|
||||
self.posed = posed;
|
||||
if self.quads == 0 && posed.1 == 0 {
|
||||
self.targets = None;
|
||||
return;
|
||||
}
|
||||
@@ -193,12 +200,14 @@ impl EntityOutline {
|
||||
self.capacity = vertices.next_power_of_two();
|
||||
self.vertices = Self::vertex_buffer(device, self.capacity);
|
||||
}
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.vertices,
|
||||
0,
|
||||
bytemuck::cast_slice(silhouettes),
|
||||
);
|
||||
if !silhouettes.is_empty() {
|
||||
uploads.buffer(
|
||||
encoder,
|
||||
&self.vertices,
|
||||
0,
|
||||
bytemuck::cast_slice(silhouettes),
|
||||
);
|
||||
}
|
||||
if self.targets.as_ref().is_some_and(|t| t.size == size) {
|
||||
return;
|
||||
}
|
||||
@@ -260,9 +269,14 @@ impl EntityOutline {
|
||||
frame: &wgpu::TextureView,
|
||||
entity_group: &wgpu::BindGroup,
|
||||
indices: &wgpu::Buffer,
|
||||
entities: &wgpu::Buffer,
|
||||
mut timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
|
||||
let Some(t) = self
|
||||
.targets
|
||||
.as_ref()
|
||||
.filter(|_| self.quads > 0 || self.posed.1 > 0)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
|
||||
@@ -277,8 +291,15 @@ impl EntityOutline {
|
||||
pass.set_pipeline(&self.silhouette);
|
||||
pass.set_bind_group(0, entity_group, &[]);
|
||||
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
|
||||
if self.quads > 0 {
|
||||
pass.set_vertex_buffer(0, self.vertices.slice(..));
|
||||
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
|
||||
}
|
||||
let (first, quads) = self.posed;
|
||||
if quads > 0 {
|
||||
pass.set_vertex_buffer(0, entities.slice(..));
|
||||
pass.draw_indexed(0..quads * 6, first as i32, 0..1);
|
||||
}
|
||||
}
|
||||
// post_effect/entity_outline.json; its last pass only copies swap back to the
|
||||
// outline target, so the blit reads swap.
|
||||
|
||||
@@ -66,12 +66,26 @@ pub struct SkinJob {
|
||||
pub overlay: u32,
|
||||
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
|
||||
pub glint: u32,
|
||||
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
|
||||
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
|
||||
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
|
||||
pub item: u32,
|
||||
/// Always 0: the shader reads the last as a zero it cannot fold away.
|
||||
pub spare: [u32; 2],
|
||||
/// How the vertices are finished: [`SkinJob::MODEL`], [`SkinJob::ITEM`] or
|
||||
/// [`SkinJob::FLAT`].
|
||||
pub mode: u32,
|
||||
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
|
||||
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
|
||||
pub cull: u32,
|
||||
/// Always 0: the shader reads it as a zero it cannot fold away.
|
||||
pub spare: u32,
|
||||
}
|
||||
|
||||
impl SkinJob {
|
||||
/// A model's polygons: the face normal posed, the job's colour.
|
||||
pub const MODEL: u32 = 0;
|
||||
/// An item model's quads (`item_render::WorldSink`): the colour by the face's
|
||||
/// axis rides in the part's first 6 normal words ([`SkinPart::item`]), shaded on
|
||||
/// the CPU, and the normal is `mesher::CULL_BACK`.
|
||||
pub const ITEM: u32 = 1;
|
||||
/// A model's polygons with no normal at all (all four bytes 0): the eyes render
|
||||
/// type's layers, which entity.wgsl leaves unshaded.
|
||||
pub const FLAT: u32 = 2;
|
||||
}
|
||||
|
||||
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
|
||||
@@ -93,6 +107,11 @@ pub struct SkinFrame {
|
||||
pub jobs: Vec<SkinJob>,
|
||||
/// Vertices the jobs write: a whole number of quads.
|
||||
pub vertices: u32,
|
||||
/// Glowing entities' silhouettes posed here (`OutlineBufferSource`'s copy of
|
||||
/// their draws, [`SkinFrame::outline_since`]): `dst` counts from the first.
|
||||
pub outline_jobs: Vec<SkinJob>,
|
||||
/// Vertices the outline jobs write.
|
||||
pub outline_vertices: u32,
|
||||
}
|
||||
|
||||
impl SkinFrame {
|
||||
@@ -101,6 +120,24 @@ impl SkinFrame {
|
||||
self.parts.clear();
|
||||
self.jobs.clear();
|
||||
self.vertices = 0;
|
||||
self.outline_jobs.clear();
|
||||
self.outline_vertices = 0;
|
||||
}
|
||||
|
||||
/// Copies the draws since job `first` into the glowing entities' outline
|
||||
/// (`Frame::glowing`'s, on the GPU), as `glowing.rs`'s `mark_outline` copies
|
||||
/// vertices: the same ones, in `color` (`Vertex::color`) with no normal.
|
||||
pub fn outline_since(&mut self, first: usize, color: u32) {
|
||||
for i in first..self.jobs.len() {
|
||||
let job = self.jobs[i];
|
||||
self.outline_jobs.push(SkinJob {
|
||||
dst: self.outline_vertices,
|
||||
color,
|
||||
mode: SkinJob::FLAT,
|
||||
..job
|
||||
});
|
||||
self.outline_vertices += job.count;
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer took the new models.
|
||||
@@ -124,7 +161,10 @@ impl SkinFrame {
|
||||
|
||||
/// Whether the renderer has anything to take.
|
||||
pub fn pending(&self) -> bool {
|
||||
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
|
||||
self.reset
|
||||
|| !self.new_model.is_empty()
|
||||
|| !self.jobs.is_empty()
|
||||
|| !self.outline_jobs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +319,8 @@ impl EntitySkin {
|
||||
}
|
||||
|
||||
/// Records the frame's jobs: they write `frame.vertices` vertices into
|
||||
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
|
||||
/// `out` from vertex `base` on, and the outline jobs `frame.outline_vertices`
|
||||
/// from vertex `outline_base` on. Call after the CPU vertices' upload.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn dispatch(
|
||||
&mut self,
|
||||
@@ -289,26 +330,32 @@ impl EntitySkin {
|
||||
frame: &SkinFrame,
|
||||
out: &wgpu::Buffer,
|
||||
base: u32,
|
||||
outline_base: u32,
|
||||
) {
|
||||
if frame.jobs.is_empty() {
|
||||
if frame.jobs.is_empty() && frame.outline_jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(model) = &self.model_buffer else {
|
||||
return;
|
||||
};
|
||||
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
|
||||
let count = frame.jobs.len() as u32;
|
||||
let count = (frame.jobs.len() + frame.outline_jobs.len()) as u32;
|
||||
let rows = count.div_ceil(ROW);
|
||||
let padded = if rows > 1 {
|
||||
(rows * ROW) as usize
|
||||
} else {
|
||||
frame.jobs.len()
|
||||
count as usize
|
||||
};
|
||||
self.staged.clear();
|
||||
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
|
||||
dst: job.dst + base,
|
||||
..*job
|
||||
}));
|
||||
let moved = |at: u32| {
|
||||
move |job: &SkinJob| SkinJob {
|
||||
dst: job.dst + at,
|
||||
..*job
|
||||
}
|
||||
};
|
||||
self.staged.extend(frame.jobs.iter().map(moved(base)));
|
||||
self.staged
|
||||
.extend(frame.outline_jobs.iter().map(moved(outline_base)));
|
||||
self.staged.resize(padded, SkinJob::default());
|
||||
self.parts.ensure(
|
||||
device,
|
||||
@@ -355,8 +402,22 @@ impl EntitySkin {
|
||||
/// the same way: the reference its tests hold the shader and the game's walk
|
||||
/// (`mobs::skin`) to. `model` is the whole store.
|
||||
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
|
||||
for job in &frame.jobs {
|
||||
expand_jobs(model, frame, &frame.jobs, frame.vertices)
|
||||
}
|
||||
|
||||
/// [`expand`] of the frame's outline jobs: the silhouettes they write.
|
||||
pub fn expand_outline(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
expand_jobs(model, frame, &frame.outline_jobs, frame.outline_vertices)
|
||||
}
|
||||
|
||||
fn expand_jobs(
|
||||
model: &[SkinVertex],
|
||||
frame: &SkinFrame,
|
||||
jobs: &[SkinJob],
|
||||
vertices: u32,
|
||||
) -> Vec<Vertex> {
|
||||
let mut out = vec![bytemuck::Zeroable::zeroed(); vertices as usize];
|
||||
for job in jobs {
|
||||
for k in 0..job.count {
|
||||
let v = model[(job.first + k) as usize];
|
||||
let part = &frame.parts[(job.part + v.part) as usize];
|
||||
@@ -372,11 +433,18 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
|
||||
(v.uv[0] + job.scroll) % 1.0
|
||||
};
|
||||
let r = job.rect;
|
||||
let (color, normal) = if job.item != 0 {
|
||||
let (color, normal) = if job.mode == SkinJob::ITEM {
|
||||
let color = part.normal[v.axis as usize].to_bits();
|
||||
(color, crate::mesher::CULL_BACK)
|
||||
} else if job.mode == SkinJob::FLAT {
|
||||
(job.color, [0; 4])
|
||||
} else {
|
||||
(job.color, [packed[0], packed[1], packed[2], -127])
|
||||
let w = if job.cull != 0 {
|
||||
crate::mesher::SHADED_CULL_W
|
||||
} else {
|
||||
-127
|
||||
};
|
||||
(job.color, [packed[0], packed[1], packed[2], w])
|
||||
};
|
||||
out[(job.dst + k) as usize] = Vertex {
|
||||
pos: pos.to_array(),
|
||||
@@ -490,6 +558,8 @@ mod tests {
|
||||
color: 0xff80_40ff,
|
||||
light: 0xf0_70,
|
||||
overlay: 1,
|
||||
// The bat's encoding: the shader must write -126 like `expand`.
|
||||
cull: 1,
|
||||
..Default::default()
|
||||
},
|
||||
SkinJob {
|
||||
@@ -514,18 +584,50 @@ mod tests {
|
||||
rect: [0.5, 0.25, 0.5625, 0.3125],
|
||||
light: 0xa0_30,
|
||||
overlay: 2 << 16,
|
||||
item: 1,
|
||||
mode: SkinJob::ITEM,
|
||||
..Default::default()
|
||||
},
|
||||
// An eyes layer: no normal, even over a culled model's encoding; the
|
||||
// tier and OIT tag ride in the overlay word.
|
||||
SkinJob {
|
||||
first: 24,
|
||||
count: 24,
|
||||
part: 1,
|
||||
dst: 96,
|
||||
rect: [0.125, 0.75, 0.25, 0.875],
|
||||
color: 0x80ff_ffff,
|
||||
light: 0xf0f0,
|
||||
overlay: crate::gfx::OIT_ENTITY | 3 << 16 | 7,
|
||||
mode: SkinJob::FLAT,
|
||||
cull: 1,
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
vertices: 96,
|
||||
vertices: 120,
|
||||
outline_jobs: Vec::new(),
|
||||
outline_vertices: 0,
|
||||
};
|
||||
// A glowing entity's silhouette: the item job and the first job again,
|
||||
// in its team colour with no normal.
|
||||
let mut frame = frame;
|
||||
frame.jobs.push(frame.jobs[2]);
|
||||
frame.jobs.last_mut().expect("pushed").dst = 120;
|
||||
frame.jobs.push(SkinJob {
|
||||
dst: 144,
|
||||
..frame.jobs[0]
|
||||
});
|
||||
frame.vertices = 192;
|
||||
frame.outline_since(4, 0xff20_c0e0);
|
||||
assert_eq!(frame.outline_vertices, 72);
|
||||
let expected = expand(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone.
|
||||
let outline = expand_outline(&model, &frame);
|
||||
// The posed vertices land after 5 others, which they leave alone, and the
|
||||
// silhouettes 3 past them.
|
||||
let before = 5u32;
|
||||
let outline_base = before + frame.vertices + 3;
|
||||
let out = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(before + frame.vertices) * 44,
|
||||
size: u64::from(outline_base + frame.outline_vertices) * 44,
|
||||
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -534,7 +636,15 @@ mod tests {
|
||||
let mut uploads = Uploads::new(&device);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
|
||||
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
|
||||
skin.dispatch(
|
||||
&device,
|
||||
&mut uploads,
|
||||
&mut encoder,
|
||||
&frame,
|
||||
&out,
|
||||
before,
|
||||
outline_base,
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: out.size(),
|
||||
@@ -557,5 +667,24 @@ mod tests {
|
||||
"vertex {i}: {got:?} != {want:?}"
|
||||
);
|
||||
}
|
||||
let silhouettes = &got[(outline_base - before) as usize..];
|
||||
assert_eq!(silhouettes.len(), outline.len());
|
||||
for (i, (got, want)) in silhouettes.iter().zip(&outline).enumerate() {
|
||||
assert_eq!(
|
||||
bytemuck::bytes_of(got),
|
||||
bytemuck::bytes_of(want),
|
||||
"silhouette {i}: {got:?} != {want:?}"
|
||||
);
|
||||
}
|
||||
// A silhouette is its draw's vertex with the colour and no normal
|
||||
// (`mark_outline`).
|
||||
for (copy, of) in outline.iter().zip(&expected[120..]) {
|
||||
let marked = Vertex {
|
||||
color: 0xff20_c0e0,
|
||||
normal: [0; 4],
|
||||
..*of
|
||||
};
|
||||
assert_eq!(bytemuck::bytes_of(copy), bytemuck::bytes_of(&marked));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@ const MODEL_WORDS: u32 = 7u;
|
||||
const PART_WORDS: u32 = 25u;
|
||||
// A job (16 words): first model vertex, vertex count, first part, first output
|
||||
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
|
||||
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
|
||||
// `keep`'s).
|
||||
// whether the overlay is the armour glint (its model UV in `glint_uv`), the mode
|
||||
// (`SkinJob::mode`: 0 a model, 1 an item's quads, 2 a model with no normal),
|
||||
// whether its quads cull back faces (`SkinJob::cull`), 1 spare (0: `keep`'s).
|
||||
const JOB_WORDS: u32 = 16u;
|
||||
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
|
||||
// (4 x i8), glint_uv (2).
|
||||
@@ -88,7 +88,8 @@ fn skin(
|
||||
let light = jobs[j + 10u];
|
||||
let overlay = jobs[j + 11u];
|
||||
let glint = jobs[j + 12u] != 0u;
|
||||
let item = jobs[j + 13u] != 0u;
|
||||
let mode = jobs[j + 13u];
|
||||
let cull = jobs[j + 14u] != 0u;
|
||||
keep = jobs[j + 15u];
|
||||
for (var k = lane; k < count; k += 64u) {
|
||||
let v = (first + k) * MODEL_WORDS;
|
||||
@@ -110,9 +111,16 @@ fn skin(
|
||||
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
|
||||
var vertex_color = color;
|
||||
var normal = pack(n);
|
||||
if item {
|
||||
if cull {
|
||||
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
|
||||
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
|
||||
}
|
||||
if mode == 1u {
|
||||
vertex_color = parts[part * PART_WORDS + 16u + axis];
|
||||
normal = 1u << 24u;
|
||||
} else if mode == 2u {
|
||||
// The eyes render type's layers: no normal (`SkinJob::FLAT`).
|
||||
normal = 0u;
|
||||
}
|
||||
let o = (dst + k) * VERTEX_WORDS;
|
||||
out[o] = bitcast<u32>(pos.x);
|
||||
|
||||
@@ -12,7 +12,7 @@ use std::sync::{
|
||||
};
|
||||
|
||||
mod generated;
|
||||
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
|
||||
pub use wgpu;
|
||||
|
||||
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
|
||||
@@ -40,8 +40,8 @@ pub fn world_drawn_hooked() -> bool {
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
|
||||
/// particles, weather and the world post pass, before the first-person hand ([`HandDrawn`]),
|
||||
/// the HUD and the screens. `color` holds the world's picture (copy it out before reading it: it is the target
|
||||
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
|
||||
#[non_exhaustive]
|
||||
pub struct WorldDrawn<'a> {
|
||||
@@ -76,6 +76,35 @@ pub struct WorldDrawn<'a> {
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the first-person hand is drawn ([`native_hand_drawn`]): after
|
||||
/// the world and the hand, before entity outlines, post effects, screen effects and the HUD.
|
||||
/// `color` holds the frame's picture with the hand in it (copy it out before reading it).
|
||||
#[non_exhaustive]
|
||||
pub struct HandDrawn<'a> {
|
||||
pub device: &'a wgpu::Device,
|
||||
pub queue: &'a wgpu::Queue,
|
||||
pub encoder: &'a mut wgpu::CommandEncoder,
|
||||
/// The frame's target and a view of it in `color_format`.
|
||||
pub color: &'a wgpu::Texture,
|
||||
pub color_view: &'a wgpu::TextureView,
|
||||
pub color_format: wgpu::TextureFormat,
|
||||
/// The size of `color`.
|
||||
pub size: [u32; 2],
|
||||
/// The hand and held item just drawn, camera-relative quads of 4 vertices: empty when no
|
||||
/// hand drew (third person, F1, a sleeping or spectating player) or a shader pack drew it.
|
||||
pub hand: &'a [Vertex],
|
||||
/// Camera-relative world to clip space for the hand: its own field of view
|
||||
/// (`hand_fov_degrees`) and the view bob, without TAA's jitter (the hand has none). Its
|
||||
/// depth is the hand's own: the hand's pass clears depth and keeps none.
|
||||
pub view_proj: Mat4,
|
||||
/// The camera's position in the world.
|
||||
pub camera: DVec3,
|
||||
/// The shared atlas the hand's UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices).
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
|
||||
/// needs and leaves the rest, so several can ask.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
@@ -94,6 +123,13 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
|
||||
}
|
||||
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
#[inline(never)]
|
||||
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
|
||||
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
|
||||
}
|
||||
|
||||
/// Asked as a frame is built and drawn: set what your mod needs.
|
||||
#[inline(never)]
|
||||
pub fn native_render_needs(needs: &mut RenderNeeds) {
|
||||
|
||||
@@ -11,6 +11,11 @@ pub trait RenderHooks: Send + Sync {
|
||||
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
@@ -34,3 +39,14 @@ pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds)
|
||||
mod_hook.render_needs(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct HandDrawnEvent<'a, 'b> {
|
||||
pub hand: &'a mut HandDrawn<'b>,
|
||||
}
|
||||
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
|
||||
let mut event = HandDrawnEvent { hand };
|
||||
for mod_hook in mods {
|
||||
mod_hook.hand_drawn(&mut event);
|
||||
}
|
||||
}
|
||||
@@ -164,8 +164,9 @@ impl PackAtlas {
|
||||
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
|
||||
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
|
||||
);
|
||||
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
|
||||
// and the arena page's vertices (`TerrainVertex`, six words each).
|
||||
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
|
||||
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
|
||||
// vertices (`TerrainVertex`, six words each).
|
||||
declarations.push_str(
|
||||
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
|
||||
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
|
||||
@@ -208,13 +209,15 @@ impl PackAtlas {
|
||||
}
|
||||
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
|
||||
let entity = if flat {
|
||||
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
|
||||
} else {
|
||||
"vec4(-1.0, -1.0, 0.0, 0.0)"
|
||||
};
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
|
||||
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
|
||||
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
|
||||
let mid_block = if flat {
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
|
||||
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
|
||||
float(hz_block_ids[3u * hz_layer + 2u]))"
|
||||
} else {
|
||||
"vec4(0.0)"
|
||||
};
|
||||
|
||||
@@ -269,20 +269,121 @@ pub(super) fn clear(
|
||||
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
|
||||
const ZEROS: u64 = 4 << 20;
|
||||
|
||||
/// What an image no `image.*` gives binds. Iris 1.11.7 runs the program, and its loads read
|
||||
/// zero, also after it stored into the image (observed with a probe pack). Here it is one texel
|
||||
/// of the declared format, cleared before every pass that binds it; loads and stores outside
|
||||
/// that texel are out of bounds (with the same probe pack on Vulkan, the loads read zero).
|
||||
pub(super) struct StandIn {
|
||||
format: wgpu::TextureFormat,
|
||||
dimension: wgpu::TextureViewDimension,
|
||||
pub(super) view: wgpu::TextureView,
|
||||
zeros: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl StandIn {
|
||||
/// Zero again, before a pass that binds it.
|
||||
pub(super) fn clear(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
encoder.copy_buffer_to_texture(
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &self.zeros,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: None,
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
self.view.texture().as_image_copy(),
|
||||
self.view.texture().size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One stand-in for each format and dimension of `wanted`.
|
||||
pub(super) fn stand_ins(
|
||||
device: &wgpu::Device,
|
||||
wanted: Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>,
|
||||
) -> Vec<StandIn> {
|
||||
wanted
|
||||
.into_iter()
|
||||
.map(|(format, dimension)| {
|
||||
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
|
||||
&& matches!(
|
||||
format,
|
||||
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
|
||||
);
|
||||
let view = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack image stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: if dimension == wgpu::TextureViewDimension::D3 {
|
||||
wgpu::TextureDimension::D3
|
||||
} else {
|
||||
wgpu::TextureDimension::D2
|
||||
},
|
||||
format,
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| if atomic {
|
||||
wgpu::TextureUsages::STORAGE_ATOMIC
|
||||
} else {
|
||||
wgpu::TextureUsages::empty()
|
||||
},
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
// New buffers start zeroed; one texel is at most 16 bytes.
|
||||
let zeros = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack image stand-in zeros"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
StandIn {
|
||||
format,
|
||||
dimension,
|
||||
view,
|
||||
zeros,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The stand-in of this format and dimension.
|
||||
pub(super) fn stand_in(
|
||||
stand_ins: &[StandIn],
|
||||
format: wgpu::TextureFormat,
|
||||
dimension: wgpu::TextureViewDimension,
|
||||
) -> Option<&StandIn> {
|
||||
stand_ins
|
||||
.iter()
|
||||
.find(|s| s.format == format && s.dimension == dimension)
|
||||
}
|
||||
|
||||
/// The pack's storage buffers, bound in set 4.
|
||||
pub(super) struct Buffers {
|
||||
pub(super) layout: wgpu::BindGroupLayout,
|
||||
objects: BTreeMap<u32, BufferObject>,
|
||||
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
|
||||
minimums: BTreeMap<u32, u64>,
|
||||
pub(super) group: Option<wgpu::BindGroup>,
|
||||
buffers: Vec<wgpu::Buffer>,
|
||||
made_for: [u32; 2],
|
||||
}
|
||||
|
||||
impl Buffers {
|
||||
/// The layout of `objects`, bound in `stages`.
|
||||
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
|
||||
/// `minimums` long.
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
objects: BTreeMap<u32, BufferObject>,
|
||||
minimums: BTreeMap<u32, u64>,
|
||||
stages: wgpu::ShaderStages,
|
||||
) -> Self {
|
||||
let entries: Vec<_> = objects
|
||||
@@ -305,6 +406,7 @@ impl Buffers {
|
||||
Self {
|
||||
layout,
|
||||
objects,
|
||||
minimums,
|
||||
group: None,
|
||||
buffers: Vec::new(),
|
||||
made_for: [0; 2],
|
||||
@@ -330,7 +432,8 @@ impl Buffers {
|
||||
.objects
|
||||
.iter()
|
||||
.map(|(binding, o)| {
|
||||
let wanted = o.size(size).next_multiple_of(4);
|
||||
let least = self.minimums.get(binding).copied().unwrap_or(0);
|
||||
let wanted = buffer_size(*o, size, least);
|
||||
if wanted > largest {
|
||||
log::warn!(
|
||||
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
|
||||
@@ -368,6 +471,13 @@ impl Buffers {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
|
||||
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
|
||||
/// OpenGL binds the smaller buffer; a multiple of 4.
|
||||
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
|
||||
object.size(render).max(least).next_multiple_of(4)
|
||||
}
|
||||
|
||||
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
|
||||
/// frames, how many of its texels are not zero, logged.
|
||||
pub(super) struct Dump {
|
||||
@@ -458,3 +568,27 @@ impl Dump {
|
||||
self.pending = Some((buffer, texel, dims, row));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
|
||||
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
|
||||
#[test]
|
||||
fn a_buffer_is_at_least_its_shaders_block() {
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
|
||||
assert_eq!(
|
||||
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
|
||||
40992
|
||||
);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
|
||||
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
|
||||
let per_pixel = BufferObject::PerPixel {
|
||||
bytes: 4,
|
||||
scale: [0.5, 0.5],
|
||||
};
|
||||
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
|
||||
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,9 @@ enum Image {
|
||||
/// The copy of a buffer or a pack image taken before a dispatch, which its loads read.
|
||||
ColorBefore(usize),
|
||||
CustomBefore(usize),
|
||||
/// An image no `image.*` gives: its stand-in of the declared format and dimension
|
||||
/// (`pack_images::StandIn`).
|
||||
StandIn(wgpu::TextureFormat, wgpu::TextureViewDimension),
|
||||
}
|
||||
|
||||
impl Image {
|
||||
@@ -423,7 +426,8 @@ pub(super) struct World<'a> {
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
/// `mc_Entity` of each block sprite layer: (id, 1 for fluids or -1) pairs.
|
||||
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and `at_midBlock.w`
|
||||
/// (its block's light): three values a layer.
|
||||
pub block_ids: &'a [i32],
|
||||
/// The atlas of the sun and moon's sprites.
|
||||
pub celestial: Option<wgpu::TextureView>,
|
||||
@@ -569,6 +573,8 @@ pub struct PackPost {
|
||||
gpu: Gpu,
|
||||
/// Zeros to clear images by copying, where the device has no `clear_texture`.
|
||||
zeros: Option<wgpu::Buffer>,
|
||||
/// What the images no `image.*` gives bind.
|
||||
stand_ins: Vec<pack_images::StandIn>,
|
||||
dump: Option<pack_images::Dump>,
|
||||
buffers: pack_images::Buffers,
|
||||
textures: Vec<pack_textures::PackTexture>,
|
||||
@@ -937,10 +943,11 @@ pub(super) struct ShadowView {
|
||||
pub unculled: Option<f32>,
|
||||
}
|
||||
|
||||
/// Set 3 of flat terrain, read by the vertex stage: `mc_Entity`'s table (-1 pairs when empty)
|
||||
/// and, per arena page, the page's vertices (a vertex reads its quad's corners for its normal).
|
||||
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
|
||||
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty) and, per arena page, the page's vertices
|
||||
/// (a vertex reads its quad's corners for its normal).
|
||||
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
|
||||
let empty = [-1, -1];
|
||||
let empty = [-1, -1, 0];
|
||||
let table = if table.is_empty() { &empty[..] } else { table };
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
@@ -994,6 +1001,8 @@ struct Resources<'a> {
|
||||
gpu: Gpu,
|
||||
/// The buffers whose format can be bound as an image.
|
||||
storable: u16,
|
||||
/// The formats and dimensions of the images no `image.*` gives, for their stand-ins.
|
||||
stand_ins: &'a std::cell::RefCell<Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>>,
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1189,6 +1198,27 @@ fn bindings(
|
||||
.and_then(|n| n.parse::<usize>().ok())
|
||||
.filter(|&n| n < BUFFERS && !group.shadow());
|
||||
let own = pack.images.iter().position(|c| c.def.name == i.name);
|
||||
let Some(format) = image_format(&i.format) else {
|
||||
notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
|
||||
return None;
|
||||
};
|
||||
let dimension = if i.ty.ends_with("3D") {
|
||||
wgpu::TextureViewDimension::D3
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
// An image no `image.*` gives reads zero, also after the program stored into it
|
||||
// (Iris 1.11.7, observed with a probe pack).
|
||||
let stand_in = |of: &str, notes: &mut Vec<String>| {
|
||||
notes.push(format!(
|
||||
"{name}: image `{of}` has no `image.{of}`; it reads zero"
|
||||
));
|
||||
let mut stand_ins = pack.stand_ins.borrow_mut();
|
||||
if !stand_ins.contains(&(format, dimension)) {
|
||||
stand_ins.push((format, dimension));
|
||||
}
|
||||
(Image::StandIn(format, dimension), format)
|
||||
};
|
||||
let (target, actual) = match (buffer, own) {
|
||||
(_, Some(k)) => (Image::Custom(k), pack.images[k].format),
|
||||
(Some(b), None) => (Image::Color(b), formats[b]),
|
||||
@@ -1202,20 +1232,10 @@ fn bindings(
|
||||
match (buffer, pack.images.iter().position(|c| c.def.name == of)) {
|
||||
(_, Some(k)) => (Image::CustomBefore(k), pack.images[k].format),
|
||||
(Some(b), None) => (Image::ColorBefore(b), formats[b]),
|
||||
(None, None) => {
|
||||
notes.push(format!("{name}: image `{of}` is not provided yet"));
|
||||
return None;
|
||||
}
|
||||
(None, None) => stand_in(of, notes),
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
notes.push(format!("{name}: image `{}` is not provided yet", i.name));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(format) = image_format(&i.format) else {
|
||||
notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
|
||||
return None;
|
||||
(None, None) => stand_in(&i.name, notes),
|
||||
};
|
||||
if actual != format {
|
||||
notes.push(format!(
|
||||
@@ -1230,11 +1250,6 @@ fn bindings(
|
||||
"atomic" => wgpu::StorageTextureAccess::Atomic,
|
||||
_ => wgpu::StorageTextureAccess::ReadWrite,
|
||||
};
|
||||
let dimension = if i.ty.ends_with("3D") {
|
||||
wgpu::TextureViewDimension::D3
|
||||
} else {
|
||||
wgpu::TextureViewDimension::D2
|
||||
};
|
||||
if let Image::Color(b) | Image::ColorBefore(b) = target
|
||||
&& pack.storable & 1 << b == 0
|
||||
{
|
||||
@@ -1686,6 +1701,8 @@ impl PackPost {
|
||||
screen: wgpu::TextureFormat,
|
||||
world: World<'_>,
|
||||
) -> Self {
|
||||
// What the pack's shaders need of its storage buffers, before its parts move out.
|
||||
let minimums = post.buffer_minimums();
|
||||
let features = device.features();
|
||||
let formats: [wgpu::TextureFormat; BUFFERS] =
|
||||
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
|
||||
@@ -1769,7 +1786,7 @@ impl PackPost {
|
||||
if gpu.storage_stages().is_empty() {
|
||||
objects.clear();
|
||||
}
|
||||
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
|
||||
let buffers = pack_images::Buffers::new(device, objects, minimums, gpu.storage_stages());
|
||||
let storable = (0..BUFFERS)
|
||||
.filter(|&i| gpu.storable(adapter, formats[i], features))
|
||||
.fold(0u16, |m, i| m | 1 << i);
|
||||
@@ -1788,6 +1805,7 @@ impl PackPost {
|
||||
}],
|
||||
})
|
||||
});
|
||||
let stand_ins = std::cell::RefCell::new(Vec::new());
|
||||
let pack = Resources {
|
||||
textures: &textures,
|
||||
images: &images,
|
||||
@@ -1796,6 +1814,7 @@ impl PackPost {
|
||||
indices: indices.as_ref(),
|
||||
gpu,
|
||||
storable,
|
||||
stand_ins: &stand_ins,
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
let in_shadow = p.group.shadow();
|
||||
@@ -2043,6 +2062,7 @@ impl PackPost {
|
||||
.filter(|p| !skip.split(',').any(|s| s == p.name))
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
if let Some(t) = &terrain {
|
||||
notes.push(format!("terrain draws with {}", t.name));
|
||||
}
|
||||
@@ -2360,6 +2380,7 @@ impl PackPost {
|
||||
shadow_culling: post.shadow_culling,
|
||||
gpu,
|
||||
zeros: None,
|
||||
stand_ins,
|
||||
dump: pack_images::Dump::new(),
|
||||
dummy_3d: device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
@@ -2455,6 +2476,13 @@ impl PackPost {
|
||||
out: [u32; 2],
|
||||
) -> wgpu::BindGroup {
|
||||
pack_images::copy(encoder, &self.images, pass.image_copies);
|
||||
for &(_, image) in &pass.images {
|
||||
if let Image::StandIn(format, dimension) = image
|
||||
&& let Some(s) = pack_images::stand_in(&self.stand_ins, format, dimension)
|
||||
{
|
||||
s.clear(encoder);
|
||||
}
|
||||
}
|
||||
if let Some(targets) = &self.targets {
|
||||
for i in (0..BUFFERS).filter(|i| pass.copies & 1 << i != 0) {
|
||||
if let (Some(t), Some(copy)) = (&targets.color[i], &targets.scratch[i]) {
|
||||
@@ -2627,6 +2655,10 @@ impl PackPost {
|
||||
Image::Custom(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
|
||||
Image::ColorBefore(i) => targets.scratch[i].as_ref().unwrap_or(&self.dummy),
|
||||
Image::CustomBefore(k) => self.images[k].before.as_ref().unwrap_or(&self.dummy),
|
||||
Image::StandIn(format, dimension) => {
|
||||
pack_images::stand_in(&self.stand_ins, format, dimension)
|
||||
.map_or(&self.dummy, |s| &s.view)
|
||||
}
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding,
|
||||
@@ -4133,6 +4165,114 @@ mod tests {
|
||||
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
|
||||
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
|
||||
/// declared format, zero again before each pass even after one stored into it.
|
||||
#[test]
|
||||
fn an_image_without_an_entry_reads_zero() {
|
||||
use wgpu::TextureViewDimension as D;
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let image = |name: &str, ty: &str, format: &str, binding| horizon_shaders::Image {
|
||||
name: name.into(),
|
||||
ty: ty.into(),
|
||||
format: format.into(),
|
||||
access: String::new(),
|
||||
binding,
|
||||
before: None,
|
||||
};
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
uniforms: Vec::new(),
|
||||
samplers: Vec::new(),
|
||||
outputs: vec![0],
|
||||
draw: false,
|
||||
images: vec![
|
||||
image("mask", "uimage3D", "r32ui", 1),
|
||||
image("other", "uimage3D", "r32ui", 2),
|
||||
image("flat", "image2D", "rgba8", 3),
|
||||
],
|
||||
buffers: Vec::new(),
|
||||
};
|
||||
let buffers = pack_images::Buffers::new(
|
||||
&device,
|
||||
Default::default(),
|
||||
Default::default(),
|
||||
wgpu::ShaderStages::FRAGMENT,
|
||||
);
|
||||
let stand_ins = std::cell::RefCell::new(Vec::new());
|
||||
let pack = Resources {
|
||||
textures: &[],
|
||||
images: &[],
|
||||
left_out: &[],
|
||||
buffers: &buffers,
|
||||
indices: None,
|
||||
gpu: Gpu::of(&adapter, device.features()),
|
||||
storable: 0,
|
||||
stand_ins: &stand_ins,
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
let (.., images) = bindings(
|
||||
device.features(),
|
||||
&interface,
|
||||
"composite",
|
||||
Group::Composite,
|
||||
&[0],
|
||||
None,
|
||||
&[T::Rgba8Unorm; BUFFERS],
|
||||
None,
|
||||
&mut notes,
|
||||
&pack,
|
||||
)
|
||||
.expect("the pass is kept");
|
||||
assert!(
|
||||
images
|
||||
== [
|
||||
(1, Image::StandIn(T::R32Uint, D::D3)),
|
||||
(2, Image::StandIn(T::R32Uint, D::D3)),
|
||||
(3, Image::StandIn(T::Rgba8Unorm, D::D2)),
|
||||
]
|
||||
);
|
||||
assert_eq!(notes.len(), 3, "{notes:?}");
|
||||
// One stand-in for each format and dimension.
|
||||
let made = pack_images::stand_ins(&device, stand_ins.take());
|
||||
assert_eq!(made.len(), 2);
|
||||
let mask = pack_images::stand_in(&made, T::R32Uint, D::D3).expect("made");
|
||||
let texture = mask.view.texture();
|
||||
queue.write_texture(
|
||||
texture.as_image_copy(),
|
||||
&7u32.to_le_bytes(),
|
||||
wgpu::TexelCopyBufferLayout::default(),
|
||||
texture.size(),
|
||||
);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
mask.clear(&mut encoder);
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout::default(),
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
assert_eq!(&read.slice(..).get_mapped_range().unwrap()[..], &[0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
|
||||
@@ -49,6 +49,10 @@ pub struct PackInputs {
|
||||
pub elytra_flying: bool,
|
||||
/// `cloudHeight`, see [`cloud_height`].
|
||||
pub cloud_height: f32,
|
||||
/// `cloudTime`: how far the game's clouds have moved along X, in blocks (`clouds::drift`
|
||||
/// times vanilla's 0.03 blocks a tick). Iris 1.11.7 gives it, though ShaderDoc doesn't list
|
||||
/// it: observed with a probe pack, three game times 0.03 apart per tick.
|
||||
pub cloud_time: f32,
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
/// The player's state, for Iris's status uniforms.
|
||||
@@ -260,6 +264,7 @@ pub const NAMES: &[&str] = &[
|
||||
"logicalHeightLimit",
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
"cloudTime",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -557,6 +562,7 @@ impl FrameUniforms {
|
||||
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
|
||||
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
|
||||
"cloudHeight" => Value::f(&[inputs.cloud_height]),
|
||||
"cloudTime" => Value::f(&[inputs.cloud_time]),
|
||||
// renderStage is NONE outside gbuffers draws.
|
||||
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
|
||||
}
|
||||
@@ -900,6 +906,25 @@ mod initial_history_tests {
|
||||
float(&u, "cameraPosition")
|
||||
);
|
||||
}
|
||||
/// Iris 1.11.7 (probe pack, a world saved at game time 5073 and loaded about ten seconds
|
||||
/// before): `cloudTime` read 157.98, 160.56 and 163.11, four seconds apart. That is the
|
||||
/// clouds' drift at 0.03 blocks a tick, 5266 ticks in.
|
||||
#[test]
|
||||
fn cloud_time_is_how_far_the_clouds_drifted() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let f = frame(&hud);
|
||||
let mut u = FrameUniforms::default();
|
||||
let drift = crate::clouds::drift(5266, 0.0, 256);
|
||||
let input = PackInputs {
|
||||
cloud_time: drift as f32 * 0.030_000_001,
|
||||
..Default::default()
|
||||
};
|
||||
u.update(&f, &input, [800, 450]);
|
||||
assert!((float(&u, "cloudTime")[0] - 157.98).abs() < 1e-3);
|
||||
// A trip across the 256-cell texture takes 256 * 400 ticks, then starts again.
|
||||
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
|
||||
@@ -147,6 +147,17 @@ impl<T> Resident<T> {
|
||||
/// Removes every section of the `gone` columns, the others keeping their order: one
|
||||
/// pass over the store, where `remove` moves a slot and looks its column up for each.
|
||||
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
|
||||
// Every column (a far teleport): emptied, without a lookup per section.
|
||||
if self.columns.iter().all(|c| gone.contains(&(c.x, c.z))) {
|
||||
self.bounds.clear();
|
||||
self.slots.clear();
|
||||
self.columns.clear();
|
||||
self.column_index.clear();
|
||||
return std::mem::take(&mut self.items)
|
||||
.into_iter()
|
||||
.map(|(_, value)| value)
|
||||
.collect();
|
||||
}
|
||||
let items = std::mem::take(&mut self.items);
|
||||
let bounds = std::mem::take(&mut self.bounds);
|
||||
self.slots.clear();
|
||||
@@ -533,7 +544,18 @@ mod tests {
|
||||
((seed >> 4) % 6) as i32 - 1,
|
||||
((seed >> 8) % 5) as i32 - 2,
|
||||
);
|
||||
if (seed >> 12).is_multiple_of(97) {
|
||||
if (seed >> 12).is_multiple_of(389) {
|
||||
// Every column at once (a far teleport), and one that holds nothing.
|
||||
let gone: FxHashSet<(i32, i32)> = (-2..=3)
|
||||
.flat_map(|x| (-2..=2).map(move |z| (x, z)))
|
||||
.collect();
|
||||
let mut removed = r.remove_columns(&gone);
|
||||
let mut expected: Vec<u32> = model.drain().map(|(_, v)| v).collect();
|
||||
removed.sort_unstable();
|
||||
expected.sort_unstable();
|
||||
assert_eq!(removed, expected);
|
||||
assert!(r.columns.is_empty() && r.column_index.is_empty());
|
||||
} else if (seed >> 12).is_multiple_of(97) {
|
||||
// Whole columns at once: the column of `key` and the next one along x.
|
||||
let gone: FxHashSet<(i32, i32)> =
|
||||
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
|
||||
|
||||
@@ -933,10 +933,7 @@ impl Graphics {
|
||||
// and render threads are raised above four cores, where vanilla
|
||||
// raises its server thread). Leaving two idled a core of a 4-thread
|
||||
// laptop while a world loaded: a teleport there took ~15% longer.
|
||||
let auto = std::thread::available_parallelism()
|
||||
.map_or(2, |n| n.get())
|
||||
.saturating_sub(1)
|
||||
.max(1);
|
||||
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
|
||||
if self.battery { auto.div_ceil(2) } else { auto }
|
||||
}
|
||||
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
|
||||
@@ -1294,7 +1291,7 @@ mod tests {
|
||||
fn automatic_workers_leave_a_core_as_vanilla_does() {
|
||||
let g = Graphics::default();
|
||||
assert_eq!(g.workers, 0);
|
||||
let cores = std::thread::available_parallelism().map_or(2, |n| n.get());
|
||||
let cores = horizon_threads::worker_count();
|
||||
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
|
||||
// A manual count is the player's own choice, kept as asked.
|
||||
let manual = Graphics { workers: 3, ..g };
|
||||
|
||||
@@ -115,6 +115,10 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
|
||||
pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
|
||||
/// enough for a world map's 1024 blocks at a block a pixel.
|
||||
pub const MOD_IMAGE_SPRITE: &str = "horizon/modimage";
|
||||
pub const MOD_IMAGE_SIZE: u32 = 1024;
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
/// Vanilla registers a texture per skin. Here the slots are shared
|
||||
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
|
||||
@@ -193,6 +197,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png"))
|
||||
.map(|file| dir.join(file)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
@@ -671,13 +685,21 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
|
||||
Self::build_over_with_image(root, defaults, false)
|
||||
}
|
||||
/// The atlas above, with a slot for a native mod's own image
|
||||
/// (`horizon/modimage`) only when `mod_image` says a mod is loaded that
|
||||
/// draws one: the slot costs 4 MiB of pixels, so a game without native
|
||||
/// mods holds none of them.
|
||||
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
/// minimap, a native mod's own image, downloaded skins, world and pack
|
||||
/// icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
@@ -688,6 +710,15 @@ impl HudAtlas {
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
));
|
||||
// A mod's own image (native `mod_image`), only when a mod is loaded.
|
||||
if mod_image {
|
||||
images.push((
|
||||
MOD_IMAGE_SPRITE.into(),
|
||||
MOD_IMAGE_SIZE,
|
||||
MOD_IMAGE_SIZE,
|
||||
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
|
||||
));
|
||||
}
|
||||
// Downloaded skins (game/scoreboard.rs).
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
@@ -730,7 +761,7 @@ impl HudAtlas {
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, root, &["font"], false)?;
|
||||
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
@@ -876,7 +907,13 @@ impl HudAtlas {
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
fn pack(
|
||||
root: &Path,
|
||||
defaults: &Path,
|
||||
folders: &[&str],
|
||||
full: bool,
|
||||
mod_image: bool,
|
||||
) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let default_textures =
|
||||
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
|
||||
@@ -1107,7 +1144,7 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
if full {
|
||||
Self::slots(&mut images, crate::trims::slot_size(root));
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
// and black quarters.
|
||||
let missing = (0..256)
|
||||
@@ -2622,6 +2659,16 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The mod image slot is paid only by a game with a native mod loaded.
|
||||
#[test]
|
||||
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
|
||||
let root = Path::new("/nonexistent/horizon-test-assets");
|
||||
let plain = HudAtlas::build(root).unwrap();
|
||||
assert!(!plain.sprites.contains_key(MOD_IMAGE_SPRITE));
|
||||
let with_mod = HudAtlas::build_over_with_image(root, root, true).unwrap();
|
||||
assert!(with_mod.sprites.contains_key(MOD_IMAGE_SPRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
|
||||
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
|
||||
|
||||
@@ -1114,7 +1114,7 @@ impl Lod {
|
||||
let keeps_observed = source
|
||||
.as_ref()
|
||||
.is_some_and(|source| source.keeps_observed());
|
||||
let cores = thread::available_parallelism().map_or(1, |n| n.get());
|
||||
let cores = horizon_threads::worker_count();
|
||||
let shared = Arc::new(Shared {
|
||||
jobs: Mutex::new(VecDeque::new()),
|
||||
mesh_jobs: Mutex::new(std::collections::BinaryHeap::new()),
|
||||
|
||||
@@ -50,6 +50,9 @@ pub struct Vertex {
|
||||
/// `Vertex::normal` of a baked-colour quad of an `entitySolid` model: the render type culls
|
||||
/// back faces, which the entity pipelines here do not, so `entity.wgsl` discards them.
|
||||
pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
|
||||
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
|
||||
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
|
||||
pub const SHADED_CULL_W: i8 = -126;
|
||||
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
|
||||
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
|
||||
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
|
||||
|
||||
@@ -7,9 +7,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
/// Threads take the next item as they finish one, so a run of slow items
|
||||
/// doesn't hold back the others.
|
||||
pub fn par_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
|
||||
let threads = std::thread::available_parallelism()
|
||||
.map_or(1, |n| n.get())
|
||||
.min(items.len());
|
||||
let threads = horizon_threads::worker_count().min(items.len());
|
||||
if threads <= 1 {
|
||||
return items.iter().map(f).collect();
|
||||
}
|
||||
|
||||
@@ -996,7 +996,14 @@ impl ParticleLevel<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
match scene.resolve(bb, Vec3::new(d[0], d[1], d[2]), 0.0, false) {
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
match scene.resolve(bb, movement, 0.0, false) {
|
||||
Ok(v) => [v.x, v.y, v.z],
|
||||
Err(_) => d,
|
||||
}
|
||||
@@ -1091,6 +1098,43 @@ pub struct ParticleEngine {
|
||||
const MAX_PARTICLES: usize = 16384;
|
||||
/// `ParticleLimit.SPORE_BLOSSOM`, the only limited particle.
|
||||
const SPORE_BLOSSOM_LIMIT: usize = 1000;
|
||||
const PARTICLE_LIGHT_CACHE_SIZE: usize = 64;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedParticleLight {
|
||||
block: [i32; 3],
|
||||
packed: u8,
|
||||
}
|
||||
|
||||
struct ParticleLightCache {
|
||||
entries: [Option<CachedParticleLight>; PARTICLE_LIGHT_CACHE_SIZE],
|
||||
}
|
||||
|
||||
impl Default for ParticleLightCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: [None; PARTICLE_LIGHT_CACHE_SIZE],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleLightCache {
|
||||
fn get(&mut self, block: [i32; 3], light_at: &dyn Fn([i32; 3]) -> u8) -> u8 {
|
||||
let hash = (block[0] as u32).wrapping_mul(73_856_093)
|
||||
^ (block[1] as u32).wrapping_mul(19_349_663)
|
||||
^ (block[2] as u32).wrapping_mul(83_492_791);
|
||||
let slot = hash as usize & (PARTICLE_LIGHT_CACHE_SIZE - 1);
|
||||
if let Some(entry) = self.entries[slot]
|
||||
&& entry.block == block
|
||||
{
|
||||
return entry.packed;
|
||||
}
|
||||
let packed = light_at(block);
|
||||
self.entries[slot] = Some(CachedParticleLight { block, packed });
|
||||
packed
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleEngine {
|
||||
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
|
||||
let mut engine = Self {
|
||||
@@ -4149,6 +4193,7 @@ impl ParticleEngine {
|
||||
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
|
||||
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
|
||||
* Quat::from_rotation_x(-pitch.to_radians());
|
||||
let mut light_cache = ParticleLightCache::default();
|
||||
for p in &self.particles {
|
||||
if p.no_render {
|
||||
continue;
|
||||
@@ -4248,7 +4293,7 @@ impl ParticleEngine {
|
||||
_ => {}
|
||||
}
|
||||
let block = ParticleLevel::block_at(p.position);
|
||||
let packed = light_at(block);
|
||||
let packed = light_cache.get(block, light_at);
|
||||
let (sky_light, block_light) = p.light_coords(
|
||||
partial,
|
||||
u32::from(packed >> 4) * 16,
|
||||
@@ -4359,6 +4404,25 @@ const CREEPER: [[f64; 2]; 12] = [
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
|
||||
let reads = std::cell::Cell::new(0);
|
||||
let light_at = |block: [i32; 3]| {
|
||||
reads.set(reads.get() + 1);
|
||||
block[0] as u8
|
||||
};
|
||||
let mut cache = ParticleLightCache::default();
|
||||
let first = [0, 0, 0];
|
||||
let colliding = [64, 0, 0];
|
||||
|
||||
assert_eq!(cache.get(first, &light_at), 0);
|
||||
assert_eq!(cache.get(first, &light_at), 0);
|
||||
assert_eq!(reads.get(), 1);
|
||||
assert_eq!(cache.get(colliding, &light_at), 64);
|
||||
assert_eq!(cache.get(first, &light_at), 0);
|
||||
assert_eq!(reads.get(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_particles_json_becomes_its_sprite_set() {
|
||||
let dir =
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! or a core on a busy machine) shows apart from one that worked. Costs two
|
||||
//! clock reads per span when enabled and a relaxed load otherwise.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
cell::{Cell, RefCell},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -14,6 +14,10 @@ use std::{
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
thread_local! {
|
||||
static SPANS: RefCell<Vec<Span>> = const { RefCell::new(Vec::new()) };
|
||||
/// Spans recorded on this thread alone, wall time only ([`record_here`]).
|
||||
static HERE: Cell<bool> = const { Cell::new(false) };
|
||||
/// How many timed spans the thread is inside: the next span's [`Span::depth`].
|
||||
static DEPTH: Cell<u8> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
|
||||
@@ -22,6 +26,8 @@ pub struct Span {
|
||||
pub name: &'static str,
|
||||
pub ms: f32,
|
||||
pub cpu_ms: f32,
|
||||
/// How many timed spans it ran inside (0: none): `tick_mobs` is inside `tick`.
|
||||
pub depth: u8,
|
||||
}
|
||||
|
||||
pub fn enable(on: bool) {
|
||||
@@ -31,19 +37,50 @@ pub fn enable(on: bool) {
|
||||
pub fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
|
||||
/// what the render thread's slow frames spent their time in, for the opt-in performance
|
||||
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
pub fn record_here(on: bool) {
|
||||
HERE.with(|h| h.set(on));
|
||||
}
|
||||
/// Whether this thread records spans: all of them on, or [`record_here`].
|
||||
#[inline]
|
||||
pub fn recording_here() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
#[inline]
|
||||
fn cpu_if_enabled() -> Option<Duration> {
|
||||
if ENABLED.load(Ordering::Relaxed) {
|
||||
thread_cpu()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
let _heap = horizon_threads::heap_tag(name);
|
||||
if !recording_here() {
|
||||
return f();
|
||||
}
|
||||
let (started, cpu) = (Instant::now(), thread_cpu());
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = thread_cpu()
|
||||
.zip(cpu)
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
@@ -52,7 +89,7 @@ pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
@@ -61,12 +98,21 @@ impl Laps {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let now = Instant::now();
|
||||
let now_cpu = cpu.and_then(|_| thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
let depth = DEPTH.with(Cell::get);
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
@@ -83,11 +129,62 @@ pub fn thread_cpu() -> Option<Duration> {
|
||||
horizon_threads::thread_cpu()
|
||||
}
|
||||
|
||||
/// The frame's spans as they were recorded, each after the spans inside it; clears them.
|
||||
pub fn take_raw() -> Vec<Span> {
|
||||
SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()))
|
||||
}
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<Span> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
merge(&take_raw())
|
||||
}
|
||||
/// What a frame spent its time in, from its spans as recorded ([`take_raw`]): the slowest of
|
||||
/// the outermost spans (summed by name, as a frame can tick several times), then the slowest
|
||||
/// span inside it while that one took at least half of it, and so on. The path of names
|
||||
/// (`tick/tick_mobs`) and the last one's milliseconds; `None` without spans.
|
||||
pub fn culprit(raw: &[Span]) -> Option<(String, f32)> {
|
||||
// The spans directly inside each one: recorded just before it, one level deeper.
|
||||
let mut inside: Vec<Vec<usize>> = vec![Vec::new(); raw.len()];
|
||||
let mut outer: Vec<usize> = Vec::new();
|
||||
for (i, span) in raw.iter().enumerate() {
|
||||
while let Some(&j) = outer.last()
|
||||
&& raw[j].depth > span.depth
|
||||
{
|
||||
outer.pop();
|
||||
inside[i].push(j);
|
||||
}
|
||||
outer.push(i);
|
||||
}
|
||||
let slowest = |group: &[usize]| {
|
||||
let mut sums: Vec<(&'static str, f32, Vec<usize>)> = Vec::new();
|
||||
for &i in group {
|
||||
match sums.iter_mut().find(|s| s.0 == raw[i].name) {
|
||||
Some(sum) => {
|
||||
sum.1 += raw[i].ms;
|
||||
sum.2.push(i);
|
||||
}
|
||||
None => sums.push((raw[i].name, raw[i].ms, vec![i])),
|
||||
}
|
||||
}
|
||||
sums.into_iter().max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
};
|
||||
let (name, mut ms, mut at) = slowest(&outer)?;
|
||||
let mut path = name.to_owned();
|
||||
loop {
|
||||
let next: Vec<usize> = at.iter().flat_map(|&i| inside[i].iter().copied()).collect();
|
||||
match slowest(&next) {
|
||||
Some((name, inner_ms, spans)) if inner_ms >= ms * 0.5 => {
|
||||
path.push('/');
|
||||
path.push_str(name);
|
||||
(ms, at) = (inner_ms, spans);
|
||||
}
|
||||
_ => return Some((path, ms)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Spans summed by name, slowest first.
|
||||
pub fn merge(spans: &[Span]) -> Vec<Span> {
|
||||
let mut sums: Vec<Span> = Vec::new();
|
||||
for span in spans {
|
||||
for &span in spans {
|
||||
match sums.iter_mut().find(|s| s.name == span.name) {
|
||||
Some(sum) => {
|
||||
sum.ms += span.ms;
|
||||
@@ -195,9 +292,15 @@ pub mod startup {
|
||||
pub struct Step {
|
||||
name: &'static str,
|
||||
started: Instant,
|
||||
traced: bool,
|
||||
/// A heap-census build's tag for the step's allocations.
|
||||
_heap: Option<horizon_threads::heap_census::Tag>,
|
||||
}
|
||||
impl Drop for Step {
|
||||
fn drop(&mut self) {
|
||||
if !self.traced {
|
||||
return;
|
||||
}
|
||||
let end = Instant::now();
|
||||
write(format_args!(
|
||||
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
|
||||
@@ -211,9 +314,12 @@ pub mod startup {
|
||||
/// Starts a step named `name` (a literal: no quotes or backslashes).
|
||||
#[inline]
|
||||
pub fn step(name: &'static str) -> Option<Step> {
|
||||
on().then(|| Step {
|
||||
let traced = on();
|
||||
(traced || horizon_threads::HEAP_CENSUS).then(|| Step {
|
||||
name,
|
||||
started: Instant::now(),
|
||||
traced,
|
||||
_heap: horizon_threads::heap_tag(name),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -309,21 +415,25 @@ mod tests {
|
||||
name: "tick",
|
||||
ms: 2.0,
|
||||
cpu_ms: 2.0,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "tick",
|
||||
ms: 3.0,
|
||||
cpu_ms: 2.9,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "hud",
|
||||
ms: 0.3,
|
||||
cpu_ms: 0.3,
|
||||
depth: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -333,7 +443,8 @@ mod tests {
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2
|
||||
cpu_ms: 0.2,
|
||||
depth: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
|
||||
@@ -341,6 +452,66 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// A thread that records on its own keeps its spans with spans off, each with how many
|
||||
/// spans it ran inside, after them.
|
||||
#[test]
|
||||
fn a_recording_thread_keeps_its_spans_and_their_nesting() {
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
super::time("tick", || {
|
||||
super::time("tick_mobs", || ());
|
||||
super::time("tick_items", || ());
|
||||
});
|
||||
super::time("renderer", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("tick_mobs", 1),
|
||||
("tick_items", 1),
|
||||
("tick", 0),
|
||||
("renderer", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
fn a_frame_is_put_down_to_the_step_that_took_most_of_it() {
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let frame = [
|
||||
span("tick_mobs", 90.0, 1),
|
||||
span("tick_items", 5.0, 1),
|
||||
span("tick", 100.0, 0),
|
||||
span("tick_mobs", 90.0, 1),
|
||||
span("tick", 100.0, 0),
|
||||
span("r_uploads", 120.0, 1),
|
||||
span("renderer", 150.0, 0),
|
||||
];
|
||||
assert_eq!(
|
||||
super::culprit(&frame),
|
||||
Some(("tick/tick_mobs".to_owned(), 180.0))
|
||||
);
|
||||
// Spread over many steps: the outer span alone.
|
||||
let spread = [
|
||||
span("tick_mobs", 40.0, 1),
|
||||
span("tick_items", 40.0, 1),
|
||||
span("tick", 300.0, 0),
|
||||
];
|
||||
assert_eq!(super::culprit(&spread), Some(("tick".to_owned(), 300.0)));
|
||||
assert_eq!(super::culprit(&[]), None);
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
|
||||
@@ -774,12 +774,23 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
|
||||
};
|
||||
(kind, crate::spans::thread_cpu().unwrap_or_default())
|
||||
});
|
||||
let _tag = horizon_threads::heap_tag(match &job.job {
|
||||
Job::Generate(..) => "stream.generate",
|
||||
Job::Decorate(..) => "stream.decorate",
|
||||
Job::Finish(..) => "stream.finish",
|
||||
Job::Light(..) => "stream.light",
|
||||
Job::Mesh(..) => "stream.mesh",
|
||||
_ => "stream.other",
|
||||
});
|
||||
let done = self.locals.with(
|
||||
worker,
|
||||
|| WorkerLocal {
|
||||
worker: self.generator.worker(),
|
||||
faces: None,
|
||||
mesh_scratch: MeshScratch::default(),
|
||||
|| {
|
||||
let _tag = horizon_threads::heap_tag("stream.worker_state");
|
||||
WorkerLocal {
|
||||
worker: self.generator.worker(),
|
||||
faces: None,
|
||||
mesh_scratch: MeshScratch::default(),
|
||||
}
|
||||
},
|
||||
|local| self.run_job(job.job, local),
|
||||
);
|
||||
@@ -1125,20 +1136,39 @@ impl Streaming {
|
||||
self.submit(None, 0, Job::Free(meshes));
|
||||
}
|
||||
}
|
||||
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
|
||||
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
|
||||
self.lit.remove(&(x, z));
|
||||
self.meshed.remove(&(x, z));
|
||||
self.lighting.remove(&(x, z));
|
||||
// By key, not by a pass over every pending section: a moved or
|
||||
// shrunk storage forgets thousands of columns in one frame.
|
||||
for sy in 0..world.layout().sections() as i32 {
|
||||
self.meshing.remove(&(x, sy, z));
|
||||
self.compile_started.remove(&(x, sy, z));
|
||||
self.waiting.remove(&(x, sy, z));
|
||||
/// The columns the server unloaded (ClientboundForgetLevelChunkPacket),
|
||||
/// often thousands at once (a teleport or a moved view): their pending
|
||||
/// sections go through [`forget_pending_sections`]. Returns the chunks
|
||||
/// they held.
|
||||
pub fn forget_columns(
|
||||
&mut self,
|
||||
world: &mut World,
|
||||
gone: &FxHashSet<(i32, i32)>,
|
||||
) -> Vec<Arc<Chunk>> {
|
||||
// Every column held (a far teleport): no pending section is left to keep, and a
|
||||
// pass over them all (80,000 at render distance 32) took 1.7 ms of the frame.
|
||||
if world.chunks.keys().all(|column| gone.contains(column)) {
|
||||
self.meshing.clear();
|
||||
self.compile_started.clear();
|
||||
self.waiting.clear();
|
||||
} else {
|
||||
forget_pending_sections(
|
||||
&mut self.meshing,
|
||||
&mut self.compile_started,
|
||||
&mut self.waiting,
|
||||
gone,
|
||||
world.layout().sections() as i32,
|
||||
);
|
||||
}
|
||||
world.dirty_sections.take_column(x, z);
|
||||
world.chunks.remove(&(x, z))
|
||||
let mut chunks = Vec::with_capacity(gone.len());
|
||||
for &(x, z) in gone {
|
||||
self.lit.remove(&(x, z));
|
||||
self.meshed.remove(&(x, z));
|
||||
self.lighting.remove(&(x, z));
|
||||
world.dirty_sections.take_column(x, z);
|
||||
chunks.extend(world.chunks.remove(&(x, z)));
|
||||
}
|
||||
chunks
|
||||
}
|
||||
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
|
||||
/// times four plus `bias`, which orders a column's jobs. A job without a
|
||||
@@ -1987,6 +2017,14 @@ impl Streaming {
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let positions = if positions.len() > INLINE_RELIGHT_MAX {
|
||||
self.relight_on_workers(world, positions)
|
||||
} else {
|
||||
positions
|
||||
};
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut changed = LightChanges::default();
|
||||
crate::light::relight(
|
||||
&mut world.chunks,
|
||||
@@ -2005,6 +2043,25 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Hands a big batch of edits (a `/fill` arriving block by block) to the
|
||||
/// workers: the columns they touch and their neighbours get full light
|
||||
/// jobs, as `World::edit_runs` does, instead of relighting every changed
|
||||
/// cell in the frame (clearing a 64x64x16 volume in the air relit the
|
||||
/// sky below it: 43 ms of one frame). The sections wait for those jobs
|
||||
/// before they are meshed again. Returns the edits left to light in the
|
||||
/// frame: those in columns that can't be lit whole yet (the edge of the
|
||||
/// loaded area).
|
||||
fn relight_on_workers(
|
||||
&self,
|
||||
world: &mut World,
|
||||
positions: Vec<(i32, i32, i32)>,
|
||||
) -> Vec<(i32, i32, i32)> {
|
||||
let (columns, inline) = whole_relight_columns(positions, |x, z| {
|
||||
self.lit.contains(&(x, z)) && self.light_region_ready(world, x, z)
|
||||
});
|
||||
world.dirty_light.extend(columns);
|
||||
inline
|
||||
}
|
||||
/// A light job computed from blocks older than some edits: redo those
|
||||
/// edits on top of its result.
|
||||
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
|
||||
@@ -2693,6 +2750,64 @@ fn same_values(
|
||||
x == y
|
||||
}
|
||||
|
||||
/// The most changed blocks one frame relights itself (`Streaming::relight_edits`): a 16^3
|
||||
/// section's worth. Past it, the columns are lit again whole on the workers.
|
||||
const INLINE_RELIGHT_MAX: usize = 4096;
|
||||
|
||||
/// Splits edits at `positions` into the columns to light again whole and the
|
||||
/// edits to relight in place. An edited column is lit whole with its
|
||||
/// neighbours (light reaches 15 blocks, never past the next column) when all
|
||||
/// nine are `ready` to be lit whole; otherwise its edits stay in place.
|
||||
fn whole_relight_columns(
|
||||
positions: Vec<(i32, i32, i32)>,
|
||||
ready: impl Fn(i32, i32) -> bool,
|
||||
) -> (FxHashSet<(i32, i32)>, Vec<(i32, i32, i32)>) {
|
||||
let around =
|
||||
|(x, z): (i32, i32)| (-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)));
|
||||
let touched: FxHashSet<(i32, i32)> = positions
|
||||
.iter()
|
||||
.map(|&(x, _, z)| (x >> 4, z >> 4))
|
||||
.collect();
|
||||
let whole: FxHashSet<(i32, i32)> = touched
|
||||
.into_iter()
|
||||
.filter(|&column| around(column).all(|(x, z)| ready(x, z)))
|
||||
.collect();
|
||||
let columns = whole.iter().flat_map(|&column| around(column)).collect();
|
||||
let inline = positions
|
||||
.into_iter()
|
||||
.filter(|&(x, _, z)| !whole.contains(&(x >> 4, z >> 4)))
|
||||
.collect();
|
||||
(columns, inline)
|
||||
}
|
||||
|
||||
/// Drops the pending sections (`meshing`, `compile_started`, `waiting`) of
|
||||
/// the `gone` columns: one pass over each store when that is cheaper than a
|
||||
/// lookup per section of every column (a far teleport at render distance 32
|
||||
/// forgets ~3,700 columns, 1.7 ms of lookups), else by key.
|
||||
fn forget_pending_sections(
|
||||
meshing: &mut FxHashMap<(i32, i32, i32), u64>,
|
||||
compile_started: &mut FxHashMap<(i32, i32, i32), u64>,
|
||||
waiting: &mut FxHashSet<(i32, i32, i32)>,
|
||||
gone: &FxHashSet<(i32, i32)>,
|
||||
sections: i32,
|
||||
) {
|
||||
let lookups = gone.len().saturating_mul(sections.max(0) as usize);
|
||||
if lookups > meshing.len() + compile_started.len() + waiting.len() {
|
||||
let kept = |&(x, _, z): &(i32, i32, i32)| !gone.contains(&(x, z));
|
||||
meshing.retain(|k, _| kept(k));
|
||||
compile_started.retain(|k, _| kept(k));
|
||||
waiting.retain(kept);
|
||||
} else {
|
||||
for &(x, z) in gone {
|
||||
for sy in 0..sections {
|
||||
meshing.remove(&(x, sy, z));
|
||||
compile_started.remove(&(x, sy, z));
|
||||
waiting.remove(&(x, sy, z));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2840,6 +2955,67 @@ mod tests {
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
/// Whether the forgotten columns are few (looked up by key) or many
|
||||
/// (one pass over each store), exactly their pending sections go.
|
||||
#[test]
|
||||
fn forgotten_columns_lose_their_pending_sections_either_way() {
|
||||
let pending: Vec<(i32, i32, i32)> = (0..4)
|
||||
.flat_map(|x| (0..3).map(move |sy| (x, sy, -x)))
|
||||
.collect();
|
||||
for gone in [
|
||||
vec![(1, -1)],
|
||||
(0..400)
|
||||
.map(|x| (x, -x))
|
||||
.filter(|&c| c != (2, -2))
|
||||
.collect(),
|
||||
] {
|
||||
let gone: FxHashSet<(i32, i32)> = gone.into_iter().collect();
|
||||
let mut meshing: FxHashMap<_, u64> = pending.iter().map(|&k| (k, 1)).collect();
|
||||
let mut started = meshing.clone();
|
||||
let mut waiting: FxHashSet<_> = pending.iter().copied().collect();
|
||||
forget_pending_sections(&mut meshing, &mut started, &mut waiting, &gone, 3);
|
||||
let mut left: Vec<_> = waiting.into_iter().collect();
|
||||
left.sort();
|
||||
let expected: Vec<_> = pending
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(x, _, z)| !gone.contains(&(x, z)))
|
||||
.collect();
|
||||
assert_eq!(left, expected, "{} gone", gone.len());
|
||||
let mut left: Vec<_> = meshing.into_keys().collect();
|
||||
left.sort();
|
||||
assert_eq!(left, expected);
|
||||
let mut left: Vec<_> = started.into_keys().collect();
|
||||
left.sort();
|
||||
assert_eq!(left, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits in two neighbouring columns light those and the ten around
|
||||
/// them again; a column next to one that can't be lit whole keeps its
|
||||
/// edits in the frame.
|
||||
#[test]
|
||||
fn whole_relights_cover_the_edited_columns_and_their_neighbours() {
|
||||
// Blocks in columns (0, 0) and (1, 0).
|
||||
let positions = vec![(3, 70, 4), (15, 64, 15), (16, 80, 0), (31, 64, 9)];
|
||||
let (columns, inline) = whole_relight_columns(positions.clone(), |_, _| true);
|
||||
let mut columns: Vec<_> = columns.into_iter().collect();
|
||||
columns.sort();
|
||||
let expected: Vec<(i32, i32)> = (-1..=2)
|
||||
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
|
||||
.collect();
|
||||
assert_eq!(columns, expected);
|
||||
assert!(inline.is_empty());
|
||||
// (2, 1) is next to (1, 0) only: (0, 0) is still lit whole.
|
||||
let (columns, inline) = whole_relight_columns(positions, |x, z| (x, z) != (2, 1));
|
||||
assert_eq!(inline, vec![(16, 80, 0), (31, 64, 9)]);
|
||||
assert_eq!(columns.len(), 9);
|
||||
assert!(columns.contains(&(-1, -1)) && columns.contains(&(1, 1)));
|
||||
// Negative coordinates floor to their column.
|
||||
let (west, _) = whole_relight_columns(vec![(-1, 64, -17)], |_, _| true);
|
||||
assert!(west.contains(&(-2, -3)) && west.contains(&(0, -1)) && west.len() == 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -223,6 +223,7 @@ pub struct Session {
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -250,8 +251,21 @@ impl Session {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
{
|
||||
path = rest.to_owned();
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
@@ -281,6 +295,7 @@ impl Session {
|
||||
to_world_ms: self.to_world_ms,
|
||||
to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)),
|
||||
to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)),
|
||||
slow_frames: self.slow_frames.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,10 +505,10 @@ mod tests {
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
}
|
||||
|
||||
@@ -502,7 +517,7 @@ mod tests {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
@@ -512,11 +527,11 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6));
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
@@ -537,4 +552,34 @@ mod tests {
|
||||
assert!(perf.to_world_ms.is_some());
|
||||
assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024);
|
||||
}
|
||||
|
||||
/// Frames of 250 ms or more count under the step that took most of them; a frame with no
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ use crate::{
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
text::Text,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::RwLock,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
@@ -64,37 +69,53 @@ pub fn set_metadata(metadata: UiMetadata) {
|
||||
pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let mut map = HashMap::new();
|
||||
let base = root.join("assets/minecraft/textures");
|
||||
let mut stack = vec![base.join("gui/sprites")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
let sprites = base.join("gui/sprites");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
|
||||
Some(files) => files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| sprites.join(file))
|
||||
.collect(),
|
||||
None => {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![sprites];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
};
|
||||
for p in files {
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
UiMetadata(map)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ impl DirtySections {
|
||||
pub struct World {
|
||||
/// Server-owned mod data attached to this dimension.
|
||||
pub attachments: horizon_world::attachments::Attachments,
|
||||
/// The holders lent to mods since the last flush, whose synced changes
|
||||
/// `flush_attachment_sync` sends.
|
||||
pub attachment_syncs: FxHashSet<horizon_world::attachments::Holder>,
|
||||
/// The level's build range and skylight (its dimension type); its
|
||||
/// chunks hold `shape.column()`.
|
||||
pub shape: LevelShape,
|
||||
@@ -203,6 +206,7 @@ impl World {
|
||||
loaded_chunks: Vec::new(),
|
||||
modified: FxHashSet::default(),
|
||||
attachments: Default::default(),
|
||||
attachment_syncs: FxHashSet::default(),
|
||||
block_entities: Default::default(),
|
||||
chest_openers: FxHashMap::default(),
|
||||
block_names: FxHashMap::default(),
|
||||
|
||||
@@ -117,6 +117,9 @@ rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
|
||||
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
|
||||
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
|
||||
system-allocator = []
|
||||
# mimalloc with a tag on each block: `horizon-ctl memory` lists the live heap by startup step and
|
||||
# thread (`heap_by_tag_mb`). For measuring only: each block costs 16 bytes more.
|
||||
alloc-census = ["horizon-threads/alloc-census"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
windows-mimalloc = []
|
||||
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
|
||||
|
||||
@@ -753,6 +753,9 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
|
||||
if let Some((acts, mem)) = brain {
|
||||
let _ = write!(out, "\tact {acts}\tmem {mem}");
|
||||
}
|
||||
if horizon_client::mobs::finalize::ageable(m.species()) {
|
||||
let _ = write!(out, "\tage {}", m.extra.age);
|
||||
}
|
||||
if m.species() == Species::Villager {
|
||||
let _ = write!(out, "\tvx {}", villager(m));
|
||||
}
|
||||
@@ -1026,6 +1029,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
m.rider = Some(horizon_client::mobs::equine::Rider::default());
|
||||
}
|
||||
"noai" => m.no_ai = true,
|
||||
"agelocked" => m.extra.age_locked = true,
|
||||
// Warden.finalizeSpawn's own part.
|
||||
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
|
||||
&mut m,
|
||||
|
||||
@@ -1425,6 +1425,29 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
|
||||
/// the recipe book and the trigger the same whole id (`award_crafted`
|
||||
/// qualifies). The bare name the crafting copy uses never matched —
|
||||
/// the second half of #7186.
|
||||
#[test]
|
||||
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
|
||||
let w = Flat;
|
||||
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
|
||||
assert!(
|
||||
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
|
||||
"the crafting copy's bare name never matched the criterion"
|
||||
);
|
||||
assert!(
|
||||
id_matches(
|
||||
&criterion["recipes"],
|
||||
"recipe",
|
||||
&crate::crafting::qualified("folder_recipe"),
|
||||
&w
|
||||
),
|
||||
"the whole id award_crafted passes matches"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LockCode.canUnlock` for a chest with
|
||||
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
|
||||
/// hand or another item does not, and a spectator always does.
|
||||
|
||||
@@ -170,6 +170,8 @@ pub struct ArrowState {
|
||||
/// The block it stuck in (`lastState`).
|
||||
pub last_state: Option<BlockStateId>,
|
||||
pub pickup: Pickup,
|
||||
/// Its entity type is `SpectralArrow` (the type is its own, not the pickup item's).
|
||||
pub spectral: bool,
|
||||
/// `pickupItemStack`: what picking the arrow up gives.
|
||||
pub pickup_item: crate::items::ItemStack,
|
||||
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
|
||||
@@ -227,6 +229,7 @@ impl ArrowState {
|
||||
shake: 0,
|
||||
last_state: None,
|
||||
pickup: Pickup::Disallowed,
|
||||
spectral: false,
|
||||
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
|
||||
fired_from_weapon: None,
|
||||
no_physics: false,
|
||||
|
||||
@@ -221,7 +221,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false),
|
||||
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
|
||||
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts.", ["horizon-ctl memory"], &[], mcp: true, local: false),
|
||||
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts. A build with the alloc-census feature adds heap_by_tag_mb: the live heap by startup step, timed span, chunk job and thread.", ["horizon-ctl memory"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
|
||||
@@ -56,6 +56,60 @@ pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath {
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity that can't breathe and has no air (`LivingDrownEvent`, posted
|
||||
/// from `LivingEntity.baseTick`), with vanilla's answers as the defaults: it
|
||||
/// drowns at -20 air, for 2 damage and 8 bubbles. Bubble count is read-only;
|
||||
/// changes to the event field do not affect the vanilla entity event. Asked on
|
||||
/// servers, for the entities `native_living_breathe` is. API 356.
|
||||
#[inline(never)]
|
||||
pub fn native_living_drown(
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
is_drowning: &mut bool,
|
||||
damage_amount: &mut f32,
|
||||
bubble_count: i32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
crate::native_client_hooks::living_drown(
|
||||
entity,
|
||||
kind,
|
||||
is_drowning,
|
||||
damage_amount,
|
||||
bubble_count,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// A drowning tick as the mods left it.
|
||||
pub struct Drowning {
|
||||
pub damage: Option<f32>,
|
||||
}
|
||||
|
||||
/// `breath` applied to `air` (`apply`), then `LivingEntity.baseTick`'s
|
||||
/// drowning, which the mods may change when `server` (`native_living_drown`).
|
||||
/// An entity that drowns is set to 0 air.
|
||||
pub fn drown(
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
air: &mut i32,
|
||||
breath: Breath,
|
||||
server: bool,
|
||||
) -> Option<Drowning> {
|
||||
let (next, drowned) = apply(*air, 300, breath);
|
||||
*air = if drowned { *air - breath.consume } else { next };
|
||||
let (mut drowning, mut damage, mut cancel) = (drowned, 2.0, false);
|
||||
if server && !breath.can_breathe && *air <= 0 {
|
||||
native_living_drown(entity, kind, &mut drowning, &mut damage, 8, &mut cancel);
|
||||
}
|
||||
if cancel || !drowning {
|
||||
return None;
|
||||
}
|
||||
*air = 0;
|
||||
Some(Drowning {
|
||||
damage: (damage > 0.0).then_some(damage),
|
||||
})
|
||||
}
|
||||
|
||||
/// The air supply after `breath`: raised up to `max` when it can breathe,
|
||||
/// lowered otherwise. The second is whether the entity now drowns
|
||||
/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::{
|
||||
/// removed keeping first-seen order. The hook may be called more than
|
||||
/// once per recipe; the answer must be the same. An unclaimed kind
|
||||
/// leaves the list empty and the recipe fails, as an unknown kind does.
|
||||
/// API @API:ingredient@.
|
||||
/// API 166.
|
||||
#[inline(never)]
|
||||
pub fn native_ingredient(
|
||||
kind: &str,
|
||||
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
augment(i, ingredients, stacks, &mut owner, &mut seen)
|
||||
})
|
||||
}
|
||||
/// A resource id with its default namespace filled in — the whole id the
|
||||
/// book, the network copy and the advancements' JSON know a recipe by,
|
||||
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
|
||||
/// on purpose (vanilla's own ids); this puts the namespace back.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
|
||||
@@ -91,6 +91,11 @@ pub struct Creative {
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
/// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines
|
||||
/// of every search item in one language (keyed by the `itemGroup.search`
|
||||
/// string), built on the first search and kept until the language or the
|
||||
/// item list changes.
|
||||
lines_cache: Mutex<Option<(String, Arc<[Vec<String>]>)>>,
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
struct Shown {
|
||||
@@ -373,6 +378,7 @@ impl Creative {
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
lines_cache: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
@@ -398,6 +404,7 @@ impl Creative {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
@@ -531,6 +538,46 @@ impl Creative {
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
/// results in the tab's order).
|
||||
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
|
||||
let english =
|
||||
names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search");
|
||||
let local = if term.is_empty() || term.starts_with('#') {
|
||||
Arc::from([])
|
||||
} else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) {
|
||||
// The exported English lines are the whole index.
|
||||
Arc::from([])
|
||||
} else {
|
||||
let language = names.display_get("itemGroup.search").unwrap_or_default();
|
||||
let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match cache.as_ref().filter(|(l, _)| *l == language) {
|
||||
Some((_, lines)) => lines.clone(),
|
||||
None => {
|
||||
let lines = self.localized_lines(&|k| names.display_get(k));
|
||||
*cache = Some((language, lines.clone()));
|
||||
lines
|
||||
}
|
||||
}
|
||||
};
|
||||
self.search_results_in(term, names, &local, english)
|
||||
}
|
||||
/// Every search item's tooltip lines through `translate`, lower-cased.
|
||||
fn localized_lines(&self, translate: &dyn Fn(&str) -> Option<String>) -> Arc<[Vec<String>]> {
|
||||
self.search_items
|
||||
.iter()
|
||||
.map(|s| {
|
||||
crate::item_tooltip::lines(s, None, translate)
|
||||
.iter()
|
||||
.map(|l| java_lower(l))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn search_results_in(
|
||||
&self,
|
||||
term: &str,
|
||||
names: &Names,
|
||||
local: &[Vec<String>],
|
||||
english: bool,
|
||||
) -> Vec<usize> {
|
||||
let all = 0..self.search_index.len();
|
||||
if term.is_empty() {
|
||||
return all.collect();
|
||||
@@ -552,18 +599,13 @@ impl Creative {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
// FullTextSearchTree: the creative tooltip lines in the selected
|
||||
// language, and the item id after a colon. The export's lines are
|
||||
// English (they come from the vanilla jars), so they stand in only
|
||||
// while the language is English; otherwise every line (name, lore,
|
||||
// effects, ...) is rebuilt through the language, as vanilla's
|
||||
// getTooltipLines does.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -573,16 +615,17 @@ impl Creative {
|
||||
} else {
|
||||
&e.lines
|
||||
};
|
||||
let exported = english || e.lines.is_empty();
|
||||
let local = local.get(i).map_or(&[][..], Vec::as_slice);
|
||||
let hit = |t: &str| {
|
||||
(exported && any(lines.iter().map(String::as_str), t))
|
||||
|| any(local.iter().map(String::as_str), t)
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
None => hit(&text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
e.namespace.contains(ns) && (e.path.contains(path) || hit(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1396,15 +1439,33 @@ mod tests {
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
let tr = |k: &str| names.get(k).map(str::to_owned);
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str| c.search_results_in(s, &names, &local, false);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// Only the selected language's lines count outside English.
|
||||
assert!(q("anvil").is_empty());
|
||||
assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_every_localized_tooltip_line() {
|
||||
// Vanilla's FullTextSearchTree indexes the selected language's
|
||||
// tooltip lines; the export's English lines do not apply.
|
||||
let c = creative(0);
|
||||
let names = Names::default();
|
||||
let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]);
|
||||
let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned());
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str, english| c.search_results_in(s, &names, &local, english);
|
||||
assert_eq!(q("наков", false), [0]);
|
||||
assert!(q("anvil", false).is_empty());
|
||||
assert_eq!(q("anvil", true), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape},
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -104,7 +104,7 @@ pub struct ItemEntity {
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
pub age: u32,
|
||||
pub age: i32,
|
||||
pub pickup_delay: u32,
|
||||
pub bob_offset: f32,
|
||||
/// `Entity.tickCount`: ticks since the item entered the level, which
|
||||
@@ -261,6 +261,7 @@ impl ItemEntity {
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -291,8 +292,11 @@ impl ItemEntity {
|
||||
// horizontal speed. The idle branch still runs bubble-column push
|
||||
// (`applyEffectsFromBlocksForLastMovements`), so lift builds up and
|
||||
// the item moves on the next wake-up (#4558).
|
||||
if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) {
|
||||
let mut scene = SceneCollisions {
|
||||
if !self.on_ground
|
||||
|| horizontal > 1.0e-5
|
||||
|| self.ticks.wrapping_add(self.id).is_multiple_of(4)
|
||||
{
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
@@ -308,17 +312,14 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else if entity_boxes.is_empty() {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still), and
|
||||
// so does the world border (`Entity.collectColliders`).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
border: border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
@@ -441,7 +442,9 @@ impl ItemEntity {
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
self.age += 1;
|
||||
if self.age != i16::MIN as i32 {
|
||||
self.age += 1;
|
||||
}
|
||||
// ItemEntity.tick refreshes immersion after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
@@ -548,8 +551,11 @@ impl ItemEntity {
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
/// delay isn't infinite, before its lifetime ends.
|
||||
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
fn mergeable(&self, age: i32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX
|
||||
&& age != i16::MIN as i32
|
||||
&& age < 6000
|
||||
&& self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
@@ -685,7 +691,7 @@ pub fn merge_with_neighbours(
|
||||
max_stack: &dyn Fn(&ItemStack) -> u32,
|
||||
) {
|
||||
let age = |items: &[ItemEntity], j: usize| {
|
||||
if j == i {
|
||||
if j == i && items[j].age != i16::MIN as i32 {
|
||||
items[j].age - 1
|
||||
} else {
|
||||
items[j].age
|
||||
@@ -729,7 +735,7 @@ pub fn merge_with_neighbours(
|
||||
let to_item = &mut items[to];
|
||||
to_item.pickup_delay = to_item.pickup_delay.max(delay);
|
||||
// This item's age goes up once its tick ends.
|
||||
to_item.age = to_age.min(from_age) + u32::from(to == i);
|
||||
to_item.age = to_age.min(from_age) + i32::from(to == i);
|
||||
if items[from].stack.count == 0 {
|
||||
removed[from] = true;
|
||||
cells.remove(from, items[from].position);
|
||||
@@ -852,6 +858,7 @@ impl ExperienceOrb {
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -916,8 +923,10 @@ impl ExperienceOrb {
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop the orb too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve(bounds, wanted, 0.0, self.on_ground)
|
||||
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -1474,6 +1483,7 @@ impl ArmorStand {
|
||||
facts: &BlockFacts,
|
||||
in_water: bool,
|
||||
push: &mut dyn FnMut(Aabb, &mut Vec3),
|
||||
border: Option<&BorderGeometry>,
|
||||
) {
|
||||
self.previous = self.position;
|
||||
if self.marker {
|
||||
@@ -1510,7 +1520,20 @@ impl ArmorStand {
|
||||
1.0
|
||||
};
|
||||
let mut velocity = wanted;
|
||||
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
|
||||
// Entity.collide: the world border's walls stop the stand too.
|
||||
let moved = SceneCollisions {
|
||||
world: scene.world,
|
||||
binding: scene.binding,
|
||||
subject: scene.subject,
|
||||
}
|
||||
.resolve_in_border(
|
||||
self.bounds(),
|
||||
wanted,
|
||||
0.0,
|
||||
self.on_ground,
|
||||
border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
);
|
||||
if let Ok(moved) = moved {
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
self.position.y + moved.y,
|
||||
@@ -1775,7 +1798,7 @@ mod armor_stand_render_tests {
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
fn item(id: &str, count: u32, x: f64, age: u32) -> ItemEntity {
|
||||
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
|
||||
let at = Vec3::new(x, 64.0, 0.5);
|
||||
ItemEntity {
|
||||
no_gravity: false,
|
||||
@@ -1799,7 +1822,7 @@ mod merge_tests {
|
||||
age,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: age,
|
||||
ticks: age.max(0) as u32,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
}
|
||||
@@ -1954,6 +1977,73 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_border_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops an
|
||||
/// item and an experience orb as it stops the player. A border of size
|
||||
/// 10 around (20, 20) has its east wall at x = 25; each body starts at
|
||||
/// x = 24 on the flat ground of the test scene, moving 1.5 east.
|
||||
#[test]
|
||||
fn the_world_border_stops_items_and_experience_orbs() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap();
|
||||
for bordered in [false, true] {
|
||||
let border = bordered.then_some(&border);
|
||||
let mut item = super::honey_slide_tests::falling_item(24.0, 4.0, 20.5, 1.5, 0.0, 0.0);
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
border,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
no_gravity: false,
|
||||
id: 2,
|
||||
position: Vec3::new(24.0, 4.0, 20.5),
|
||||
previous: Vec3::new(24.0, 4.0, 20.5),
|
||||
velocity: Vec3::new(1.5, 0.0, 0.0),
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: true,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border);
|
||||
for (what, east) in [
|
||||
("item", item.bounds().max_position().x),
|
||||
("orb", orb.bounds().max_position().x),
|
||||
] {
|
||||
if bordered {
|
||||
assert!(
|
||||
(east - 25.0).abs() < 1.0e-7,
|
||||
"the {what} stops on the wall at x = 25, got {east}"
|
||||
);
|
||||
} else {
|
||||
assert!(east > 25.0, "no border: the {what} goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod honey_slide_tests {
|
||||
use super::*;
|
||||
@@ -1984,7 +2074,8 @@ mod honey_slide_tests {
|
||||
let mut item = falling_item(at.x, at.y, at.z, 0.0, 0.0, 0.0);
|
||||
item.on_ground = true;
|
||||
item.age = 0;
|
||||
item.id = 1;
|
||||
item.ticks = 0;
|
||||
item.id = 0;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(
|
||||
@@ -1995,6 +2086,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
@@ -2059,6 +2151,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -2355,6 +2448,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
@@ -2373,6 +2467,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
@@ -2465,6 +2560,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
@@ -2513,6 +2609,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.x
|
||||
@@ -2616,7 +2713,53 @@ mod armor_stand_motion_tests {
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
stand.tick(&mut scene, facts, false, &mut |_, _| {});
|
||||
stand.tick(&mut scene, facts, false, &mut |_, _| {}, None);
|
||||
}
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops an
|
||||
/// armor stand as it stops the player. A border of size 10 around
|
||||
/// (20, 20) has its east wall at x = 25; the stand starts at x = 24 on the
|
||||
/// test scene's flat ground, moving 1.5 east.
|
||||
#[test]
|
||||
fn the_world_border_stops_an_armor_stand() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap();
|
||||
for bordered in [false, true] {
|
||||
let mut stand = ArmorStand::new(1, Vec3::new(24.0, 4.0, 20.5), 0.0);
|
||||
stand.on_ground = true;
|
||||
stand.velocity = Vec3::new(1.5, 0.0, 0.0);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
stand.tick(
|
||||
&mut scene,
|
||||
&facts,
|
||||
false,
|
||||
&mut |_, _| {},
|
||||
bordered.then_some(&border),
|
||||
);
|
||||
let east = stand.bounds().max_position().x;
|
||||
if bordered {
|
||||
assert!(
|
||||
(east - 25.0).abs() < 1.0e-7,
|
||||
"stops on the wall, got {east}"
|
||||
);
|
||||
} else {
|
||||
assert!(east > 25.0, "no border: goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -262,7 +262,8 @@ mod tests {
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
&[],
|
||||
None
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
@@ -275,7 +276,8 @@ mod tests {
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
&[],
|
||||
None
|
||||
));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
@@ -289,6 +291,7 @@ mod tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
@@ -377,7 +380,7 @@ mod tests {
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
still.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
still.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
|
||||
@@ -391,7 +394,7 @@ mod tests {
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
|
||||
assert_eq!(marker.position, at);
|
||||
assert_eq!(marker.velocity, Vec3::ZERO);
|
||||
|
||||
@@ -403,7 +406,7 @@ mod tests {
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
|
||||
assert!((carried.position.x - (at.x + 0.014)).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - 0.014 * f64::from(0.8f32)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
@@ -413,7 +416,7 @@ mod tests {
|
||||
binding: &collision,
|
||||
subject: None,
|
||||
};
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
|
||||
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ fn string(s: &str) -> Tag {
|
||||
}
|
||||
/// Entity type ID (`id`).
|
||||
pub fn kind(tag: &Tag) -> Option<String> {
|
||||
text(tag.get("id"))
|
||||
horizon_client_core::identifier::normalize_id(&text(tag.get("id"))?)
|
||||
}
|
||||
pub fn position(tag: &Tag) -> Option<Vec3> {
|
||||
read_doubles::<3>(tag.get("Pos")).map(|p| Vec3::new(p[0], p[1], p[2]))
|
||||
@@ -519,8 +519,10 @@ pub fn mob_to_nbt(
|
||||
}
|
||||
}
|
||||
let species = m.species();
|
||||
// MushroomCow saves its variant under `Type` (below), not `variant`.
|
||||
if species != crate::mobs::Species::Mooshroom
|
||||
// `VariantUtils.writeVariant`: only the species that store the name. A
|
||||
// mooshroom saves `Type` (below), a fox `Type`, a horse and a llama a
|
||||
// packed `Variant` int, so those report the component but save no name.
|
||||
if crate::mobs::finalize::saves_variant_name(species)
|
||||
&& let Some((_, variant)) =
|
||||
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
|
||||
{
|
||||
@@ -1643,7 +1645,7 @@ pub fn mob_from_nbt(
|
||||
m.extra.vector[0] = 0.7;
|
||||
}
|
||||
}
|
||||
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
|
||||
if crate::mobs::finalize::saves_variant_name(m.species())
|
||||
&& let Some(v) = text(tag.get("variant"))
|
||||
{
|
||||
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
|
||||
@@ -2034,7 +2036,10 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
("Item", item_to_nbt(&e.stack)),
|
||||
("Health", Tag::Short(e.health as i16)),
|
||||
("Fire", Tag::Short(e.fire_ticks as i16)),
|
||||
("Age", Tag::Short(e.age.min(i16::MAX as u32) as i16)),
|
||||
(
|
||||
"Age",
|
||||
Tag::Short(e.age.clamp(i16::MIN as i32, i16::MAX as i32) as i16),
|
||||
),
|
||||
(
|
||||
"PickupDelay",
|
||||
Tag::Short(e.pickup_delay.min(i16::MAX as u32) as i16),
|
||||
@@ -2111,8 +2116,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
// Age -32768 marks items that never despawn; Horizon counts upwards.
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
age: number(tag.get("Age")).unwrap_or(0) as i32,
|
||||
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
|
||||
bob_offset,
|
||||
ticks: 0,
|
||||
@@ -2236,7 +2240,12 @@ pub fn arrow_to_nbt(
|
||||
return None;
|
||||
};
|
||||
let base = Base {
|
||||
id: "minecraft:arrow",
|
||||
// A spectral arrow is its own entity type.
|
||||
id: if a.spectral || &*a.pickup_item.item == "minecraft:spectral_arrow" {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
},
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
@@ -2296,10 +2305,17 @@ pub fn arrow_from_nbt(
|
||||
let position = position(tag)?;
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
let from_player = player.is_some() && owner == player;
|
||||
// `getDefaultPickupItem`: a `SpectralArrow` is picked up as its own item.
|
||||
let spectral = kind(tag).as_deref() == Some("minecraft:spectral_arrow");
|
||||
let default_item = if spectral {
|
||||
"minecraft:spectral_arrow"
|
||||
} else {
|
||||
"minecraft:arrow"
|
||||
};
|
||||
let item = tag
|
||||
.get("item")
|
||||
.and_then(item_from_nbt)
|
||||
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:arrow", 1));
|
||||
.unwrap_or_else(|| crate::items::ItemStack::new(default_item, 1));
|
||||
let kind = ProjectileKind::Arrow {
|
||||
base_damage: float(tag.get("damage")).unwrap_or(2.0),
|
||||
critical: flag(tag, "crit"),
|
||||
@@ -2320,6 +2336,7 @@ pub fn arrow_from_nbt(
|
||||
a.has_been_shot = flag(tag, "HasBeenShot");
|
||||
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;
|
||||
a.pickup_item = item;
|
||||
a.fired_from_weapon = tag.get("weapon").and_then(item_from_nbt);
|
||||
p.stuck = a.in_ground;
|
||||
@@ -2401,12 +2418,13 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
|
||||
|
||||
/// The thrown item projectiles Horizon saves: each entity type's id is also
|
||||
/// the item it saves when none was loaded (`getDefaultItem`).
|
||||
const THROWABLE_ITEMS: [&str; 5] = [
|
||||
const THROWABLE_ITEMS: [&str; 6] = [
|
||||
"minecraft:snowball",
|
||||
"minecraft:egg",
|
||||
"minecraft:ender_pearl",
|
||||
"minecraft:splash_potion",
|
||||
"minecraft:lingering_potion",
|
||||
"minecraft:experience_bottle",
|
||||
];
|
||||
/// The item a throwable flies as: its stack (a potion's), the mod type's
|
||||
/// item, or the item its entity type is named after.
|
||||
@@ -2430,6 +2448,7 @@ pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
|
||||
"minecraft:egg" => Some(ProjectileKind::Egg),
|
||||
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
|
||||
// The entity type is the id; the `Item` read later may be another potion.
|
||||
"minecraft:experience_bottle" => Some(ProjectileKind::ExperienceBottle),
|
||||
_ => Some(ProjectileKind::Potion(
|
||||
Box::new(crate::items::ItemStack::new(id, 1)),
|
||||
id == "minecraft:lingering_potion",
|
||||
@@ -2452,12 +2471,17 @@ pub fn saved_with_chunk(p: &Projectile) -> bool {
|
||||
| ProjectileKind::Egg
|
||||
| ProjectileKind::EnderPearl
|
||||
| ProjectileKind::Potion(..)
|
||||
| ProjectileKind::ExperienceBottle
|
||||
| ProjectileKind::LlamaSpit
|
||||
| ProjectileKind::ShulkerBullet { target: None, .. }
|
||||
| ProjectileKind::Thrown(_) => true,
|
||||
ProjectileKind::Arrow { .. } => p.arrow.is_some(),
|
||||
// Not written yet: tridents save from their own list.
|
||||
// Not written yet: tridents save from their own list, and a bullet
|
||||
// with a target would lose it.
|
||||
ProjectileKind::Trident
|
||||
| ProjectileKind::LlamaSpit
|
||||
| ProjectileKind::ShulkerBullet { .. }
|
||||
| ProjectileKind::ShulkerBullet {
|
||||
target: Some(_), ..
|
||||
}
|
||||
| ProjectileKind::WitherSkull { .. } => false,
|
||||
}
|
||||
}
|
||||
@@ -2520,6 +2544,59 @@ pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Project
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `LlamaSpit` and `ShulkerBullet` as `/summon` or a chunk loads them:
|
||||
/// `Projectile`'s `Owner`, `LeftOwner`, `NoGravity` and the motion. A bullet
|
||||
/// loaded without a `Target` has none, so it falls (`ShulkerBullet.tick`).
|
||||
pub fn spit_or_bullet_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
|
||||
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
|
||||
target: None,
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
seed: 0,
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
let owner = owner_uuid(tag);
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.owner_uuid = owner;
|
||||
p.no_gravity = flag(tag, "NoGravity");
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// The save of [`spit_or_bullet_from_nbt`]'s kinds: `Projectile`'s keys.
|
||||
pub fn spit_or_bullet_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
if !matches!(
|
||||
p.kind,
|
||||
ProjectileKind::LlamaSpit | ProjectileKind::ShulkerBullet { target: None, .. }
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put_projectile(&mut tag, p, owner);
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
/// `Projectile`'s `Owner`.
|
||||
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
|
||||
uuid_from_tag(tag.get("Owner"))
|
||||
@@ -2626,6 +2703,7 @@ pub struct TridentSave<'a> {
|
||||
pub shake: u32,
|
||||
pub dealt_damage: bool,
|
||||
pub creative_only: bool,
|
||||
pub no_gravity: bool,
|
||||
pub item: &'a crate::items::ItemStack,
|
||||
}
|
||||
pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
@@ -2655,6 +2733,12 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
|
||||
] {
|
||||
put(&mut tag, key, value);
|
||||
}
|
||||
// Entity.saveWithoutId writes NoGravity only when set.
|
||||
if t.no_gravity {
|
||||
put(&mut tag, "NoGravity", Tag::Byte(1));
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"NoGravity".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// (position, motion, yaw, pitch, in ground, life, shake, dealt damage,
|
||||
@@ -2669,6 +2753,7 @@ pub struct TridentNbt {
|
||||
pub shake: u32,
|
||||
pub dealt_damage: bool,
|
||||
pub creative_only: bool,
|
||||
pub no_gravity: bool,
|
||||
pub item: crate::items::ItemStack,
|
||||
}
|
||||
pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
@@ -2683,6 +2768,7 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
|
||||
shake: number(tag.get("shake")).unwrap_or(0).max(0) as u32,
|
||||
dealt_damage: flag(tag, "DealtDamage"),
|
||||
creative_only: number(tag.get("pickup")) == Some(2),
|
||||
no_gravity: flag(tag, "NoGravity"),
|
||||
item: tag
|
||||
.get("item")
|
||||
.and_then(item_from_nbt)
|
||||
@@ -3309,6 +3395,31 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::items::ItemStack;
|
||||
|
||||
#[test]
|
||||
fn mob_loading_accepts_default_namespace_and_preserves_custom_namespaces() {
|
||||
let cow = crate::mobs::sensor_scan_tests::kind();
|
||||
let types = [
|
||||
("minecraft:cow".into(), cow.clone()),
|
||||
("test:cow".into(), cow),
|
||||
]
|
||||
.into();
|
||||
let pos = || {
|
||||
Tag::List(
|
||||
[4.5_f64, 220.0, 6.5]
|
||||
.map(|v| Tag::Double(v.to_bits()))
|
||||
.to_vec(),
|
||||
)
|
||||
};
|
||||
for id in ["cow", ":cow", "minecraft:cow", "test:cow"] {
|
||||
let tag = Tag::compound([("id", string(id)), ("Pos", pos()), ("NoAI", Tag::Byte(1))]);
|
||||
let mob = mob_from_nbt(&tag, &types, 7, None).expect(id);
|
||||
assert_eq!(mob.position, Vec3::new(4.5, 220.0, 6.5));
|
||||
assert!(mob.no_ai);
|
||||
}
|
||||
let unknown = Tag::compound([("id", string("other:cow")), ("Pos", pos())]);
|
||||
assert!(mob_from_nbt(&unknown, &types, 8, None).is_none());
|
||||
}
|
||||
|
||||
/// A saved rocket and trident come back with their own Life, LifeTime,
|
||||
/// flags and position (the keys vanilla's addAdditionalSaveData writes).
|
||||
#[test]
|
||||
@@ -3343,12 +3454,16 @@ mod tests {
|
||||
shake: 3,
|
||||
dealt_damage: true,
|
||||
creative_only: true,
|
||||
no_gravity: true,
|
||||
item: &trident,
|
||||
},
|
||||
[1, 2, 3, 4],
|
||||
None,
|
||||
);
|
||||
let t = trident_from_nbt(&tag).unwrap();
|
||||
// `Entity.saveWithoutId` writes NoGravity only when set.
|
||||
assert!(t.no_gravity);
|
||||
assert!(matches!(tag.get("NoGravity"), Some(Tag::Byte(1))));
|
||||
assert_eq!((t.in_ground, t.life, t.shake), (true, 9, 3));
|
||||
assert_eq!((t.dealt_damage, t.creative_only), (true, true));
|
||||
assert_eq!((t.yaw, t.pitch), (10.0, 20.0));
|
||||
@@ -3856,6 +3971,14 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
// alone, so a spawn egg carrying one hatches that variant.
|
||||
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
|
||||
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
|
||||
// Horse.applyImplicitComponent: `setVariant` sets the coat colour and
|
||||
// keeps the markings the spawn picked (`getTypeVariant() & -256`),
|
||||
// which Horizon keeps in `extra.variant2` and leaves alone here.
|
||||
crate::mobs::Species::Horse => (Some("minecraft:horse/variant"), None),
|
||||
// Llama.applyImplicitComponent, which a trader llama inherits.
|
||||
crate::mobs::Species::Llama | crate::mobs::Species::TraderLlama => {
|
||||
(Some("minecraft:llama/variant"), None)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
|
||||
@@ -4755,6 +4878,69 @@ mod item_component_tests {
|
||||
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
|
||||
}
|
||||
|
||||
/// `Horse.applyImplicitComponent` and `Llama.applyImplicitComponent`: the
|
||||
/// `.../variant` component sets the coat colour. A horse's markings are
|
||||
/// the other half of the packed variant its spawn picked and stay.
|
||||
#[test]
|
||||
fn a_horse_or_llama_spawn_egg_picks_its_variant_component() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let spawn = |kind: &str, id: u32| {
|
||||
Mob::new(
|
||||
id,
|
||||
Arc::new(data.types[kind].clone()),
|
||||
Vec3::new(0.0, 4.0, 0.0),
|
||||
0.0,
|
||||
)
|
||||
};
|
||||
// A black horse (Horse.Variant.BLACK, 4) with a white field (markings
|
||||
// 2), as a spawn in a plains leaves it.
|
||||
let mut horse = spawn("minecraft:horse", 1);
|
||||
(horse.extra.variant, horse.extra.variant2) = (4, 2);
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:horse_spawn_egg", 1);
|
||||
egg.data_mut().set_other(
|
||||
"minecraft:horse/variant",
|
||||
Some(string("minecraft:dark_brown")),
|
||||
);
|
||||
apply_item_components(&mut horse, &egg);
|
||||
assert_eq!(horse.extra.variant, 6, "Horse.Variant.DARK_BROWN");
|
||||
assert_eq!(
|
||||
horse.extra.variant2, 2,
|
||||
"the markings the spawn picked stay"
|
||||
);
|
||||
// `Horse.get(DataComponents.HORSE_VARIANT)` reports it back.
|
||||
let (id, variant) = crate::mobs::finalize::variant_component(&horse).unwrap();
|
||||
assert_eq!(
|
||||
(id, variant.as_str()),
|
||||
("minecraft:horse/variant", "minecraft:dark_brown")
|
||||
);
|
||||
// The save keeps the packed `Variant` int and gains no `variant` name,
|
||||
// which only `VariantUtils.writeVariant` species store.
|
||||
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(tag.get("variant"), None);
|
||||
assert!(
|
||||
matches!(tag.get("Variant"), Some(Tag::Int(v)) if *v == 6 | 2 << 8),
|
||||
"colour 6 and markings 2 packed"
|
||||
);
|
||||
// A llama, as a spawn in a plains leaves it: creamy.
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:llama_spawn_egg", 1);
|
||||
egg.data_mut()
|
||||
.set_other("minecraft:llama/variant", Some(string("minecraft:gray")));
|
||||
let mut llama = spawn("minecraft:llama", 2);
|
||||
assert_eq!(llama.extra.variant, 0);
|
||||
apply_item_components(&mut llama, &egg);
|
||||
assert_eq!(llama.extra.variant, 3, "Llama.Variant.GRAY");
|
||||
// TraderLlama inherits the llama's arm.
|
||||
let mut trader = spawn("minecraft:trader_llama", 3);
|
||||
apply_item_components(&mut trader, &egg);
|
||||
assert_eq!(trader.extra.variant, 3, "the trader llama reads it too");
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
|
||||
/// spawned, when the type is its own — the mob's compound, the data merged
|
||||
/// over it and loaded back, as the spawn egg site does it.
|
||||
@@ -5285,6 +5471,72 @@ mod mod_thrown_nbt_tests {
|
||||
// a type no mod declares now is not a thrown kind.
|
||||
assert!(throwable_kind("gone:bolt").is_none());
|
||||
}
|
||||
fn summoned(id: &str, extra: &[(&str, Tag)]) -> Tag {
|
||||
let mut c = vec![
|
||||
("id", Tag::String(id.into())),
|
||||
("Pos", doubles(&[0.5, 110.0, 0.5])),
|
||||
("Motion", doubles(&[0.0, 0.0, 0.0])),
|
||||
];
|
||||
c.extend(extra.iter().cloned());
|
||||
Tag::compound(c)
|
||||
}
|
||||
|
||||
/// Four summoned kinds had no loader and stayed inert (a spectral arrow,
|
||||
/// a shulker bullet, a llama's spit and an experience bottle never fell).
|
||||
#[test]
|
||||
fn summoned_projectiles_of_every_kind_load_and_honour_no_gravity() {
|
||||
use crate::entity_rules::EntityRules;
|
||||
let flag = ("NoGravity", Tag::Byte(1));
|
||||
let arrow = arrow_from_nbt(&summoned("minecraft:spectral_arrow", &[]), None, |_| None)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(arrow.is_spectral_arrow() && !arrow.no_gravity);
|
||||
let held = arrow_from_nbt(
|
||||
&summoned("minecraft:spectral_arrow", std::slice::from_ref(&flag)),
|
||||
None,
|
||||
|_| None,
|
||||
)
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(held.no_gravity);
|
||||
// ...and it is saved as the entity type it is.
|
||||
let saved = arrow_to_nbt(&arrow, [1; 4], None, None, None).unwrap();
|
||||
assert_eq!(kind(&saved).as_deref(), Some("minecraft:spectral_arrow"));
|
||||
let plain = arrow_from_nbt(&summoned("minecraft:arrow", &[]), None, |_| None)
|
||||
.unwrap()
|
||||
.0;
|
||||
let saved = arrow_to_nbt(&plain, [1; 4], None, None, None).unwrap();
|
||||
assert_eq!(kind(&saved).as_deref(), Some("minecraft:arrow"));
|
||||
|
||||
for id in ["minecraft:llama_spit", "minecraft:shulker_bullet"] {
|
||||
let p = spit_or_bullet_from_nbt(&summoned(id, &[]), None).unwrap();
|
||||
assert_eq!(p.kind.id(), id);
|
||||
assert!(!p.no_gravity && p.position == Vec3::new(0.5, 110.0, 0.5));
|
||||
let held =
|
||||
spit_or_bullet_from_nbt(&summoned(id, std::slice::from_ref(&flag)), None).unwrap();
|
||||
assert!(held.no_gravity);
|
||||
}
|
||||
assert!(spit_or_bullet_from_nbt(&summoned("minecraft:arrow", &[]), None).is_none());
|
||||
// a spit and a target-less bullet are saved and selectable (`/data`, `@e`).
|
||||
let spit = spit_or_bullet_from_nbt(&summoned("minecraft:llama_spit", &[]), None).unwrap();
|
||||
assert!(saved_with_chunk(&spit));
|
||||
let saved = spit_or_bullet_to_nbt(&spit, [1; 4], None, None).unwrap();
|
||||
assert_eq!(kind(&saved).as_deref(), Some("minecraft:llama_spit"));
|
||||
|
||||
// A summoned experience bottle flies as the bottle item, with a bottle's gravity.
|
||||
let bottle =
|
||||
throwable_from_nbt(&summoned("minecraft:experience_bottle", &[]), None).unwrap();
|
||||
assert_eq!(bottle.kind.id(), "minecraft:experience_bottle");
|
||||
assert!(!bottle.no_gravity && (bottle.get_gravity() - 0.07).abs() < 1e-12);
|
||||
let held =
|
||||
throwable_from_nbt(&summoned("minecraft:experience_bottle", &[flag]), None).unwrap();
|
||||
assert!(held.no_gravity && held.get_gravity() == 0.0);
|
||||
let saved = throwable_to_nbt(&bottle, [1; 4], None, None).unwrap();
|
||||
assert_eq!(kind(&saved).as_deref(), Some("minecraft:experience_bottle"));
|
||||
let item = saved.get("Item").and_then(item_from_nbt).unwrap();
|
||||
assert_eq!(&*item.item, "minecraft:experience_bottle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_gravity_survives_save_and_load_for_orbs() {
|
||||
// Entity.saveWithoutId / load: NoGravity round-trips on a kind that
|
||||
|
||||
@@ -109,6 +109,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
max_stack,
|
||||
);
|
||||
},
|
||||
living_heal: |entity, amount, cancel| {
|
||||
crate::mod_api::dispatch_living_heal(crate::mod_api::mods(), entity, amount, cancel);
|
||||
},
|
||||
living_breathe: |entity, kind, can_breathe, consume, refill| {
|
||||
crate::mod_api::dispatch_living_breathe(
|
||||
crate::mod_api::mods(),
|
||||
@@ -119,6 +122,30 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
refill,
|
||||
);
|
||||
},
|
||||
living_drown: |entity, kind, is_drowning, damage_amount, bubble_count, cancel| {
|
||||
crate::mod_api::dispatch_living_drown(
|
||||
crate::mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
is_drowning,
|
||||
damage_amount,
|
||||
bubble_count,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
living_fall: |entity, kind, distance, multiplier, cancel| {
|
||||
crate::mod_api::dispatch_living_fall(
|
||||
crate::mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
distance,
|
||||
multiplier,
|
||||
cancel,
|
||||
);
|
||||
},
|
||||
living_jump: |entity, kind| {
|
||||
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
|
||||
},
|
||||
ingredient: |kind, data, items| {
|
||||
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
|
||||
},
|
||||
|
||||
@@ -56,6 +56,7 @@ mod agent_settings;
|
||||
mod allays;
|
||||
mod armor_stands;
|
||||
mod atlas_slots;
|
||||
mod attachment_sync;
|
||||
mod banners;
|
||||
mod beacon;
|
||||
mod beds;
|
||||
@@ -2011,7 +2012,10 @@ impl UiResources {
|
||||
horizon_client::gfx::panorama::Faces::load(root, defaults)
|
||||
})
|
||||
});
|
||||
let atlas = startup::time("ui.atlas", || HudAtlas::build_over(root, defaults));
|
||||
let mod_image = !horizon_client::mod_api::mods().is_empty();
|
||||
let atlas = startup::time("ui.atlas", || {
|
||||
HudAtlas::build_over_with_image(root, defaults, mod_image)
|
||||
});
|
||||
let panorama = panorama
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
@@ -2395,6 +2399,7 @@ struct Game<'a> {
|
||||
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
|
||||
gpu_passes: bool,
|
||||
screen: Screen,
|
||||
observed_screen: Option<native_hooks::ObservedScreen>,
|
||||
window: Option<Arc<Window>>,
|
||||
renderer: Option<Renderer>,
|
||||
/// Avoid an endless rebuild loop if the new device is lost as well.
|
||||
@@ -2704,6 +2709,7 @@ struct Game<'a> {
|
||||
waypoints: horizon_client::locator_bar::ClientWaypoints,
|
||||
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
|
||||
locator_camera: Option<([f64; 3], f32, f32, f32)>,
|
||||
native_camera_frame: Option<horizon_client::mod_api::NativeCamera>,
|
||||
/// `LocalPlayer.postEffects`: the chains the server has the screen run
|
||||
/// (`ClientboundPostEffectsPacket`), in order.
|
||||
post_effects: Vec<String>,
|
||||
@@ -3778,13 +3784,13 @@ impl Game<'_> {
|
||||
/// Item entity `i` leaves the level (`Entity.discard`); mods hear it.
|
||||
pub(super) fn remove_dropped(&mut self, i: usize) -> ItemEntity {
|
||||
let e = self.dropped.remove(i);
|
||||
self.native_entities_left("minecraft:item", &[e.id]);
|
||||
self.native_entity_left(e.id);
|
||||
e
|
||||
}
|
||||
/// Experience orb `i` leaves the level (`Entity.discard`); mods hear it.
|
||||
pub(super) fn remove_orb(&mut self, i: usize) -> ExperienceOrb {
|
||||
let o = self.orbs.remove(i);
|
||||
self.native_entities_left("minecraft:experience_orb", &[o.id]);
|
||||
self.native_entity_left(o.id);
|
||||
o
|
||||
}
|
||||
/// The item entity `spawn_item` just added, unless a native mod kept it
|
||||
@@ -4009,9 +4015,6 @@ impl Game<'_> {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut removed = vec![false; items.len()];
|
||||
let mut cells = horizon_client::entities::ItemCells::new(&items);
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
@@ -4060,6 +4063,7 @@ impl Game<'_> {
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// Entity.handleOnAboveBubbleColumn: the server sends the particles of
|
||||
@@ -4085,6 +4089,9 @@ impl Game<'_> {
|
||||
&& (alive || item.age == 6000 && item.position.y > -128.0)
|
||||
&& item.ticks.is_multiple_of(rate)
|
||||
{
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
horizon_client::entities::merge_with_neighbours(
|
||||
&mut items,
|
||||
&mut removed,
|
||||
@@ -4094,6 +4101,22 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let item = &mut items[i];
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
|
||||
let mut extra_life = 0;
|
||||
self.native_item_expire(item.id, &item.stack, &mut extra_life);
|
||||
if extra_life > 0 {
|
||||
item.age = 6000 - extra_life.min(32766);
|
||||
alive = true;
|
||||
} else if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_item_despawn(
|
||||
item.id,
|
||||
&item.stack.item,
|
||||
item.stack.count as i32,
|
||||
[item.position.x, item.position.y, item.position.z],
|
||||
);
|
||||
}
|
||||
}
|
||||
if !alive || removed[i] {
|
||||
removed[i] = true;
|
||||
cells.remove(i, item.position);
|
||||
@@ -4105,8 +4128,7 @@ impl Game<'_> {
|
||||
self.pack.assets.registry,
|
||||
world_items!(self),
|
||||
);
|
||||
// shortcut: age is the fresh-drop clock; use entity ticks for saved/frozen-age phase parity.
|
||||
if environment.should_play_lava_hurt_sound(item.age) {
|
||||
if environment.should_play_lava_hurt_sound(item.ticks) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.burn",
|
||||
@@ -5535,7 +5557,7 @@ impl Game<'_> {
|
||||
f.stuck = Vec3::ZERO;
|
||||
f.velocity = Vec3::ZERO;
|
||||
}
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
let scene = collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: Some(collisions::PowderSnowSubject {
|
||||
@@ -5554,7 +5576,13 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
// On an error the caller keeps the block as it was before this tick.
|
||||
let movement = scene.resolve_with_context(
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
let movement = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &[],
|
||||
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
|
||||
}
|
||||
.resolve_with_context(
|
||||
horizon_client::falling_block::bounds(f.position),
|
||||
wanted,
|
||||
0.0,
|
||||
@@ -5987,9 +6015,8 @@ impl Game<'_> {
|
||||
if entity && hand == Hand::Off && self.with_hand(hand, |g| g.interact_entity(None))? {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
// A client of a server never hits a fence knot with its crosshair:
|
||||
// `LeadItem.useOn` and `LeashFenceKnotEntity.interact` reach the
|
||||
// server as a click on the fence block.
|
||||
// A click on the fence block: `LeadItem.useOn`. A client whose crosshair
|
||||
// met the knot first sends Interact with the knot's id instead.
|
||||
if on_block && self.with_hand(hand, Game::interact_knot) {
|
||||
return Ok(Some(hand));
|
||||
}
|
||||
@@ -6035,8 +6062,7 @@ impl Game<'_> {
|
||||
|| self.interact_boat(target)
|
||||
|| self.interact_cart(target)
|
||||
|| self.interact_mob(target)?
|
||||
// Knots are reached only through the crosshair pick for now: a
|
||||
// guest's Interact naming a knot's id is a known gap.
|
||||
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|
||||
|| target.is_none() && self.interact_knot())
|
||||
}
|
||||
/// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a
|
||||
@@ -7299,6 +7325,9 @@ impl Game<'_> {
|
||||
if running {
|
||||
horizon_client::spans::time("tick_lan_packets", || self.tick_lan_packets());
|
||||
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
|
||||
// What the blocks spawned (a dispensed minecart, a lit TNT, a
|
||||
// placed boat) joins before the entities tick.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.lan_mark_synced();
|
||||
}
|
||||
if running {
|
||||
@@ -7333,34 +7362,35 @@ impl Game<'_> {
|
||||
self.handle_entity_portals();
|
||||
self.handle_end_gateway_portals();
|
||||
});
|
||||
let before = self.dropped_ids();
|
||||
horizon_client::spans::time("tick_items", || self.tick_items());
|
||||
if self.remote.is_none() {
|
||||
self.hoppers_catch_items();
|
||||
}
|
||||
// Merged, burnt, despawned or picked up.
|
||||
self.native_items_left_since(&before);
|
||||
self.native_leaves_flush();
|
||||
self.native_joins_flush(false, 0);
|
||||
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
|
||||
}
|
||||
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
|
||||
// The ride the player's file names, once the vehicle is in the world.
|
||||
self.restore_root_vehicle();
|
||||
if running {
|
||||
let mobs_before = self.mob_ids();
|
||||
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
|
||||
// Dead, despawned or gone into a hive.
|
||||
self.native_mobs_left_since(&mobs_before);
|
||||
self.native_leaves_flush();
|
||||
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
|
||||
self.tick_mod_container_viewers();
|
||||
let before = self.orb_ids();
|
||||
horizon_client::spans::time("tick_experience", || self.tick_experience());
|
||||
// Merged, despawned or picked up.
|
||||
self.native_orbs_left_since(&before);
|
||||
self.native_leaves_flush();
|
||||
horizon_client::spans::time("entity_portals", || {
|
||||
self.find_entity_portals();
|
||||
self.resolve_arrivals();
|
||||
});
|
||||
}
|
||||
// The rest of the tick's spawns join, and what left is told of.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.native_leaves_flush();
|
||||
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
|
||||
self.tick_friends();
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
@@ -8086,6 +8116,10 @@ impl Game<'_> {
|
||||
self.settings.file.bool("hideSplashTexts"),
|
||||
);
|
||||
}
|
||||
// ScreenEvent.Render.Post over the title's own screens: a mod draws
|
||||
// over them here (a pack browser over Create New World, say), and the
|
||||
// toasts still come after, as over any screen.
|
||||
self.fire_title_screen_render_post(&mut batch);
|
||||
self.draw_toasts(&mut batch, false);
|
||||
self.draw_tutorial_toasts(&mut batch, false);
|
||||
self.draw_pack_toasts(&mut batch, false);
|
||||
@@ -8431,6 +8465,21 @@ impl Game<'_> {
|
||||
let view = self.screen_view(batch, screen);
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
/// `native_screen_render_post` (`ScreenEvent.Render.Post`) for the title's
|
||||
/// own screen (`title`, `world_select`, `world_create`, `multiplayer`…),
|
||||
/// once a frame, after it has drawn and before the toasts. Not when a
|
||||
/// screen of the game's own is over the title (Options draws and fires
|
||||
/// its own), nor while a dialog, a prompt or a connect or load hides it.
|
||||
/// Client only, read-only: what a mod draws is its own.
|
||||
fn fire_title_screen_render_post(&self, batch: &mut HudBatch) {
|
||||
if self.screen != Screen::Playing || self.screen_over_playing() || self.remote_loading() {
|
||||
return;
|
||||
}
|
||||
let Some(view) = self.title_screen_view(batch) else {
|
||||
return;
|
||||
};
|
||||
self.native_screen_render_post(batch, &view);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
|
||||
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
|
||||
/// as the screen's constructor and `containerTick` do.
|
||||
@@ -8745,9 +8794,15 @@ impl Game<'_> {
|
||||
/// (special ones never do).
|
||||
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
|
||||
for (recipe, ingredients) in crafts {
|
||||
self.trigger_recipe_crafted(recipe, ingredients);
|
||||
// The whole id: the advancements' `recipe_crafted` criteria name
|
||||
// `minecraft:x`, and the crafting copy names the recipe bare.
|
||||
let recipe = horizon_client::crafting::qualified(recipe);
|
||||
self.trigger_recipe_crafted(&recipe, ingredients);
|
||||
}
|
||||
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
|
||||
let recipes: Vec<String> = crafts
|
||||
.iter()
|
||||
.map(|(r, _)| horizon_client::crafting::qualified(r))
|
||||
.collect();
|
||||
let index = &world_stations!(self).recipe_index;
|
||||
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
|
||||
self.recipe_unlocked(&unlocked);
|
||||
@@ -10761,10 +10816,18 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
// Mod block entities: their mods draw them (`native_block_entity_submit`),
|
||||
// culled by the bounds they give.
|
||||
let mods = r
|
||||
.visible_block_entities(self.settings.chunk_fade)
|
||||
.filter(|(_, state)| !self.res.block_entity_render().renders(*state));
|
||||
// culled by the bounds they give. A world without any (`has_mods`) has none
|
||||
// to find among its other unrendered block entities (furnaces, hoppers...).
|
||||
let mods = self
|
||||
.world
|
||||
.block_entities
|
||||
.has_mods()
|
||||
.then(|| {
|
||||
r.visible_block_entities(self.settings.chunk_fade)
|
||||
.filter(|(_, state)| !self.res.block_entity_render().renders(*state))
|
||||
})
|
||||
.into_iter()
|
||||
.flatten();
|
||||
let untemplated = self.untemplated_mod_block_entities(camera);
|
||||
self.mod_block_entity_geometry(
|
||||
mods.chain(untemplated.iter()),
|
||||
@@ -12842,7 +12905,17 @@ impl Game<'_> {
|
||||
let target = self.bucket_target(content, &hit);
|
||||
let mut canceled = false;
|
||||
self.native_fill_bucket(stack, target, &mut canceled);
|
||||
if canceled || self.bucket_empty_cancelled(stack, target) {
|
||||
// `mayUseItemAt(blockPos2)`: a block that refuses the fluid passes
|
||||
// it to the block beside, so a mod hears that block too (powder
|
||||
// snow places like a block and never goes there).
|
||||
let beside_too = content != BucketContent::Powder && hit.beside() != target;
|
||||
if !canceled && beside_too {
|
||||
self.native_fill_bucket(stack, hit.beside(), &mut canceled);
|
||||
}
|
||||
if canceled
|
||||
|| self.bucket_empty_cancelled(stack, target)
|
||||
|| beside_too && self.bucket_empty_cancelled(stack, hit.beside())
|
||||
{
|
||||
return Ok(UseResult::Fail);
|
||||
}
|
||||
let pos = BlockPos::new(target[0], target[1], target[2]);
|
||||
@@ -13249,6 +13322,7 @@ impl Game<'_> {
|
||||
target,
|
||||
self.remote.is_none(),
|
||||
&mut next_float,
|
||||
Some(self.border.geometry()),
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
@@ -13326,9 +13400,12 @@ impl Game<'_> {
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
let at = self.eye_position();
|
||||
let old_level = self.experience.level;
|
||||
let gave = self.experience.give(gained as i32);
|
||||
self.native_player_level_changed(old_level);
|
||||
// Player.giveExperienceLevels runs per level, so the first multiple of 5
|
||||
// crossed plays the sound (and starts the cooldown for the rest).
|
||||
if let Some(level) = self.experience.give(gained as i32)
|
||||
if let Some(level) = gave
|
||||
&& self.ticks > self.last_level_up + 100
|
||||
{
|
||||
self.last_level_up = self.ticks;
|
||||
@@ -13527,6 +13604,29 @@ impl Game<'_> {
|
||||
if canceled {
|
||||
continue;
|
||||
}
|
||||
let (mut x, mut y, mut z, mut pitch, mut yaw, mut roll, mut pose_scale) =
|
||||
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0);
|
||||
self.native_render_hand_pose(
|
||||
hand,
|
||||
item.as_ref(),
|
||||
a,
|
||||
attack,
|
||||
inverse_height,
|
||||
&mut x,
|
||||
&mut y,
|
||||
&mut z,
|
||||
&mut pitch,
|
||||
&mut yaw,
|
||||
&mut roll,
|
||||
&mut pose_scale,
|
||||
);
|
||||
// No mod moved the hand (always so without one): no matrices to build.
|
||||
let pose = [x, y, z, pitch, yaw, roll, pose_scale];
|
||||
let to_world = if pose == [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] {
|
||||
to_world
|
||||
} else {
|
||||
to_world * native_hooks::hand_pose_matrix(pose)
|
||||
};
|
||||
// The main hand on its arm, the off hand on the other one.
|
||||
let left = self.settings.main_hand_left != (hand == Hand::Off);
|
||||
let Some(stack) = item else {
|
||||
@@ -14254,7 +14354,7 @@ impl Game<'_> {
|
||||
let lines = vec![(horizon_client::text::Text::literal(text), u32::MAX)];
|
||||
let (x, y, w, h) =
|
||||
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), [0.0; 2]);
|
||||
Self::tooltip_box(batch, atlas, x, y, w, h);
|
||||
self.tooltip_box(batch, None, x, y, w, h);
|
||||
Self::tooltip_text(batch, atlas, &lines, x, y);
|
||||
}
|
||||
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
|
||||
@@ -14812,10 +14912,19 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// `DedicatedServer.saveAllChunks` tells the management protocol's clients when a save starts and when
|
||||
// it ends without failing, for every save: the command, the autosave, the pause.
|
||||
let dedicated = self.options.dedicated.is_some();
|
||||
if dedicated {
|
||||
dedicated::events::record_server("saving", serde_json::json!({}));
|
||||
}
|
||||
self.save_level(wait)?;
|
||||
self.save_parked_levels(wait)?;
|
||||
self.save_game_metadata()?;
|
||||
self.native_world_saved(wait);
|
||||
if dedicated {
|
||||
dedicated::events::record_server("saved", serde_json::json!({}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn save_game_metadata(&mut self) -> Result<()> {
|
||||
@@ -14831,12 +14940,17 @@ impl Game<'_> {
|
||||
self.save_schedule();
|
||||
self.save_warden_tracker();
|
||||
self.sync_level();
|
||||
self.store_owner();
|
||||
let stored_owner = self.store_owner();
|
||||
write_level_for_role(
|
||||
&mut self.level,
|
||||
&self.world_root,
|
||||
self.options.execution_role,
|
||||
)?;
|
||||
// PlayerEvent.SaveToFile for the owner, once level.dat is written.
|
||||
if let Some((uuid, name)) = stored_owner {
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(uuid, &name, &directory);
|
||||
}
|
||||
// PlayerList.saveAll: every placed player saves with the server, to
|
||||
// their own file (`PlayerDataStorage.save`); the owner is also in the
|
||||
// file level.dat names, which is the one it loads from
|
||||
@@ -14848,9 +14962,11 @@ impl Game<'_> {
|
||||
// The vehicle tree goes into a player's file when they leave
|
||||
// (`take_parent_vehicle`); here it stays in the chunk saves.
|
||||
let mut host = host;
|
||||
let directory = horizon_client::lan::playerdata::directory(&self.world_root);
|
||||
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
|
||||
self.guest_attachments_into(g);
|
||||
horizon_client::lan::playerdata::save(g, &self.world_root);
|
||||
self.native_player_save_to_file(g.uuid, &g.name, &directory);
|
||||
}
|
||||
self.lan = Some(host);
|
||||
}
|
||||
@@ -14937,6 +15053,7 @@ impl Game<'_> {
|
||||
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
self.observe_screen();
|
||||
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
|
||||
// server starts, with the server's chunk map and progress.
|
||||
if let Some(starting) = &self.integrated_starting {
|
||||
@@ -15279,6 +15396,32 @@ impl Game<'_> {
|
||||
let distance = (terrain_distance * 16) as f32;
|
||||
let depth_far =
|
||||
horizon_client::camera::depth_far(terrain_distance, self.settings.cloud_range);
|
||||
// Only native mods read it: no matrices or allocation per frame without one.
|
||||
let mods_read_camera = !horizon_client::mod_api::mods().is_empty();
|
||||
self.native_camera_frame = (mods_read_camera && !title).then(|| {
|
||||
let partial = camera_alpha as f32;
|
||||
horizon_client::mod_api::NativeCamera {
|
||||
position: camera.to_array(),
|
||||
eye_height: self.view.eye_height_old
|
||||
+ (self.view.eye_height - self.view.eye_height_old) * partial,
|
||||
partial_tick: partial,
|
||||
yaw,
|
||||
pitch,
|
||||
fov_degrees,
|
||||
near: 0.05,
|
||||
far: depth_far,
|
||||
view: (bob * spin * horizon_client::camera::view_matrix(yaw, pitch))
|
||||
.to_cols_array(),
|
||||
projection: glam::Mat4::perspective_rh(
|
||||
fov_degrees.to_radians(),
|
||||
size[0] as f32 / size[1].max(1) as f32,
|
||||
depth_far,
|
||||
0.05,
|
||||
)
|
||||
.to_cols_array(),
|
||||
dimension: self.dim.id().to_string(),
|
||||
}
|
||||
});
|
||||
self.view_frustum = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
@@ -16781,14 +16924,19 @@ impl Game<'_> {
|
||||
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
|
||||
// Options keeps its text on the key: its own key path routes a key's
|
||||
// text between its boxes and its key-bind capture, and no key that
|
||||
// carries text closes that screen or moves its focus.
|
||||
// carries text closes that screen or moves its focus. The title's
|
||||
// screens keep theirs for the same reason: their key path types into
|
||||
// the world name, the seed and a server's address.
|
||||
let mut typed: Option<String> = None;
|
||||
if let WindowEvent::KeyboardInput {
|
||||
event: key_event, ..
|
||||
} = &mut event
|
||||
&& key_event.state == ElementState::Pressed
|
||||
{
|
||||
if self.screen_hooks_open() && self.screen != Screen::Options {
|
||||
if self.screen_hooks_open()
|
||||
&& self.screen != Screen::Options
|
||||
&& !control::title_owns_text(&self.screen, self.title.is_some())
|
||||
{
|
||||
typed = key_event.text.take().map(|text| text.to_string());
|
||||
}
|
||||
let canceled = match key_event.physical_key {
|
||||
@@ -17766,12 +17914,19 @@ impl Game<'_> {
|
||||
result => result?,
|
||||
};
|
||||
self.apply_cursor_icon();
|
||||
// The frame's spans, taken every frame they are recorded so they never
|
||||
// pile up: the slow-frame line's, and the session's (`perf.frame`).
|
||||
let frame_spans = if horizon_client::spans::recording_here() {
|
||||
horizon_client::spans::take_raw()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
|
||||
// Spans timed between frames (`idle`) come first.
|
||||
let spans = horizon_client::spans::take();
|
||||
let spans = horizon_client::spans::merge(&frame_spans);
|
||||
let between = between.as_secs_f32() * 1000.0;
|
||||
if cpu > threshold || between > threshold {
|
||||
// The render thread's own CPU time in the frame:
|
||||
@@ -17875,7 +18030,8 @@ impl Game<'_> {
|
||||
if self.title.is_some() {
|
||||
self.perf.title_shown();
|
||||
} else if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.perf.frame(elapsed.as_secs_f32() * 1000.0);
|
||||
self.perf
|
||||
.frame(elapsed.as_secs_f32() * 1000.0, &frame_spans);
|
||||
if startup::filling() {
|
||||
let mut fill: Vec<(&'static str, usize)> =
|
||||
self.streaming.stages().to_vec();
|
||||
@@ -18199,6 +18355,7 @@ impl<'a> Game<'a> {
|
||||
debug: false,
|
||||
gpu_passes: false,
|
||||
screen: Screen::Playing,
|
||||
observed_screen: None,
|
||||
window: None,
|
||||
renderer: None,
|
||||
last_device_recovery: None,
|
||||
@@ -18376,6 +18533,7 @@ impl<'a> Game<'a> {
|
||||
waypoint_styles: prepared.waypoint_styles,
|
||||
waypoints: Default::default(),
|
||||
locator_camera: None,
|
||||
native_camera_frame: None,
|
||||
post_effects: Vec::new(),
|
||||
spectated_post_effect: None,
|
||||
experience_display: Default::default(),
|
||||
@@ -20709,6 +20867,14 @@ pub fn run_with() -> Result<()> {
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
if options.execution_role.is_client()
|
||||
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
|
||||
.allows(horizon_telemetry::Kind::Perf)
|
||||
{
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
|
||||
@@ -614,6 +614,11 @@ impl Game<'_> {
|
||||
let criterion = &a.criteria[ci].name;
|
||||
if self.adv.progress.grant(&a.id, criterion, &date) {
|
||||
self.advancement_progress(&a.id, criterion, true);
|
||||
if let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_advancement_criterion_grant(player, &name, &a.id, criterion);
|
||||
}
|
||||
}
|
||||
self.progress_changed(ai);
|
||||
if !was && self.adv.progress.done(a) {
|
||||
@@ -740,7 +745,9 @@ impl Game<'_> {
|
||||
}
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
self.experience.give(a.rewards.experience);
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
|
||||
@@ -810,17 +810,23 @@ impl Game<'_> {
|
||||
subject: None,
|
||||
};
|
||||
let mut tops: Vec<BlockPos> = Vec::new();
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push_over(
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
collision,
|
||||
bounds,
|
||||
velocity,
|
||||
|pos| tops.push(pos),
|
||||
);
|
||||
});
|
||||
a.tick(
|
||||
&mut scene,
|
||||
facts,
|
||||
in_water,
|
||||
&mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push_over(
|
||||
world,
|
||||
registry,
|
||||
facts,
|
||||
collision,
|
||||
bounds,
|
||||
velocity,
|
||||
|pos| tops.push(pos),
|
||||
);
|
||||
},
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
}
|
||||
@@ -1067,7 +1073,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
self.armor_stand_items(a, model, base, &posed, packed, skin.as_deref_mut(), out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet. One pose map for every layer
|
||||
// (`reset` keeps its capacity).
|
||||
let mut layer_poses = Poses::default();
|
||||
@@ -1380,7 +1386,9 @@ impl Game<'_> {
|
||||
impl Game<'_> {
|
||||
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
|
||||
/// items in the third-person contexts, and a non-armour head item on the
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale). With
|
||||
/// `skin`, items that can are posed on the GPU with the stand.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn armor_stand_items(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
@@ -1388,8 +1396,21 @@ impl Game<'_> {
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
light: u32,
|
||||
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
out: &mut Vec<Vertex>| {
|
||||
let on_gpu = skin
|
||||
.as_deref_mut()
|
||||
.is_some_and(|skin| self.skin_item(state, pose, light, lights, skin));
|
||||
if !on_gpu {
|
||||
self.submit_item_world(state, pose, light, out);
|
||||
}
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::mobs::render::part_matrix;
|
||||
let owner = super::item_draw::owner_at(
|
||||
@@ -1429,7 +1450,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.resolve_item(stack, context, Some(&owner), seed)
|
||||
};
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
submit(&state, pose, out);
|
||||
}
|
||||
// `CustomHeadLayer`: a worn skull draws the skull model.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
@@ -1490,7 +1511,7 @@ impl Game<'_> {
|
||||
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
|
||||
let state =
|
||||
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
submit(&state, pose, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Attachment sync (docs/native-mods.md, "Server attachments"): the server
|
||||
//! sends the synced values of the holders a level pass lent to mods, and whole
|
||||
//! snapshots to the players who started seeing a holder; a client keeps them.
|
||||
use super::{Game, lan::LanHost};
|
||||
use crate::lan::{
|
||||
guest::Guest,
|
||||
hub::{AttachmentSnapshot, Hub},
|
||||
};
|
||||
use horizon_protocol::attachment_sync::{self, Message};
|
||||
use horizon_world::attachments::{Attachments, Holder};
|
||||
|
||||
/// The guests `holder`'s changes go to: those in the level in place who see it,
|
||||
/// and a player's own wherever they are.
|
||||
fn recipients(hub: &Hub, holder: Holder) -> impl Iterator<Item = &Guest> {
|
||||
let owner = match holder {
|
||||
Holder::Player(uuid) => hub.guests.iter().find(|g| g.uuid == uuid),
|
||||
_ => None,
|
||||
};
|
||||
let sees = move |g: &Guest| match holder {
|
||||
Holder::Level => true,
|
||||
Holder::Chunk([x, z]) => g.sent_chunks.contains(&(x, z)),
|
||||
Holder::BlockEntity([x, _, z]) => g.sent_chunks.contains(&(x >> 4, z >> 4)),
|
||||
Holder::Entity(id) => g.tracker.is_tracking(id as i32),
|
||||
Holder::Player(_) => owner.is_some_and(|o| g.tracker.is_tracking(o.entity_id)),
|
||||
_ => false,
|
||||
};
|
||||
hub.guests.iter().filter(move |g| {
|
||||
matches!(holder, Holder::Player(uuid) if g.uuid == uuid) || (hub.here(g) && sees(g))
|
||||
})
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The messages that give a player `holder`'s synced attachments whole: its
|
||||
/// own, and for a chunk those of its block entities. A player holder with
|
||||
/// none still gives one, to drop what the client holds.
|
||||
fn attachment_snapshot(&mut self, holder: Holder) -> Vec<Message> {
|
||||
let changes = match holder {
|
||||
// Read in place: a chunk lent to a mod is copied and saved.
|
||||
Holder::Chunk([x, z]) => self
|
||||
.world
|
||||
.chunks
|
||||
.get(&(x, z))
|
||||
.and_then(|chunk| chunk.saved.as_ref())
|
||||
.map(|extras| extras.attachments.synced()),
|
||||
// Read in place: lending the map would give the entity one.
|
||||
Holder::Entity(id) => self
|
||||
.persist
|
||||
.uuids
|
||||
.get(&id)
|
||||
.and_then(|uuid| self.entity_attachments.get(uuid))
|
||||
.map(|map| map.synced()),
|
||||
_ => self
|
||||
.attachment_map_quiet(holder)
|
||||
.ok()
|
||||
.map(|map| map.synced()),
|
||||
}
|
||||
.unwrap_or_default();
|
||||
let mut holders = vec![(holder, changes)];
|
||||
if let Holder::Chunk([x, z]) = holder {
|
||||
holders.extend(
|
||||
self.world
|
||||
.block_entities
|
||||
.chunk_attachments(x, z)
|
||||
.into_iter()
|
||||
.map(|((x, y, z), map)| (Holder::BlockEntity([x, y, z]), map.synced())),
|
||||
);
|
||||
}
|
||||
holders
|
||||
.into_iter()
|
||||
.filter(|(holder, changes)| !changes.is_empty() || matches!(holder, Holder::Player(_)))
|
||||
.map(|(holder, changes)| Message {
|
||||
holder,
|
||||
replace: true,
|
||||
changes,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The holders players started seeing are sent whole to them, in the pass
|
||||
/// of the level they are in (another's stay queued), then the synced
|
||||
/// changes of the holders lent to mods since the last pass to the players
|
||||
/// who see them.
|
||||
pub(super) fn flush_attachment_sync(&mut self, host: &mut LanHost) {
|
||||
let hub = &mut host.hub;
|
||||
for snapshot in std::mem::take(&mut hub.attachment_snapshots) {
|
||||
let AttachmentSnapshot { player, holder } = snapshot;
|
||||
let Some(guest) = hub.guests.iter().find(|g| g.uuid == player) else {
|
||||
continue;
|
||||
};
|
||||
if !guest.reads_attachment_sync() {
|
||||
continue;
|
||||
}
|
||||
if guest.dimension != hub.config.dimension {
|
||||
hub.attachment_snapshots.push(snapshot);
|
||||
continue;
|
||||
}
|
||||
// A guest is a player holder, whose entity another player tracks.
|
||||
let holder = match holder {
|
||||
Holder::Entity(id) => hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.entity_id == id as i32)
|
||||
.map_or(holder, |g| Holder::Player(g.uuid)),
|
||||
_ => holder,
|
||||
};
|
||||
for message in self.attachment_snapshot(holder) {
|
||||
guest.send_attachment_sync(&message);
|
||||
}
|
||||
}
|
||||
for holder in std::mem::take(&mut self.world.attachment_syncs) {
|
||||
let Ok(map) = self.attachment_map_quiet(holder) else {
|
||||
continue;
|
||||
};
|
||||
let changes = map.take_sync_changes();
|
||||
if changes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let message = Message {
|
||||
holder,
|
||||
replace: false,
|
||||
changes,
|
||||
};
|
||||
for guest in recipients(hub, holder) {
|
||||
guest.send_attachment_sync(&message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A `horizon:attachment_sync` payload the server sent the window.
|
||||
pub(super) fn remote_attachment_sync(&mut self, data: &[u8]) {
|
||||
let message = match attachment_sync::decode(data) {
|
||||
Ok(message) => message,
|
||||
Err(e) => return log::debug!("Horizon: a bad attachment sync: {e}"),
|
||||
};
|
||||
let Some(remote) = self.remote.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let map = remote.attachments.entry(message.holder).or_default();
|
||||
if message.replace {
|
||||
*map = Attachments::default();
|
||||
}
|
||||
for change in message.changes {
|
||||
map.apply_sync(change);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1121,6 +1121,16 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_vehicle_create(
|
||||
self.boats.last().unwrap().entity_type().as_str(),
|
||||
[
|
||||
self.boats.last().unwrap().position.x,
|
||||
self.boats.last().unwrap().position.y,
|
||||
self.boats.last().unwrap().position.z,
|
||||
],
|
||||
);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -1345,18 +1355,24 @@ impl Game<'_> {
|
||||
/// reason that destroys the boat (DISCARDED included), while the boat item
|
||||
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
|
||||
/// a creative hit discards it. True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return false;
|
||||
}
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
b.hurt_time = 10;
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
self.native_vehicle_destroy("boat", at);
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
// `AbstractChestBoat.remove` spills the chest through
|
||||
|
||||
@@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec<String> {
|
||||
|
||||
/// The `namespace:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
let suffix = format!(".{extension}");
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(root) {
|
||||
let mut out: Vec<String> = files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(&suffix))
|
||||
.filter(|path| !path.is_empty() && !path.ends_with('/'))
|
||||
.map(|path| format!("{namespace}:{path}"))
|
||||
.collect();
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
|
||||
@@ -69,6 +69,8 @@ pub(super) enum Target {
|
||||
Falling(u32),
|
||||
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
|
||||
Rocket(u32),
|
||||
/// A thrown trident, by its entity id (`minecraft:trident`).
|
||||
Trident(u32),
|
||||
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
|
||||
Spawner(u32),
|
||||
}
|
||||
@@ -152,6 +154,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Trident(id)
|
||||
| Target::Spawner(id)) = t
|
||||
else {
|
||||
return None;
|
||||
@@ -159,6 +162,17 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
i32::try_from(id).ok()
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `native_player_level_change` for a level that was `from` before an experience change.
|
||||
pub(super) fn native_player_level_changed(&mut self, from: i32) {
|
||||
let to = self.experience.level;
|
||||
if from != to
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_level_change(player, &name, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.client_mod_command(command) {
|
||||
@@ -538,6 +552,11 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| [r.position.x, r.position.y, r.position.z]),
|
||||
Target::Trident(id) => self
|
||||
.tridents
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.map(|t| [t.position.x, t.position.y, t.position.z]),
|
||||
Target::Spawner(id) => self
|
||||
.ominous_spawners
|
||||
.iter()
|
||||
@@ -592,6 +611,7 @@ impl Game<'_> {
|
||||
Target::Tnt(_) => "minecraft:tnt".into(),
|
||||
Target::Falling(_) => "minecraft:falling_block".into(),
|
||||
Target::Rocket(_) => "minecraft:firework_rocket".into(),
|
||||
Target::Trident(_) => "minecraft:trident".into(),
|
||||
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
|
||||
}
|
||||
}
|
||||
@@ -894,6 +914,7 @@ impl Game<'_> {
|
||||
Target::Tnt(id),
|
||||
Target::Falling(id),
|
||||
Target::Rocket(id),
|
||||
Target::Trident(id),
|
||||
Target::Spawner(id),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -919,6 +940,7 @@ impl Game<'_> {
|
||||
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
|
||||
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
|
||||
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
|
||||
Target::Trident(id) => self.tridents.iter().any(|t| t.id == id),
|
||||
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
|
||||
}
|
||||
}
|
||||
@@ -1589,6 +1611,12 @@ impl Game<'_> {
|
||||
r.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Trident(id) => {
|
||||
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id) {
|
||||
t.position = v;
|
||||
t.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
if let Some(s) = self.ominous_spawners.iter_mut().find(|s| s.id == id) {
|
||||
s.position = [v.x, v.y, v.z];
|
||||
@@ -2091,12 +2119,12 @@ impl Game<'_> {
|
||||
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
|
||||
Target::Falling(id) => self.falling.retain(|f| f.id != id),
|
||||
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
|
||||
Target::Trident(id) => self.tridents.retain(|t| t.id != id),
|
||||
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
let before = self.orb_ids();
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
self.native_orbs_left_since(&before);
|
||||
self.native_leaves_flush();
|
||||
}
|
||||
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -2175,7 +2203,8 @@ impl Game<'_> {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
self.spawn_mob_spawned(&short, pos)
|
||||
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
|
||||
name = Text::translate(&entity_name_key(&kind), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
@@ -2231,6 +2260,10 @@ impl Game<'_> {
|
||||
beam: None,
|
||||
invulnerable: false,
|
||||
});
|
||||
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
|
||||
if !self.native_joins_flush(false, self.next_entity) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
name = Text::translate("entity.minecraft.end_crystal", &[]);
|
||||
}
|
||||
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
|
||||
@@ -2250,7 +2283,11 @@ impl Game<'_> {
|
||||
let empty = horizon_world::nbt::Tag::Compound(Default::default());
|
||||
cart.spawner = Some(Box::new(Spawner::load(&empty)));
|
||||
}
|
||||
let id = cart.id;
|
||||
self.carts.push(cart);
|
||||
if !self.native_joins_flush(false, id) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
|
||||
}
|
||||
// SummonCommand.createEntity: any other type from an empty compound.
|
||||
@@ -2731,6 +2768,7 @@ impl Game<'_> {
|
||||
Ok(count)
|
||||
}
|
||||
/// Player.giveExperiencePoints including negative amounts.
|
||||
/// The caller reports the level change to the mods.
|
||||
fn give_points(&mut self, amount: i32) {
|
||||
let e = &mut self.experience;
|
||||
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
|
||||
@@ -2916,6 +2954,7 @@ impl Game<'_> {
|
||||
/// ExperienceCommand's add and set on the player in place: false when
|
||||
/// the points to set do not fit the level.
|
||||
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
|
||||
let old_level = self.experience.level;
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
@@ -2934,8 +2973,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.experience.progress = amount as f32 / needed as f32;
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
true
|
||||
}
|
||||
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
|
||||
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
|
||||
/// tells the management protocol's clients).
|
||||
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
if self.options.dedicated.is_some() {
|
||||
let (value_type, json) = match value {
|
||||
Value::Bool(b) => ("boolean", serde_json::json!(b)),
|
||||
Value::Int(i) => ("integer", serde_json::json!(i)),
|
||||
};
|
||||
super::dedicated::events::record_server(
|
||||
"gamerule",
|
||||
serde_json::json!({
|
||||
"key": format!("minecraft:{}", rule.name),
|
||||
"valueType": value_type,
|
||||
"value": json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
return Ok(0);
|
||||
@@ -2958,8 +3019,7 @@ impl Game<'_> {
|
||||
if current == value {
|
||||
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
|
||||
}
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
self.set_game_rule(rule, value);
|
||||
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
|
||||
Ok(match value {
|
||||
Value::Bool(b) => i32::from(b),
|
||||
@@ -3803,6 +3863,7 @@ mod look_at_tests {
|
||||
Target::Tnt(7),
|
||||
Target::Falling(7),
|
||||
Target::Rocket(7),
|
||||
Target::Trident(7),
|
||||
Target::Spawner(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
|
||||
@@ -173,7 +173,8 @@ impl Game<'_> {
|
||||
let raw = self.persist.raw.get(&id);
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, raw)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, raw))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, raw))?
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, raw))
|
||||
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, random_uuid(), owner, raw))?
|
||||
}
|
||||
Target::Crystal(id) => {
|
||||
let c = self.crystals.iter().find(|c| c.id == id)?;
|
||||
@@ -223,6 +224,11 @@ impl Game<'_> {
|
||||
let r = self.rockets.iter().find(|r| r.id == id)?;
|
||||
r.to_nbt(uuid, player_uuid, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Trident(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let t = self.tridents.iter().find(|t| t.id == id)?;
|
||||
t.to_nbt(uuid, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
let s = self.ominous_spawners.iter().find(|s| s.id == id)?;
|
||||
let [x, y, z] = s.position;
|
||||
@@ -855,6 +861,8 @@ impl Game<'_> {
|
||||
Some(Target::Falling(id))
|
||||
} else if self.rockets.iter().any(|r| r.id == id) {
|
||||
Some(Target::Rocket(id))
|
||||
} else if self.tridents.iter().any(|t| t.id == id) {
|
||||
Some(Target::Trident(id))
|
||||
} else if self.ominous_spawners.iter().any(|s| s.id == id) {
|
||||
Some(Target::Spawner(id))
|
||||
} else {
|
||||
@@ -879,7 +887,9 @@ impl Game<'_> {
|
||||
let target = self.spawn_entity_from_nbt(tag);
|
||||
self.native_mobs_joined(from, false);
|
||||
// A mod kept the entity out: tryAddFreshEntityWithPassengers is false.
|
||||
if target.is_none() && self.persist.joins_canceled != canceled {
|
||||
if self.persist.joins_canceled != canceled
|
||||
&& target.is_none_or(|t| self.target_position(t).is_none())
|
||||
{
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
Ok(target)
|
||||
|
||||
@@ -276,7 +276,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn tick_worldborder(&mut self) {
|
||||
let remaining = self.border.remaining_ticks();
|
||||
self.border.tick();
|
||||
if remaining > 0
|
||||
&& self.border.remaining_ticks() == 0
|
||||
&& (self.serving.is_some() || self.options.dedicated.is_some())
|
||||
{
|
||||
self.native_world_border_bounds_change_finish();
|
||||
}
|
||||
self.player_border();
|
||||
self.border_damage();
|
||||
}
|
||||
|
||||
@@ -233,6 +233,14 @@ impl Game<'_> {
|
||||
self.sign_input(None, Some(text));
|
||||
return Ok(());
|
||||
}
|
||||
// The title's screens: the world name, the seed, a server's address.
|
||||
// A screen opened over the title (Options, say) keeps its own text.
|
||||
if title_owns_text(&self.screen, self.title.is_some())
|
||||
&& let Some(title) = &mut self.title
|
||||
{
|
||||
title.type_text(text);
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
@@ -1459,10 +1467,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The title's own screens (not one opened over it, as Options is) take the
|
||||
/// text: the world name, the seed, a server's address.
|
||||
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
|
||||
title_up && *screen == Screen::Playing
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_title_takes_text_only_on_its_own_screens() {
|
||||
assert!(title_owns_text(&Screen::Playing, true));
|
||||
assert!(!title_owns_text(&Screen::Playing, false));
|
||||
assert!(!title_owns_text(&Screen::Options, true));
|
||||
assert!(!title_owns_text(&Screen::Chat, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_names_the_protocol_the_release_and_the_command_groups() {
|
||||
let reply = hello();
|
||||
|
||||
@@ -263,6 +263,10 @@ pub(super) fn named_mob_pickable(
|
||||
alive && Some(id) != riding && Some(id) != camera
|
||||
}
|
||||
|
||||
/// What an entity drew on the GPU path: the skin frame's mark before its draws, and their
|
||||
/// camera-relative bounds (`cull_skinned`).
|
||||
pub(super) type Skinned = Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn player_view(&self) -> Option<PlayerView> {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -398,6 +402,19 @@ impl Game<'_> {
|
||||
at: [f64; 3],
|
||||
persistent: bool,
|
||||
scripted: bool,
|
||||
) -> Option<u32> {
|
||||
self.spawn_mob_reason(kind, at, persistent, scripted, !persistent)
|
||||
}
|
||||
/// `spawn_mob_for` with the spawn reason's NATURAL flag explicit: only a
|
||||
/// natural spawn rolls the jockey mounts of zombie horses, husks and
|
||||
/// drowned (`reinforcement` and spawner spawns pass `false`).
|
||||
pub(super) fn spawn_mob_reason(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
at: [f64; 3],
|
||||
persistent: bool,
|
||||
scripted: bool,
|
||||
natural: bool,
|
||||
) -> Option<u32> {
|
||||
let kind = if kind.contains(':') {
|
||||
kind.to_owned()
|
||||
@@ -427,10 +444,13 @@ impl Game<'_> {
|
||||
let id = mob.id;
|
||||
self.mobs.push(mob);
|
||||
self.native_mobs_joined(self.mobs.len() - 1, false);
|
||||
if !self.mobs.iter().any(|m| m.id == id) {
|
||||
return None;
|
||||
}
|
||||
self.enchant_spawn_equipment(id);
|
||||
self.halloween_spawn_head(id);
|
||||
if !persistent && !scripted {
|
||||
self.spawn_jockeys(id, true);
|
||||
self.spawn_jockeys(id, natural);
|
||||
}
|
||||
Some(id)
|
||||
}
|
||||
@@ -1049,9 +1069,9 @@ impl Game<'_> {
|
||||
for e in &mut events[from..] {
|
||||
if let MobEvent::Projectile(p) = e
|
||||
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
|
||||
&& *target == Who::Player
|
||||
&& *target == Some(Who::Player)
|
||||
{
|
||||
*target = Who::Mob(id);
|
||||
*target = Some(Who::Mob(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2106,6 +2126,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::PufferfishSting {
|
||||
mob,
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
poison_ticks,
|
||||
} => {
|
||||
// Pufferfish.touch: the poison and
|
||||
// `playSound(PUFFER_FISH_STING, 1, 1)` sit inside the
|
||||
// `hurtServer` branch, so only a hit that landed poisons
|
||||
// and stings, at the fish's own position as
|
||||
// `Entity.playSound` puts it, and not when it is silent.
|
||||
let landed = self.mob_hits_mob(target, amount, source);
|
||||
if landed && let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
t.add_effect("minecraft:poison", poison_ticks, 0);
|
||||
}
|
||||
if landed && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if !m.silent {
|
||||
self.handle_mob_events(vec![MobEvent::Sound(
|
||||
"entity.puffer_fish.sting",
|
||||
at,
|
||||
1.0,
|
||||
1.0,
|
||||
)])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::SonicBoom {
|
||||
target,
|
||||
attacker,
|
||||
@@ -2280,21 +2328,29 @@ impl Game<'_> {
|
||||
thrown.thrower = Some(thrower);
|
||||
}
|
||||
}
|
||||
MobEvent::DropItem { item, count, at } => self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
MobEvent::DropItem {
|
||||
mob,
|
||||
item,
|
||||
count,
|
||||
at,
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
),
|
||||
} => {
|
||||
self.native_entity_drop_item(mob, &item, count as i32, at);
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
MobEvent::DropGoatHorn {
|
||||
mob,
|
||||
screaming,
|
||||
@@ -4737,7 +4793,16 @@ impl Game<'_> {
|
||||
// flies as: the splash happens where the hit was, a lingering
|
||||
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
|
||||
// and the potion that lands names the hurt it deals.
|
||||
(ProjectileKind::Potion(stack, _), hit) => {
|
||||
(kind @ (ProjectileKind::Potion(..) | ProjectileKind::ExperienceBottle), hit) => {
|
||||
// A bottle flies as the bottle item (`getDefaultItem`).
|
||||
let bottle;
|
||||
let stack = match kind {
|
||||
ProjectileKind::Potion(stack, _) => stack,
|
||||
_ => {
|
||||
bottle = Box::new(ItemStack::new("minecraft:experience_bottle", 1));
|
||||
&bottle
|
||||
}
|
||||
};
|
||||
let block = match hit {
|
||||
Hit::Block { pos, face, .. } => Some((
|
||||
pos,
|
||||
@@ -5330,7 +5395,7 @@ impl Game<'_> {
|
||||
.map_or(2.0, |f| f.0 as f32 * 2.0);
|
||||
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
|
||||
// then playEatingSound, which wolves leave empty.
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
m.heal(heal);
|
||||
consume = true;
|
||||
}
|
||||
// Wolf.mobInteract: the owner puts wolf armor on a tamed adult.
|
||||
@@ -5468,7 +5533,7 @@ impl Game<'_> {
|
||||
.and_then(|i| i.food.as_ref())
|
||||
.map_or(1.0, |f| f.0 as f32);
|
||||
// TamableAnimal.feed.
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
m.heal(heal);
|
||||
consume = true;
|
||||
eat = eating_sound(m, &mut self.world.random);
|
||||
}
|
||||
@@ -5524,7 +5589,7 @@ impl Game<'_> {
|
||||
Species::IronGolem
|
||||
if item == "minecraft:iron_ingot" && m.health < m.attrs.max_health =>
|
||||
{
|
||||
m.health = (m.health + 25.0).min(m.attrs.max_health);
|
||||
m.heal(25.0);
|
||||
consume = true;
|
||||
sound = Some("entity.iron_golem.repair");
|
||||
}
|
||||
@@ -6259,16 +6324,13 @@ impl Game<'_> {
|
||||
/// poses entities: a new frame of draws, the store restarted when the
|
||||
/// renderer's no longer matches. Once per frame, before any entity draws.
|
||||
pub(super) fn begin_skinning(&self) {
|
||||
let stored = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.filter(|r| r.skins_entities())
|
||||
.and_then(|r| r.skinned_models());
|
||||
match stored {
|
||||
Some(stored) => self
|
||||
.skin_models
|
||||
.borrow_mut()
|
||||
.begin(&mut self.skin_frame.borrow_mut(), stored),
|
||||
let renderer = self.renderer.as_ref().filter(|r| r.skins_entities());
|
||||
match renderer.and_then(|r| r.skinned_models()) {
|
||||
Some(stored) => {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
models.begin(&mut self.skin_frame.borrow_mut(), stored);
|
||||
models.eyes = renderer.is_some_and(|r| r.skins_eyes());
|
||||
}
|
||||
None => self.skin_frame.borrow_mut().clear_draws(),
|
||||
}
|
||||
}
|
||||
@@ -6287,7 +6349,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
out: &mut Vec<Vertex>,
|
||||
entity_start: usize,
|
||||
skinned: Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>,
|
||||
skinned: Skinned,
|
||||
) {
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
@@ -6455,12 +6517,15 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
let body = out.len();
|
||||
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
|
||||
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
|
||||
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
|
||||
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
|
||||
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
|
||||
// then only the outline draws.
|
||||
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
|
||||
let posable = skinning
|
||||
&& !is_player
|
||||
&& !layers_only
|
||||
&& outline.is_none()
|
||||
&& silhouette_on_gpu
|
||||
&& armor.is_empty()
|
||||
&& !matches!(
|
||||
mob.species(),
|
||||
@@ -6539,12 +6604,18 @@ impl Game<'_> {
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
// Held items join the GPU path's draws, their silhouette too.
|
||||
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
if let Some(rgb) = outline {
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if let Some(((first, _, _), _)) = skinned {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.skin_frame.borrow_mut().outline_since(first, color);
|
||||
}
|
||||
if mob.invisible {
|
||||
out.truncate(entity_start);
|
||||
continue;
|
||||
|
||||
@@ -1100,6 +1100,7 @@ impl Game<'_> {
|
||||
| K::EnderPearl
|
||||
| K::LlamaSpit
|
||||
| K::Potion { .. }
|
||||
| K::ExperienceBottle
|
||||
| K::Thrown(_) => (0.25, 0.2125),
|
||||
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
|
||||
(0.3125, 0.265_625)
|
||||
|
||||
@@ -15,8 +15,8 @@ use horizon_client::{
|
||||
properties::{GAME_MODES, ServerProperties, SystemEntropy},
|
||||
user_cache::UserCache,
|
||||
user_lists::{
|
||||
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
|
||||
format_date,
|
||||
BanEntry, BanList, Change, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry,
|
||||
WhiteList, format_date,
|
||||
},
|
||||
},
|
||||
text::Text,
|
||||
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The event a change of a list is (`operators_added`, `bans_removed`...), and the entry it is about.
|
||||
fn listed<E>(list: &str, change: Change<E>) -> (String, E) {
|
||||
match change {
|
||||
Change::Added(e) => (format!("{list}_added"), e),
|
||||
Change::Removed(e) => (format!("{list}_removed"), e),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a ban says besides who it is for: the reason and the source, and when it ends (milliseconds, or none).
|
||||
fn ban_fields<T>(ban: &BanEntry<T>) -> serde_json::Value {
|
||||
serde_json::json!({ "reason": ban.reason, "source": ban.source, "expires": ban.expires })
|
||||
}
|
||||
|
||||
/// The operator, whitelist and ban lists (DedicatedPlayerList), shared with
|
||||
/// the connection threads that check logins.
|
||||
pub(super) struct Lists {
|
||||
@@ -431,6 +444,30 @@ impl Lists {
|
||||
enforce_whitelist: false,
|
||||
op_level: 4,
|
||||
};
|
||||
// Each list tells the event stream what changes in it, as vanilla's tell their `NotificationService`.
|
||||
l.ops.watch(|change| {
|
||||
let (kind, e) = listed("operators", change);
|
||||
events::record(
|
||||
&kind,
|
||||
&e.user.name,
|
||||
e.user.id,
|
||||
serde_json::json!({ "level": e.level, "bypassesPlayerLimit": e.bypasses_player_limit }),
|
||||
);
|
||||
});
|
||||
l.white.watch(|change| {
|
||||
let (kind, e) = listed("allowlist", change);
|
||||
events::record(&kind, &e.user.name, e.user.id, serde_json::json!({}));
|
||||
});
|
||||
l.bans.watch(|change| {
|
||||
let (kind, e) = listed("bans", change);
|
||||
events::record(&kind, &e.user.name, e.user.id, ban_fields(&e));
|
||||
});
|
||||
l.ip_bans.watch(|change| {
|
||||
let (kind, e) = listed("ip_bans", change);
|
||||
let mut fields = ban_fields(&e);
|
||||
fields["ip"] = e.user.clone().into();
|
||||
events::record_server(&kind, fields);
|
||||
});
|
||||
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
|
||||
let warn = |what: &str, r: std::result::Result<(), String>| {
|
||||
if let Err(e) = r {
|
||||
@@ -853,6 +890,9 @@ impl Game<'_> {
|
||||
self.settle_spawn()?;
|
||||
let gate_lists = lists.clone();
|
||||
let gate: LoginGate = Arc::new(move |address, uuid, name| {
|
||||
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, however it ends. The management
|
||||
// protocol's `server/activity` notification is rate limited from these.
|
||||
events::record_server("login_activity", serde_json::json!({}));
|
||||
let user = NameAndId {
|
||||
id: uuid,
|
||||
name: name.to_owned(),
|
||||
@@ -1794,6 +1834,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setMaxPlayers`: the live login limit, and `max-players` in
|
||||
/// server.properties.
|
||||
fn set_max_players(&mut self, state: &mut ServerState, max_players: usize) {
|
||||
state.max_players = max_players;
|
||||
if let Some(lan) = &self.lan {
|
||||
lan.hub.lan.shared.set_max_players(max_players);
|
||||
}
|
||||
self.store_property("max-players", max_players.to_string());
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().enforce_whitelist = on;
|
||||
self.store_property("enforce-whitelist", on.to_string());
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
pub(super) fn store_property(&mut self, key: &str, value: String) {
|
||||
let path = PathBuf::from("server.properties");
|
||||
|
||||
@@ -21,11 +21,21 @@ static EVENTS: Mutex<Events> = Mutex::new(Events {
|
||||
|
||||
/// Records one event: its kind, the player's name and UUID, and what else it says.
|
||||
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save; an IP
|
||||
/// ban), and what else it says.
|
||||
pub(in crate::game) fn record_server(kind: &str, fields: Value) {
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
fn push(kind: &str, mut fields: Value) {
|
||||
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
fields["seq"] = events.next.into();
|
||||
fields["type"] = kind.into();
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
fields["time"] = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
@@ -52,6 +62,7 @@ pub(super) fn since(since: u64) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// One test: the events are one process-wide list, so two tests recording at once would count each other's.
|
||||
#[test]
|
||||
fn events_are_numbered_and_read_from_a_number() {
|
||||
let first = since(u64::MAX)["next"].as_u64().unwrap();
|
||||
@@ -65,5 +76,11 @@ mod tests {
|
||||
assert_eq!(events[1]["message"], "hi");
|
||||
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
|
||||
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
|
||||
// An event of the server itself (a world save) carries no player.
|
||||
record_server("saving", json!({}));
|
||||
let event = &since(first + 2)["events"][0];
|
||||
assert_eq!(event["type"], "saving");
|
||||
assert_eq!(event["seq"].as_u64(), Some(first + 3));
|
||||
assert!(event.get("name").is_none() && event.get("uuid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
command::vanilla::DIFFICULTIES,
|
||||
gamerules::{self, Value as GameRuleValue},
|
||||
server::{
|
||||
profiles,
|
||||
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
@@ -29,6 +30,15 @@ struct Op {
|
||||
bypass: Option<bool>,
|
||||
}
|
||||
|
||||
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
|
||||
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
|
||||
let (kind, value) = match value {
|
||||
GameRuleValue::Bool(b) => ("boolean", json!(b)),
|
||||
GameRuleValue::Int(i) => ("integer", json!(i)),
|
||||
};
|
||||
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
|
||||
}
|
||||
|
||||
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
|
||||
fn shown(user: &NameAndId) -> String {
|
||||
format!(
|
||||
@@ -49,6 +59,62 @@ impl Game<'_> {
|
||||
let entries = body["entries"].as_array().cloned().unwrap_or_default();
|
||||
let lists = state.lists.clone();
|
||||
match body["method"].as_str().unwrap_or_default() {
|
||||
// `GameRulesService.get`: every rule the server has, with its type and current value.
|
||||
"gamerules" => {
|
||||
let rules = &self.ui.rules;
|
||||
let all: Vec<Value> = rules
|
||||
.available_rules()
|
||||
.map(|r| typed_rule(r, rules.get(r.name).unwrap_or(r.default)))
|
||||
.collect();
|
||||
return json!({ "value": all });
|
||||
}
|
||||
// `GameRulesService.update` through `MinecraftGameRuleServiceImpl.updateGameRule`: the rule is set,
|
||||
// logged and told on every update, even to the value it has (as vanilla's sends its notification
|
||||
// then too, which the parity script checks). What doesn't fit answers with the texts of vanilla's
|
||||
// codecs, as the parity script saw them.
|
||||
"gamerules/update" => {
|
||||
let update = &body["value"];
|
||||
let Some(key) = update["key"].as_str() else {
|
||||
return json!({ "invalid": format!("No key key in MapLike[{update}]") });
|
||||
};
|
||||
let rule = gamerules::rule(key).filter(|r| self.ui.rules.get(r.name).is_some());
|
||||
let Some(rule) = rule else {
|
||||
let key = if key.contains(':') {
|
||||
key.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{key}")
|
||||
};
|
||||
return json!({ "invalid": format!(
|
||||
"Unknown registry key in ResourceKey[minecraft:root / minecraft:game_rule]: {key}"
|
||||
) });
|
||||
};
|
||||
let wanted = &update["value"];
|
||||
let value = match (rule.default, wanted) {
|
||||
(GameRuleValue::Bool(_), Value::Bool(b)) => Ok(GameRuleValue::Bool(*b)),
|
||||
(GameRuleValue::Bool(_), other) => Err(format!("Not a boolean: {other}")),
|
||||
(GameRuleValue::Int(_), other) => match other.as_i64() {
|
||||
Some(n) if (i64::from(rule.min)..=i64::from(rule.max)).contains(&n) => {
|
||||
Ok(GameRuleValue::Int(n as i32))
|
||||
}
|
||||
Some(n) => Err(format!(
|
||||
"Value {n} outside of range [{}:{}]",
|
||||
rule.min, rule.max
|
||||
)),
|
||||
None => Err(format!("Not a number: {other}")),
|
||||
},
|
||||
};
|
||||
let value = match value {
|
||||
Ok(value) => value,
|
||||
Err(invalid) => return json!({ "invalid": invalid }),
|
||||
};
|
||||
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
|
||||
rpc(format!(
|
||||
"Game rule '{}' updated from '{current}' to '{value}'",
|
||||
rule.name
|
||||
));
|
||||
self.set_game_rule(rule, value);
|
||||
return json!({ "value": typed_rule(rule, value) });
|
||||
}
|
||||
"serversettings/autosave" => {
|
||||
return json!({ "value": auto_save(state) });
|
||||
}
|
||||
@@ -120,6 +186,63 @@ impl Game<'_> {
|
||||
self.set_force_game_mode(force);
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/max_players" => {
|
||||
return json!({ "value": state.max_players });
|
||||
}
|
||||
"serversettings/max_players/set" => {
|
||||
let Some(max_players) = body["value"]
|
||||
.as_i64()
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
else {
|
||||
return json!({ "error": "max players must be a non-negative integer" });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update max players from {} to {}",
|
||||
state.max_players, max_players
|
||||
));
|
||||
self.set_max_players(state, max_players);
|
||||
return json!({ "value": state.max_players });
|
||||
}
|
||||
"serversettings/use_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/use_allowlist/set" => {
|
||||
let Some(use_allowlist) = body["value"].as_bool() else {
|
||||
return json!({ "error": "using allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().use_whitelist;
|
||||
rpc(format!(
|
||||
"Update using allowlist from {} to {}",
|
||||
current, use_allowlist
|
||||
));
|
||||
self.set_using_whitelist(&lists, use_allowlist);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist/set" => {
|
||||
let Some(enforce) = body["value"].as_bool() else {
|
||||
return json!({ "error": "enforce allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().enforce_whitelist;
|
||||
rpc(format!(
|
||||
"Update enforce allowlist from {} to {}",
|
||||
current, enforce
|
||||
));
|
||||
self.set_enforce_whitelist(&lists, enforce);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
@@ -451,3 +574,22 @@ fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
|
||||
reason,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_game_rule_is_written_as_vanillas_typed_codec_does() {
|
||||
let pvp = gamerules::rule("pvp").unwrap();
|
||||
assert_eq!(
|
||||
typed_rule(pvp, GameRuleValue::Bool(false)),
|
||||
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
|
||||
);
|
||||
let tick = gamerules::rule("minecraft:random_tick_speed").unwrap();
|
||||
assert_eq!(
|
||||
typed_rule(tick, GameRuleValue::Int(3)),
|
||||
json!({ "key": "minecraft:random_tick_speed", "type": "integer", "value": 3 })
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -327,6 +327,7 @@ mod tests {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ mod tests {
|
||||
// A shulker bullet takes a blow, with no pick radius round its box.
|
||||
let bullet = Projectile::new(
|
||||
ProjectileKind::ShulkerBullet {
|
||||
target: Who::Player,
|
||||
target: Some(Who::Player),
|
||||
dir: None,
|
||||
steps: 0,
|
||||
delta: [0.0; 3],
|
||||
|
||||
@@ -1216,6 +1216,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.boats.push(boat);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let boat = self.boats.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
boat.entity_type().as_str(),
|
||||
[boat.position.x, boat.position.y, boat.position.z],
|
||||
);
|
||||
}
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
/// `MinecartDispenseItemBehavior`.
|
||||
@@ -1255,6 +1262,13 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.carts.push(cart);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
cart.kind.id(),
|
||||
[cart.position.x, cart.position.y, cart.position.z],
|
||||
);
|
||||
}
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
|
||||
|
||||
@@ -513,6 +513,9 @@ impl Game<'_> {
|
||||
// `/summon ender_dragon` outside the End: no fight, but the dragon flies.
|
||||
self.tick_fight();
|
||||
}
|
||||
// The spikes' crystals (placed above) join before they light a fire:
|
||||
// a mod that cancels one keeps its fire out too.
|
||||
self.native_joins_flush(false, 0);
|
||||
self.tick_crystals();
|
||||
self.tick_eyes();
|
||||
if self.dim == Dim::End {
|
||||
@@ -1456,6 +1459,7 @@ impl Game<'_> {
|
||||
// A gateway keeps the item in its level (teleportSameDimension
|
||||
// moves it): it doesn't leave, so no native_entity_leave_level.
|
||||
self.dropped.remove(i);
|
||||
self.native_leave_skip(id);
|
||||
self.detach_entity(id);
|
||||
tag
|
||||
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
@@ -1479,11 +1483,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs.retain(|m| !gone.contains(&m.id));
|
||||
for g in gone {
|
||||
self.native_leave_skip(g);
|
||||
self.detach_entity(g);
|
||||
}
|
||||
tag
|
||||
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
|
||||
let pearl = Box::new(self.creatures.projectiles.remove(i));
|
||||
self.native_leave_skip(id);
|
||||
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
|
||||
let arrival = GatewayArrival {
|
||||
traveller: Arriving::Pearl(pearl),
|
||||
|
||||
@@ -996,8 +996,7 @@ impl Entity for ThrownTrident {
|
||||
!self.in_ground || self.previous != self.position
|
||||
}
|
||||
fn selector_target(&self) -> Option<Target> {
|
||||
// No `Target` variant for it yet: `@e` does not find tridents.
|
||||
None
|
||||
Some(Target::Trident(self.id))
|
||||
}
|
||||
/// Like any arrow (`AbstractArrow.push`) not pushed while in the ground.
|
||||
fn pushed_by_explosion(&self) -> bool {
|
||||
@@ -1078,3 +1077,18 @@ mod no_gravity_stand_tests {
|
||||
assert!(!stand.triggers_blocks());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trident_selector_tests {
|
||||
use super::Entity as _;
|
||||
use crate::game::{commands::Target, trident::ThrownTrident};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
/// `@e`, `/kill` and `/data` reach a thrown trident like any other entity
|
||||
/// (`EntityTypeTest.forClass(Entity.class)`: every entity in the level).
|
||||
#[test]
|
||||
fn a_thrown_trident_is_a_selector_target() {
|
||||
let t = ThrownTrident::remote(9, Vec3::new(0.5, 100.0, 0.5), 0.0, 0.0);
|
||||
assert_eq!(t.selector_target(), Some(Target::Trident(9)));
|
||||
}
|
||||
}
|
||||
@@ -623,7 +623,6 @@ impl Game<'_> {
|
||||
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
|
||||
spared: exploding.filter(|_| self.combat.explosion_source_named),
|
||||
};
|
||||
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
|
||||
let (items_len, orbs_len) = (self.dropped.len(), self.orbs.len());
|
||||
let (mut items_kept, mut orbs_kept) = (0, 0);
|
||||
let (mut boats_gone, mut carts_gone) = (0, 0);
|
||||
@@ -723,8 +722,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.dropped.drain(items_kept..items_len);
|
||||
self.orbs.drain(orbs_kept..orbs_len);
|
||||
self.native_items_left_since(&items_before);
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
self.native_leaves_flush();
|
||||
// Frames, paintings, cushions and armour stands last: vanilla lists
|
||||
// the entities in reach before hurting any, so the items they drop
|
||||
// here are neither destroyed nor pushed by the same blast.
|
||||
@@ -856,6 +854,9 @@ impl Game<'_> {
|
||||
damage_type: &str,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
) -> Result<()> {
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_explosion_prime(center, radius);
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
let d = [
|
||||
p.position.x - center[0],
|
||||
|
||||
@@ -36,6 +36,7 @@ pub(super) struct Hook {
|
||||
pub(super) previous: Vec3,
|
||||
velocity: Vec3,
|
||||
state: State,
|
||||
tick_count: i32,
|
||||
life: i32,
|
||||
on_ground: bool,
|
||||
nibble: i32,
|
||||
@@ -64,6 +65,67 @@ fn bounds(p: Vec3) -> Aabb {
|
||||
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
|
||||
)
|
||||
}
|
||||
fn segment_entry(start: Vec3, end: Vec3, bounds: Aabb) -> Option<f64> {
|
||||
let min = bounds.min_position();
|
||||
let max = bounds.max_position();
|
||||
let start = [start.x, start.y, start.z];
|
||||
let min = [min.x, min.y, min.z];
|
||||
let max = [max.x, max.y, max.z];
|
||||
if (0..3).all(|axis| start[axis] >= min[axis] && start[axis] < max[axis]) {
|
||||
return None;
|
||||
}
|
||||
let delta = [end.x - start[0], end.y - start[1], end.z - start[2]];
|
||||
let mut entry: f64 = 0.0;
|
||||
let mut exit: f64 = 1.0;
|
||||
for axis in 0..3 {
|
||||
if delta[axis] == 0.0 {
|
||||
if start[axis] < min[axis] || start[axis] > max[axis] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = (min[axis] - start[axis]) / delta[axis];
|
||||
let b = (max[axis] - start[axis]) / delta[axis];
|
||||
entry = entry.max(a.min(b));
|
||||
exit = exit.min(a.max(b));
|
||||
if entry > exit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(entry <= 1.0 && exit >= 0.0).then_some(entry)
|
||||
}
|
||||
/// `ProjectileUtil.computeMargin`: the fishing hook's target-box margin grows
|
||||
/// to 0.3 blocks over its first eight ticks.
|
||||
fn entity_hit_margin(tick_count: i32) -> f64 {
|
||||
f64::from((tick_count - 2).clamp(0, 6)) / 20.0
|
||||
}
|
||||
fn first_entity_hit(
|
||||
start: Vec3,
|
||||
end: Vec3,
|
||||
margin: f64,
|
||||
targets: impl IntoIterator<Item = (u32, Aabb)>,
|
||||
) -> Option<u32> {
|
||||
let mut hit = None;
|
||||
let mut hit_at = f64::INFINITY;
|
||||
for (id, target_bounds) in targets {
|
||||
if let Some(at) = segment_entry(
|
||||
start,
|
||||
end,
|
||||
target_bounds.inflate(Vec3::new(margin, margin, margin)),
|
||||
) && at < hit_at
|
||||
{
|
||||
hit = Some(id);
|
||||
hit_at = at;
|
||||
}
|
||||
}
|
||||
hit
|
||||
}
|
||||
fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
|
||||
item.velocity = item.velocity.add_vector(delta);
|
||||
// The change is made outside ItemEntity::tick, so its velocity comparison
|
||||
// cannot request the immediate motion update for tracking clients.
|
||||
item.needs_sync = true;
|
||||
}
|
||||
impl Hook {
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
bounds(self.position)
|
||||
@@ -170,6 +232,7 @@ impl Game<'_> {
|
||||
previous: start,
|
||||
velocity: v,
|
||||
state: State::Flying,
|
||||
tick_count: 0,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
@@ -221,7 +284,7 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity = pulled(m.velocity);
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
pull_item(item, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1));
|
||||
hooked_item = true;
|
||||
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
|
||||
t.velocity = pulled(t.velocity);
|
||||
@@ -566,6 +629,7 @@ impl Game<'_> {
|
||||
if !self.holds_rod() || d2 > 1024.0 {
|
||||
return;
|
||||
}
|
||||
h.tick_count += 1;
|
||||
// The players it can catch, as they stand now (the host's own hook;
|
||||
// guests' hooks use the ones taken as their turns start).
|
||||
if self.lan_acting.is_none()
|
||||
@@ -600,58 +664,67 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob or other player it
|
||||
// touches, or (via `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touches = |b: Aabb| {
|
||||
let (a, z) = (b.min_position(), b.max_position());
|
||||
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
|
||||
// checkCollision: find the first block and entity on the movement
|
||||
// segment so a fast hook cannot tunnel or catch through a wall.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let block_hit = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
let hit = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
let owner = self.lan_acting;
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.conn != owner && touches(t.bounds))
|
||||
.map(|t| t.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
|
||||
// ArmorStand.isPickable is false for a marker.
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
|
||||
.map(|a| a.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
|
||||
// boat the owner rides (approximating `leftOwner`, as the
|
||||
// players above skip their owner).
|
||||
let own = self.own_vehicle();
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| Some(b.id) != own && touches(b.bounds()))
|
||||
.map(|b| b.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| touches(i.bounds()))
|
||||
.map(|i| i.id)
|
||||
})
|
||||
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
|
||||
.or_else(|| {
|
||||
self.tnts
|
||||
.iter()
|
||||
.find(|t| t.id != 0 && touches(t.bounds()))
|
||||
.map(|t| t.id)
|
||||
});
|
||||
let entity_end = block_hit.map_or(to, |hit| hit.location);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
let hit = first_entity_hit(
|
||||
h.position,
|
||||
entity_end,
|
||||
entity_hit_margin(h.tick_count),
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (m.id, m.bounds()))
|
||||
.chain(
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.filter(|t| t.conn != owner)
|
||||
.map(|t| (t.id, t.bounds)),
|
||||
)
|
||||
.chain(
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.filter(|a| a.health > 0.0 && !a.marker)
|
||||
.map(|a| (a.id, a.bounds())),
|
||||
)
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| Some(b.id) != own_vehicle)
|
||||
.map(|b| (b.id, b.bounds())),
|
||||
)
|
||||
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
|
||||
.chain(
|
||||
self.tnts
|
||||
.iter()
|
||||
.filter(|t| t.id != 0)
|
||||
.map(|t| (t.id, t.bounds())),
|
||||
),
|
||||
);
|
||||
if let Some(id) = hit {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
@@ -664,26 +737,7 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: the block the path hits before the move, then
|
||||
// `onHitBlock`'s rebound.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
});
|
||||
if let Some(hit) = hit.ok().flatten() {
|
||||
if let Some(hit) = block_hit {
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
@@ -1124,6 +1178,7 @@ impl Hook {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
state: State::Flying,
|
||||
tick_count: 0,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
@@ -1419,8 +1474,9 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rebound;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use super::{entity_hit_margin, first_entity_hit, pull_item, rebound};
|
||||
use horizon_client::{entities::ItemEntity, items::ItemStack};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
|
||||
#[test]
|
||||
@@ -1429,4 +1485,86 @@ mod tests {
|
||||
let hit = Vec3::new(0.0, 0.0, -0.5);
|
||||
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reeling_an_item_requests_an_immediate_motion_update() {
|
||||
let mut item = ItemEntity {
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
id: 1,
|
||||
stack: ItemStack::new("minecraft:stone", 1),
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: Vec3::ZERO,
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::new(0.2, 0.0, -0.1),
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
};
|
||||
|
||||
pull_item(&mut item, Vec3::new(0.5, 0.1, -0.2));
|
||||
|
||||
assert!((item.velocity.x - 0.7).abs() < 1.0e-12);
|
||||
assert!((item.velocity.y - 0.1).abs() < 1.0e-12);
|
||||
assert!((item.velocity.z + 0.3).abs() < 1.0e-12);
|
||||
assert!(item.needs_sync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_hits_a_small_target_between_ticks() {
|
||||
let target = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
|
||||
|
||||
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.8, 0.0, 0.0), target).is_some());
|
||||
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.4, 0.0, 0.0), target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_uses_vanillas_growing_entity_hit_margin() {
|
||||
let start = Vec3::ZERO;
|
||||
let end = Vec3::new(1.0, 0.0, 0.0);
|
||||
let target = Aabb::new(Vec3::new(0.5, 0.31, -0.1), Vec3::new(0.7, 0.5, 0.1));
|
||||
|
||||
assert_eq!(entity_hit_margin(2), 0.0);
|
||||
assert_eq!(entity_hit_margin(8), 0.3);
|
||||
assert_eq!(
|
||||
first_entity_hit(start, end, entity_hit_margin(8), [(1, target)]),
|
||||
None
|
||||
);
|
||||
let within_margin = Aabb::new(Vec3::new(0.5, 0.29, -0.1), Vec3::new(0.7, 0.5, 0.1));
|
||||
assert_eq!(
|
||||
first_entity_hit(start, end, entity_hit_margin(8), [(1, within_margin)]),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_does_not_hit_a_target_it_starts_inside() {
|
||||
let target = Aabb::new(Vec3::new(-0.1, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
|
||||
|
||||
assert_eq!(
|
||||
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, [(1, target)]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_uses_the_first_target_along_its_path() {
|
||||
let start = Vec3::ZERO;
|
||||
let end = Vec3::new(1.0, 0.0, 0.0);
|
||||
let near = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
|
||||
let far = Aabb::new(Vec3::new(0.8, -0.1, -0.1), Vec3::new(0.95, 0.15, 0.1));
|
||||
|
||||
assert_eq!(
|
||||
first_entity_hit(start, end, 0.0, [(2, far), (1, near)]),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,14 @@ use horizon_client::{
|
||||
/// `Abilities.walkingSpeed`.
|
||||
const WALKING_SPEED: f32 = 0.1;
|
||||
|
||||
fn valid_fov_modifier(modifier: f32, fallback: f32) -> f32 {
|
||||
if modifier.is_finite() && (0.01..=10.0).contains(&modifier) {
|
||||
modifier
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Player.isScoping`: using a spyglass.
|
||||
pub(super) fn scoping(&self) -> bool {
|
||||
@@ -75,9 +83,12 @@ impl Game<'_> {
|
||||
pub(super) fn tick_fov(&mut self) {
|
||||
let first_person = self.camera_type.first_person();
|
||||
let effect = self.fov_effect_scale();
|
||||
let target = self
|
||||
.fov_player()
|
||||
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
|
||||
let target = self.fov_player().map_or(1.0, |player| {
|
||||
let base = field_of_view_modifier(&player, first_person, effect);
|
||||
let mut modifier = base;
|
||||
self.native_compute_fov_modifier(&player, base, &mut modifier);
|
||||
valid_fov_modifier(modifier, base)
|
||||
});
|
||||
let target = self.zoomed_fov_target(target);
|
||||
// A server's spectator camera on a mob: its eye height. Its modifier
|
||||
// stays 1 (another player's own modifier is not modelled here); the
|
||||
@@ -219,3 +230,15 @@ impl Game<'_> {
|
||||
hud_layout::paint(batch, &self.gui.atlas, &draws);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::valid_fov_modifier;
|
||||
#[test]
|
||||
fn modifier_validation() {
|
||||
assert_eq!(valid_fov_modifier(0.5, 1.0), 0.5);
|
||||
for value in [f32::NAN, f32::INFINITY, 0.0, 10.1] {
|
||||
assert_eq!(valid_fov_modifier(value, 1.0), 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -593,6 +593,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
if let Some(stack) = &f.item {
|
||||
let mut canceled = false;
|
||||
self.native_render_item_in_frame(f.id, f.pos, stack, &mut canceled);
|
||||
if canceled {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(id) = map {
|
||||
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
|
||||
let depth = if f.invisible { 0.5 } else { 0.4375 };
|
||||
@@ -623,6 +630,9 @@ impl Game<'_> {
|
||||
items.push((stack, f.id, m.as_mat4(), item_light));
|
||||
}
|
||||
// `extractRenderState`: FIXED, seeded by the frame entity.
|
||||
let skinning = self.skinning();
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
for (stack, id, m, light) in items {
|
||||
let entity_start = out.len();
|
||||
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
|
||||
@@ -632,13 +642,15 @@ impl Game<'_> {
|
||||
Some(&owner),
|
||||
id as i32,
|
||||
);
|
||||
self.submit_item_world(
|
||||
&state,
|
||||
m,
|
||||
horizon_client::block_entity_render::vertex_light(light),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
let light = horizon_client::block_entity_render::vertex_light(light);
|
||||
// Posed on the GPU when every layer can be.
|
||||
let skinned = skinning
|
||||
.then(|| self.submit_item_skinned(&state, &[m], light, lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
self.submit_item_world(&state, m, light, out);
|
||||
}
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
for (id, m, light) in maps {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -478,20 +478,59 @@ impl Game<'_> {
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let sink = (
|
||||
item_hdr_light(state, light),
|
||||
(state.hdr_light == 0).then_some(lights),
|
||||
state.hdr_tier,
|
||||
);
|
||||
for &pose in poses {
|
||||
let renderer = &self.pack.item_renderer;
|
||||
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
|
||||
if !self.skin_item(state, pose, light, lights, &mut skin) {
|
||||
// Decided before any draw, the same for every pose.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((mark, skin.bounds))
|
||||
}
|
||||
/// One pose of [`Self::submit_item_skinned`] into an entity's open
|
||||
/// `skin` (its bounds grow by the item's): false, with nothing drawn,
|
||||
/// when the item cannot take the GPU path.
|
||||
pub(super) fn skin_item(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
lights: (glam::Vec3, glam::Vec3),
|
||||
skin: &mut horizon_client::mobs::skin::Skin,
|
||||
) -> bool {
|
||||
let sink = (
|
||||
item_hdr_light(state, light),
|
||||
(state.hdr_light == 0).then_some(lights),
|
||||
state.hdr_tier,
|
||||
);
|
||||
let renderer = &self.pack.item_renderer;
|
||||
state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, skin)
|
||||
}
|
||||
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
|
||||
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
|
||||
/// nothing drawn, when the state cannot take it.
|
||||
pub(super) fn skin_item_into(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
skinned: &mut super::creatures::Skinned,
|
||||
) -> bool {
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
|
||||
return false;
|
||||
};
|
||||
*skinned = Some(match skinned.take() {
|
||||
Some((first, before)) => (
|
||||
first,
|
||||
match (before, bounds) {
|
||||
(Some((a, b)), Some((c, d))) => Some((a.min(c), b.max(d))),
|
||||
(one, other) => one.or(other),
|
||||
},
|
||||
),
|
||||
None => (mark, bounds),
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
@@ -579,8 +618,8 @@ impl Game<'_> {
|
||||
seed,
|
||||
);
|
||||
let glowing = self.entity_glowing(item.id);
|
||||
// Posed on the GPU when every layer can be (the outline reads the CPU's vertices).
|
||||
let skinned = (skinning && !glowing)
|
||||
// Posed on the GPU when every layer can be, its silhouette too when it glows.
|
||||
let skinned = skinning
|
||||
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
@@ -591,6 +630,10 @@ impl Game<'_> {
|
||||
if glowing {
|
||||
let rgb = super::glowing::team_color(self.mob_team(item.id));
|
||||
self.mark_outline(&out[entity_start..], rgb);
|
||||
if let Some(((first, _, _), _)) = skinned {
|
||||
let color = horizon_client::mobs::render::abgr(rgb);
|
||||
self.skin_frame.borrow_mut().outline_since(first, color);
|
||||
}
|
||||
} else {
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
|
||||
@@ -1304,6 +1304,7 @@ impl Game<'_> {
|
||||
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
|
||||
let lan = Lan::start_with(sync.clone(), net)?;
|
||||
let mut hub = Hub::new(lan, sync, encoder, config);
|
||||
hub.sync_attachments = !crate::mod_api::mods().is_empty();
|
||||
hub.flat_world = self.world_flat;
|
||||
hub.debug_world = self.world_debug;
|
||||
let port = hub.lan.port;
|
||||
@@ -1417,10 +1418,14 @@ impl Game<'_> {
|
||||
guest: &mut horizon_client::lan::guest::Guest,
|
||||
) {
|
||||
self.guest_attachments_into(guest);
|
||||
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
playerdata::save_owner(guest, &mut self.level, &self.world_root);
|
||||
} else if guest.placed {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
if guest.placed {
|
||||
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
|
||||
playerdata::save_owner(guest, &mut self.level, &self.world_root);
|
||||
} else {
|
||||
playerdata::save(guest, &self.world_root);
|
||||
}
|
||||
let directory = playerdata::directory(&self.world_root);
|
||||
self.native_player_save_to_file(guest.uuid, &guest.name, &directory);
|
||||
}
|
||||
self.save_guest_records(host, guest);
|
||||
}
|
||||
@@ -1616,35 +1621,28 @@ impl Game<'_> {
|
||||
host.play.entry(conn).or_default().stats = stats;
|
||||
}
|
||||
/// The integrated server's owner into the world's player, for `Level::write`.
|
||||
pub(super) fn store_owner(&mut self) {
|
||||
let Some(uuid) = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.owner.as_ref())
|
||||
.map(|o| o.uuid)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some((riding, dim, others)) = self.lan.as_ref().and_then(|h| {
|
||||
/// Returns the owner's uuid and name when they were stored.
|
||||
pub(super) fn store_owner(&mut self) -> Option<(u128, String)> {
|
||||
let uuid = self.lan.as_ref().and_then(|h| h.owner.as_ref())?.uuid;
|
||||
let (riding, dim, others) = self.lan.as_ref().and_then(|h| {
|
||||
let g = h.hub.guests.iter().find(|g| g.uuid == uuid && g.placed)?;
|
||||
let others = self.ridden_by_others(&h.hub.guests, &g.dimension, Some(uuid), true);
|
||||
Some((g.vehicle, g.dimension.clone(), others))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
})?;
|
||||
// The vehicle the owner rides (`ServerPlayer.saveParentVehicle`).
|
||||
let vehicle = self
|
||||
.parent_vehicle_in(None, &dim, riding, &others, false)
|
||||
.map(|(tag, _)| tag);
|
||||
let Some(host) = &self.lan else {
|
||||
return;
|
||||
};
|
||||
let host = self.lan.as_ref()?;
|
||||
let mut stored = None;
|
||||
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == uuid && g.placed) {
|
||||
playerdata::store(g, &mut self.level, vehicle);
|
||||
self.save_guest_records(host, g);
|
||||
stored = Some(g.name.clone());
|
||||
} else if let Some(saved) = host.owner.as_ref().and_then(|o| o.saved.as_ref()) {
|
||||
self.level.position = saved.position;
|
||||
}
|
||||
stored.map(|name| (uuid, name))
|
||||
}
|
||||
|
||||
/// The world's default game mode (`defaultgamemode`).
|
||||
@@ -1948,6 +1946,47 @@ impl Game<'_> {
|
||||
HostEvent::Flight { conn, flying } => {
|
||||
self.lan_flight_toggled(&mut host, conn, flying);
|
||||
}
|
||||
HostEvent::Sneak { conn, sneaking } => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_toggle_sneak(uuid, &name, sneaking);
|
||||
});
|
||||
}
|
||||
}
|
||||
HostEvent::Kicked {
|
||||
conn,
|
||||
player,
|
||||
name,
|
||||
reason,
|
||||
} => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The same poll can have removed the guest (their `Left`): the mods hear the
|
||||
// kick anyway, without the guest's state swapped in.
|
||||
if host.hub.guest(conn).is_some() {
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_kicked(player, &name, &reason);
|
||||
});
|
||||
} else {
|
||||
self.native_player_kicked(player, &name, &reason);
|
||||
}
|
||||
}
|
||||
HostEvent::Input { conn, flags } => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_input(uuid, &name, flags);
|
||||
});
|
||||
}
|
||||
}
|
||||
HostEvent::MainHandChanged(conn, left) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
@@ -1960,6 +1999,25 @@ impl Game<'_> {
|
||||
self.native_player_locale_change(uuid, &name, &locale);
|
||||
}
|
||||
}
|
||||
HostEvent::PlayerLoaded(conn) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_client_loaded_world(uuid, &name);
|
||||
}
|
||||
}
|
||||
HostEvent::RecipeBookSettings {
|
||||
conn,
|
||||
book,
|
||||
open,
|
||||
filtering,
|
||||
} => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_recipe_book_settings_change(
|
||||
uuid, &name, book, open, filtering,
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Packet(conn, packet) => {
|
||||
self.lan_guest_packet(&mut host, conn, *packet);
|
||||
if !host.commands.is_empty() {
|
||||
@@ -2011,6 +2069,7 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first {
|
||||
host.hub.poll(&mut ctx)
|
||||
@@ -2261,6 +2320,7 @@ impl Game<'_> {
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
// Player packets may already have been drained before
|
||||
// scheduled block ticks (`tick_lan_packets`).
|
||||
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first && !pass.packets_drained {
|
||||
host.hub.poll(&mut ctx)
|
||||
@@ -2350,6 +2410,7 @@ impl Game<'_> {
|
||||
self.lan_sync_border(&mut host);
|
||||
host.hub.anticheat_ping();
|
||||
drop(holding);
|
||||
self.flush_attachment_sync(&mut host);
|
||||
self.flush_native_net(&mut host);
|
||||
self.flush_far_terrain(&mut host);
|
||||
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
|
||||
@@ -2385,6 +2446,9 @@ impl Game<'_> {
|
||||
};
|
||||
self.guest_attachments
|
||||
.insert(g.uuid, std::mem::take(&mut g.attachments));
|
||||
// PlayerEvent.LoadFromFile: the player's data is read and applied,
|
||||
// the player not yet placed.
|
||||
self.native_player_load_from_file(g.uuid, &g.name, &playerdata::directory(&root));
|
||||
if !known || force {
|
||||
g.game_mode = default_mode;
|
||||
}
|
||||
|
||||
@@ -769,6 +769,34 @@ impl Game<'_> {
|
||||
p.guest = Some(conn);
|
||||
}
|
||||
}
|
||||
let guest = &host.hub.guests[i];
|
||||
let listening = !horizon_client::mod_api::mods().is_empty();
|
||||
for projectile in projectiles..self.creatures.projectiles.len() {
|
||||
if !listening {
|
||||
break;
|
||||
}
|
||||
let item = match &self.creatures.projectiles[projectile].kind {
|
||||
horizon_client::projectiles::ProjectileKind::Snowball => {
|
||||
Some("minecraft:snowball".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Egg => {
|
||||
Some("minecraft:egg".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::EnderPearl => {
|
||||
Some("minecraft:ender_pearl".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Trident => {
|
||||
Some("minecraft:trident".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Potion(stack, _) => {
|
||||
Some(stack.item.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = item {
|
||||
self.native_player_launch_projectile(guest.uuid, &guest.name, &item);
|
||||
}
|
||||
}
|
||||
// The guest's swings are theirs: the host's own count stays the host's.
|
||||
play.swings += self.swings_started.wrapping_sub(swings);
|
||||
self.swings_started = swings;
|
||||
@@ -1836,6 +1864,8 @@ impl Game<'_> {
|
||||
}),
|
||||
Some(conn),
|
||||
);
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_block_damage_abort(guest.uuid, &guest.name, a.pos);
|
||||
}
|
||||
4 | 5 => {
|
||||
if !spectator {
|
||||
@@ -1845,6 +1875,13 @@ impl Game<'_> {
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
// hand of the item in use acting.
|
||||
6 => {
|
||||
let listening = !horizon_client::mod_api::mods().is_empty();
|
||||
let (uuid, name) = if listening {
|
||||
let guest = &host.hub.guests[gi];
|
||||
(guest.uuid, guest.name.clone())
|
||||
} else {
|
||||
(0, String::new())
|
||||
};
|
||||
self.as_guest(host, conn, |g| {
|
||||
let Some(hand) = g.used_hand() else {
|
||||
return;
|
||||
@@ -1853,7 +1890,18 @@ impl Game<'_> {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& let Some(stack) = g.stack_in(slot).cloned()
|
||||
{
|
||||
if listening
|
||||
&& &*stack.item == "minecraft:bow"
|
||||
&& let Some(arrow) = g
|
||||
.projectile_slot("minecraft:bow")
|
||||
.and_then(|slot| g.stack_in(slot).map(|s| s.item.clone()))
|
||||
{
|
||||
g.native_player_ready_arrow(uuid, &name, &arrow);
|
||||
}
|
||||
g.release_used_item(&stack, ticks, false);
|
||||
if listening {
|
||||
g.native_player_stop_using_item(uuid, &name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1957,6 +2005,7 @@ impl Game<'_> {
|
||||
g.reset_attack_strength();
|
||||
});
|
||||
self.native_player_attack_cooldown_reset(player, &name);
|
||||
self.native_player_arm_swing(player, &name, "mainhand");
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
@@ -2125,6 +2174,17 @@ impl Game<'_> {
|
||||
{
|
||||
return;
|
||||
}
|
||||
// LeashFenceKnotEntity.interact: a knot has no entity here, only its fence.
|
||||
if let Some(knot) = self.knot_at_id(p.id) {
|
||||
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
|
||||
let swung = self.as_guest(host, conn, |g| {
|
||||
let swings = g.swings_started;
|
||||
g.with_hand(hand, |g| g.use_knot(knot, true));
|
||||
g.swings_started != swings
|
||||
});
|
||||
self.lan_swing_back(host, conn, swung);
|
||||
return;
|
||||
}
|
||||
let before = host.hub.guests[gi].vehicle;
|
||||
if self
|
||||
.look_at_entity(host, gi, p.id, Some(p.location))
|
||||
@@ -2291,6 +2351,8 @@ impl Game<'_> {
|
||||
if !spectator && let Some(slot) = slot {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_player_bundle_item_select(guest.uuid, &guest.name, p.slot, p.index);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
@@ -2907,6 +2969,15 @@ impl Game<'_> {
|
||||
if let Some(b) = self.tracked_special_box(id) {
|
||||
return Some(b);
|
||||
}
|
||||
if let Some(knot) = self.knot_at_id(id) {
|
||||
let knot = horizon_client::mobs::leash::knot_state(knot);
|
||||
let p = knot.position;
|
||||
let half = knot.width / 2.0;
|
||||
return Some(Aabb::new(
|
||||
Vec3::new(p.x - half, p.y, p.z - half),
|
||||
Vec3::new(p.x + half, p.y + knot.height, p.z + half),
|
||||
));
|
||||
}
|
||||
self.creatures
|
||||
.projectiles
|
||||
.iter()
|
||||
@@ -2914,6 +2985,14 @@ impl Game<'_> {
|
||||
.map(|p| p.bounds())
|
||||
}
|
||||
|
||||
/// The fence of the knot whose entity id is `id` (`lan_assign_ids`).
|
||||
fn knot_at_id(&self, id: i32) -> Option<[i32; 3]> {
|
||||
self.knot_ids
|
||||
.iter()
|
||||
.find(|&(_, &knot)| knot as i32 == id)
|
||||
.map(|(&fence, _)| fence)
|
||||
}
|
||||
|
||||
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
|
||||
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return;
|
||||
@@ -3453,6 +3532,10 @@ impl Game<'_> {
|
||||
};
|
||||
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
|
||||
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
|
||||
// PlayerEvent.Clone, at the end of `restoreFrom`: the data is copied,
|
||||
// the new player not yet placed.
|
||||
let (cloned, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_clone(cloned, &name, true);
|
||||
let clock_rate = self.network_clock_rate();
|
||||
let ctx = TickCtx {
|
||||
world: &mut self.world,
|
||||
@@ -4019,6 +4102,7 @@ impl Game<'_> {
|
||||
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
|
||||
let riding = g.vehicle.is_some();
|
||||
let mut soul = None;
|
||||
let mut jumps = 0;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -4079,6 +4163,7 @@ impl Game<'_> {
|
||||
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
|
||||
// the server's copy jumps with the player, so the velocity
|
||||
// it sends them keeps the jump.
|
||||
jumps += 1;
|
||||
let _ = w.jump_from_ground();
|
||||
}
|
||||
let landed = (|| -> horizon_client::Result<()> {
|
||||
@@ -4193,6 +4278,16 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
if jumps > 0
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
{
|
||||
self.as_guest(host, conn, |game| {
|
||||
for _ in 0..jumps {
|
||||
game.native_player_jump(uuid, &name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,13 @@ impl Game<'_> {
|
||||
if let Some(maps) = maps {
|
||||
self.register_made_maps(maps);
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
// A taken anvil result waits for AnvilCraftEvent.Pre: what the click
|
||||
// dropped waits with it (lan_station_op throws it or refuses the take).
|
||||
let dropped = if menu.anvil_craft.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
std::mem::take(&mut menu.dropped)
|
||||
};
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
|
||||
@@ -597,6 +597,12 @@ impl Game<'_> {
|
||||
Some(g) => g.entity_id,
|
||||
None => owner(p.owner),
|
||||
};
|
||||
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.baseTick's
|
||||
// `setSharedFlagOnFire(remainingFireTicks > 0)`, which a client's
|
||||
// isOnFire reads to draw the flames (a Flame bow's arrow).
|
||||
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
match &p.kind {
|
||||
K::Arrow { critical, .. } => {
|
||||
let a = p.arrow.as_deref();
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
menu::{AnvilCraft, Kind, Menu},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -80,6 +80,8 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
let mut menu = Box::new(Menu::new(kind));
|
||||
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
|
||||
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& kind == Kind::Enchanting
|
||||
{
|
||||
@@ -136,14 +138,42 @@ impl Game<'_> {
|
||||
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
(slots, gs.menu.data[0])
|
||||
});
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, op);
|
||||
let recomputed = before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
let mut undo = None;
|
||||
let hook = gs.menu.anvil_hook;
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
|
||||
undo = hook.then(|| menu.anvil_undo());
|
||||
op(menu, env);
|
||||
});
|
||||
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
|
||||
let mut anvil_craft = gs.menu.anvil_craft.take();
|
||||
let mut cancelled = false;
|
||||
if let Some(craft) = &anvil_craft {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut cancel = false;
|
||||
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
|
||||
if cancel && let Some(undo) = undo {
|
||||
// The take is refused: the result, inputs, cost, inventory
|
||||
// and cursor are as before the click (the resync below
|
||||
// tells the client).
|
||||
anvil_craft = None;
|
||||
cancelled = true;
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
|
||||
} else {
|
||||
gs.menu.anvil_take(&self.menu_env());
|
||||
let dropped = std::mem::take(&mut gs.menu.dropped);
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
}
|
||||
}
|
||||
// A cancelled take runs no createResult: the inputs are untouched.
|
||||
let recomputed = !cancelled
|
||||
&& before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
});
|
||||
if recomputed && gs.kind == StationKind::Anvil {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
@@ -172,7 +202,7 @@ impl Game<'_> {
|
||||
if let Some(amount) = gs.menu.ground.take() {
|
||||
self.grindstone_taken(gs.pos, amount);
|
||||
}
|
||||
self.lan_station_combined(host, conn, &mut gs);
|
||||
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
|
||||
if let Some(button) = gs.menu.enchant_button.take() {
|
||||
self.lan_enchant(host, gi, &mut gs, button);
|
||||
}
|
||||
@@ -305,8 +335,15 @@ impl Game<'_> {
|
||||
|
||||
/// The rest of a taken anvil or smithing result's `onTake`: the guest
|
||||
/// pays the levels, the anvil wears (level event 1030 or 1029), the
|
||||
/// smithing table sounds (1044).
|
||||
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
|
||||
/// smithing table sounds (1044). An anvil's `craft` is then reported to
|
||||
/// the mods (`AnvilCraftEvent.Post`).
|
||||
fn lan_station_combined(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
gs: &mut GuestStation,
|
||||
craft: Option<&AnvilCraft>,
|
||||
) {
|
||||
let change = std::mem::take(&mut gs.menu.level_change);
|
||||
if change != 0 {
|
||||
self.as_guest(host, conn, |g| g.give_levels(change));
|
||||
@@ -320,6 +357,12 @@ impl Game<'_> {
|
||||
let creative = gs.menu.creative;
|
||||
// A broken anvil closes the menu on the next stillValid check.
|
||||
self.anvil_used(gs.pos, creative);
|
||||
if let Some(craft) = craft
|
||||
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
|
||||
{
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ impl Game<'_> {
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
self.native_entity_unleash(id, &kind);
|
||||
if used
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
@@ -336,7 +337,7 @@ impl Game<'_> {
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `LeadItem.useOn` and `LeashFenceKnotEntity.interact` on a fence.
|
||||
/// `LeadItem.useOn` on the fence the crosshair hits.
|
||||
pub(super) fn interact_knot(&mut self) -> bool {
|
||||
let Some(hit) = self.pick() else {
|
||||
return false;
|
||||
@@ -354,7 +355,15 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let knot_pos = hit.pos;
|
||||
// `FenceBlock.useWithoutItem` / `LeadItem.useOn` only tie the player's own mobs
|
||||
// (`LeadItem.bindPlayerMobs`). A client whose crosshair met the knot sends Interact with
|
||||
// the knot's id instead. Taking a mob back here would undo a tie: the client sends the
|
||||
// click once for each hand.
|
||||
self.use_knot(hit.pos, false)
|
||||
}
|
||||
/// A click at the fence at `knot_pos`: `knot_hit` for `LeashFenceKnotEntity.interact`,
|
||||
/// otherwise `LeadItem.useOn`. Returns whether it was used.
|
||||
pub(super) fn use_knot(&mut self, knot_pos: [i32; 3], knot_hit: bool) -> bool {
|
||||
let knot = leash::knot_state(knot_pos).center;
|
||||
let center = glam::DVec3::new(knot.x, knot.y, knot.z);
|
||||
let at = [center.x, center.y, center.z];
|
||||
@@ -362,27 +371,6 @@ impl Game<'_> {
|
||||
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
|
||||
.collect();
|
||||
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
|
||||
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
|
||||
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
|
||||
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
|
||||
// post inside that box went through the knot first. Any other click on
|
||||
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
|
||||
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
|
||||
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
|
||||
let box_ = leash::knot_state(knot_pos);
|
||||
let half = box_.width / 2.0 + 1.0e-6;
|
||||
(hit.point[0] - box_.position.x).abs() <= half
|
||||
&& (hit.point[2] - box_.position.z).abs() <= half
|
||||
&& hit.point[1] >= box_.position.y - 1.0e-6
|
||||
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
|
||||
// The knot is an entity: a hit on it counts only within the
|
||||
// entity interaction range (`filterHitResult`), shorter than
|
||||
// the block range the fence was hit at.
|
||||
&& self.eye_position().is_some_and(|eye| {
|
||||
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
|
||||
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
|
||||
})
|
||||
};
|
||||
let held_item = self.held().map(|s| s.item.to_string());
|
||||
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
|
||||
// Shears snip every lead on the knot.
|
||||
|
||||
@@ -635,7 +635,6 @@ impl Game<'_> {
|
||||
// over the 40 a vehicle takes) destroys them. Lightning is no fire
|
||||
// source, so a TNT cart does not prime. `ExperienceOrb.hurtServer`:
|
||||
// 5 damage off 5 health. Last first, since each removes its entry.
|
||||
let orbs_before = self.orb_ids();
|
||||
let mut left = self.thunder_count(strike, ThunderHit::Vehicle)
|
||||
+ self.thunder_count(strike, ThunderHit::Orb);
|
||||
for h in self.entity_handles_rev() {
|
||||
@@ -659,7 +658,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
self.native_leaves_flush();
|
||||
// Every player in the box, the server's players too (0.6 x 1.8).
|
||||
let strike_players = move |g: &mut Self| {
|
||||
if let Some(p) = g.player.as_ref().map(|p| p.position)
|
||||
@@ -679,7 +678,6 @@ impl Game<'_> {
|
||||
strike_players(self);
|
||||
self.each_guest_hit(strike_players);
|
||||
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
|
||||
let before = self.dropped_ids();
|
||||
let mut left = self.thunder_count(strike, ThunderHit::Item);
|
||||
for h in self.entity_handles_rev() {
|
||||
if left == 0 {
|
||||
@@ -696,7 +694,7 @@ impl Game<'_> {
|
||||
self.dropped.remove(i);
|
||||
}
|
||||
}
|
||||
self.native_items_left_since(&before);
|
||||
self.native_leaves_flush();
|
||||
struck
|
||||
}
|
||||
/// `LightningBolt.shouldRenderAtSqrDistance`: a bolt is drawn within 64
|
||||
|
||||
@@ -34,7 +34,7 @@ impl Game<'_> {
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
m.heal(heal);
|
||||
used = true;
|
||||
}
|
||||
let mut happy = false;
|
||||
|
||||
@@ -740,6 +740,7 @@ impl Game<'_> {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
return;
|
||||
}
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
@@ -763,10 +764,10 @@ impl Game<'_> {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let item = &self.dropped[i];
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting (age was
|
||||
// counted already); `checkInsideBlocks` only follows a move.
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting;
|
||||
// `checkInsideBlocks` only follows a move.
|
||||
let moved = item.position != item.previous
|
||||
|| (item.age.wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4);
|
||||
|| item.ticks.wrapping_add(item.id).is_multiple_of(4);
|
||||
if !moved || item.stack.count == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -866,6 +867,11 @@ impl Game<'_> {
|
||||
let front = offset(pos, facing);
|
||||
let face = facing.opposite();
|
||||
if let Some(target) = c.view(front, false) {
|
||||
self.native_hopper_inventory_search(
|
||||
[pos.0, pos.1, pos.2],
|
||||
Some([front.0, front.1, front.2]),
|
||||
None,
|
||||
);
|
||||
return self.eject_into(pos, hopper, &target, face);
|
||||
}
|
||||
let Some(id) = self.entity_container_at([
|
||||
@@ -875,15 +881,62 @@ impl Game<'_> {
|
||||
]) else {
|
||||
return false;
|
||||
};
|
||||
self.native_hopper_inventory_search([pos.0, pos.1, pos.2], None, Some(id));
|
||||
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Where a container is in the world for the mods: its block, or the block a chest
|
||||
/// boat or minecart is in (its view sits under a placeholder key).
|
||||
fn container_world_pos(&self, view: &View) -> [i32; 3] {
|
||||
let (x, y, z) = view.parts[0].0;
|
||||
if x == i32::MAX - 15 && y == super::minecarts::CART_KEY_Y {
|
||||
let id = z as u32;
|
||||
let at = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.position)
|
||||
.or_else(|| self.boats.iter().find(|b| b.id == id).map(|b| b.position));
|
||||
if let Some(p) = at {
|
||||
return [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
}
|
||||
}
|
||||
[x, y, z]
|
||||
}
|
||||
/// `InventoryMoveItemEvent` for the item in `slot` of `holder` going from `source` to
|
||||
/// `destination`: whether a mod canceled the move. Nothing is looked up with no mod loaded.
|
||||
fn hopper_move_canceled(
|
||||
&self,
|
||||
holder: &View,
|
||||
slot: usize,
|
||||
source: &View,
|
||||
destination: &View,
|
||||
) -> bool {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let containers = self.containers();
|
||||
let Some(stack) = containers.get(holder, slot) else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
self.container_world_pos(source),
|
||||
self.container_world_pos(destination),
|
||||
&stack.item,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
|
||||
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
|
||||
if self.containers().is_full(target, face) {
|
||||
return false;
|
||||
}
|
||||
for slot in 0..hopper.len() {
|
||||
if self.hopper_move_canceled(hopper, slot, hopper, target) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(hopper, slot) else {
|
||||
continue;
|
||||
};
|
||||
@@ -946,9 +999,27 @@ impl Game<'_> {
|
||||
let c = self.containers();
|
||||
let Some(source) = c.view(above, false) else {
|
||||
let id = self.entity_container_at(at)?;
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
hopper.parts[0].0.0,
|
||||
hopper.parts[0].0.1,
|
||||
hopper.parts[0].0.2,
|
||||
],
|
||||
None,
|
||||
Some(id),
|
||||
);
|
||||
return self
|
||||
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
|
||||
};
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
hopper.parts[0].0.0,
|
||||
hopper.parts[0].0.1,
|
||||
hopper.parts[0].0.2,
|
||||
],
|
||||
Some([above.0, above.1, above.2]),
|
||||
None,
|
||||
);
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
@@ -959,6 +1030,18 @@ impl Game<'_> {
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
[above.0, above.1, above.2],
|
||||
self.container_world_pos(hopper),
|
||||
&bone_meal.item,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
@@ -984,6 +1067,9 @@ impl Game<'_> {
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -141,6 +141,15 @@ impl Game<'_> {
|
||||
json!({ "active": active, "cached": cached })
|
||||
}),
|
||||
"renderer": renderer,
|
||||
// A build with the `alloc-census` feature: the live heap by the
|
||||
// startup step, span or pool job that allocated it, else its thread.
|
||||
"heap_by_tag_mb": horizon_threads::HEAP_CENSUS.then(|| {
|
||||
horizon_threads::heap_census::census()
|
||||
.into_iter()
|
||||
.take(60)
|
||||
.map(|(tag, bytes)| json!([tag, mb(bytes.max(0) as u64)]))
|
||||
.collect::<Vec<_>>()
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1875,6 +1875,13 @@ impl Game<'_> {
|
||||
.modified
|
||||
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
|
||||
self.carts.push(cart);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
cart.kind.id(),
|
||||
[cart.position.x, cart.position.y, cart.position.z],
|
||||
);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -2134,7 +2141,13 @@ impl Game<'_> {
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, mut damage: f32, creative: bool, fire: bool) {
|
||||
let kind = self.carts[i].kind.id();
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage(kind, &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let destroyed = {
|
||||
let c = &mut self.carts[i];
|
||||
c.hurt_dir = -c.hurt_dir;
|
||||
@@ -2154,6 +2167,14 @@ impl Game<'_> {
|
||||
if !tnt_prime {
|
||||
self.release_vehicle_container(id);
|
||||
}
|
||||
if !tnt_prime {
|
||||
let at = [
|
||||
self.carts[i].position.x,
|
||||
self.carts[i].position.y,
|
||||
self.carts[i].position.z,
|
||||
];
|
||||
self.native_vehicle_destroy(self.carts[i].kind.id(), at);
|
||||
}
|
||||
let mut c = self.carts.remove(i);
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
|
||||
@@ -187,10 +187,11 @@ impl Game<'_> {
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
skin: Option<&mut super::creatures::Skinned>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, skin, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
self.head_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.golem_flower_geometry(mob, camera, alpha, light, out);
|
||||
@@ -376,6 +377,7 @@ impl Game<'_> {
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
/// `skin`: the mob's GPU path draws, which a lit item joins when it can.
|
||||
pub(super) fn held_item_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
@@ -383,6 +385,7 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
lit: bool,
|
||||
mut skin: Option<&mut super::creatures::Skinned>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
|
||||
@@ -483,6 +486,12 @@ impl Game<'_> {
|
||||
horizon_client::cit::Hand::Main
|
||||
};
|
||||
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, hand);
|
||||
if lit
|
||||
&& let Some(skinned) = skin.as_deref_mut()
|
||||
&& self.skin_item_into(&state, t, light, skinned)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
} else {
|
||||
|
||||
@@ -97,11 +97,29 @@ impl Game<'_> {
|
||||
self.serving.is_some() || self.options.dedicated.is_some()
|
||||
}
|
||||
|
||||
/// Access a server-owned holder's attachment map.
|
||||
/// Access a holder's attachment map, to lend to a mod: a server's, whose
|
||||
/// synced changes are sent at the pass's end, or a client's copy of what
|
||||
/// its server synced.
|
||||
pub(super) fn attachment_map(
|
||||
&mut self,
|
||||
holder: horizon_client::mod_api::Holder,
|
||||
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
|
||||
{
|
||||
if self.native_authoritative() {
|
||||
self.world.attachment_syncs.insert(holder);
|
||||
return self.attachment_map_quiet(holder);
|
||||
}
|
||||
match self.remote.as_mut() {
|
||||
Some(remote) => Ok(remote.attachments.entry(holder).or_default()),
|
||||
None => Err(horizon_client::mod_api::AttachmentError::MissingHolder),
|
||||
}
|
||||
}
|
||||
|
||||
/// `attachment_map` without recording the holder for the sync.
|
||||
pub(super) fn attachment_map_quiet(
|
||||
&mut self,
|
||||
holder: horizon_client::mod_api::Holder,
|
||||
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
|
||||
{
|
||||
use horizon_client::mod_api::AttachmentError;
|
||||
if !self.native_authoritative() {
|
||||
@@ -162,6 +180,7 @@ impl horizon_client::mod_api::LevelRandomHost for super::GameLevel<'_> {
|
||||
if !self.native_authoritative {
|
||||
return Err(horizon_client::mod_api::AttachmentError::MissingHolder);
|
||||
}
|
||||
self.world.attachment_syncs.insert(holder);
|
||||
world_attachments(self.world, holder)
|
||||
}
|
||||
fn random(&mut self) -> Option<&mut horizon_client::mod_api::LegacyRandom> {
|
||||
|
||||
@@ -483,4 +483,16 @@ impl Host for super::Game<'_> {
|
||||
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item_stack(batch, stack, at, size)
|
||||
}
|
||||
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
|
||||
self.native_mod_image(width, height, rgba)
|
||||
}
|
||||
fn instance_dir(&self) -> PathBuf {
|
||||
self.native_instance_dir()
|
||||
}
|
||||
fn camera(&self) -> Option<NativeCamera> {
|
||||
self.native_camera()
|
||||
}
|
||||
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
|
||||
self.native_block_collision(pos)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ pub(super) fn content_root(
|
||||
paths.extend(dev.iter().cloned());
|
||||
let mut known = None;
|
||||
let mut mods: Vec<ContentMod> = Vec::new();
|
||||
// The first load's list stays (`set_local`): a reload does not hash the files again.
|
||||
let announce = !horizon_protocol::mod_list::is_set();
|
||||
let mut announced = Vec::new();
|
||||
for path in &paths {
|
||||
let Ok(package) = Package::read(path) else {
|
||||
continue;
|
||||
@@ -121,8 +124,10 @@ pub(super) fn content_root(
|
||||
id: m.id.clone(),
|
||||
content,
|
||||
};
|
||||
if let Err(e) = mod_content::accept_mod(&mut mods, candidate, known) {
|
||||
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
|
||||
match mod_content::accept_mod(&mut mods, candidate, known) {
|
||||
Ok(_) if announce => announced.push(announced_entry(m, path)),
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id),
|
||||
}
|
||||
}
|
||||
let (blocks, items) = native_declared();
|
||||
@@ -146,6 +151,9 @@ pub(super) fn content_root(
|
||||
}
|
||||
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
|
||||
Ok(out) => {
|
||||
if announce {
|
||||
horizon_protocol::mod_list::set_local(announced);
|
||||
}
|
||||
if out != root {
|
||||
log::info!(
|
||||
"Horizon: content from {} mod(s) in {}",
|
||||
@@ -168,6 +176,34 @@ pub(super) fn content_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded mod as a server tells joining clients (`horizon_protocol::mod_list`): its manifest's
|
||||
/// id, version and side, and the SHA-256 of its `.hmod` (none for a folder).
|
||||
fn announced_entry(
|
||||
m: &horizon_mods::Manifest,
|
||||
path: &std::path::Path,
|
||||
) -> horizon_protocol::mod_list::Entry {
|
||||
use horizon_mods::Side;
|
||||
use horizon_protocol::mod_list;
|
||||
use sha2::{Digest, Sha256};
|
||||
let sha256 = if path.is_file() {
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.map(|bytes| Sha256::digest(bytes).into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
mod_list::Entry {
|
||||
id: m.id.clone(),
|
||||
version: m.version.clone(),
|
||||
sha256,
|
||||
side: match m.side {
|
||||
Side::Both => mod_list::Side::Both,
|
||||
Side::Server => mod_list::Side::Server,
|
||||
Side::Client => mod_list::Side::Client,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The mods' mob effects join vanilla's (`brewing::effect`), once the root
|
||||
/// holding their network ids is built. The startup root's effects, none
|
||||
/// included, stay until restart: a pack reload after a Mods switch builds a
|
||||
@@ -237,10 +273,20 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
|
||||
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
|
||||
const BUILD_INFO: &str = "native-mods.json";
|
||||
|
||||
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
|
||||
fn built_with_mods(built: &str) -> bool {
|
||||
/// The native mods the launcher built this game with, `id version, id version`, for the crash
|
||||
/// report (`built`: its [`BUILD_INFO`]). None for a release or Nightly, which have no mods.
|
||||
fn native_mods_detail(built: &str) -> Option<String> {
|
||||
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
|
||||
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
|
||||
let list = info["mods"]
|
||||
.as_array()?
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let text = |key: &str| m[key].as_str().unwrap_or("?").to_owned();
|
||||
format!("{} {}", text("id"), text("version"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
(!list.is_empty()).then_some(list)
|
||||
}
|
||||
|
||||
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
|
||||
@@ -330,8 +376,13 @@ impl Game<'_> {
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.unwrap_or_default();
|
||||
let built_mods = native_mods_detail(&built);
|
||||
let built_with_mods = built_mods.is_some();
|
||||
if let Some(list) = built_mods {
|
||||
horizon_client::crash::set_detail("Native Mods", list);
|
||||
}
|
||||
horizon_client::gfx::native_render::set_mods_built_in(
|
||||
built_with_mods(&built)
|
||||
built_with_mods
|
||||
|| self
|
||||
.settings
|
||||
.mods
|
||||
@@ -392,6 +443,18 @@ mod tests {
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_crash_report_lists_the_builds_native_mods() {
|
||||
let built = r#"{"mods":[{"id":"machine_sample","version":"0.1.0","sha256":null},{"id":"pipes","version":"2.0"}]}"#;
|
||||
assert_eq!(
|
||||
native_mods_detail(built).as_deref(),
|
||||
Some("machine_sample 0.1.0, pipes 2.0")
|
||||
);
|
||||
// A release has no file; a build without mods has an empty list.
|
||||
assert_eq!(native_mods_detail(""), None);
|
||||
assert_eq!(native_mods_detail(r#"{"mods":[]}"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
|
||||
|
||||
@@ -320,7 +320,7 @@ impl Game<'_> {
|
||||
loved = true;
|
||||
}
|
||||
if m.health < m.attrs.max_health && heal > 0.0 {
|
||||
m.health = (m.health + heal).min(m.attrs.max_health);
|
||||
m.heal(heal);
|
||||
used = true;
|
||||
}
|
||||
if m.is_baby() && age_up > 0 && !m.extra.age_locked {
|
||||
|
||||
@@ -117,6 +117,7 @@ fn target_id(t: Target) -> Option<u32> {
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Trident(id)
|
||||
| Target::Spawner(id) => Some(id),
|
||||
Target::Player | Target::Guest(_) => None,
|
||||
}
|
||||
@@ -258,9 +259,38 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities joining the level (`native_entity_join_level`), for every kind in
|
||||
/// the entity walk. Kept out of `native_hooks.rs`, whose lines may not hold macros.
|
||||
/// The entities the walk does not hold: Horizon keeps these in lists of their own.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Apart {
|
||||
Crystal(usize),
|
||||
Cushion(usize),
|
||||
Painting(usize),
|
||||
Frame(usize),
|
||||
Dragon,
|
||||
}
|
||||
|
||||
/// One entity a mod is told of: in the walk, or apart from it.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Slot {
|
||||
Walk(super::entity::EntityHandle),
|
||||
Apart(Apart),
|
||||
}
|
||||
|
||||
/// Entities joining and leaving the level (`native_entity_join_level`,
|
||||
/// `native_entity_leave_level`), for every kind in the entity walk and the
|
||||
/// five kept apart from it. Kept out of `native_hooks.rs`, whose lines may not hold macros.
|
||||
impl Game<'_> {
|
||||
/// Whether an entity may be waiting to be told of: one with an id above
|
||||
/// the last announced, or one of the kinds that take their id late and
|
||||
/// have none. Reads a counter and five lists' ids; no walk.
|
||||
fn joins_pending(&self) -> bool {
|
||||
self.next_entity > self.persist.joined_mark
|
||||
|| self.tnts.iter().any(|t| t.id == 0)
|
||||
|| self.falling.iter().any(|f| f.id == 0)
|
||||
|| self.creatures.projectiles.iter().any(|p| p.id == 0)
|
||||
|| self.tridents.iter().any(|t| t.id == 0)
|
||||
|| self.rockets.iter().any(|r| r.id == 0)
|
||||
}
|
||||
/// Tells `native_entity_join_level` once for every entity added since the
|
||||
/// last call, of any kind, in the order they were created (ids count up
|
||||
/// from one counter). A mod that cancels takes the entity back out here,
|
||||
@@ -268,11 +298,10 @@ impl Game<'_> {
|
||||
/// `loaded_from_disk` is for the entities that came with a chunk. Returns
|
||||
/// whether `watch` (an id, or 0) is still in the level. Walks the lists
|
||||
/// with static dispatch and allocates nothing but the kind name of an
|
||||
/// entity it announces; nothing runs when no mod is loaded or on a client
|
||||
/// of a server (which tells nothing).
|
||||
/// entity it announces; nothing runs when no mod is loaded, on a client
|
||||
/// of a server (which tells nothing), or when nothing is new.
|
||||
pub(super) fn native_joins_flush(&mut self, loaded_from_disk: bool, watch: u32) -> bool {
|
||||
use super::entity::Entity;
|
||||
if self.remote.is_some() || crate::mod_api::mods().is_empty() {
|
||||
if self.remote.is_some() || crate::mod_api::mods().is_empty() || !self.joins_pending() {
|
||||
return true;
|
||||
}
|
||||
for t in &mut self.tnts {
|
||||
@@ -291,11 +320,9 @@ impl Game<'_> {
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
}
|
||||
let mut kept = true;
|
||||
while let Some((handle, id)) = self.next_unannounced() {
|
||||
while let Some((slot, id)) = self.next_unannounced() {
|
||||
self.persist.joined_mark = id;
|
||||
let (kind, at) = super::entity::with_entity!(self, handle, e => {
|
||||
(e.native_type(), e.position())
|
||||
});
|
||||
let (kind, at) = self.slot_kind_and_position(slot);
|
||||
let mut canceled = false;
|
||||
let dimension = self.dim.id();
|
||||
self.native_entity_join_level(
|
||||
@@ -311,32 +338,165 @@ impl Game<'_> {
|
||||
self.forget_entity(id);
|
||||
// Handles are indexes: the entity is taken out before any
|
||||
// other change to its list.
|
||||
self.discard_entity(handle);
|
||||
self.discard_slot(slot);
|
||||
kept &= id != watch;
|
||||
} else {
|
||||
self.persist.live.push((id, kind.into_owned(), true));
|
||||
}
|
||||
}
|
||||
// Ids taken and never announced (the dragon's parts, another level's
|
||||
// entities) must not keep the next flush walking.
|
||||
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
|
||||
kept
|
||||
}
|
||||
/// The entity with the lowest id above the last one announced, by handle.
|
||||
fn next_unannounced(&self) -> Option<(super::entity::EntityHandle, u32)> {
|
||||
/// The entity with the lowest id above the last one announced.
|
||||
fn next_unannounced(&self) -> Option<(Slot, u32)> {
|
||||
use super::entity::Entity;
|
||||
let mark = self.persist.joined_mark;
|
||||
let mut best: Option<(super::entity::EntityHandle, u32)> = None;
|
||||
for handle in self.entity_handles() {
|
||||
let id = {
|
||||
use super::entity::Entity;
|
||||
super::entity::with_entity!(self, handle, e => e.id())
|
||||
};
|
||||
let mut best: Option<(Slot, u32)> = None;
|
||||
let mut offer = |slot: Slot, id: u32| {
|
||||
if id > mark && best.is_none_or(|(_, b)| id < b) {
|
||||
best = Some((handle, id));
|
||||
best = Some((slot, id));
|
||||
}
|
||||
};
|
||||
for handle in self.entity_handles() {
|
||||
offer(
|
||||
Slot::Walk(handle),
|
||||
super::entity::with_entity!(self, handle, e => e.id()),
|
||||
);
|
||||
}
|
||||
for (i, c) in self.crystals.iter().enumerate() {
|
||||
offer(Slot::Apart(Apart::Crystal(i)), c.id);
|
||||
}
|
||||
for (i, c) in self.cushions.iter().enumerate() {
|
||||
offer(Slot::Apart(Apart::Cushion(i)), c.id);
|
||||
}
|
||||
for (i, p) in self.paintings.iter().enumerate() {
|
||||
offer(Slot::Apart(Apart::Painting(i)), p.id);
|
||||
}
|
||||
for (i, f) in self.item_frames.iter().enumerate() {
|
||||
offer(Slot::Apart(Apart::Frame(i)), f.id);
|
||||
}
|
||||
if let Some(d) = &self.dragon {
|
||||
offer(Slot::Apart(Apart::Dragon), d.id);
|
||||
}
|
||||
best
|
||||
}
|
||||
/// The type id and position a mod is told of.
|
||||
fn slot_kind_and_position(&self, slot: Slot) -> (std::borrow::Cow<'static, str>, Vec3) {
|
||||
use super::entity::Entity;
|
||||
let block = |p: [i32; 3]| {
|
||||
Vec3::new(
|
||||
f64::from(p[0]) + 0.5,
|
||||
f64::from(p[1]) + 0.5,
|
||||
f64::from(p[2]) + 0.5,
|
||||
)
|
||||
};
|
||||
match slot {
|
||||
Slot::Walk(h) => {
|
||||
super::entity::with_entity!(self, h, e => (e.native_type(), e.position()))
|
||||
}
|
||||
Slot::Apart(Apart::Crystal(i)) => {
|
||||
("minecraft:end_crystal".into(), self.crystals[i].position)
|
||||
}
|
||||
Slot::Apart(Apart::Cushion(i)) => {
|
||||
("minecraft:cushion".into(), self.cushions[i].position)
|
||||
}
|
||||
Slot::Apart(Apart::Painting(i)) => {
|
||||
("minecraft:painting".into(), block(self.paintings[i].pos))
|
||||
}
|
||||
Slot::Apart(Apart::Frame(i)) => {
|
||||
let f = &self.item_frames[i];
|
||||
let kind = if f.glow {
|
||||
"minecraft:glow_item_frame"
|
||||
} else {
|
||||
"minecraft:item_frame"
|
||||
};
|
||||
(kind.into(), block(f.pos))
|
||||
}
|
||||
Slot::Apart(Apart::Dragon) => (
|
||||
"minecraft:ender_dragon".into(),
|
||||
self.dragon.as_ref().map_or(Vec3::ZERO, |d| d.position),
|
||||
),
|
||||
}
|
||||
}
|
||||
/// Takes the entity out of the level, before it ever ticked.
|
||||
fn discard_slot(&mut self, slot: Slot) {
|
||||
match slot {
|
||||
Slot::Walk(h) => self.discard_entity(h),
|
||||
Slot::Apart(Apart::Crystal(i)) => drop(self.crystals.remove(i)),
|
||||
Slot::Apart(Apart::Cushion(i)) => drop(self.cushions.remove(i)),
|
||||
Slot::Apart(Apart::Painting(i)) => drop(self.paintings.remove(i)),
|
||||
Slot::Apart(Apart::Frame(i)) => drop(self.item_frames.remove(i)),
|
||||
Slot::Apart(Apart::Dragon) => self.dragon = None,
|
||||
}
|
||||
}
|
||||
/// Entities that were moved, not added (a portal's arrival keeps being
|
||||
/// silent): everything now in the level counts as announced.
|
||||
/// silent): everything now in the level counts as announced, and will
|
||||
/// be told when it leaves.
|
||||
pub(super) fn native_joins_skip(&mut self) {
|
||||
if self.remote.is_none() && !crate::mod_api::mods().is_empty() {
|
||||
while let Some((slot, id)) = self.next_unannounced() {
|
||||
self.persist.joined_mark = id;
|
||||
let kind = self.slot_kind_and_position(slot).0.into_owned();
|
||||
self.persist.live.push((id, kind, true));
|
||||
}
|
||||
}
|
||||
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
|
||||
}
|
||||
/// An entity taken out of the level without leaving it (an item through
|
||||
/// an End gateway): forgotten, not told of.
|
||||
pub(super) fn native_leave_skip(&mut self, id: u32) {
|
||||
if let Ok(i) = self.persist.live.binary_search_by_key(&id, |l| l.0) {
|
||||
self.persist.live.remove(i);
|
||||
}
|
||||
}
|
||||
/// Entity `id` has left the level: told once, if mods were told it joined.
|
||||
pub(super) fn native_entity_left(&mut self, id: u32) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(i) = self.persist.live.binary_search_by_key(&id, |l| l.0) {
|
||||
let (id, kind, _) = self.persist.live.remove(i);
|
||||
let dimension = self.dim.id();
|
||||
self.native_entity_leave_level(dimension, id, &kind);
|
||||
}
|
||||
}
|
||||
/// Tells `native_entity_leave_level` for every announced entity that is
|
||||
/// no longer in any list (died, despawned, picked up, burnt, blown up, gone
|
||||
/// through a portal, unloaded), lowest id first. Marks the entities still
|
||||
/// there in the registry and tells the rest: no allocation, and nothing
|
||||
/// runs when no mod is loaded or none was announced.
|
||||
pub(super) fn native_leaves_flush(&mut self) {
|
||||
use super::entity::Entity;
|
||||
if self.persist.live.is_empty() || self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
for l in &mut self.persist.live {
|
||||
l.2 = false;
|
||||
}
|
||||
let live = &mut self.persist.live;
|
||||
let mut seen = |id: u32| {
|
||||
if let Ok(i) = live.binary_search_by_key(&id, |l| l.0) {
|
||||
live[i].2 = true;
|
||||
}
|
||||
};
|
||||
super::entity::for_each_entity!(super::entity::entity_lists_of!(self), e => {
|
||||
seen(e.id());
|
||||
});
|
||||
self.crystals.iter().for_each(|c| seen(c.id));
|
||||
self.cushions.iter().for_each(|c| seen(c.id));
|
||||
self.paintings.iter().for_each(|p| seen(p.id));
|
||||
self.item_frames.iter().for_each(|f| seen(f.id));
|
||||
if let Some(d) = &self.dragon {
|
||||
seen(d.id);
|
||||
}
|
||||
while let Some(i) = self.persist.live.iter().position(|l| !l.2) {
|
||||
let (id, kind, _) = self.persist.live.remove(i);
|
||||
let dimension = self.dim.id();
|
||||
self.native_entity_leave_level(dimension, id, &kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -365,8 +365,7 @@ impl Game<'_> {
|
||||
if self.carried.is_some() {
|
||||
self.draw_carried(batch);
|
||||
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
|
||||
let lines = self.stack_tooltip(&stack.1);
|
||||
self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1);
|
||||
self.draw_stack_tooltip(batch, &stack.1, cursor.0, cursor.1);
|
||||
} else {
|
||||
self.draw_slot_tooltip(batch);
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ impl Game<'_> {
|
||||
/// for a player who is off (who joins hidden), until a mod sets it again
|
||||
/// or the server stops. Works anywhere on a server, inside a player's
|
||||
/// hook too; false on a client and in a world nobody else can join.
|
||||
/// API @API:hide_player@.
|
||||
/// API 314.
|
||||
pub(super) fn native_hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
|
||||
let Some(host) = &mut self.lan else {
|
||||
return false;
|
||||
@@ -420,7 +420,7 @@ impl Game<'_> {
|
||||
/// `[discord] events` lists `announce`: `name` and `uuid` say who it is
|
||||
/// about (empty and 0 for no one), `message` is the line (`§` codes colour it).
|
||||
/// Only on a dedicated server; false elsewhere and for an empty message.
|
||||
/// API @API:server_event@.
|
||||
/// API 163.
|
||||
pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool {
|
||||
if self.options.dedicated.is_none() || message.trim().is_empty() {
|
||||
return false;
|
||||
|
||||
@@ -101,7 +101,7 @@ impl Game<'_> {
|
||||
// feed(player, hand, stack, 2, 1).
|
||||
let sound = nautilus::eat_sound(m);
|
||||
let m = &mut self.mobs[index];
|
||||
m.health = (m.health + 2.0).min(m.attrs.max_health);
|
||||
m.heal(2.0);
|
||||
self.use_nautilus_food(item);
|
||||
self.nautilus_sound(index, sound);
|
||||
return true;
|
||||
|
||||
@@ -461,11 +461,14 @@ pub(super) fn stage_pack(
|
||||
progress.set(1);
|
||||
// The world's models and sprites and the GUI atlas read the same root and nothing of
|
||||
// each other: about a second each, side by side.
|
||||
let mod_image = !horizon_client::mod_api::mods().is_empty();
|
||||
// The sound events, the GUI sprites' metadata and the language are read here too, not in
|
||||
// the frame that applies the reload (about 20 ms of it).
|
||||
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
|
||||
let atlas =
|
||||
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
|
||||
let atlas = s.spawn(|| {
|
||||
HudAtlas::build_over_with_image(&root, vanilla_root, mod_image)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
|
||||
let small = s.spawn(|| {
|
||||
(
|
||||
|
||||
@@ -24,6 +24,26 @@ fn zombie_kind(s: Species) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Which `finalizeSpawn` mounts a mob may roll for its spawn reason.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct MountRolls {
|
||||
zombie_horse_rider: bool,
|
||||
camel_husk: bool,
|
||||
nautilus: bool,
|
||||
}
|
||||
|
||||
/// `ZombieHorse.finalizeSpawn` (NATURAL only), `Husk.finalizeSpawn`
|
||||
/// (`triedToSpawnCamelHusk = reason != NATURAL`) and `Drowned.finalizeSpawn`
|
||||
/// (never a baby; vanilla also allows STRUCTURE, which comes from worldgen and
|
||||
/// never reaches `spawn_jockeys`, so only `natural` is modelled here).
|
||||
fn mount_rolls(species: Species, baby: bool, natural: bool) -> MountRolls {
|
||||
MountRolls {
|
||||
zombie_horse_rider: natural && species == Species::ZombieHorse,
|
||||
camel_husk: natural && species == Species::Husk,
|
||||
nautilus: natural && species == Species::Drowned && !baby,
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Entity.startRiding` for two mobs.
|
||||
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
|
||||
@@ -333,6 +353,7 @@ impl Game<'_> {
|
||||
};
|
||||
let (species, baby) = (m.species(), m.is_baby());
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
let mounts = mount_rolls(species, baby, natural);
|
||||
match species {
|
||||
s if zombie_kind(s) && baby => {
|
||||
if self.world.random.next_float() < CHICKEN_JOCKEY_ODDS {
|
||||
@@ -358,7 +379,7 @@ impl Game<'_> {
|
||||
// comes from EntityType.create, which makes no zombie in peaceful
|
||||
// (canSpawn), so a peaceful zombie horse spawns without one.
|
||||
Species::ZombieHorse
|
||||
if natural
|
||||
if mounts.zombie_horse_rider
|
||||
&& (self.ui.difficulty != 0
|
||||
|| horizon_client::mobs::spawn_rules::allowed_in_peaceful(
|
||||
Species::Zombie,
|
||||
@@ -375,8 +396,8 @@ impl Game<'_> {
|
||||
}
|
||||
// After Zombie.finalizeSpawn's chickens: Husk and Drowned mounts.
|
||||
match species {
|
||||
Species::Husk if natural => self.spawn_camel_husk_jockey(id, at),
|
||||
Species::Drowned if natural && !baby => self.spawn_zombie_nautilus_jockey(id, at),
|
||||
Species::Husk if mounts.camel_husk => self.spawn_camel_husk_jockey(id, at),
|
||||
Species::Drowned if mounts.nautilus => self.spawn_zombie_nautilus_jockey(id, at),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -572,3 +593,34 @@ impl Game<'_> {
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn spawner_and_reinforcement_spawns_get_no_mounts() {
|
||||
for (species, baby) in [
|
||||
(Species::ZombieHorse, false),
|
||||
(Species::Husk, false),
|
||||
(Species::Drowned, false),
|
||||
] {
|
||||
let none = MountRolls {
|
||||
zombie_horse_rider: false,
|
||||
camel_husk: false,
|
||||
nautilus: false,
|
||||
};
|
||||
assert_eq!(mount_rolls(species, baby, false), none, "{species:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_spawns_roll_their_own_mount() {
|
||||
let r = |s, b| mount_rolls(s, b, true);
|
||||
assert!(r(Species::ZombieHorse, false).zombie_horse_rider);
|
||||
assert!(r(Species::Husk, false).camel_husk);
|
||||
assert!(r(Species::Drowned, false).nautilus);
|
||||
assert!(!r(Species::Drowned, true).nautilus);
|
||||
assert!(!r(Species::Zombie, false).camel_husk);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ pub struct EntityMemory {
|
||||
pub(super) joins_canceled: u32,
|
||||
/// The highest entity id `native_entity_join_level` has been told of.
|
||||
pub(super) joined_mark: u32,
|
||||
/// The entities mods were told of and that have not left since: id (in
|
||||
/// increasing order), type, and whether the last leave sweep saw it.
|
||||
/// Only filled while a mod is loaded.
|
||||
pub(super) live: Vec<(u32, String, bool)>,
|
||||
}
|
||||
|
||||
/// The key that marks a compound queued with the chunk loads as a fresh add
|
||||
@@ -355,6 +359,10 @@ impl Game<'_> {
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
| "minecraft:arrow"
|
||||
| "minecraft:spectral_arrow"
|
||||
| "minecraft:llama_spit"
|
||||
| "minecraft:shulker_bullet"
|
||||
| "minecraft:experience_bottle"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:wind_charge"
|
||||
@@ -398,7 +406,7 @@ impl Game<'_> {
|
||||
self.remember_tags(id, &tag);
|
||||
}
|
||||
}
|
||||
"minecraft:arrow" => {
|
||||
"minecraft:arrow" | "minecraft:spectral_arrow" => {
|
||||
if let Some((mut p, in_block)) = entity_nbt::arrow_from_nbt(
|
||||
&tag,
|
||||
player_uuid,
|
||||
@@ -431,6 +439,17 @@ impl Game<'_> {
|
||||
self.remember_last_projectile_tags(&tag);
|
||||
}
|
||||
}
|
||||
"minecraft:llama_spit" | "minecraft:shulker_bullet" => {
|
||||
if let Some(mut p) = entity_nbt::spit_or_bullet_from_nbt(&tag, player_uuid)
|
||||
{
|
||||
self.claim_owner(&mut p);
|
||||
self.next_entity += 1;
|
||||
p.id = self.next_entity;
|
||||
self.restore_portal_cooldown(p.id, &tag);
|
||||
self.add_projectile(p);
|
||||
self.remember_last_projectile_tags(&tag);
|
||||
}
|
||||
}
|
||||
id if entity_nbt::throwable_kind(id).is_some() => {
|
||||
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
|
||||
self.claim_owner(&mut p);
|
||||
@@ -485,8 +504,9 @@ impl Game<'_> {
|
||||
let mut t = super::trident::ThrownTrident::from_nbt(n);
|
||||
t.id = self.take_entity_id();
|
||||
// One that came through a portal keeps its cooldown.
|
||||
self.restore_portal_cooldown(t.id, &tag);
|
||||
let id = t.id;
|
||||
self.tridents.push(t);
|
||||
self.remember(id, uuid, tag);
|
||||
}
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
@@ -1326,7 +1346,6 @@ impl Game<'_> {
|
||||
})
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
let mobs_before = self.mob_ids();
|
||||
self.mobs.retain(|m| {
|
||||
let keep = !leaving.contains(&m.id);
|
||||
if !keep {
|
||||
@@ -1334,7 +1353,6 @@ impl Game<'_> {
|
||||
}
|
||||
keep
|
||||
});
|
||||
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
|
||||
self.dropped.retain(|e| {
|
||||
let keep = !gone(e.position);
|
||||
if !keep {
|
||||
@@ -1344,9 +1362,7 @@ impl Game<'_> {
|
||||
});
|
||||
self.orbs.retain(|o| !gone(o.position));
|
||||
// Unloaded with their chunk: PersistentEntitySectionManager.stopTracking.
|
||||
self.native_mobs_left_since(&mobs_before);
|
||||
self.native_items_left_since(&items_before);
|
||||
self.native_orbs_left_since(&orbs_before);
|
||||
self.native_leaves_flush();
|
||||
// the ones saved with the chunk go with it (a trident too, unsaved).
|
||||
self.creatures.projectiles.retain(|p| {
|
||||
let leaves = entity_nbt::saved_with_chunk(p) || p.arrow.is_some();
|
||||
@@ -1512,7 +1528,8 @@ impl Game<'_> {
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()))
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()))?;
|
||||
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()))
|
||||
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, raw.as_ref()))?;
|
||||
Some((
|
||||
chunk_of(p.position),
|
||||
with_raw(tag, self.persist.raw.get(&p.id)),
|
||||
@@ -1565,10 +1582,11 @@ impl Game<'_> {
|
||||
i: usize,
|
||||
keys: &HashSet<ChunkKey>,
|
||||
) -> Option<(ChunkKey, Tag)> {
|
||||
let t = &self.tridents[i];
|
||||
keys.contains(&chunk_of(t.position)).then(|| {
|
||||
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:trident", t.position);
|
||||
(chunk_of(t.position), t.to_nbt(uuid, raw.as_ref()))
|
||||
let (id, at) = (self.tridents[i].id, self.tridents[i].position);
|
||||
keys.contains(&chunk_of(at)).then(|| {
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag = self.tridents[i].to_nbt(uuid, self.persist.raw.get(&id));
|
||||
(chunk_of(at), tag)
|
||||
})
|
||||
}
|
||||
pub(super) fn save_tnt(
|
||||
@@ -1770,6 +1788,7 @@ mod tests {
|
||||
},
|
||||
}),
|
||||
copy_on_death: false,
|
||||
sync: false,
|
||||
};
|
||||
let root_id = [1, 2, 3, 4];
|
||||
let child_id = [5, 6, 7, 8];
|
||||
|
||||
@@ -691,7 +691,7 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(m, self.own_entity_id(), camera, alpha as f32, packed, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, out);
|
||||
self.held_item_geometry(m, camera, alpha, packed, true, None, out);
|
||||
// CustomHeadLayer: a pumpkin, skull or other head item.
|
||||
self.head_item_geometry(m, camera, alpha, packed, true, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
@@ -802,7 +802,7 @@ impl Game<'_> {
|
||||
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
let wings = wings_start..verts.len();
|
||||
self.shoulder_parrots_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, None, &mut verts);
|
||||
self.head_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
|
||||
let height = if m.extra.crouching { 1.5 } else { 1.8 };
|
||||
// PictureInPictureRenderer: the box centre, (size, size, -size);
|
||||
|
||||
@@ -761,8 +761,7 @@ impl Game<'_> {
|
||||
if (effect == "instant_damage") != inverted {
|
||||
m.damage((6 << amp.min(30)) as f32, Damage::of(DamageKind::Magic));
|
||||
} else {
|
||||
m.health =
|
||||
(m.health + (4 << amp.min(30)) as f32).min(m.attrs.max_health);
|
||||
m.heal((4 << amp.min(30)) as f32);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -780,6 +779,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn apply_effect_action(&mut self, action: EffectAction) {
|
||||
let action = match action {
|
||||
EffectAction::Heal(mut amount) => {
|
||||
if let Some(player) = self.player.as_ref() {
|
||||
let mut cancel = false;
|
||||
horizon_client::hunger::native_living_heal(
|
||||
player.entity_id as u32,
|
||||
&mut amount,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
}
|
||||
EffectAction::Heal(amount)
|
||||
}
|
||||
action => action,
|
||||
};
|
||||
let creative = self.creative;
|
||||
let rules = self.world.health_rules;
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
@@ -1382,7 +1398,7 @@ impl Game<'_> {
|
||||
let harm = m.potion_harm(amount(6));
|
||||
m.hurt(harm, Some(at), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + amount(4)).min(m.kind.max_health);
|
||||
m.heal(amount(4));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -1725,8 +1741,7 @@ impl Game<'_> {
|
||||
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
|
||||
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
|
||||
.min(m.kind.max_health);
|
||||
m.heal((intensity * f64::from(4 << amp) + 0.5) as i32 as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 543 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user