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:
Ludovic Marie committed 2026-11-02 17:56:19 +01:00
commit 60aa0ec37f
543 files changed
+44533 -5206

No files matched your search

+3 -2
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+9
View File
@@ -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();
+199 -81
View File
@@ -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)
}
+184 -22
View File
@@ -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| &section.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| &section.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"
);
}
}
+93 -6
View File
@@ -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.
+2 -1
View File
@@ -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);
+59 -38
View File
@@ -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);
}
}
}
+63 -5
View File
@@ -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 {
+46 -6
View File
@@ -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);
}
}
+162 -22
View File
@@ -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();
+2 -5
View File
@@ -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 };
+53 -6
View File
@@ -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");
+1 -1
View File
@@ -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
+1 -3
View File
@@ -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();
}
+66 -2
View File
@@ -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 =
+184 -13
View File
@@ -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() {
+193 -17
View File
@@ -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();
+52 -7
View File
@@ -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)]
);
}
}
+51 -30
View File
@@ -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(),
+3
View File
@@ -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,
+23
View File
@@ -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.
+3
View File
@@ -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)",
+54
View File
@@ -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
+13 -1
View File
@@ -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)?;
+82 -21
View File
@@ -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);
+164 -21
View File
@@ -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]
+9 -6
View File
@@ -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);
}
+265 -13
View File
@@ -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
+27
View File
@@ -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);
},
+201 -35
View File
@@ -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
+36 -15
View File
@@ -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);
}
}
}
+18 -2
View File
@@ -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
+12
View File
@@ -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() {
+66 -5
View File
@@ -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();
}
+22
View File
@@ -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();
+107 -36
View File
@@ -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)
+59 -2
View File
@@ -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,
}
}
+1 -1
View File
@@ -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)
}
+6
View File
@@ -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),
+16 -2
View File
@@ -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)));
}
}
+4 -3
View File
@@ -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],
+212 -74
View File
@@ -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)
);
}
}
+26 -3
View File
@@ -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);
}
}
}
+19 -7
View File
@@ -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();
+52 -9
View File
@@ -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);
}
+84 -20
View File
@@ -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();
+54 -11
View File
@@ -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);
}
}
}
+11 -23
View File
@@ -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.
+2 -4
View File
@@ -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
+1 -1
View File
@@ -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;
+89 -3
View File
@@ -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;
};
+9
View File
@@ -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<_>>()
}),
})
}
}
+22 -1
View File
@@ -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.
+10 -1
View File
@@ -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 {
+20 -1
View File
@@ -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> {
+12
View File
@@ -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)
}
}
+69 -6
View File
@@ -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()));
+1 -1
View File
@@ -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 {
+181 -21
View File
@@ -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;
+1 -1
View File
@@ -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;
+5 -2
View File
@@ -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(|| {
(
+55 -3
View File
@@ -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);
}
}
+31 -12
View File
@@ -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);
+20 -5
View File
@@ -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