Merge main into #7199

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 14:43:11 +01:00
commit f622f09367
196 files changed
+8602 -896

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
Generated
+1
View File
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -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 {
+6 -1
View File
@@ -1206,8 +1206,13 @@ impl Audio {
/// `SoundManager.reload` (resource packs changed): stop every sound, then
/// read sound events and sound files from the new resource root.
pub fn reload(&mut self, root: &Path) {
self.reload_with(root, SoundRegistry::load(root));
}
/// [`Self::reload`] with the root's sound events already read
/// (`SoundRegistry::load`, which a pack reload runs off the render thread).
pub fn reload_with(&mut self, root: &Path, registry: SoundRegistry) {
self.stop_all();
self.engine.registry = SoundRegistry::load(root);
self.engine.registry = registry;
self.backend.root = root.to_owned();
let mut cache = self.backend.cache.lock().unwrap();
cache.entries.clear();
+192 -80
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:
@@ -150,9 +197,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 +320,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 +361,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"
);
}
}
+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);
}
}
}
+12
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
@@ -70,8 +70,11 @@ pub struct SkinJob {
/// 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],
/// 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,
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
@@ -376,7 +379,12 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} 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 +498,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 {
@@ -16,8 +16,8 @@ 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).
// it draws an item's quads (`SkinJob::item`), 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).
@@ -89,6 +89,7 @@ fn skin(
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let item = jobs[j + 13u] != 0u;
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,6 +111,10 @@ 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 cull {
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
}
if item {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
@@ -273,16 +273,20 @@ const ZEROS: u64 = 4 << 20;
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 +309,7 @@ impl Buffers {
Self {
layout,
objects,
minimums,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
@@ -330,7 +335,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 +374,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 +471,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);
}
}
@@ -1686,6 +1686,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 +1771,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);
+10
View File
@@ -193,6 +193,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;
};
@@ -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
@@ -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,
}
+173 -12
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,49 @@ 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) {
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 +88,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 +97,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 +128,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;
@@ -309,21 +405,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 +433,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 +442,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() {
+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)]
);
}
}
+64 -31
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
@@ -50,41 +55,69 @@ fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
}
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
pub fn load_metadata(root: &Path) {
set_metadata(read_metadata(root));
}
/// The GUI sprites' nine-slice metadata of a resource root, read off the
/// render thread by a pack reload ([`read_metadata`]) and put in use with
/// [`set_metadata`].
pub struct UiMetadata(HashMap<String, NineSlice>);
/// Puts read metadata in use ([`load_metadata`] in two halves).
pub fn set_metadata(metadata: UiMetadata) {
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(metadata.0);
}
/// Reads the metadata [`load_metadata`] puts in use, without putting it in use.
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);
}
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
UiMetadata(map)
}
/// The sprite's nine-slice metadata, when its `.png.mcmeta` has any.
fn nine_slice_metadata(sprite: &str) -> Option<NineSlice> {
+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.
+12
View File
@@ -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);
+102 -19
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,
@@ -1775,7 +1784,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 +1808,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 +1963,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 +2060,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 +2072,7 @@ mod honey_slide_tests {
false,
&fluids,
&[],
None,
);
ys.push(item.velocity.y);
}
@@ -2059,6 +2137,7 @@ mod honey_slide_tests {
false,
&fluids,
&[],
None,
);
}
assert!(
@@ -2355,6 +2434,7 @@ mod slime_bounce_tests {
false,
&fluids,
&[],
None,
);
assert_eq!(falling.position.y, 4.0);
assert!(falling.velocity.y > 0.0);
@@ -2373,6 +2453,7 @@ mod slime_bounce_tests {
false,
&fluids,
&[],
None,
);
assert!(!falling.needs_sync);
}
@@ -2465,6 +2546,7 @@ mod entity_collision_tests {
false,
&fluids,
boxes,
None,
);
}
item.position.y
@@ -2513,6 +2595,7 @@ mod entity_collision_tests {
false,
&fluids,
&[],
None,
);
}
item.position.x
+5 -2
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);
+125 -30
View File
@@ -677,10 +677,10 @@ pub fn mob_to_nbt(
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
map.insert("Bred".into(), Tag::Byte(i8::from(m.extra.bred)));
// AbstractHorse.addAdditionalSaveData; Horizon does not model the
// eating haystack yet.
map.entry("EatingHaystack".into()).or_insert(Tag::Byte(0));
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -1608,6 +1608,7 @@ pub fn mob_from_nbt(
if crate::mobs::equine::is_equine(m.species()) {
m.extra.tame = flag(tag, "Tame");
m.extra.variant3 = number(tag.get("Temper")).unwrap_or(0).clamp(0, 100) as u8;
m.extra.bred = flag(tag, "Bred");
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
let v = number(tag.get("Variant")).unwrap_or(0) as i32;
m.extra.variant = (v & 0xFF).clamp(0, 6) as u8;
@@ -2033,7 +2034,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),
@@ -2110,8 +2114,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,
@@ -2412,7 +2415,7 @@ const THROWABLE_ITEMS: [&str; 5] = [
fn throwable_item(kind: &ProjectileKind) -> Option<&str> {
match kind {
ProjectileKind::Thrown(t) => Some(t.item),
ProjectileKind::Potion(stack) => Some(&stack.item),
ProjectileKind::Potion(stack, _) => Some(&stack.item),
_ => THROWABLE_ITEMS.iter().find(|id| **id == kind.id()).copied(),
}
}
@@ -2428,20 +2431,37 @@ pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
"minecraft:snowball" => Some(ProjectileKind::Snowball),
"minecraft:egg" => Some(ProjectileKind::Egg),
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
_ => Some(ProjectileKind::Potion(Box::new(
crate::items::ItemStack::new(id, 1),
))),
// The entity type is the id; the `Item` read later may be another potion.
_ => Some(ProjectileKind::Potion(
Box::new(crate::items::ItemStack::new(id, 1)),
id == "minecraft:lingering_potion",
)),
}
}
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
/// and [`throwable_to_nbt`] write.
pub fn saved_with_chunk(p: &Projectile) -> bool {
matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|| throwable_item(&p.kind).is_some()
// No wildcard: a kind added to `ProjectileKind` must say here whether its
// chunk saves it (`Entity.shouldBeSaved` is true for every projectile in
// vanilla), and a `true` needs a writer in `entity_chunks`.
match &p.kind {
ProjectileKind::LargeFireball { .. }
| ProjectileKind::SmallFireball
| ProjectileKind::WindCharge
| ProjectileKind::BreezeWindCharge
| ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::EnderPearl
| ProjectileKind::Potion(..)
| ProjectileKind::Thrown(_) => true,
ProjectileKind::Arrow { .. } => p.arrow.is_some(),
// Not written yet: tridents save from their own list.
ProjectileKind::Trident
| ProjectileKind::LlamaSpit
| ProjectileKind::ShulkerBullet { .. }
| ProjectileKind::WitherSkull { .. } => false,
}
}
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
@@ -2472,7 +2492,7 @@ pub fn throwable_to_nbt(
// effects); when there is none, `getItem`'s default is written.
if tag.get("Item").is_none() {
let stack = match &p.kind {
ProjectileKind::Potion(stack) => (**stack).clone(),
ProjectileKind::Potion(stack, _) => (**stack).clone(),
_ => crate::items::ItemStack::new(item, 1),
};
put(&mut tag, "Item", item_to_nbt(&stack));
@@ -2486,7 +2506,7 @@ pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Project
let mut kind = throwable_kind(&kind(tag)?)?;
// `readAdditionalSaveData` sets the `Item` it flies as, potion effects
// and all; a tag without one keeps `getItem`'s default.
if let ProjectileKind::Potion(stack) = &mut kind
if let ProjectileKind::Potion(stack, _) = &mut kind
&& let Some(item) = tag.get("Item").and_then(item_from_nbt)
{
**stack = item;
@@ -2745,12 +2765,25 @@ pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
put(&mut tag, "block_pos", Tag::IntArray(p.pos.to_vec()));
tag
}
/// `BlockAttachedEntity.readAdditionalSaveData`: `block_pos` when saved; a
/// summoned one has none, and `setPos` puts its block at the block holding
/// the summon position.
fn attached_block_pos(tag: &Tag) -> Option<[i32; 3]> {
match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => {
let at = position(tag)?;
Some([
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
])
}
}
}
/// The block position, facing and variant of a saved painting.
pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => return None,
};
let pos = attached_block_pos(tag)?;
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
Some((pos, facing, text(tag.get("variant"))?))
}
@@ -2863,10 +2896,7 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
tag
}
pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => return None,
};
let pos = attached_block_pos(tag)?;
Some(ItemFrame {
id,
pos,
@@ -4902,7 +4932,10 @@ mod fireball_nbt_tests {
assert_eq!(p.kind.id(), id);
assert_eq!(
p.kind,
ProjectileKind::Potion(Box::new(crate::items::ItemStack::new(id, 1)))
ProjectileKind::Potion(
Box::new(crate::items::ItemStack::new(id, 1)),
id == "minecraft:lingering_potion"
)
);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
@@ -4924,7 +4957,7 @@ mod fireball_nbt_tests {
),
]);
let p = throwable_from_nbt(&harming, None).unwrap();
let ProjectileKind::Potion(stack) = &p.kind else {
let ProjectileKind::Potion(stack, _) = &p.kind else {
panic!("a potion");
};
assert_eq!(stack.potion(), Some("minecraft:harming"));
@@ -4948,6 +4981,67 @@ mod fireball_nbt_tests {
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
}
#[test]
fn wind_charges_in_flight_are_saved_and_read_back() {
// vanilla 26.3 saves them (`Entity.shouldBeSaved` is true; the round
// trip of a vanilla world lost all four before this).
for id in ["minecraft:wind_charge", "minecraft:breeze_wind_charge"] {
let saved = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[4.5, 80.0, 10.5])),
("NoGravity", Tag::Byte(1)),
("acceleration_power", Tag::Double(0.1_f64.to_bits())),
]);
let p = fireball_from_nbt(&saved, None).unwrap();
assert_eq!(p.kind.id(), id);
assert!(saved_with_chunk(&p), "{id}");
let tag = fireball_to_nbt(&p, [1; 4], None, Some(&saved)).unwrap();
assert_eq!(kind(&tag).as_deref(), Some(id));
assert_eq!(tag.get("NoGravity"), Some(&Tag::Byte(1)));
}
}
#[test]
fn a_lingering_potion_keeps_its_type_whatever_item_it_flies_as() {
// `/summon minecraft:lingering_potion ~ ~ ~ {Item:{id:"minecraft:splash_potion"}}`
// is a `ThrownLingeringPotion` in vanilla and stays one when saved.
let tag = Tag::compound([
("id", Tag::String("minecraft:lingering_potion".into())),
("Pos", doubles(&[24.5, 80.0, 8.5])),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:splash_potion", 1)),
),
]);
let p = throwable_from_nbt(&tag, None).unwrap();
assert_eq!(p.kind.id(), "minecraft:lingering_potion");
let back = throwable_to_nbt(&p, [1; 4], None, Some(&tag)).unwrap();
assert_eq!(kind(&back).as_deref(), Some("minecraft:lingering_potion"));
let item = back.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:splash_potion");
}
#[test]
fn a_summoned_painting_or_frame_has_no_block_pos_and_sits_at_its_position() {
// BlockAttachedEntity.setPos: the block holding the summon position.
let painting = Tag::compound([
("id", Tag::String("minecraft:painting".into())),
("Pos", doubles(&[28.5, 80.0, 6.5])),
("variant", Tag::String("minecraft:kebab".into())),
("facing", Tag::Byte(3)),
]);
assert_eq!(
painting_from_nbt(&painting),
Some(([28, 80, 6], 3, "minecraft:kebab".to_string()))
);
let frame = Tag::compound([
("id", Tag::String("minecraft:item_frame".into())),
("Pos", doubles(&[-0.5, 80.0, 6.5])),
("Facing", Tag::Byte(1)),
]);
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
@@ -4981,11 +5075,12 @@ mod fireball_nbt_tests {
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
}
// the chunk saves arrows, fireballs and these, and so takes them with it.
// the chunk saves arrows, fireballs, wind charges and these, and so takes them with it.
let kinds = [
(ProjectileKind::SmallFireball, true),
(ProjectileKind::Snowball, true),
(ProjectileKind::WindCharge, false),
(ProjectileKind::WindCharge, true),
(ProjectileKind::BreezeWindCharge, true),
(ProjectileKind::Trident, false),
];
for (kind, saved) in kinds {
+136 -51
View File
@@ -3828,9 +3828,7 @@ impl Game<'_> {
stuck: Vec3::ZERO,
});
// ServerLevel.addFreshEntity.
if !self.native_entity_joins(self.next_entity, "minecraft:item", position, false) {
self.dropped.pop();
} else {
if self.native_joins_flush(false, self.next_entity) {
self.native_item_spawn(self.next_entity, &item, count, position);
}
}
@@ -3943,8 +3941,8 @@ impl Game<'_> {
// THROW and a creative tab's drop included.
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
// Player.drop: the drop statistics.
self.stats.add("dropped", &thrown.item, thrown.count as i32);
self.stats.custom("drop", 1);
self.award_stat("dropped", &thrown.item, thrown.count as i32);
self.award_custom_stat("drop", 1);
let player = self.native_acting_id();
self.native_item_dropped(player, &thrown.item, thrown.count);
self.throw_from_eyes(thrown, eye, true);
@@ -4011,9 +4009,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());
@@ -4062,6 +4057,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
@@ -4087,6 +4083,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,
@@ -4096,6 +4095,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);
@@ -4107,8 +4122,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",
@@ -4171,7 +4185,7 @@ impl Game<'_> {
let thrower = item.thrower;
match self.give(item.stack.clone()) {
None => {
self.stats.add("picked_up", &kind, before as i32);
self.award_stat("picked_up", &kind, before as i32);
if let Some(thrower) = thrower {
self.trigger_thrown_picked_up_by_player(&item.stack, thrower);
}
@@ -4190,8 +4204,7 @@ impl Game<'_> {
}
Some(rest) => {
if rest.count < before {
self.stats
.add("picked_up", &kind, (before - rest.count) as i32);
self.award_stat("picked_up", &kind, (before - rest.count) as i32);
if let Some(thrower) = thrower {
self.trigger_thrown_picked_up_by_player(&rest, thrower);
}
@@ -4422,7 +4435,7 @@ impl Game<'_> {
});
// NoteBlock.attack awards the statistic.
if note {
self.stats.custom("play_noteblock", 1);
self.award_custom_stat("play_noteblock", 1);
}
self.process_level_events()
}
@@ -5207,7 +5220,7 @@ impl Game<'_> {
PlayerEffect::DropItem(_) => {}
PlayerEffect::EatCake => {
// CakeBlock.eat
self.stats.custom("eat_cake_slice", 1);
self.award_custom_stat("eat_cake_slice", 1);
if let Some(p) = self.player.as_mut() {
let food = &mut p.health.food;
food.food = (food.food + 2).min(20);
@@ -5422,7 +5435,7 @@ impl Game<'_> {
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.award_custom_stat("open_enderchest", 1);
self.anger_piglins(true);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
@@ -5461,7 +5474,7 @@ impl Game<'_> {
_ => "",
};
if !stat.is_empty() {
self.stats.custom(stat, 1);
self.award_custom_stat(stat, 1);
}
// Opening a piglin-guarded container angers the piglins who see it.
if self.guarded_by_piglins(&name) {
@@ -5538,7 +5551,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 {
@@ -5557,7 +5570,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,
@@ -5742,7 +5761,7 @@ impl Game<'_> {
if wears && per_block > 0 && max > 0 {
self.damage_held(per_block);
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.lan_break_pos = Some(pos);
@@ -5851,7 +5870,7 @@ impl Game<'_> {
.correct_for_drops(held.as_ref(), &facts.name);
if harvest {
// Block.playerDestroy: the mined statistic, the exhaustion and the drops.
self.stats.add("mined", &facts.name, 1);
self.award_stat("mined", &facts.name, 1);
if let Some(p) = self.player.as_mut() {
p.cause_food_exhaustion(0.005);
}
@@ -6230,7 +6249,7 @@ impl Game<'_> {
&& result == horizon_world::level::InteractionResult::Success
&& let Some(used) = held.as_ref()
{
self.stats.add("used", &used.item, 1);
self.award_stat("used", &used.item, 1);
self.consume_held();
}
// FlowerPotBlock.useItemOn: the plant is potted, used up, and counted.
@@ -6238,8 +6257,8 @@ impl Game<'_> {
&& result == horizon_world::level::InteractionResult::Success
&& let Some(used) = held.as_ref()
{
self.stats.add("used", &used.item, 1);
self.stats.custom("pot_flower", 1);
self.award_stat("used", &used.item, 1);
self.award_custom_stat("pot_flower", 1);
self.consume_held();
if let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
@@ -6258,8 +6277,8 @@ impl Game<'_> {
.name
.clone();
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
"minecraft:note_block" => self.award_custom_stat("tune_noteblock", 1),
"minecraft:bell" => self.award_custom_stat("bell_ring", 1),
"minecraft:lever" => self.lever_puff(hit.pos),
_ => {}
}
@@ -6499,7 +6518,7 @@ impl Game<'_> {
// BlockItem.useOn: a consumable that could not be placed is used,
// which ItemStack.useOn counts as a use of the item.
if self.start_eating(&info) {
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
} else {
// BlockItem.place's FAIL.
self.use_failed = true;
@@ -6546,7 +6565,7 @@ impl Game<'_> {
}
self.process_level_events()?;
// BlockItem.place: the used statistic and the placed_block trigger.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.trigger_block("minecraft:placed_block", target, &stack);
self.native_place_block(&stack, target, state);
self.check_wither_spawn(target)?;
@@ -6640,7 +6659,7 @@ impl Game<'_> {
let group = horizon_client::cooldowns::group(stack, None);
self.add_cooldown(group, instrument.use_ticks());
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) {
use horizon_client::items::ConsumeEffect;
@@ -6874,7 +6893,7 @@ impl Game<'_> {
0.8,
pitch,
);
self.stats.add("used", &item, 1);
self.award_stat("used", &item, 1);
}
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
@@ -7277,6 +7296,8 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
// Entities added since the last tick join the level before it ticks.
self.native_joins_flush(false, 0);
self.share_spawn_conditions();
horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes"));
self.update_chunk_tickets();
@@ -8081,6 +8102,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);
@@ -8426,6 +8451,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.
@@ -8740,9 +8780,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);
@@ -10297,7 +10343,7 @@ impl Game<'_> {
// (native_item_crafted).
let player = self.native_acting_id();
self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken);
self.stats.add("crafted", &made.0, made.1 as i32);
self.award_stat("crafted", &made.0, made.1 as i32);
self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]);
if !shift || crafted >= 64 {
break;
@@ -11365,8 +11411,7 @@ impl Game<'_> {
mob.kind.width,
mob.kind.height,
);
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
if crit {
self.critical_hit(
self.lan_acting,
@@ -12080,7 +12125,7 @@ impl Game<'_> {
}
self.close_container();
if self.remote.is_none() {
self.stats.custom("leave_game", 1);
self.award_custom_stat("leave_game", 1);
}
self.lan_close(false);
self.free_player_beds()?;
@@ -12482,7 +12527,7 @@ impl Game<'_> {
return Ok(false);
}
if let Some(stat) = result.stat {
self.stats.custom(stat, 1);
self.award_custom_stat(stat, 1);
}
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
@@ -12617,7 +12662,7 @@ impl Game<'_> {
self.audio.play(&sound, Some(at), 1.0, 1.0);
}
// ItemStack.useOn: a use that acted counts as one of the item.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.damage_held(damage);
self.post_block_game_event("minecraft:block_change", pos, self.sculk_actor(), Some(new));
Ok(true)
@@ -12714,7 +12759,7 @@ impl Game<'_> {
let broken = stack.item.clone();
let gone = stack.clone();
*cell = None;
self.stats.add("broken", &broken, 1);
self.award_stat("broken", &broken, 1);
// LivingEntity.onEquippedItemBroken: the sound and the crumbs.
self.player_equipment_broke(
horizon_world::player_inventory::EquipmentSlot::MainHand,
@@ -12805,7 +12850,7 @@ impl Game<'_> {
self.process_level_events()?;
// `player.awardStat(Stats.ITEM_USED.get(this))`,
// `getPickupSound` and `level.gameEvent(player, FLUID_PICKUP)`.
self.stats.add("used", "minecraft:bucket", 1);
self.award_stat("used", "minecraft:bucket", 1);
let sound = if filled == "minecraft:lava_bucket" {
"minecraft:item.bucket.fill_lava"
} else {
@@ -12925,7 +12970,7 @@ impl Game<'_> {
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
if !self.creative {
self.consume_held();
}
@@ -13126,9 +13171,7 @@ impl Game<'_> {
health: 5,
});
// ServerLevel.addFreshEntity.
if !self.native_entity_joins(self.next_entity, "minecraft:experience_orb", at, false) {
self.orbs.pop();
}
self.native_joins_flush(false, 0);
}
fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool {
let pos = BlockPos::new(target[0], target[1], target[2]);
@@ -13247,6 +13290,7 @@ impl Game<'_> {
target,
self.remote.is_none(),
&mut next_float,
Some(self.border.geometry()),
)
});
self.orbs = orbs;
@@ -13324,9 +13368,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;
@@ -14128,7 +14175,7 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
self.occupy_bed(head);
self.stats.custom("sleep_in_bed", 1);
self.award_custom_stat("sleep_in_bed", 1);
self.trigger_player("minecraft:slept_in_bed");
self.stats.reset("custom", "minecraft:time_since_rest");
}
@@ -14810,10 +14857,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<()> {
@@ -14829,12 +14885,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
@@ -14846,9 +14907,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);
}
@@ -16779,14 +16842,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 {
@@ -17764,12 +17832,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:
@@ -17873,7 +17948,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();
@@ -18978,6 +19054,7 @@ impl Game<'_> {
if let Some(host) = self.lan.as_mut() {
host.hub.reload_resources(sync);
}
self.run_native_advancement_grants();
}
Err(e) => {
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
@@ -20706,6 +20783,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
+48 -4
View File
@@ -611,13 +611,45 @@ impl Game<'_> {
for (ai, ci) in hits {
let a = &tree.list[ai];
let was = self.adv.progress.done(a);
self.adv.progress.grant(&a.id, &a.criteria[ci].name, &date);
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) {
self.award(ai);
}
}
}
/// `PlayerAdvancements.award` / `revoke` after a criterion's progress changed.
fn advancement_progress(&mut self, advancement: &str, criterion: &str, granted: bool) {
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_progress(player, &name, advancement, criterion, granted);
}
}
/// The criteria `CriteriaTriggers.TICK` granted network players in the hub
/// (their first tick and a reload), told to the mods.
pub(super) fn run_native_advancement_grants(&mut self) {
let Some(host) = self.lan.as_mut() else {
return;
};
for batch in std::mem::take(&mut host.hub.native_advancement_grants) {
for grant in batch.grants {
self.native_player_advancement_progress(
batch.player,
&batch.name,
&grant.advancement,
&grant.criterion,
true,
);
}
}
}
/// `PlayerAdvancements.progressChanged`, kept for a network player's client.
fn progress_changed(&mut self, index: usize) {
if self.lan_acting.is_some() {
@@ -692,7 +724,7 @@ impl Game<'_> {
match result {
Ok(unlocked) => {
self.recipe_unlocked(&unlocked);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
@@ -713,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
@@ -1962,6 +1996,7 @@ impl Game<'_> {
let was = self.adv.progress.done(a);
if self.adv.progress.grant(&a.id, c, &date) {
changed = true;
self.advancement_progress(&a.id, c, true);
self.progress_changed(index);
if !was && self.adv.progress.done(a) {
self.award(index);
@@ -1976,16 +2011,25 @@ impl Game<'_> {
let a = &tree.list[index];
self.progress_changed(index);
match criterion {
Some(c) => self.adv.progress.revoke(&a.id, c),
Some(c) => self.revoke_criterion(&a.id, c),
None => {
let mut any = false;
for c in &a.criteria {
any |= self.adv.progress.revoke(&a.id, &c.name);
any |= self.revoke_criterion(&a.id, &c.name);
}
any
}
}
}
/// `PlayerAdvancements.revoke(holder, criterion)`: the progress change and
/// the hook for it.
fn revoke_criterion(&mut self, advancement: &str, criterion: &str) -> bool {
let revoked = self.adv.progress.revoke(advancement, criterion);
if revoked {
self.advancement_progress(advancement, criterion, false);
}
revoked
}
}
impl Game<'_> {
/// `ClientAdvancements.update` (ClientboundUpdateAdvancementsPacket):
+1 -1
View File
@@ -272,7 +272,7 @@ impl Game<'_> {
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
self.stats.add("used", &held.item, 1);
self.award_stat("used", &held.item, 1);
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
let key = (pos[0], pos[1], pos[2]);
if !smokey
+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
+1 -1
View File
@@ -873,7 +873,7 @@ impl Game<'_> {
let Some(book) = &l.book else { return false };
let pages = view_pages(book.data());
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
self.stats.custom("interact_with_lectern", 1);
self.award_custom_stat("interact_with_lectern", 1);
self.open_book(BookScreen::View {
pages,
page,
+1 -1
View File
@@ -51,7 +51,7 @@ impl Game<'_> {
self.native_campfire_start(pos, &held.item);
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
self.award_custom_stat("interact_with_campfire", 1);
self.post_block_game_event(
"minecraft:block_change",
pos,
+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,7 +66,7 @@ impl Game<'_> {
{
// addBook: one book goes in (a creative player keeps theirs).
let book = book.clone();
self.stats.add("used", &book.item, 1);
self.award_stat("used", &book.item, 1);
let sound = if &*book.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.insert.enchanted"
} else {
+4 -5
View File
@@ -265,9 +265,9 @@ impl Game<'_> {
// hit), in tenths.
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
match *name {
"resisted" => self.stats.custom("damage_resisted", *amount),
"absorbed" => self.stats.custom("damage_absorbed", *amount),
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
"resisted" => self.award_custom_stat("damage_resisted", *amount),
"absorbed" => self.award_custom_stat("damage_absorbed", *amount),
"blocked" => self.award_custom_stat("damage_blocked_by_shield", *amount),
_ => {}
}
continue;
@@ -308,8 +308,7 @@ impl Game<'_> {
}
}
// Player.actuallyHurt: damage_taken in tenths.
self.stats
.custom("damage_taken", (amount * 10.0).round() as i32);
self.award_custom_stat("damage_taken", (amount * 10.0).round() as i32);
self.combat.entries.push(Entry {
damage_type: name,
damage: amount,
+43 -10
View File
@@ -159,6 +159,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) {
@@ -2175,7 +2186,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() {
@@ -2202,13 +2214,7 @@ impl Game<'_> {
health: 5,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_entity_joins(
self.next_entity,
"minecraft:experience_orb",
pos,
false,
) {
self.orbs.pop();
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
}
name = Text::translate("entity.minecraft.experience_orb", &[]);
@@ -2256,7 +2262,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.
@@ -2737,6 +2747,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;
@@ -2922,6 +2933,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);
@@ -2940,8 +2952,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);
@@ -2964,8 +2998,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),
@@ -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();
}
+1 -1
View File
@@ -60,7 +60,7 @@ impl Game<'_> {
if level < 7 {
self.composter_add(pos, &layers);
self.native_compost_item(pos, &stack.item);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
+1 -1
View File
@@ -49,7 +49,7 @@ impl Game<'_> {
) {
self.emit_consume(consumable, stack, 16);
// ServerPlayer: the used statistic and the consume_item trigger.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.trigger_item("minecraft:consume_item", stack);
// FoodProperties.onConsume, the player's one ConsumableListener.
if let Some((nutrition, saturation)) = info.food {
+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();
+69 -24
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()?;
@@ -427,6 +431,9 @@ 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 {
@@ -1290,9 +1297,9 @@ impl Game<'_> {
// entity, so a kill by a mob (a tame wolf credited to its owner)
// leaves it out; ServerPlayer.awardKillScore counts the rest.
if !killed_by_mob(m) {
self.stats.add("killed", &m.kind.id, 1);
self.award_stat("killed", &m.kind.id, 1);
}
self.stats.custom("mob_kills", 1);
self.award_custom_stat("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
@@ -2103,6 +2110,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,
@@ -2277,21 +2312,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,
@@ -2946,7 +2989,7 @@ impl Game<'_> {
super::commands::Target::Guest,
);
self.for_player(who, |g| {
g.stats.custom("animals_bred", 1);
g.award_custom_stat("animals_bred", 1);
g.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
});
}
@@ -3883,7 +3926,7 @@ impl Game<'_> {
if rang {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
self.stats.custom("bell_ring", 1);
self.award_custom_stat("bell_ring", 1);
}
}
}
@@ -4101,7 +4144,7 @@ impl Game<'_> {
let _ = self.process_level_events();
self.native_target_hit(self.dim.id(), pos, strength);
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
self.award_custom_stat("target_hit", 1);
let kind = if p
.arrow
.as_ref()
@@ -4734,7 +4777,7 @@ 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) => {
(ProjectileKind::Potion(stack, _), hit) => {
let block = match hit {
Hit::Block { pos, face, .. } => Some((
pos,
@@ -6111,7 +6154,7 @@ impl Game<'_> {
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
@@ -6142,7 +6185,7 @@ impl Game<'_> {
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
@@ -6284,7 +6327,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 {
@@ -6536,7 +6579,9 @@ 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 unless an outline reads their vertices.
let skin = (skinning && outline.is_none()).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);
}
@@ -119,7 +119,7 @@ impl Game<'_> {
};
let stack = stack.clone();
self.block_entity_event(pos, 1, WOBBLE_POSITIVE);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
// consumeAndReturn(1): one item goes in (a creative player keeps theirs).
let inserted = match &mut pot_item {
Some(p) => {
+68 -10
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 {
@@ -641,6 +678,18 @@ struct ServerState {
force_game_mode: bool,
}
/// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag.
fn auto_save(state: &ServerState) -> bool {
state.autosave
}
/// `MinecraftServer.setAutoSave`: enable or disable periodic saves and report whether it changed.
fn set_auto_save(state: &mut ServerState, enabled: bool) -> bool {
let changed = state.autosave != enabled;
state.autosave = enabled;
changed
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
/// (`sendSuccess(..., true)`): the operators hear of them.
const BROADCAST: &[&str] = &[
@@ -1063,8 +1112,12 @@ impl Game<'_> {
self.save_game()?;
}
if !paused && !state.autosave {
// `save-off`: the periodic save inside the tick is skipped.
self.ticks = self.ticks.max(1);
// `MinecraftServer.autoSave` keeps its schedule while every level's
// `noSave` flag is set, but writes nothing. The tick below counts down
// once, so reset one tick early when this save would have been due.
if self.ticks_until_autosave <= 1 {
self.ticks_until_autosave = self.next_autosave_interval() + 1;
}
}
// MinecraftServer.tickServer: while paused only the connections run.
// `MinecraftServer.runServer` catches what the tick throws, writes the crash
@@ -1372,19 +1425,17 @@ impl Game<'_> {
}
}
"save-off" => {
if state.autosave {
state.autosave = false;
if set_auto_save(state, false) {
self.console_reply(Text::translate("commands.save.disabled", &[]));
} else {
self.console_reply(Text::translate("commands.save.alreadyOff", &[]));
}
}
"save-on" => {
if state.autosave {
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
} else {
state.autosave = true;
if set_auto_save(state, true) {
self.console_reply(Text::translate("commands.save.enabled", &[]));
} else {
self.console_reply(Text::translate("commands.save.alreadyOn", &[]));
}
}
"list" => {
@@ -1780,6 +1831,13 @@ impl Game<'_> {
}
}
/// `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());
}
}
@@ -3,10 +3,11 @@
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
use super::{GAME_MODES, ServerState, log};
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,63 @@ 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`: the rule is set only when its value changes, and logged.
"gamerules/update" => {
let update = &body["value"];
let Some(rule) = update["key"]
.as_str()
.and_then(gamerules::rule)
.filter(|r| self.ui.rules.get(r.name).is_some())
else {
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
};
let wanted = &update["value"];
let value = match (rule.default, wanted) {
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
(GameRuleValue::Int(_), n) => n
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.filter(|n| (rule.min..=rule.max).contains(n))
.map(GameRuleValue::Int),
_ => None,
};
let Some(value) = value else {
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
if current != value {
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) });
}
"serversettings/autosave/set" => {
let Some(enabled) = body["value"].as_bool() else {
return json!({ "error": "autosave must be a boolean" });
};
rpc(format!(
"Update autosave from {} to {}",
auto_save(state),
enabled
));
set_auto_save(state, enabled);
return json!({ "value": auto_save(state) });
}
"serversettings/motd" => {
return json!({
"value": self.lan.as_ref().map(|lan| lan.hub.lan.shared.motd()).unwrap_or_default()
@@ -105,6 +172,46 @@ impl Game<'_> {
self.set_force_game_mode(force);
return json!({ "value": state.force_game_mode });
}
"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] });
}
@@ -436,3 +543,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,
}
}
@@ -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)
}
+2 -2
View File
@@ -1316,8 +1316,8 @@ impl Game<'_> {
view: &horizon_client::advancements::EntityView,
blow: Option<(DamageKind, bool)>,
) {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.award_stat("killed", "minecraft:ender_dragon", 1);
self.award_custom_stat("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
// KilledTrigger: the damage source `die` was given.
let blow = blow.map(|(kind, by_player)| {
+45
View File
@@ -21,6 +21,7 @@ use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_core::position::BlockPos;
use horizon_world::nbt::Tag;
use horizon_world::{level::data::PushReaction, piston_push};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
/// What `Entity.thunderHit` does to a kind: it sets the entity on fire and
@@ -61,6 +62,8 @@ pub(super) trait Entity: EntityRules {
/// What a selector finds this entity as (`@e`), or `None` for one it cannot
/// name yet. Required, so a new kind states which.
fn selector_target(&self) -> Option<Target>;
/// The entity type id a native mod's join hook names (`minecraft:oak_boat`).
fn native_type(&self) -> Cow<'static, str>;
/// Saves entity `index` of this kind into its chunk's entity list: the
/// chunk and the NBT when it lies in `keys` and is saved with its chunk.
/// Required, so a new kind states how it saves.
@@ -313,6 +316,15 @@ macro_rules! define_entity_handles {
$(.chain((0..self$(.$list)+.len()).map(EntityHandle::$kind)))*
}
/// Takes the entity out of its list (`Entity.discard` before it ever ticked).
pub(super) fn discard_entity(&mut self, handle: EntityHandle) {
match handle {
$(EntityHandle::$kind(i) => {
self$(.$list)+.remove(i);
})*
}
}
/// [`Self::entity_handles`], last of each list first, so the
/// caller can remove the entity it is on.
pub(super) fn entity_handles_rev(&self) -> impl Iterator<Item = EntityHandle> + use<> {
@@ -452,6 +464,9 @@ impl Game<'_> {
}
impl Entity for Mob {
fn native_type(&self) -> Cow<'static, str> {
super::native_hooks::mob_kind(self).into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -534,6 +549,9 @@ impl Entity for Mob {
}
impl Entity for ItemEntity {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:item".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -576,6 +594,9 @@ impl Entity for ItemEntity {
}
impl Entity for ExperienceOrb {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:experience_orb".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -615,6 +636,9 @@ impl Entity for ExperienceOrb {
}
impl Entity for Boat {
fn native_type(&self) -> Cow<'static, str> {
self.entity_type().into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -658,6 +682,9 @@ impl Entity for Boat {
}
impl Entity for Minecart {
fn native_type(&self) -> Cow<'static, str> {
self.kind.id().into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -711,6 +738,9 @@ fn armor_stand_size(stand: &ArmorStand) -> (f64, f64) {
}
impl Entity for ArmorStand {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:armor_stand".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -783,6 +813,9 @@ impl Entity for ArmorStand {
}
impl Entity for PrimedTnt {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:tnt".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -824,6 +857,9 @@ impl Entity for PrimedTnt {
}
impl Entity for FallingBlock {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:falling_block".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -841,6 +877,9 @@ impl Entity for FallingBlock {
}
impl Entity for Projectile {
fn native_type(&self) -> Cow<'static, str> {
self.kind.id().into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -901,6 +940,9 @@ impl Entity for Projectile {
}
impl Entity for Rocket {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:firework_rocket".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -933,6 +975,9 @@ impl Entity for Rocket {
}
impl Entity for ThrownTrident {
fn native_type(&self) -> Cow<'static, str> {
"minecraft:trident".into()
}
fn save_nbt(
game: &mut Game<'_>,
index: usize,
@@ -856,6 +856,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],
+1 -1
View File
@@ -343,7 +343,7 @@ impl Game<'_> {
.get("minecraft:fishes")
.is_some_and(|t| t.contains(stack.item.as_str()))
{
self.stats.custom("fish_caught", 1);
self.award_custom_stat("fish_caught", 1);
}
let mut item = ItemStack {
item: Arc::from(stack.item.as_str()),
+2 -2
View File
@@ -44,7 +44,7 @@ impl Game<'_> {
if used {
self.swing_arm();
// ItemStack.useOn: an item interaction counts as a use.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
Ok(used)
@@ -89,7 +89,7 @@ impl Game<'_> {
);
self.damage_held(1);
// ItemStack.useOn: an item interaction counts as a use.
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
// ServerPlayerGameMode.useItemOn: a consuming useOn reports it.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
+1 -1
View File
@@ -834,7 +834,7 @@ impl Game<'_> {
if let Some(mut host) = self.lan.take() {
self.flush_native_net(&mut host);
// PlayerList.removeAll: the owner and every guest leave the game.
host.award_leave_game_to_all();
self.award_leave_game_to_all(&mut host);
self.save_leaving_guests(&mut host);
let conns: Vec<u32> = host.hub.guests.iter().map(|g| g.conn).collect();
for conn in conns {
@@ -492,6 +492,32 @@ impl Game<'_> {
}
Some((mark, skin.bounds))
}
/// [`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,
+1 -1
View File
@@ -143,7 +143,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.award_custom_stat("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
+131 -34
View File
@@ -191,29 +191,48 @@ pub(super) struct LanHost {
pub(super) states: lan::entity::States,
}
impl LanHost {
impl Game<'_> {
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
/// player is saved. Once per guest, however often they are saved.
pub(super) fn award_leave_game(&mut self, conn: u32) {
if let Some(play) = self.play.get_mut(&conn)
&& !play.left_game
{
play.left_game = true;
/// player is saved, heard by native mods first. Once per guest, however
/// often they are saved.
pub(super) fn award_leave_game(
&mut self,
host: &mut LanHost,
conn: u32,
player: u128,
name: &str,
) {
let Some(play) = host.play.get_mut(&conn) else {
return;
};
if std::mem::replace(&mut play.left_game, true) {
return;
}
let mut cancel = false;
self.native_player_stat_award(
player,
name,
"minecraft:custom",
"minecraft:leave_game",
1,
&mut cancel,
);
if !cancel && let Some(play) = host.play.get_mut(&conn) {
play.stats.custom("leave_game", 1);
}
}
/// `PlayerList.removeAll`: every player who joined leaves with the world.
pub(super) fn award_leave_game_to_all(&mut self) {
let conns: Vec<u32> = self
pub(super) fn award_leave_game_to_all(&mut self, host: &mut LanHost) {
let players: Vec<(u32, u128, String)> = host
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| g.conn)
.map(|g| (g.conn, g.uuid, g.name.clone()))
.collect();
for conn in conns {
self.award_leave_game(conn);
for (conn, player, name) in players {
self.award_leave_game(host, conn, player, &name);
}
}
}
@@ -1334,7 +1353,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return;
};
host.award_leave_game_to_all();
self.award_leave_game_to_all(&mut host);
self.save_leaving_guests(&mut host);
host.hub.disconnect_all();
// Give the writer threads a moment to flush the disconnect packets.
@@ -1394,10 +1413,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);
}
@@ -1593,35 +1616,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`).
@@ -1883,7 +1899,7 @@ impl Game<'_> {
}
self.lan_crafting_left(&mut host, &mut guest);
if guest.placed {
host.award_leave_game(guest.conn);
self.award_leave_game(&mut host, guest.conn, guest.uuid, &guest.name);
}
// PlayerList.remove: the vehicle it alone rides goes too.
self.take_parent_vehicle(&mut host, &mut guest);
@@ -1923,6 +1939,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());
@@ -1935,6 +1992,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() {
@@ -1986,6 +2062,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)
@@ -2007,6 +2084,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the quiet commands");
}
if !host.hub.native_advancement_grants.is_empty() {
self.lan = Some(host);
self.run_native_advancement_grants();
host = self
.lan
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
@@ -2160,6 +2245,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)
@@ -2198,6 +2284,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the mod messages");
}
if !host.hub.native_advancement_grants.is_empty() {
self.lan = Some(host);
self.run_native_advancement_grants();
host = self
.lan
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
@@ -2276,6 +2370,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;
}
+69 -2
View File
@@ -103,7 +103,7 @@ pub(super) struct GuestPlay {
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
/// way out, the statistic is awarded once (`Game::award_leave_game`).
pub(super) left_game: bool,
last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
@@ -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);
@@ -2291,6 +2340,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) => {
@@ -3453,6 +3504,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 +4074,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 +4135,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<()> {
@@ -4183,7 +4240,7 @@ impl Game<'_> {
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
game.award_custom_stat("fall_one_cm", *cm);
}
}
game.record_combat(&events);
@@ -4193,6 +4250,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);
@@ -633,7 +633,7 @@ impl Game<'_> {
// `potion_contents`, whose effects the guest's splash and
// whose colour its drawing need. A plain splash potion is the
// client's default and is not sent.
K::Potion(stack) if stack.extra.is_some() || stack.count != 1 => {
K::Potion(stack, _) if stack.extra.is_some() || stack.count != 1 => {
e.meta.push((
8,
serializer::ITEM_STACK,
+2 -2
View File
@@ -320,9 +320,9 @@ impl Game<'_> {
if died {
match s.attacker {
Some(conn) => {
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
}
None => self.stats.custom("player_kills", 1),
None => self.award_custom_stat("player_kills", 1),
}
}
}
+55 -12
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);
}
}
}
@@ -390,7 +433,7 @@ impl Game<'_> {
// Player.onEnchantmentPerformed, and EnchantmentMenu's awardStat(ENCHANT_ITEM).
self.as_guest(host, conn, |game| {
game.give_levels(-need);
game.stats.custom("enchant_item", 1);
game.award_custom_stat("enchant_item", 1);
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
game.trigger_enchanted_item(&enchanted, need);
});
+1
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())
{
+3 -3
View File
@@ -240,7 +240,7 @@ impl Game<'_> {
self.mace_hurt_enemy(index);
}
if weapon.is_some() {
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
self.post_attack(index);
// MaceItem.postHurtEnemy
@@ -323,7 +323,7 @@ impl Game<'_> {
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:player");
// MaceItem.postHurtEnemy
@@ -352,7 +352,7 @@ impl Game<'_> {
.get(&stack.item)
.is_some_and(|i| i.weapon_damage.is_some())
{
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
}
self.post_attack_bursts(&stack, "minecraft:armor_stand");
// MaceItem.postHurtEnemy
+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;
};
+2 -2
View File
@@ -1178,7 +1178,7 @@ impl Game<'_> {
return true;
}
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
@@ -1224,7 +1224,7 @@ impl Game<'_> {
self.consume_held();
self.give_or_drop_stack(locked);
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
true
}
+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 {
@@ -483,4 +483,7 @@ 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 instance_dir(&self) -> PathBuf {
self.native_instance_dir()
}
}
+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()));
@@ -258,6 +258,87 @@ 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.
impl Game<'_> {
/// 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,
/// before it ticks again, and `/summon` of it fails (`joins_canceled`).
/// `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).
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() {
return true;
}
for t in &mut self.tnts {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
}
for f in &mut self.falling {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut f.id);
}
for p in &mut self.creatures.projectiles {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut p.id);
}
for t in &mut self.tridents {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
}
for r in &mut self.rockets {
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() {
self.persist.joined_mark = id;
let (kind, at) = super::entity::with_entity!(self, handle, e => {
(e.native_type(), e.position())
});
let mut canceled = false;
let dimension = self.dim.id();
self.native_entity_join_level(
dimension,
id,
&kind,
[at.x, at.y, at.z],
loaded_from_disk,
&mut canceled,
);
if canceled {
self.persist.joins_canceled += 1;
self.forget_entity(id);
// Handles are indexes: the entity is taken out before any
// other change to its list.
self.discard_entity(handle);
kept &= id != watch;
}
}
kept
}
/// The entity with the lowest id above the last one announced, by handle.
fn next_unannounced(&self) -> Option<(super::entity::EntityHandle, u32)> {
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())
};
if id > mark && best.is_none_or(|(_, b)| id < b) {
best = Some((handle, id));
}
}
best
}
/// Entities that were moved, not added (a portal's arrival keeps being
/// silent): everything now in the level counts as announced.
pub(super) fn native_joins_skip(&mut self) {
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
}
}
#[cfg(test)]
mod tests {
use super::*;
+453 -59
View File
@@ -296,8 +296,10 @@
//! `native_extract_item`, `native_move_items`, and `native_dimension` for a
//! machine's tick, in `machines/native_items.rs`).
//!
//! API 47 adds entities joining a level (`native_entity_join_level`), for
//! item entities and experience orbs so far.
//! API 47 adds entities joining a level (`native_entity_join_level`); it tells
//! every kind the game keeps in its entity lists (items, orbs, mobs, boats,
//! minecarts, armor stands, TNT, falling blocks, projectiles, rockets,
//! tridents).
//!
//! API 46 adds bounded world-space rim and colored-light requests
//! (`native_visuals`) and opt-in texture-matched emissive material
@@ -2417,13 +2419,15 @@ impl Game<'_> {
/// `&self`, so it reads and draws only. The Win and Options screens call
/// it on their own path, after their drawing.
///
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips (the server pack prompt
/// hides an open screen too, and that screen does not fire under it),
/// and draw over what it drew, while vanilla's `ScreenEvent.Render.Post`
/// fires for them.
/// It does not fire for the screens drawn over gameplay after it (a
/// server dialog, the server pack, link and code-of-conduct prompts, a
/// book, a sign editor): those keep `Screen::Playing`, which it skips (the
/// server pack prompt hides an open screen too, and that screen does not
/// fire under it), and draw over what it drew, while vanilla's
/// `ScreenEvent.Render.Post` fires for them. The title's own screens
/// (`title`, `world_select`, `world_create`, `multiplayer`…) fire it too,
/// their `screen.id` from the title state machine and the whole window
/// their `rect` (API @API:native_instance_dir@), and no dialog or prompt over them.
/// API 78.
pub(super) fn native_screen_render_post(
&self,
@@ -2443,10 +2447,11 @@ impl Game<'_> {
/// no screen button, no drag, no hotbar key, no sort or header button.
///
/// It fires before anything the screen does with the press, on the window
/// only, and only while a screen of the game's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`). It does not fire on the title screen itself, the connect and
/// loading screens, nor for a server
/// only, and while a screen of the game's or the title's is open (Options
/// and the pack list opened from the title screen count, with no player
/// and no `menu`; the title's own screens since API @API:native_instance_dir@, their
/// `screen.id` from the title state machine). It does not fire on the
/// connect and loading screens, nor for a server
/// dialog, the server pack, link and code-of-conduct prompts, a book or a
/// sign editor drawn over gameplay, nor while a world loads or saves: the
/// click there stays the game's own. A gamepad click or a click the
@@ -2533,9 +2538,10 @@ impl Game<'_> {
///
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
/// while a screen of the game's or the title's is open (Options and the
/// pack list opened from the title screen count, with no player and no
/// `menu`; the title's own since API @API:native_instance_dir@). It
/// does not fire on the connect and loading
/// screens, nor for a server dialog, the server pack, link and
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
/// nor while a world loads or saves: the scroll there stays the game's
@@ -2571,10 +2577,10 @@ impl Game<'_> {
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// keys and the narrator — and while a screen of the game's or the title's
/// is open (Options and the pack list opened from the title screen count,
/// with no player and no `menu`; the title's own since API @API:native_instance_dir@). It does
/// not fire on the connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
@@ -2673,6 +2679,23 @@ impl Game<'_> {
partial_tick: self.clock.fraction() as f32,
}
}
/// The title's own screen as a native mod sees it (`NativeScreenView`):
/// its id from the title state machine (`title`, `world_select`,
/// `world_create`, `multiplayer`, `add_server`…), no menu and no menu
/// title, the whole window as its rectangle and no slots (no menu is
/// open). None while no title screen is up. API @API:native_instance_dir@.
pub(super) fn title_screen_view(&self, batch: &HudBatch) -> Option<NativeScreenView<'_>> {
let title = self.title.as_ref()?;
Some(NativeScreenView {
id: title.mode.id(),
menu: None,
title: None,
rect: [0.0, 0.0, batch.width, batch.height],
slots: 0,
mouse: self.native_cursor(),
partial_tick: self.clock.fraction() as f32,
})
}
/// The open screen's container panel in GUI pixels (`screen`'s, the one
/// this frame draws), or the whole window for a screen with none
/// (`NativeScreenView.rect`).
@@ -2719,15 +2742,16 @@ impl Game<'_> {
canceled
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
/// and no connect or world load is in progress. The one gate they all ask
/// before they fire.
/// renderer, either the title's own (`title`, `world_select`,
/// `world_create`, `multiplayer`…) or a screen over the world
/// (`Screen::Playing` behind it, so not a book, a sign editor, a server
/// dialog or a prompt drawn over gameplay), and no connect or world load
/// is in progress. The one gate they all ask before they fire.
pub(super) fn screen_hooks_open(&self) -> bool {
self.renderer.is_some()
&& self.screen != Screen::Playing
&& !self.screen_over_playing()
&& !self.remote_loading()
&& (self.screen != Screen::Playing || self.title.is_some())
}
/// The open screen as an input hook sees it (`NativeScreenView`), or None
/// when `screen_hooks_open` says no screen is. `screen.mouse` is the
@@ -2741,7 +2765,10 @@ impl Game<'_> {
}
let renderer = self.renderer.as_ref()?;
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(self.screen_view(&batch, self.screen))
if self.screen != Screen::Playing {
return Some(self.screen_view(&batch, self.screen));
}
self.title_screen_view(&batch)
}
/// Whether the key press `key` over the open screen went no further
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
@@ -3135,6 +3162,11 @@ impl Game<'_> {
) {
mod_api::dispatch_player_toggle_flight(self, mod_api::mods(), player, name, flying, cancel);
}
/// A guest's client changed sneaking in its player input packet, after the hub applied the new state.
/// The guest's state is swapped in. Server-side. @API:player_toggle_sneak@.
pub(super) fn native_player_toggle_sneak(&mut self, player: u128, name: &str, sneaking: bool) {
mod_api::dispatch_player_toggle_sneak(self, mod_api::mods(), player, name, sneaking);
}
/// A player finished editing a sign: `ServerGamePacketListenerImpl.updateSignText`
/// has taken their update packet and every check has passed (they were
/// editing that sign, within reach, the sign is not waxed, the chat filter
@@ -3572,6 +3604,57 @@ impl Game<'_> {
) {
mod_api::dispatch_player_advancement_done(self, mod_api::mods(), player, name, advancement);
}
/// A criterion of a server player's advancement was granted or revoked,
/// after its progress changed and before any completion rewards, except
/// the `minecraft:tick` criteria granted on a network player's first tick
/// or a reload, which are heard after that tick's rewards and packets. A
/// grant or revoke that changes nothing is not heard. `granted` is false for a
/// revoke. Helpers looking up the guest through `self.lan` may find
/// nothing during its packet. @API:player_advancement_progress@.
pub(super) fn native_player_advancement_progress(
&mut self,
player: u128,
name: &str,
advancement: &str,
criterion: &str,
granted: bool,
) {
mod_api::dispatch_player_advancement_progress(
self,
mod_api::mods(),
player,
name,
advancement,
criterion,
granted,
);
}
/// A statistic is about to be awarded to a server player, before its count
/// and scoreboard criteria change. `stat_type` and `stat` are ids such as
/// `minecraft:mined` and `minecraft:stone`, and `value` the amount. Set
/// `cancel` to award nothing. Helpers looking up the guest through
/// `self.lan` may find nothing during its packet. Server-side.
/// @API:player_stat_award@.
pub(super) fn native_player_stat_award(
&mut self,
player: u128,
name: &str,
stat_type: &str,
stat: &str,
value: i32,
cancel: &mut bool,
) {
mod_api::dispatch_player_stat_award(
self,
mod_api::mods(),
player,
name,
stat_type,
stat,
value,
cancel,
);
}
/// A server player's experience points are about to increase from an orb pickup,
/// after mending has taken its share. Change `amount` to change the points
/// added. The player's state is not swapped in, so helpers that look them up
@@ -4465,6 +4548,27 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
}
/// A player took an anvil's result at `pos` (`AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` holds the inputs and the result as they were before. Set `cancel` to refuse the take: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. @API:native_anvil_craft_pre@.
pub(super) fn native_anvil_craft_pre(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
cancel: &mut bool,
) {
mod_api::dispatch_anvil_craft_pre(self, mod_api::mods(), player, name, pos, craft, cancel);
}
/// A player took an anvil's result at `pos` and the levels, inputs and anvil wear are done (`AnvilCraftEvent.Post`); a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not called when `native_anvil_craft_pre` cancelled. @API:native_anvil_craft_post@.
pub(super) fn native_anvil_craft_post(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
) {
mod_api::dispatch_anvil_craft_post(self, mod_api::mods(), player, name, pos, craft);
}
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
pub(super) fn native_prepare_grindstone(
&mut self,
@@ -4786,6 +4890,223 @@ impl Game<'_> {
mod_api::dispatch_entity_enter_block(self, mod_api::mods(), entity, kind, pos, block);
}
/// A server player finished loading the world around them after joining,
/// respawning or changing dimension. The guest's state is not swapped in,
/// so helpers that look them up through `self.lan` may find nothing while
/// a guest packet is handled. Server-side. @API:player_client_loaded_world@.
pub(super) fn native_player_client_loaded_world(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_client_loaded_world(self, mod_api::mods(), player, name);
}
/// A server player released the use-item key before the item finished
/// being used. The guest's state is not swapped in, so helpers that look
/// them up through `self.lan` may find nothing during packet handling.
/// @API:player_stop_using_item@.
pub(super) fn native_player_stop_using_item(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_stop_using_item(self, mod_api::mods(), player, name);
}
/// A server player's recipe book open or filter setting changed. `book`
/// is the book type's protocol id. The guest's state is not swapped in.
/// @API:player_recipe_book_settings_change@.
pub(super) fn native_player_recipe_book_settings_change(
&mut self,
player: u128,
name: &str,
book: i32,
open: bool,
filtering: bool,
) {
mod_api::dispatch_player_recipe_book_settings_change(
self,
mod_api::mods(),
player,
name,
book,
open,
filtering,
);
}
/// A server player earned one criterion of an advancement, after the
/// progress was recorded. @API:player_advancement_criterion_grant@.
pub(super) fn native_player_advancement_criterion_grant(
&mut self,
player: u128,
name: &str,
advancement: &str,
criterion: &str,
) {
mod_api::dispatch_player_advancement_criterion_grant(
self,
mod_api::mods(),
player,
name,
advancement,
criterion,
);
}
/// A server player released a drawn bow with `arrow` selected. The guest's
/// state is not swapped in during packet handling. @API:player_ready_arrow@.
pub(super) fn native_player_ready_arrow(&mut self, player: u128, name: &str, arrow: &str) {
mod_api::dispatch_player_ready_arrow(self, mod_api::mods(), player, name, arrow);
}
/// A server player selected a bundle item in `slot` at `index`. The guest's
/// state is not swapped in during packet handling. @API:player_bundle_item_select@.
pub(super) fn native_player_bundle_item_select(
&mut self,
player: u128,
name: &str,
slot: i32,
index: i32,
) {
mod_api::dispatch_player_bundle_item_select(
self,
mod_api::mods(),
player,
name,
slot,
index,
);
}
/// A server player stopped digging `pos` before the block broke, after
/// the server cleared the dig and sent the block back to the client.
/// The player's state is not swapped in. @API:block_damage_abort@.
pub(super) fn native_block_damage_abort(&mut self, player: u128, name: &str, pos: [i32; 3]) {
mod_api::dispatch_block_damage_abort(self, mod_api::mods(), player, name, pos);
}
/// A hopper at `pos` began an item transfer attempt. This runs after its
/// cooldown and enabled checks, before pushing or pulling any items.
/// Server-side, including the single-player server. @API:prepare_hopper_transfer@.
pub(super) fn native_prepare_hopper_transfer(&self, pos: [i32; 3]) {
mod_api::dispatch_prepare_hopper_transfer(self, mod_api::mods(), pos);
}
/// A hopper is about to move one item between containers, a composter's
/// bone meal included. Set `cancel` to leave it in its source slot. A chest
/// boat or minecart is reported at the block it is in. @API:inventory_move_item@.
pub(super) fn native_inventory_move_item(
&self,
source: [i32; 3],
destination: [i32; 3],
item: &str,
cancel: &mut bool,
) {
mod_api::dispatch_inventory_move_item(
self,
mod_api::mods(),
source,
destination,
item,
cancel,
);
}
/// A hopper selected a container while searching for items to push or
/// pull. Exactly one of `container` and `entity` identifies the target.
/// @API:hopper_inventory_search@.
pub(super) fn native_hopper_inventory_search(
&self,
hopper: [i32; 3],
container: Option<[i32; 3]>,
entity: Option<u32>,
) {
mod_api::dispatch_hopper_inventory_search(self, mod_api::mods(), hopper, container, entity);
}
/// A vault at `pos` is about to eject a loot item. @API:vault_loot@.
pub(super) fn native_vault_loot(&self, pos: [i32; 3], item: &str) {
mod_api::dispatch_vault_loot(self, mod_api::mods(), pos, item);
}
/// A timed world-border size change finished on the server, after its
/// final size was applied. Server-side, including the single-player
/// server. @API:world_border_bounds_change_finish@.
pub(super) fn native_world_border_bounds_change_finish(&mut self) {
mod_api::dispatch_world_border_bounds_change_finish(self, mod_api::mods());
}
/// A server player launched a projectile item, after it was created.
/// `item` is the projectile's held item. @API:player_launch_projectile@.
pub(super) fn native_player_launch_projectile(&mut self, player: u128, name: &str, item: &str) {
mod_api::dispatch_player_launch_projectile(self, mod_api::mods(), player, name, item);
}
/// A server explosion is about to begin at `center`, with `radius` (Bukkit's
/// `ExplosionPrimeEvent`). This is listen-only: the explosion proceeds as
/// usual. Server-side, including the single-player server. @API:explosion_prime@.
pub(super) fn native_explosion_prime(&mut self, center: [f64; 3], radius: f32) {
mod_api::dispatch_explosion_prime(self, mod_api::mods(), center, radius);
}
/// A dropped item entity reached its 6000-tick lifetime and is about to be
/// removed (Bukkit's `ItemDespawnEvent`). @API:item_despawn@.
pub(super) fn native_item_despawn(
&mut self,
entity: u32,
item: &str,
count: i32,
pos: [f64; 3],
) {
mod_api::dispatch_item_despawn(self, mod_api::mods(), entity, item, count, pos);
}
/// A mob is about to drop `count` of `item` at `pos` (Bukkit's
/// `EntityDropItemEvent`). @API:entity_drop_item@.
pub(super) fn native_entity_drop_item(
&mut self,
entity: u32,
item: &str,
count: i32,
pos: [f64; 3],
) {
mod_api::dispatch_entity_drop_item(self, mod_api::mods(), entity, item, count, pos);
}
/// A leashed mob's leash was removed, before any lead drop is handled
/// (Bukkit's `EntityUnleashEvent`). @API:entity_unleash@.
pub(super) fn native_entity_unleash(&mut self, entity: u32, kind: &str) {
mod_api::dispatch_entity_unleash(self, mod_api::mods(), entity, kind);
}
/// A vehicle is about to take damage from a player's attack. `kind` is
/// `boat` or the minecart type; mods may change `damage` or set `cancel`
/// to leave its damage and wobble unchanged. The attack itself still
/// completes. Server-side. @API:native_vehicle_damage@.
pub(super) fn native_vehicle_damage(
&mut self,
kind: &str,
damage: &mut f32,
cancel: &mut bool,
) {
mod_api::dispatch_vehicle_damage(self, mod_api::mods(), kind, damage, cancel);
}
/// A boat or minecart was created at `pos`, after it joined the level.
/// `kind` is the vehicle type. Server-side. @API:native_vehicle_create@.
pub(super) fn native_vehicle_create(&mut self, kind: &str, pos: [f64; 3]) {
mod_api::dispatch_vehicle_create(self, mod_api::mods(), kind, pos);
}
/// A boat or minecart is about to be destroyed at `pos`. TNT minecarts
/// that prime instead of breaking are not heard. Server-side.
/// @API:native_vehicle_destroy@.
pub(super) fn native_vehicle_destroy(&mut self, kind: &str, pos: [f64; 3]) {
mod_api::dispatch_vehicle_destroy(self, mod_api::mods(), kind, pos);
}
/// A guest sent the server's current `PlayerInput` flags, after they were applied to the hub. The guest's state is swapped in. Server-side. @API:player_input@.
pub(super) fn native_player_input(&mut self, player: u128, name: &str, flags: i32) {
mod_api::dispatch_player_input(self, mod_api::mods(), player, name, flags);
}
/// A guest's arm swing was handled by the server. `hand` is `mainhand`. Server-side. @API:player_arm_swing@.
pub(super) fn native_player_arm_swing(&mut self, player: u128, name: &str, hand: &str) {
mod_api::dispatch_player_arm_swing(self, mod_api::mods(), player, name, hand);
}
/// A guest's accepted movement made the server jump them from the ground. Server-side. @API:player_jump@.
pub(super) fn native_player_jump(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_jump(self, mod_api::mods(), player, name);
}
/// A guest was disconnected by a server kick, after the hub recorded the rendered `reason`. The guest's state is
/// swapped in unless they were already removed in the same poll. @API:player_kicked@.
pub(super) fn native_player_kicked(&mut self, player: u128, name: &str, reason: &str) {
mod_api::dispatch_player_kicked(self, mod_api::mods(), player, name, reason);
}
/// A server player's experience level changed, after the new level was applied. @API:player_level_change@.
pub(super) fn native_player_level_change(
&mut self,
player: u128,
name: &str,
from: i32,
to: i32,
) {
mod_api::dispatch_player_level_change(self, mod_api::mods(), player, name, from, to);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
@@ -5313,6 +5634,48 @@ impl Game<'_> {
pub(super) fn native_player_respawned(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_respawned(self, mod_api::mods(), player, name);
}
/// A player was saved to the world's store (NeoForge's
/// `PlayerEvent.SaveToFile`, `PlayerList.save`): when they leave or the
/// world closes, when the dedicated server saves everyone, and for an
/// integrated server's owner when the world saves them. LAN guests are
/// not heard on integrated autosaves. Once for each save. `directory`
/// is the world's player directory (`players/data`): write your own file
/// there, named `<uuid>.<suffix>`, never the player's `.dat`. The player
/// may be leaving, so no player's state is swapped in. Not cancellable.
pub(super) fn native_player_save_to_file(
&mut self,
player: u128,
name: &str,
directory: &std::path::Path,
) {
mod_api::dispatch_player_save_to_file(self, mod_api::mods(), player, name, directory);
}
/// A player was loaded from the world's save (NeoForge's
/// `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`): their `.dat`
/// has been read and applied, and they are not placed in a level yet.
/// `directory` is the world's player directory (`players/data`): a mod
/// reads its own file there, named `<uuid>.<suffix>` as
/// `getPlayerFile(suffix)` names it. Heard once for each join, for the
/// owner of an integrated server as for anyone else, on a server, with no
/// player's state swapped in. Not cancellable.
pub(super) fn native_player_load_from_file(
&mut self,
player: u128,
name: &str,
directory: &std::path::Path,
) {
mod_api::dispatch_player_load_from_file(self, mod_api::mods(), player, name, directory);
}
/// A player's data was copied to their new player (NeoForge's
/// `PlayerEvent.Clone`, from `ServerPlayer.restoreFrom`): `was_death` is
/// true after a death, false when they left the End after the credits
/// (`respawn` with `keepAllPlayerData`). The uuid stays the same, so the
/// old and the new player share `player`. Heard before the new player is
/// placed, on a server, with no player's state swapped in. Not
/// cancellable.
pub(super) fn native_player_clone(&mut self, player: u128, name: &str, was_death: bool) {
mod_api::dispatch_player_clone(self, mod_api::mods(), player, name, was_death);
}
/// The number of players with the mod container at `pos` of `dimension`
/// open went from `previous` to `viewers` (vanilla's
/// `ContainerOpenersCounter`, as a barrel keeps it): 0 to more than 0 is
@@ -5547,9 +5910,15 @@ impl Game<'_> {
/// an arrival through a portal). `entity` is its ID and `kind` its type
/// (`minecraft:item`). It is already in the level's lists, so helpers that
/// take an entity ID find it. Set `canceled` to keep it out: it is taken
/// back out as if never added, and a `/summon` of it fails. 47 tells
/// item entities and experience orbs; later APIs add other kinds, so check
/// `kind`. Server-side.
/// back out as if never added, and a `/summon` of it fails. Tells every
/// kind in the game's entity lists (items, orbs, mobs, boats, minecarts,
/// armor stands, primed TNT, falling blocks, projectiles, rockets,
/// tridents), once each, in the order they were created; check `kind`. A
/// gameplay spawn is told when the next tick starts (a drop, an orb, a mob, a
/// `/summon` and a chunk's entities are told at once). A mod that
/// cancels one of the others sees it removed before it ticks again.
/// Not told: end crystals, the dragon, cushions, paintings, item frames and
/// the entities Horizon keeps without simulating. Server-side.
pub(super) fn native_entity_join_level(
&mut self,
dimension: &str,
@@ -5792,6 +6161,19 @@ impl Game<'_> {
) {
mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled);
}
/// A dropped item reaches its lifetime on the server (`ItemEntity.tick`,
/// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`,
/// in ticks, which starts at zero; a positive total keeps the item that much
/// longer, zero or less lets it expire. Extensions are capped at 32766
/// ticks. @API:item_expire@.
pub(super) fn native_item_expire(
&mut self,
entity: u32,
stack: &ItemStack,
extra_life: &mut i32,
) {
mod_api::dispatch_item_expire(self, mod_api::mods(), entity, stack, extra_life);
}
/// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's
/// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe`
/// the recipe the grid matched (`minecraft:oak_planks`) and `stack` the
@@ -7254,6 +7636,15 @@ impl Game<'_> {
.chat
.add(atlas, line, None, ChatSource::Client, self.ticks, focused);
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
pub(super) fn native_instance_dir(&self) -> PathBuf {
Self::game_dir()
}
/// A native mod's own folder: `horizon-mods/.horizon-mod-data/<id>/`,
/// where the WebAssembly mod of the same id kept its storage, so a
/// player's settings carry over. Not created here.
@@ -7546,6 +7937,8 @@ impl Game<'_> {
}
/// The mobs from index `from` on are new in the world.
pub(super) fn native_mobs_joined(&mut self, from: usize, natural: bool) {
// A mod can keep a mob out; it is then taken out and not told of.
self.native_joins_flush(false, 0);
for i in from..self.mobs.len() {
let m = &self.mobs[i];
let (kind, pos) = (mob_kind(m), [m.position.x, m.position.y, m.position.z]);
@@ -7626,34 +8019,6 @@ impl Game<'_> {
.collect();
self.native_entities_left("minecraft:experience_orb", &gone);
}
/// `native_entity_join_level` for `entity`, just put at the end of its
/// list: false when a mod canceled, and the caller takes it back out.
/// A client of a server tells nothing.
pub(super) fn native_entity_joins(
&mut self,
entity: u32,
kind: &str,
pos: [f64; 3],
loaded_from_disk: bool,
) -> bool {
if self.remote.is_some() {
return true;
}
let mut canceled = false;
let dimension = self.dim.id();
self.native_entity_join_level(
dimension,
entity,
kind,
pos,
loaded_from_disk,
&mut canceled,
);
if canceled {
self.persist.joins_canceled += 1;
}
!canceled
}
/// The acting player's blow killed mob `index`.
pub(super) fn native_kill_event(&mut self, index: usize) {
let (entity, kind) = (self.mobs[index].id, mob_kind(&self.mobs[index]));
@@ -9089,9 +9454,12 @@ mod tests {
// split): horizon-mixins resolves the crate by its package.
format!("horizon_client_lib::crafting::native_{name}")
} else if menu_hooks.contains(name) {
format!("horizon_client::menu::native_{name}")
// menu.rs compiles into horizon-client-lib (#5446's
// split): horizon-mixins resolves the crate by its package.
format!("horizon_client_lib::menu::native_{name}")
} else if breath_hooks.contains(name) {
format!("horizon_client::breathing::native_{name}")
// breathing.rs compiles into horizon-client-lib too.
format!("horizon_client_lib::breathing::native_{name}")
} else if level_hooks.contains(name) {
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
} else {
@@ -9100,6 +9468,32 @@ mod tests {
assert_eq!(hook["target"].as_str().unwrap(), target);
}
assert_eq!(names, declared);
// Every target's crate really compiles its module: #5446 moved
// files between horizon-client and horizon-client-lib, and a
// target left on the old crate applies to no bundle — the string
// comparison above shares its expectation with the catalog, so it
// alone cannot catch a move. The first path segment after the
// crate must be a module of that crate's lib root.
let client_root = include_str!("../lib.rs");
let lib_root = include_str!("../client_lib.rs");
for hook in &hooks {
let target = hook["target"].as_str().unwrap();
let (root, crate_name, rest) =
if let Some(rest) = target.strip_prefix("horizon_client::") {
(client_root, "horizon_client", rest)
} else if let Some(rest) = target.strip_prefix("horizon_client_lib::") {
(lib_root, "horizon_client_lib", rest)
} else {
// The other crates (render, world) are their own packages,
// never split.
continue;
};
let module = rest.split("::").next().unwrap_or(rest);
assert!(
root.contains(&format!("mod {module};")),
"{crate_name} does not compile module `{module}` (target {target})"
);
}
}
/// The damage hook gets a mob's source from its damage: the causing mob,
+37 -5
View File
@@ -297,6 +297,9 @@ impl Game<'_> {
textures,
cem,
block_entity_shapes,
sounds,
ui_metadata,
language,
} = staged;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
@@ -396,10 +399,16 @@ impl Game<'_> {
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
std::mem::take(&mut self.gui.atlas.blinks);
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
horizon_client::ui::set_metadata(ui_metadata);
// Read with the language in use when the reload started: one picked since then wins.
let code = lang::code();
lang::set(if language.code == code {
language
} else {
lang::Language::load(&[&root], &code)
});
self.refresh_remote_language();
self.audio.reload(&root);
self.audio.reload_with(&root, sounds);
self.clear_lod();
// A pack that may replace leaf models falls back to `Off` before the
// reload marks every section dirty.
@@ -452,11 +461,27 @@ 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 (render, atlas, panorama) = std::thread::scope(|s| {
// 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 panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
(assets.reload(&root), atlas.join(), panorama.join())
let small = s.spawn(|| {
(
horizon_client::sound_events::SoundRegistry::load(&root),
horizon_client::ui::read_metadata(&root),
lang::Language::load(&[&root], &lang::code()),
)
});
(
assets.reload(&root),
atlas.join(),
panorama.join(),
small
.join()
.unwrap_or_else(|p| std::panic::resume_unwind(p)),
)
});
let render = Arc::new(render?);
let mut atlas = atlas.unwrap_or_else(|p| std::panic::resume_unwind(p))?;
@@ -489,6 +514,9 @@ pub(super) fn stage_pack(
textures,
cem,
block_entity_shapes,
sounds,
ui_metadata,
language,
})
}
@@ -503,6 +531,10 @@ pub(super) struct StagedPack {
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
/// The block entities' models, the packs' custom entity models over vanilla's.
pub block_entity_shapes: Arc<horizon_client::block_entity_render::BlockEntityShapes>,
/// The root's sound events, GUI sprite metadata and current language.
pub sounds: horizon_client::sound_events::SoundRegistry,
pub ui_metadata: horizon_client::ui::UiMetadata,
pub language: lang::Language,
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
+14 -19
View File
@@ -43,6 +43,8 @@ pub struct EntityMemory {
/// Entities a native mod kept out of the level (`native_entity_join_level`),
/// counted so `/summon` can fail as vanilla's refused add does.
pub(super) joins_canceled: u32,
/// The highest entity id `native_entity_join_level` has been told of.
pub(super) joined_mark: u32,
}
/// The key that marks a compound queued with the chunk loads as a fresh add
@@ -342,6 +344,8 @@ impl Game<'_> {
let from_disk = !take_fresh(&mut tag);
// An End gateway's traveller never left the level: nothing joins.
let moved = take_moved(&mut tag);
// Whatever was added since the last tick joins before this one.
self.native_joins_flush(false, 0);
let kind = entity_nbt::kind(&tag).unwrap_or_default();
let uuid = entity_nbt::uuid(&tag);
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
@@ -358,6 +362,8 @@ impl Game<'_> {
| "minecraft:snowball"
| "minecraft:egg"
| "minecraft:ender_pearl"
| "minecraft:splash_potion"
| "minecraft:lingering_potion"
| "minecraft:falling_block"
| "minecraft:firework_rocket"
| "minecraft:trident"
@@ -377,37 +383,19 @@ impl Game<'_> {
if let Some(e) =
entity_nbt::item_entity_from_nbt(&tag, self.next_entity, bob)
{
let (id, p) = (e.id, e.position);
self.remember(e.id, uuid, tag);
self.dropped.push(e);
if !moved
&& !self.native_entity_joins(
id,
"minecraft:item",
[p.x, p.y, p.z],
from_disk,
)
{
self.dropped.pop();
self.forget_entity(id);
}
}
}
"minecraft:experience_orb" => {
for mut orb in entity_nbt::orbs_from_nbt(&tag) {
self.next_entity += 1;
orb.id = self.next_entity;
let (id, p) = (orb.id, orb.position);
let id = orb.id;
// An orb that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.orbs.push(orb);
self.remember_tags(id, &tag);
let kind = "minecraft:experience_orb";
if !moved
&& !self.native_entity_joins(id, kind, [p.x, p.y, p.z], from_disk)
{
self.orbs.pop();
}
}
}
"minecraft:arrow" => {
@@ -669,6 +657,13 @@ impl Game<'_> {
}
_ => self.persist.unknown.entry(key).or_default().push(tag),
}
// PersistentEntitySectionManager.addEntity: every kind that
// just joined tells the mods; a moved one does not join.
if moved {
self.native_joins_skip();
} else {
self.native_joins_flush(from_disk, 0);
}
}
self.native_entities_load(self.dim.id(), [key.0, key.1], count);
}
@@ -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);
+1 -1
View File
@@ -603,7 +603,7 @@ impl Game<'_> {
if let Some(mud) = self.pack.assets.registry.default_state("minecraft:mud") {
self.level_set(pos, mud)?;
}
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
Ok(true)
}
@@ -56,7 +56,7 @@ impl Game<'_> {
return Ok(());
};
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.stats.add("used", "minecraft:bucket", 1);
self.award_stat("used", "minecraft:bucket", 1);
if let Some(eye) = self
.player
.as_ref()
+1 -1
View File
@@ -87,7 +87,7 @@ impl Game<'_> {
}
self.damage_held(1);
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
self.stats.add("used", "minecraft:shears", 1);
self.award_stat("used", "minecraft:shears", 1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, shears);
Ok(true)
+2 -2
View File
@@ -434,7 +434,7 @@ impl Game<'_> {
.raids
.create_or_extend(center, difficulty, amplifier, seed);
if triggered {
self.stats.custom("raid_trigger", 1);
self.award_custom_stat("raid_trigger", 1);
self.trigger_player("minecraft:voluntary_exile");
self.native_raid_state_change(id, center, "triggered");
}
@@ -660,7 +660,7 @@ impl Game<'_> {
EffectInstance::new("hero_of_the_village", raid::HERO_DURATION, amplifier);
effect.visible = false;
g.potions.effects.add(effect);
g.stats.custom("raid_win", 1);
g.award_custom_stat("raid_win", 1);
g.trigger_player("minecraft:hero_of_the_village");
}
}),
+30 -2
View File
@@ -6468,6 +6468,7 @@ impl Game<'_> {
// collisions and the pull of the nearest player between the server's
// updates; the server removes it on pickup.
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
let border = Some(self.border.geometry());
let mut lava_random = None;
let mut next_float = || {
lava_random
@@ -6501,7 +6502,15 @@ impl Game<'_> {
l.steps -= 1;
}
let before = o.position;
let kept = o.tick(world, facts, collision, target, false, &mut next_float);
let kept = o.tick(
world,
facts,
collision,
target,
false,
&mut next_float,
border,
);
// `tick` set `previous` to the stepped position: keep the
// pre-step one, as `xo` is in vanilla.
o.previous = old;
@@ -6519,11 +6528,12 @@ impl Game<'_> {
// ItemEntity.tick runs on a client too: gravity, collisions and
// friction move the item between the server's updates (every 20
// ticks), and only the server removes it.
let (world, registry, facts, collision) = (
let (world, registry, facts, collision, border) = (
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
Some(self.border.geometry()),
);
for d in self.dropped.iter_mut().filter(|_| running) {
d.tick(
@@ -6535,6 +6545,7 @@ impl Game<'_> {
&self.res.motion().fluids,
// The server decides what an item rests on; this copy only moves between updates.
&[],
border,
);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
@@ -7358,6 +7369,23 @@ impl Game<'_> {
// and the click ends.
self.with_hand(hand, Game::swing_arm);
return true;
} else if let Some(stack) = held
.as_ref()
.filter(|s| s.item.ends_with("_spawn_egg"))
.filter(|_| {
self.may_build()
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos))
})
{
// SpawnEggItem.useOn on a client level: FAIL for a mob that
// cannot spawn (the click ends, no swing), else SUCCESS (swing).
// Either way no UseItem follows, so the server does not spawn
// a second mob from the fluid ray (ItemStack.useOn passes in
// adventure mode off can_place_on, which the filter above keeps).
if self.spawn_egg_allowed(&stack.item) {
self.with_hand(hand, Game::swing_arm);
}
return true;
} else if let Some(stack) = held.as_ref()
&& (self.predict_item_on_block(stack, &hit, hand)
|| self.predict_map_on_banner(stack, &hit))
@@ -611,16 +611,17 @@ impl Game<'_> {
// fuse down (the swell and flash follow it) between the server's
// updates (every 10 ticks), and a client discards it when the fuse
// runs out; only the server explodes it.
let (world, collision, assets, facts, fluids) = (
let (world, collision, assets, facts, fluids, border) = (
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
);
self.tnts.retain_mut(|t| {
// A fluid lookup that fails leaves it where its move put it.
let _ = t.tick(world, collision, assets, facts, fluids);
let _ = t.tick(world, collision, assets, facts, fluids, border);
t.fuse > 0
});
// FallingBlockEntity.tick runs on a client too: the block falls by
+1 -1
View File
@@ -71,7 +71,7 @@ impl Game<'_> {
});
} else {
self.close_container();
self.stats.custom("leave_game", 1);
self.award_custom_stat("leave_game", 1);
self.lan_close(false);
self.free_player_beds()?;
self.pending_save = Some(ClosingSave {
@@ -252,6 +252,12 @@ impl Game<'_> {
play.respawn_requested = false;
play.won_game = false;
}
// PlayerList.respawn(keepAllPlayerData = true), CHANGED_DIMENSION:
// PlayerEvent.Clone with `wasDeath` false.
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_clone(uuid, &name, false);
}
self.start_server_trip(host, conn, Dim::Overworld, None);
self.portals.player.cooldown = 10;
}
+1 -1
View File
@@ -313,7 +313,7 @@ impl Game<'_> {
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
+2 -3
View File
@@ -255,7 +255,7 @@ impl Game<'_> {
// itemAttackInteraction: hurtEnemy (statistic, wear), and the
// post-attack enchantments when the target was hurt.
if let Some(stack) = self.held().cloned() {
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
if hurt {
self.post_attack(index);
}
@@ -263,8 +263,7 @@ impl Game<'_> {
}
// damageStatsAndHearts.
let actual = old_health - self.mobs[index].health;
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
if actual > 2.0 {
let indicator = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DamageIndicator,
+4 -2
View File
@@ -562,7 +562,7 @@ impl Game<'_> {
_ => "",
};
if !stat.is_empty() {
self.stats.custom(stat, 1);
self.award_custom_stat(stat, 1);
}
true
}
@@ -979,6 +979,7 @@ impl Game<'_> {
}
/// `Player.giveExperienceLevels`.
pub(super) fn give_levels(&mut self, levels: i32) {
let old_level = self.experience.level;
let xp = &mut self.experience;
xp.level = xp.level.saturating_add(levels);
if xp.level < 0 {
@@ -986,6 +987,7 @@ impl Game<'_> {
xp.progress = 0.0;
xp.total = 0;
}
self.native_player_level_changed(old_level);
}
/// Whether the result slot may be taken (`mayPickup`).
fn may_take_result(&self) -> bool {
@@ -1051,7 +1053,7 @@ impl Game<'_> {
}
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.stats.custom("enchant_item", 1);
self.award_custom_stat("enchant_item", 1);
self.trigger_enchanted_item(&enchanted_item, lapis_needed as i32);
self.play_at("minecraft:block.enchantment_table.use", pos, 1.0, pitch);
self.station_changed();
+57 -27
View File
@@ -194,6 +194,30 @@ impl Game<'_> {
self.stats.write(&path);
}
}
/// `ServerPlayer.awardStat`: `n` more of the statistic `ty` (a bare type
/// such as `used`) and `key`, unless a native mod cancels the award.
pub(super) fn award_stat(&mut self, ty: &str, key: &str, n: i32) {
// No mod, no name copied: the per-tick counters come through here.
let acting = if horizon_client::mod_api::mods().is_empty() {
None
} else {
self.lan_acting_uuid.zip(self.lan_acting_name.clone())
};
if let Some((player, name)) = acting {
let mut cancel = false;
namespaced(ty, |stat_type| {
self.native_player_stat_award(player, &name, stat_type, key, n, &mut cancel)
});
if cancel {
return;
}
}
self.stats.add(ty, key, n);
}
/// `Player.awardStat(Identifier)`: `n` more of the custom statistic `key`.
pub(super) fn award_custom_stat(&mut self, key: &str, n: i32) {
namespaced(key, |key| self.award_stat("custom", key, n));
}
/// The per-tick counters (`Player.tick`/`ServerPlayer.tick` awardStat).
pub(super) fn tick_stats(&mut self) {
self.load_stats();
@@ -207,17 +231,16 @@ impl Game<'_> {
};
let alive = !p.health.dead_or_dying();
let crouching = p.pose == horizon_client::pose::Pose::Crouching;
let s = &mut self.stats;
s.custom("play_time", 1);
s.custom("total_world_time", 1);
self.award_custom_stat("play_time", 1);
self.award_custom_stat("total_world_time", 1);
if alive {
s.custom("time_since_death", 1);
self.award_custom_stat("time_since_death", 1);
if !sleeping {
s.custom("time_since_rest", 1);
self.award_custom_stat("time_since_rest", 1);
}
}
if crouching {
s.custom("sneak_time", 1);
self.award_custom_stat("sneak_time", 1);
}
}
fn tick_movement_stats(&mut self) {
@@ -269,51 +292,58 @@ impl Game<'_> {
.map(|m| m.species())
.ok_or(())
});
let s = &mut self.stats;
match riding {
Some(Ok(sp)) => {
use horizon_client::mobs::Species;
// `checkRidingStatistics`: only these mounts count, the horse
// family by the full 3D distance; any other mount nothing.
match sp {
Species::Pig => s.custom("pig_one_cm", total),
Species::Strider => s.custom("strider_one_cm", total),
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
Species::Pig => self.award_custom_stat("pig_one_cm", total),
Species::Strider => self.award_custom_stat("strider_one_cm", total),
Species::HappyGhast => self.award_custom_stat("happy_ghast_one_cm", total),
Species::Nautilus | Species::ZombieNautilus => {
s.custom("nautilus_one_cm", total)
self.award_custom_stat("nautilus_one_cm", total)
}
sp if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", total)
self.award_custom_stat("horse_one_cm", total)
}
_ => {}
}
}
Some(Err(())) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
self.award_custom_stat("boat_one_cm", total)
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
s.custom("minecart_one_cm", total)
self.award_custom_stat("minecart_one_cm", total)
}
}
None if swimming => s.custom("swim_one_cm", total),
None if swimming => self.award_custom_stat("swim_one_cm", total),
None if eye_in_water.fluid == horizon_client::blocks::Fluid::Water => {
s.custom("walk_under_water_one_cm", total)
self.award_custom_stat("walk_under_water_one_cm", total)
}
None if in_water => s.custom("walk_on_water_one_cm", horizontal),
None if climbing => s.custom("climb_one_cm", (d.y * 100.0).round() as i32),
None if on_ground => {
if sprinting {
s.custom("sprint_one_cm", horizontal)
} else if crouching {
s.custom("crouch_one_cm", horizontal)
} else {
s.custom("walk_one_cm", horizontal)
None if in_water => {
if horizontal > 0 {
self.award_custom_stat("walk_on_water_one_cm", horizontal)
}
}
None if gliding => s.custom("aviate_one_cm", total),
None if climbing => {
self.award_custom_stat("climb_one_cm", (d.y * 100.0).round() as i32)
}
None if on_ground => {
if horizontal > 0 {
if sprinting {
self.award_custom_stat("sprint_one_cm", horizontal)
} else if crouching {
self.award_custom_stat("crouch_one_cm", horizontal)
} else {
self.award_custom_stat("walk_one_cm", horizontal)
}
}
}
None if gliding => self.award_custom_stat("aviate_one_cm", total),
// Airborne and not gliding: more than 25 cm sideways per move
// (creative flight, or a long jump), not only flying.
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
None if horizontal > 25 => self.award_custom_stat("fly_one_cm", horizontal),
None => {}
}
}
+64 -11
View File
@@ -50,17 +50,20 @@ impl PrimedTnt {
assets: &horizon_client::assets::Assets,
facts: &horizon_client::blocks::BlockFacts,
fluids: &horizon_client::motion::FluidResources,
border: Option<&horizon_core::geometry::border::BorderGeometry>,
) -> Result<Vec<BlockPos>> {
self.previous = self.position;
self.velocity.y -= self.get_gravity();
let mut scene = SceneCollisions {
let scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let wanted = self.velocity;
// Entity.collide: the world border's walls stop it too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.resolve_in_border(self.bounds(), wanted, 0.0, self.on_ground, border)
.unwrap_or(Vec3::ZERO);
if horizon_client::entities::move_applies(wanted, moved) {
self.position = Vec3::new(
@@ -246,6 +249,7 @@ impl Game<'_> {
&self.pack.assets,
self.res.facts(),
&self.res.motion().fluids,
Some(self.border.geometry()),
)?;
for pos in tops {
self.bubble_column_particles(pos);
@@ -541,6 +545,55 @@ mod tests {
assert!(!tnt.on_ground);
}
/// `Entity.collectColliders` (#7080): the world border's wall stops primed
/// TNT. A border of size 10 around (20, 20) has its east wall at x = 25;
/// the TNT starts at x = 24 on the test scene's flat ground, 1.5 east.
#[test]
fn the_world_border_stops_primed_tnt() {
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 = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let border =
horizon_core::geometry::border::BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984)
.unwrap();
for bordered in [false, true] {
let at = Vec3::new(24.0, 4.0, 20.5);
let mut tnt = PrimedTnt {
no_gravity: false,
id: 0,
position: at,
previous: at,
velocity: Vec3::new(1.5, 0.0, 0.0),
fuse: 80,
on_ground: true,
owner: None,
guest: None,
power: 4.0,
};
let border = bordered.then_some(&border);
tnt.tick(&world, &collision, &assets, &facts, &fluids, border)
.unwrap();
let east = tnt.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]
fn primed_tnt_follows_water_currents_after_moving() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -580,7 +633,7 @@ mod tests {
guest: None,
power: 4.0,
};
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert_eq!(
tnt.position.x, at.x,
@@ -592,14 +645,14 @@ mod tests {
tnt.velocity
);
assert_eq!(tnt.fuse, 79);
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
assert!(tnt.on_ground);
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 1;
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert_eq!(tnt.fuse, 0);
assert_eq!(
@@ -610,7 +663,7 @@ mod tests {
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.fuse = 80;
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert_eq!(
tnt.velocity.x, 0.0,
@@ -626,7 +679,7 @@ mod tests {
.unwrap();
tnt.on_ground = false;
tnt.velocity = Vec3::ZERO;
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
assert!(
tnt.position.y < at.y && tnt.velocity.y < 0.0,
@@ -683,7 +736,7 @@ mod tests {
power: 4.0,
};
for _ in 0..10 {
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
}
assert!(
@@ -704,7 +757,7 @@ mod tests {
tnt.velocity = Vec3::ZERO;
tnt.on_ground = false;
for _ in 0..4 {
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap();
}
assert!(
@@ -764,7 +817,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..30 {
tops.extend(
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap(),
);
}
@@ -778,7 +831,7 @@ mod tests {
let mut tnt = primed();
for _ in 0..10 {
assert!(
tnt.tick(&world, &collision, &assets, &facts, &fluids)
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
.unwrap()
.is_empty()
);
+1 -1
View File
@@ -59,7 +59,7 @@ impl Game<'_> {
*cell = None;
}
}
self.stats.add("used", TOTEM, 1);
self.award_stat("used", TOTEM, 1);
self.trigger_item(
"minecraft:used_totem",
&horizon_client::items::ItemStack::new(TOTEM, 1),
+2 -2
View File
@@ -251,7 +251,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talked_to_villager", 1);
self.award_custom_stat("talked_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -470,7 +470,7 @@ impl Game<'_> {
/// for a server's player (when the trader is still in the level).
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, offers: &[MerchantOffer]) {
for offer in offers {
self.stats.custom("traded_with_villager", 1);
self.award_custom_stat("traded_with_villager", 1);
let merchant = mob.and_then(|id| self.mobs.iter().find(|m| m.id == id));
let kind = merchant.map(super::native_hooks::mob_kind);
let villager = merchant.map(|m| self.mob_entity(m));
+2 -1
View File
@@ -167,7 +167,7 @@ impl Game<'_> {
};
if taken > 0 {
// awardStat(ITEM_USED), then stackToInsert.consume(count, player).
self.stats.add("used", &stack.item, 1);
self.award_stat("used", &stack.item, 1);
for _ in 0..taken {
self.consume_held();
}
@@ -190,6 +190,7 @@ impl Game<'_> {
}
// Vec3.atBottomCenterOf(pos).relative(UP, 1.2), lowered by spawnItem.
VaultEvent::Eject(stack, v) => {
self.native_vault_loot(pos, &stack.item);
self.spawn_item(stack, [center[0], center[1] + 0.7 - 0.125, center[2]], v, 0)
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ impl Game<'_> {
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
self.audio
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
self.stats.add("used", "minecraft:wind_charge", 1);
self.award_stat("used", "minecraft:wind_charge", 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
+18 -5
View File
@@ -1260,10 +1260,10 @@ mod tests {
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
}
/// Every vanilla item on the ground, turned, shaded and unshaded: the GPU path's
/// vertices (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each state it takes. Needs the prepared
/// resources.
/// Every vanilla item on the ground, held in either hand, framed and worn on the
/// head, turned, shaded and unshaded: the GPU path's vertices (its jobs expanded as
/// `entity_skin.wgsl` does) are the CPU path's, to the bit and in the same order, for
/// each state it takes. Needs the prepared resources.
#[test]
fn items_posed_on_the_gpu_draw_what_the_cpu_draws() {
use crate::gfx::entity_skin::{SkinFrame, expand};
@@ -1281,11 +1281,24 @@ mod tests {
let view = StackView::new(id, 1);
r.resolve(&view, DisplayContext::Ground, None, None, 7, &NoEnv)
};
// Each display context's transforms (`ItemTransform`s) pose the same quads.
let contexts = [
DisplayContext::Ground,
DisplayContext::ThirdPersonRightHand,
DisplayContext::ThirdPersonLeftHand,
DisplayContext::Fixed,
DisplayContext::Head,
];
let states: Vec<_> = r
.models
.definitions
.keys()
.map(|id| (id, resolve(id)))
.flat_map(|id| {
contexts.map(|context| {
let view = StackView::new(id, 1);
(id, r.resolve(&view, context, None, None, 7, &NoEnv))
})
})
.collect();
// Each sprite its own rect, so a run drawn with another's shows.
let mut sprites: HashMap<String, Rect> = HashMap::new();
+47
View File
@@ -1102,4 +1102,51 @@ mod tests {
other(&item, "minecraft:custom_model_data")
);
}
#[test]
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
use horizon_protocol::buf::{Reader, Writer};
use horizon_protocol::item::Slot;
use horizon_protocol::registry::Ids;
let mut components = vec![String::new(); component::RARITY as usize + 1];
components[component::RARITY as usize] = "minecraft:rarity".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
item.enchant("minecraft:aqua_affinity", 1);
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|_, _| None,
&Some(item.clone()),
);
let mut w = Writer::new();
Slot::write(&wire, &mut w);
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
let back = from_network(&received, &builtin, &dynamic);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:rarity")),
Some(&Tag::String("epic".into()))
);
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
}
}
+39 -5
View File
@@ -1053,7 +1053,7 @@ pub fn lines(
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
/// the component's own styles (including explicit `false`) winning on children.
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
let lines: Vec<crate::text::Text> = match raw {
Tag::List(entries) => entries
.iter()
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
.collect(),
Tag::Compound(_) | Tag::String(_) => {
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
}
_ => Vec::new(),
};
lines
.into_iter()
.map(|mut text| {
for span in &mut text.spans {
span.style.inherit(&base);
}
@@ -1360,6 +1369,31 @@ mod tests {
assert_eq!(rarity(&sword, "common"), "rare");
}
#[test]
fn lore_texts_handles_compound_lists() {
// Direct command components from issue #6405
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
let comp = horizon_items::item_data::command_components(cmd).unwrap();
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
let stack = ItemStack {
item: "minecraft:stick".into(),
count: 1,
damage,
extra,
};
assert_eq!(rarity(&stack, "common"), "epic");
let name = styled_name(&stack, || "fallback".into());
assert_eq!(name.plain(), "Simple Custom Name");
assert_eq!(name.spans[0].style.italic, Some(false));
let lore = lore_texts(&stack);
assert_eq!(lore.len(), 1);
assert_eq!(lore[0].plain(), "Simple Custom Lore");
assert_eq!(lore[0].spans[0].style.italic, Some(false));
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn name_line_translates_items_of_any_namespace() {
let translate = |key: &str| match key {
+98 -4
View File
@@ -35,6 +35,13 @@ pub struct ServerAdvancements {
pub struct FirstTick {
pub recipe_book_add: Option<Vec<u8>>,
pub update_advancements: Vec<u8>,
pub granted: Vec<CriterionGrant>,
}
/// A criterion `CriteriaTriggers.TICK` newly granted, for the game to tell the mods.
pub struct CriterionGrant {
pub advancement: String,
pub criterion: String,
}
impl ServerAdvancements {
@@ -89,9 +96,15 @@ impl ServerAdvancements {
})
.collect();
let date = crate::advancements::date_text(now_millis.div_euclid(1000));
let mut granted = Vec::new();
for (a, got) in self.tree.list.iter().zip(&obtained) {
for c in got {
progress.grant(&a.id, c, &date);
if progress.grant(&a.id, c, &date) {
granted.push(CriterionGrant {
advancement: a.id.clone(),
criterion: (*c).to_owned(),
});
}
}
}
// AdvancementRewards.grant: the recipes, in the advancements' order.
@@ -126,6 +139,7 @@ impl ServerAdvancements {
FirstTick {
recipe_book_add,
update_advancements: w.data,
granted,
}
}
@@ -135,13 +149,15 @@ impl ServerAdvancements {
/// drops a criterion it lost), the next `flushDirty` is a first packet
/// again, and the next `CriteriaTriggers.TICK` meets the new tree's
/// `minecraft:tick` criteria, rewarding the recipes of the advancements
/// that complete. Returns the recipe book packet those recipes need.
/// that complete. The criteria it grants are added to `granted`. Returns
/// the recipe book packet those recipes need.
pub fn reload(
&self,
sync: &SyncData,
now_millis: i64,
progress: &mut Progress,
book: &mut Settings,
granted: &mut Vec<CriterionGrant>,
) -> Option<Vec<u8>> {
let by_id: HashMap<&str, &crate::advancements::Advancement> =
self.tree.list.iter().map(|a| (a.id.as_str(), a)).collect();
@@ -159,8 +175,14 @@ impl ServerAdvancements {
for a in &self.tree.list {
let was_done = progress.done(a);
for c in &a.criteria {
if c.trigger == "minecraft:tick" && c.conditions.is_null() {
progress.grant(&a.id, &c.name, &date);
if c.trigger == "minecraft:tick"
&& c.conditions.is_null()
&& progress.grant(&a.id, &c.name, &date)
{
granted.push(CriterionGrant {
advancement: a.id.clone(),
criterion: c.name.clone(),
});
}
}
if !was_done && progress.done(a) {
@@ -694,6 +716,78 @@ mod tests {
/// A recipe whose ingredient kind no loader knows stays out of the book:
/// the server does not load it, and an empty ingredient slot in its
/// place would mislead the client.
/// The shared probe: a recipe the player crafts is learned by the
/// name the crafting copy hands out, and rejoining sends what the
/// book knows.
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
"key":{"X":"minecraft:raw_copper"},
"result":{"id":"minecraft:copper_ingot"}}"#;
let root = if pack {
resources(name, &[])
} else {
resources(name, &[(name, shaped)])
};
let packs = pack.then(|| {
vec![(
format!("recipe/minecraft:{name}"),
vec![shaped.as_bytes().to_vec()],
)]
});
let overlay = match &packs {
Some(p) => horizon_world::loot::PackOverlay { files: p },
None => horizon_world::loot::PackOverlay::NONE,
};
let sync = SyncData::load(&root, &overlay).unwrap();
// What the craft hands the book: find_with over the crafting
// copy, the way award_crafted calls it.
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
let grid = vec![Some(horizon_items::items::ItemStack {
item: "minecraft:raw_copper".into(),
count: 1,
damage: 0,
extra: None,
})];
let (found, _) = recipes
.find_with(&grid, 1, &|_| None)
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
assert_eq!(
found, name,
"{name}: the crafting copy names it bare, folder and pack alike"
);
// The book learns what the craft awards; rejoining sends it.
let mut book = crate::recipe_book::Settings::default();
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
assert_eq!(
unlocked,
[format!("minecraft:{name}")],
"{name}: the book knows it under its whole id"
);
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
let mut r = Reader::new(&bytes);
let add =
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
horizon_protocol::packets::recipes::RecipeBookAdd,
>>::read(&mut r)
.unwrap();
assert!(
!add.entries.is_empty(),
"{name}: the crafted recipe reaches the book after rejoining"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("folder_recipe", false);
}
#[test]
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("pack_recipe", true);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+40
View File
@@ -1003,6 +1003,46 @@ fn login_and_configure(
}),
)?;
}
// The mods that must be the same on both sides, named when they are not
// (horizon_protocol::mod_list); the registry hash below only sees ids.
let server_mods = horizon_protocol::mod_list::local();
if brand == "horizon" && !server_mods.is_empty() {
send(
c,
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
channel: horizon_protocol::mod_list::CHANNEL.into(),
data: horizon_protocol::mod_list::encode(&horizon_protocol::mod_list::shared(
&server_mods,
)),
}),
)?;
send(c, cfg::Clientbound::Ping(cfg::Ping { id: 1 }))?;
// A client that does not answer has no mods to offer.
let mut client_mods = Vec::new();
loop {
let (id, payload) = c.recv().map_err(e)?;
match decode_config(id, &payload).map_err(e)? {
cfg::Serverbound::CustomPayload(p)
if p.channel == horizon_protocol::mod_list::CHANNEL =>
{
client_mods = horizon_protocol::mod_list::decode(&p.data).unwrap_or_default();
}
cfg::Serverbound::Pong(p) if p.id == 1 => break,
cfg::Serverbound::ClientInformation(i) => info = i,
_ => {}
}
}
let difference = horizon_protocol::mod_list::compare(&server_mods, &client_mods);
if !difference.is_empty() {
let text = difference.reason();
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(
text.as_str(),
));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
return Ok(None);
}
}
if brand == "horizon"
&& let Some(hash) = sync.content_identity
{
+1 -8
View File
@@ -82,14 +82,7 @@ pub struct SyncData {
pub advancements: super::advancements::ServerAdvancements,
}
/// A resource id with its default namespace filled in.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
use crate::crafting::qualified;
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
+70 -6
View File
@@ -276,6 +276,9 @@ pub struct Guest {
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
/// last sent about itself.
pub sent_shared: i8,
/// The shoulder parrots (Player.DATA_SHOULDER_PARROT_LEFT and _RIGHT) the
/// player's client was last sent about itself.
pub sent_shoulders: [Option<i32>; 2],
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
pub last_death: Option<(String, [i32; 3])>,
/// The last action sequence to acknowledge at the next tick (-1: none).
@@ -450,6 +453,7 @@ impl Guest {
sent_xp: None,
sent_air: (300, 0.0),
sent_shared: 0,
sent_shoulders: [None; 2],
last_death: None,
ack_up_to: std::cell::Cell::new(-1),
sent_selected: 0,
@@ -1102,13 +1106,55 @@ fn network_stack(
}
// Lore lines as loaded or as a server mod wrote them (a list of text
// components), so a server menu's items can say more than their name.
if let Some(horizon_world::nbt::Tag::List(lines)) =
s.data().and_then(|d| d.other("minecraft:lore"))
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
let lines = match lore_tag {
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
// A string is one literal line, as ComponentSerialization decodes a string tag.
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
Some(vec![lore_tag.clone()])
}
_ => None,
};
if let Some(lines) = lines {
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:lore").is_some())
{
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines.clone()),
));
stack.removed.push(horizon_protocol::item::component::LORE);
}
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
let index = match r {
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
"common" => Some(0),
"uncommon" => Some(1),
"rare" => Some(2),
"epic" => Some(3),
_ => None,
},
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
_ => None,
};
if let Some(index) = index {
stack.added.push((
horizon_protocol::item::component::RARITY,
horizon_protocol::item::Component::Int(index),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
{
stack
.removed
.push(horizon_protocol::item::component::RARITY);
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
@@ -1237,6 +1283,24 @@ mod tests {
);
}
#[test]
fn rarity_reaches_the_client() {
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
let mut writer = Writer::new();
horizon_protocol::item::Slot::write(&sent, &mut writer);
let mut reader = Reader::new(&writer.data);
let received = horizon_protocol::item::Slot::read(&mut reader)
.unwrap()
.unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
}
#[test]
fn styled_custom_name_survives_inventory_sync() {
let name = Tag::compound([
+134 -7
View File
@@ -135,6 +135,32 @@ pub enum HostEvent {
MainHandChanged(u32, bool),
/// A guest changed their client language option.
LocaleChanged(u32, String),
/// A guest's client finished loading the level around them.
PlayerLoaded(u32),
/// A guest changed recipe book settings.
RecipeBookSettings {
conn: u32,
book: i32,
open: bool,
filtering: bool,
},
/// A guest changed sneaking in a player input packet, already applied.
Sneak {
conn: u32,
sneaking: bool,
},
/// A loaded guest sent their current player input flags.
Input {
conn: u32,
flags: i32,
},
/// A guest was kicked, with the reason rendered by the hub.
Kicked {
conn: u32,
player: u128,
name: String,
reason: String,
},
/// A guest's client started (`flying`) or stopped flying (their abilities packet), applied to
/// the guest already: the game tells the mods, which may refuse it.
Flight {
@@ -175,6 +201,13 @@ pub enum HostEvent {
},
}
/// The criteria `CriteriaTriggers.TICK` granted one player.
pub struct TickGrants {
pub player: u128,
pub name: String,
pub grants: Vec<super::advancements::CriterionGrant>,
}
/// What one player started and stopped tracking, in order, for the game to
/// tell the mods.
pub struct TrackingBatch {
@@ -198,9 +231,17 @@ fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
pub struct Hub {
/// Clicks on and closings of native mods' menus, for the game.
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
/// Kicks waiting for the game thread to notify native mods.
pending_kicks: Vec<HostEvent>,
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
/// tell the mods.
pub native_advancement_grants: Vec<TickGrants>,
/// Whether any native mod is loaded: `Input` and `Sneak` events only exist for them, so
/// the hub queues none otherwise. The game sets it before each poll.
pub mods_loaded: bool,
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
/// only players of at least that permission level see them, in the
/// player list, the world and their joining and leaving.
@@ -346,6 +387,19 @@ fn shoulder_meta(state: &mut EntityState, shoulders: [Option<i32>; 2]) {
}
}
/// The shoulder parrot slots that differ from what the player's client was
/// last sent, as entity data: 0 for none, else the variant's id plus one.
fn shoulder_changes(now: [Option<i32>; 2], sent: [Option<i32>; 2]) -> Vec<(u8, i32, Value)> {
(19..)
.zip(now.into_iter().zip(sent))
.filter(|(_, (now, sent))| now != sent)
.map(|(index, (now, _))| {
let id = now.map_or(0, |v| v + 1);
(index, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id))
})
.collect()
}
impl Hub {
/// Player.getDisplayName: the name formatted for the player's team
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
@@ -546,7 +600,10 @@ impl Hub {
entity_gone: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
pending_kicks: Vec::new(),
native_tracking: Vec::new(),
native_advancement_grants: Vec::new(),
mods_loaded: false,
hidden: Default::default(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
@@ -810,8 +867,15 @@ impl Hub {
// The log's "lost connection" line gives the reason, as vanilla's.
let text = self.english(&reason);
if let Some(g) = self.guest_mut(conn) {
g.left_reason = text;
g.left_reason = text.clone();
let (player, name) = (g.uuid, g.name.clone());
g.sender.disconnect(reason);
self.pending_kicks.push(HostEvent::Kicked {
conn,
player,
name,
reason: text,
});
}
}
@@ -1510,7 +1574,7 @@ impl Hub {
pub fn poll(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
self.ticks += 1;
self.anticheat_tick(ctx.tick_rate);
let mut events = Vec::new();
let mut events = std::mem::take(&mut self.pending_kicks);
for event in self.lan.poll() {
match event {
NetEvent::Joined(j) => {
@@ -2610,6 +2674,11 @@ impl Hub {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
g.send(&raw(ids::UPDATE_ADVANCEMENTS, first.update_advancements));
self.native_advancement_grants.push(TickGrants {
player: g.uuid,
name: g.name.clone(),
grants: first.granted,
});
g.advancements_visible = sync.advancements.visibility(&g.advancements);
g.advancements_changed.clear();
} else {
@@ -2672,6 +2741,17 @@ impl Hub {
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
}));
}
// ServerEntity.sendDirtyEntityData: the parrots on the player's
// shoulders go to the player too, whose own model draws them.
let g = &mut self.guests[i];
if g.shoulders != g.sent_shoulders {
let values = shoulder_changes(g.shoulders, g.sent_shoulders);
g.sent_shoulders = g.shoulders;
g.send(&C::SetEntityData(play::SetEntityData {
id: g.entity_id,
values,
}));
}
// Sent when the health or the food level changes, or the saturation
// reaches or leaves zero; a dead player's health of 0 too.
let g = &mut self.guests[i];
@@ -2744,6 +2824,7 @@ impl Hub {
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64);
for g in &mut self.guests {
let mut granted = Vec::new();
// PlayerAdvancements.reload: isFirstPacket again, the progress
// read against the new tree; a player whose advancements were
// never loaded does that on their first tick.
@@ -2752,12 +2833,20 @@ impl Hub {
}
g.advancements_visible.clear();
g.advancements_changed.clear();
if let Some(add) =
sync.advancements
.reload(&sync, now, &mut g.advancements, &mut g.recipe_book)
{
if let Some(add) = sync.advancements.reload(
&sync,
now,
&mut g.advancements,
&mut g.recipe_book,
&mut granted,
) {
g.send(&raw(ids::RECIPE_BOOK_ADD, add));
}
self.native_advancement_grants.push(TickGrants {
player: g.uuid,
name: g.name.clone(),
grants: granted,
});
}
// broadcastAll(ClientboundUpdateTagsPacket).
self.broadcast(&C::UpdateTags(sync.tags.clone()));
@@ -2985,6 +3074,7 @@ impl Hub {
g.loaded = true;
g.loading = false;
g.load_timeout = 0;
events.push(HostEvent::PlayerLoaded(conn));
}
// PlayerChunkSender.onChunkBatchReceivedByClient.
S::ChunkBatchReceived(p) => {
@@ -3030,8 +3120,18 @@ impl Hub {
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
{
let settings = &mut self.guests[i].recipe_book;
let changed = settings.open[book.index()] != p.open
|| settings.filtering[book.index()] != p.filtering;
settings.open[book.index()] = p.open;
settings.filtering[book.index()] = p.filtering;
if changed {
events.push(HostEvent::RecipeBookSettings {
conn,
book: p.book_type,
open: p.open,
filtering: p.filtering,
});
}
}
}
S::ClientInformation(info) => {
@@ -3068,7 +3168,21 @@ impl Hub {
let g = &mut self.guests[i];
g.input = p.flags;
if g.client_loaded() {
g.sneaking = p.flags & 32 != 0;
// The events only tell the mods: nothing is queued with none loaded.
let listening = self.mods_loaded;
if listening {
events.push(HostEvent::Input {
conn,
flags: i32::from(p.flags),
});
}
let sneaking = p.flags & 32 != 0;
if g.sneaking != sneaking {
g.sneaking = sneaking;
if listening {
events.push(HostEvent::Sneak { conn, sneaking });
}
}
}
// handlePlayerInput only records keys and updates crouching.
// Sprinting persists after the sprint key is released (or
@@ -3804,6 +3918,7 @@ impl Hub {
g.sent_xp = None;
g.sent_air = (300, 0.0);
g.sent_shared = 0;
g.sent_shoulders = [None; 2];
let (cx, cz) = g.chunk();
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
x: cx,
@@ -3974,6 +4089,18 @@ mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// The owner's own shoulder parrots are sent when a slot changes.
#[test]
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
assert!(shoulder_changes([None; 2], [None; 2]).is_empty());
let landed = shoulder_changes([Some(1), None], [None; 2]);
assert_eq!(landed.len(), 1);
assert_eq!((landed[0].0, &landed[0].2), (19, &Value::Id(2)));
let left = shoulder_changes([None; 2], [Some(1), Some(4)]);
assert_eq!(left.len(), 2);
assert_eq!((left[1].0, &left[1].2), (20, &Value::Id(0)));
}
/// The counts of what is stuck in a player are sent when set, and a
/// count back at zero is sent as the default.
#[test]
+15 -4
View File
@@ -15,11 +15,22 @@ use std::{
sync::Arc,
};
/// `PlayerDataStorage`'s player directory (`players/data`), created on first use
/// as its constructor does with `mkdirs`.
pub fn directory(world: &Path) -> PathBuf {
let dir = directory_path(world);
if let Err(e) = std::fs::create_dir_all(&dir) {
log::warn!("Horizon: {} is not created: {e}", dir.display());
}
dir
}
fn directory_path(world: &Path) -> PathBuf {
world.join("players").join("data")
}
pub fn path(world: &Path, uuid: u128) -> PathBuf {
world
.join("players")
.join("data")
.join(format!("{}.dat", uuid_string(uuid)))
directory_path(world).join(format!("{}.dat", uuid_string(uuid)))
}
/// `UUIDUtil.uuidToIntArray`.
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more