mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #7228
Main's allowlist settings (#7191) took management test ids 75-94; the max players block moves to 100-108 and its restart read to 109, after the allowlist ones. Both sides of the setting handlers are kept. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
55d24da573
109 files changed
+5275
-580
No files matched your search
@@ -96,8 +96,9 @@ jobs:
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
|
||||
# prepared vanilla data, so without it they pass at once.
|
||||
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
|
||||
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
|
||||
# recompiling the workspace with a different dependency graph.
|
||||
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
|
||||
cargo +stable test --workspace --doc
|
||||
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
|
||||
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
|
||||
|
||||
Generated
+1
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -182,12 +182,24 @@ where
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = CollisionContext::position(bounds.min_position().y);
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// [`Self::resolve`] with the entity's captured collision context.
|
||||
pub fn resolve_with_context(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
context: CollisionContext<'_>,
|
||||
) -> Result<Vec3> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -223,23 +235,12 @@ impl SceneCollisions<'_, '_> {
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
@@ -326,6 +327,88 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
|
||||
/// building one: no block that can collide in its inner cells, and no block that
|
||||
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
|
||||
/// the scan decides). The same cells, blocks and section flags the scan reads.
|
||||
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
|
||||
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
|
||||
/// its cell; a global palette, or a section outside the column, may.
|
||||
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
type Error = io::Error;
|
||||
/// The resident column, looked up once per chunk the scan enters.
|
||||
@@ -339,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
Ok(self.section_reaches_out(chunk, section_y))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
@@ -818,3 +895,88 @@ mod shulker_collision_tests {
|
||||
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
|
||||
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
|
||||
/// reaching into the cell above) and the scene's stone: wherever the cells say no
|
||||
/// block can collide, the full resolution returns the movement, to the bit; the rest
|
||||
/// is left to it. Needs the prepared resources.
|
||||
#[test]
|
||||
fn cells_without_collision_leave_the_movement_as_resolving_does() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
// Torches, and a fence at the bottom of a section above air.
|
||||
for x in 17..30 {
|
||||
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
|
||||
}
|
||||
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
|
||||
let scene = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
|
||||
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
|
||||
assert!(scene.cells_without_collision(torches));
|
||||
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
|
||||
assert!(scene.cells_without_collision(over_floor));
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut skipped, mut resolved) = (0, 0);
|
||||
for i in 0..4000 {
|
||||
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
|
||||
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
|
||||
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
|
||||
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
|
||||
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
|
||||
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
|
||||
let d = Vec3::new(
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
);
|
||||
let fast = scene.cells_without_collision(bb.expand_towards(d));
|
||||
let mut full = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
let moved = full.resolve(bb, d, 0.0, false).unwrap();
|
||||
if fast {
|
||||
skipped += 1;
|
||||
assert_eq!(
|
||||
moved.components().map(f64::to_bits),
|
||||
d.components().map(f64::to_bits),
|
||||
"{bb:?} by {d:?}"
|
||||
);
|
||||
} else {
|
||||
resolved += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
skipped > 500 && resolved > 500,
|
||||
"{skipped} skipped, {resolved} resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
var back = c;
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
|
||||
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
|
||||
@@ -23,7 +23,12 @@ use horizon_optifine::fusion::{
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
num::NonZeroU16,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
@@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
for path in mcmeta_files(&textures) {
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
|
||||
/// from the folders (linked folders too: mods' content links into the packs' tree).
|
||||
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
|
||||
return files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| textures.join(file))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut dirs = vec![textures.to_owned()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1447,6 +1447,13 @@ fn instance_descriptor(
|
||||
}
|
||||
descriptor.backends = backends.with_env();
|
||||
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
|
||||
// FXC spends about 2.6 s on every start compiling the pipelines (DXC about
|
||||
// 1 s); what it compiled once is kept in the game folder and read back
|
||||
// after that (third_party/wgpu-hal/src/dx12/disk_cache.rs).
|
||||
#[cfg(windows)]
|
||||
if dx12 {
|
||||
wgpu::hal::dx12::disk_cache::set_file(dx12_shader_cache(&crate::logging::game_home()));
|
||||
}
|
||||
if !cfg!(debug_assertions) && !dx12 {
|
||||
descriptor
|
||||
.flags
|
||||
@@ -1460,6 +1467,11 @@ fn instance_descriptor(
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
/// Where DX12 keeps the shaders it compiled, inside a game folder.
|
||||
pub fn dx12_shader_cache(home: &std::path::Path) -> std::path::PathBuf {
|
||||
home.join("cache").join("dx12-shaders.bin")
|
||||
}
|
||||
|
||||
/// The window's display connection, so the OpenGL backend creates its EGL
|
||||
/// display from the X11 or Wayland connection the window lives on. Without
|
||||
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -223,6 +223,7 @@ pub struct Session {
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -250,8 +251,21 @@ impl Session {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
{
|
||||
path = rest.to_owned();
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
@@ -281,6 +295,7 @@ impl Session {
|
||||
to_world_ms: self.to_world_ms,
|
||||
to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)),
|
||||
to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)),
|
||||
slow_frames: self.slow_frames.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,10 +505,10 @@ mod tests {
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
}
|
||||
|
||||
@@ -502,7 +517,7 @@ mod tests {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
@@ -512,11 +527,11 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6));
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
@@ -537,4 +552,34 @@ mod tests {
|
||||
assert!(perf.to_world_ms.is_some());
|
||||
assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024);
|
||||
}
|
||||
|
||||
/// Frames of 250 ms or more count under the step that took most of them; a frame with no
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ use crate::{
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
text::Text,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::RwLock,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
@@ -64,37 +69,53 @@ pub fn set_metadata(metadata: UiMetadata) {
|
||||
pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let mut map = HashMap::new();
|
||||
let base = root.join("assets/minecraft/textures");
|
||||
let mut stack = vec![base.join("gui/sprites")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
let sprites = base.join("gui/sprites");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
|
||||
Some(files) => files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| sprites.join(file))
|
||||
.collect(),
|
||||
None => {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![sprites];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
};
|
||||
for p in files {
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
UiMetadata(map)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
augment(i, ingredients, stacks, &mut owner, &mut seen)
|
||||
})
|
||||
}
|
||||
/// A resource id with its default namespace filled in — the whole id the
|
||||
/// book, the network copy and the advancements' JSON know a recipe by,
|
||||
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
|
||||
/// on purpose (vanilla's own ids); this puts the namespace back.
|
||||
pub fn qualified(id: &str) -> String {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
|
||||
@@ -91,6 +91,11 @@ pub struct Creative {
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
/// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines
|
||||
/// of every search item in one language (keyed by the `itemGroup.search`
|
||||
/// string), built on the first search and kept until the language or the
|
||||
/// item list changes.
|
||||
lines_cache: Mutex<Option<(String, Arc<[Vec<String>]>)>>,
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
struct Shown {
|
||||
@@ -373,6 +378,7 @@ impl Creative {
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
lines_cache: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
@@ -398,6 +404,7 @@ impl Creative {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
@@ -531,6 +538,46 @@ impl Creative {
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
/// results in the tab's order).
|
||||
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
|
||||
let english =
|
||||
names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search");
|
||||
let local = if term.is_empty() || term.starts_with('#') {
|
||||
Arc::from([])
|
||||
} else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) {
|
||||
// The exported English lines are the whole index.
|
||||
Arc::from([])
|
||||
} else {
|
||||
let language = names.display_get("itemGroup.search").unwrap_or_default();
|
||||
let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match cache.as_ref().filter(|(l, _)| *l == language) {
|
||||
Some((_, lines)) => lines.clone(),
|
||||
None => {
|
||||
let lines = self.localized_lines(&|k| names.display_get(k));
|
||||
*cache = Some((language, lines.clone()));
|
||||
lines
|
||||
}
|
||||
}
|
||||
};
|
||||
self.search_results_in(term, names, &local, english)
|
||||
}
|
||||
/// Every search item's tooltip lines through `translate`, lower-cased.
|
||||
fn localized_lines(&self, translate: &dyn Fn(&str) -> Option<String>) -> Arc<[Vec<String>]> {
|
||||
self.search_items
|
||||
.iter()
|
||||
.map(|s| {
|
||||
crate::item_tooltip::lines(s, None, translate)
|
||||
.iter()
|
||||
.map(|l| java_lower(l))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn search_results_in(
|
||||
&self,
|
||||
term: &str,
|
||||
names: &Names,
|
||||
local: &[Vec<String>],
|
||||
english: bool,
|
||||
) -> Vec<usize> {
|
||||
let all = 0..self.search_index.len();
|
||||
if term.is_empty() {
|
||||
return all.collect();
|
||||
@@ -552,18 +599,13 @@ impl Creative {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
// FullTextSearchTree: the creative tooltip lines in the selected
|
||||
// language, and the item id after a colon. The export's lines are
|
||||
// English (they come from the vanilla jars), so they stand in only
|
||||
// while the language is English; otherwise every line (name, lore,
|
||||
// effects, ...) is rebuilt through the language, as vanilla's
|
||||
// getTooltipLines does.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -573,16 +615,17 @@ impl Creative {
|
||||
} else {
|
||||
&e.lines
|
||||
};
|
||||
let exported = english || e.lines.is_empty();
|
||||
let local = local.get(i).map_or(&[][..], Vec::as_slice);
|
||||
let hit = |t: &str| {
|
||||
(exported && any(lines.iter().map(String::as_str), t))
|
||||
|| any(local.iter().map(String::as_str), t)
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
None => hit(&text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
e.namespace.contains(ns) && (e.path.contains(path) || hit(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1396,15 +1439,33 @@ mod tests {
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
let tr = |k: &str| names.get(k).map(str::to_owned);
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str| c.search_results_in(s, &names, &local, false);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// Only the selected language's lines count outside English.
|
||||
assert!(q("anvil").is_empty());
|
||||
assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_every_localized_tooltip_line() {
|
||||
// Vanilla's FullTextSearchTree indexes the selected language's
|
||||
// tooltip lines; the export's English lines do not apply.
|
||||
let c = creative(0);
|
||||
let names = Names::default();
|
||||
let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]);
|
||||
let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned());
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str, english| c.search_results_in(s, &names, &local, english);
|
||||
assert_eq!(q("наков", false), [0]);
|
||||
assert!(q("anvil", false).is_empty());
|
||||
assert_eq!(q("anvil", true), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape},
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -104,7 +104,7 @@ pub struct ItemEntity {
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
pub age: u32,
|
||||
pub age: i32,
|
||||
pub pickup_delay: u32,
|
||||
pub bob_offset: f32,
|
||||
/// `Entity.tickCount`: ticks since the item entered the level, which
|
||||
@@ -261,6 +261,7 @@ impl ItemEntity {
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -291,8 +292,11 @@ impl ItemEntity {
|
||||
// horizontal speed. The idle branch still runs bubble-column push
|
||||
// (`applyEffectsFromBlocksForLastMovements`), so lift builds up and
|
||||
// the item moves on the next wake-up (#4558).
|
||||
if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) {
|
||||
let mut scene = SceneCollisions {
|
||||
if !self.on_ground
|
||||
|| horizontal > 1.0e-5
|
||||
|| self.ticks.wrapping_add(self.id).is_multiple_of(4)
|
||||
{
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
@@ -308,17 +312,14 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else if entity_boxes.is_empty() {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still), and
|
||||
// so does the world border (`Entity.collectColliders`).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
border: border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
@@ -441,7 +442,9 @@ impl ItemEntity {
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
self.age += 1;
|
||||
if self.age != i16::MIN as i32 {
|
||||
self.age += 1;
|
||||
}
|
||||
// ItemEntity.tick refreshes immersion after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
@@ -548,8 +551,11 @@ impl ItemEntity {
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
/// delay isn't infinite, before its lifetime ends.
|
||||
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
fn mergeable(&self, age: i32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX
|
||||
&& age != i16::MIN as i32
|
||||
&& age < 6000
|
||||
&& self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
@@ -685,7 +691,7 @@ pub fn merge_with_neighbours(
|
||||
max_stack: &dyn Fn(&ItemStack) -> u32,
|
||||
) {
|
||||
let age = |items: &[ItemEntity], j: usize| {
|
||||
if j == i {
|
||||
if j == i && items[j].age != i16::MIN as i32 {
|
||||
items[j].age - 1
|
||||
} else {
|
||||
items[j].age
|
||||
@@ -729,7 +735,7 @@ pub fn merge_with_neighbours(
|
||||
let to_item = &mut items[to];
|
||||
to_item.pickup_delay = to_item.pickup_delay.max(delay);
|
||||
// This item's age goes up once its tick ends.
|
||||
to_item.age = to_age.min(from_age) + u32::from(to == i);
|
||||
to_item.age = to_age.min(from_age) + i32::from(to == i);
|
||||
if items[from].stack.count == 0 {
|
||||
removed[from] = true;
|
||||
cells.remove(from, items[from].position);
|
||||
@@ -852,6 +858,7 @@ impl ExperienceOrb {
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -916,8 +923,10 @@ impl ExperienceOrb {
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop the orb too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve(bounds, wanted, 0.0, self.on_ground)
|
||||
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2034,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),
|
||||
@@ -2111,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,
|
||||
|
||||
@@ -4009,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());
|
||||
@@ -4060,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
|
||||
@@ -4085,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,
|
||||
@@ -4094,6 +4095,15 @@ 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;
|
||||
}
|
||||
}
|
||||
if !alive || removed[i] {
|
||||
removed[i] = true;
|
||||
cells.remove(i, item.position);
|
||||
@@ -4105,8 +4115,7 @@ impl Game<'_> {
|
||||
self.pack.assets.registry,
|
||||
world_items!(self),
|
||||
);
|
||||
// shortcut: age is the fresh-drop clock; use entity ticks for saved/frozen-age phase parity.
|
||||
if environment.should_play_lava_hurt_sound(item.age) {
|
||||
if environment.should_play_lava_hurt_sound(item.ticks) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.burn",
|
||||
@@ -5535,7 +5544,7 @@ impl Game<'_> {
|
||||
f.stuck = Vec3::ZERO;
|
||||
f.velocity = Vec3::ZERO;
|
||||
}
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
let scene = collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: Some(collisions::PowderSnowSubject {
|
||||
@@ -5554,7 +5563,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,
|
||||
@@ -8080,6 +8095,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);
|
||||
@@ -8425,6 +8444,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.
|
||||
@@ -8739,9 +8773,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);
|
||||
@@ -13243,6 +13283,7 @@ impl Game<'_> {
|
||||
target,
|
||||
self.remote.is_none(),
|
||||
&mut next_float,
|
||||
Some(self.border.geometry()),
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
@@ -14806,10 +14847,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<()> {
|
||||
@@ -16782,14 +16832,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 {
|
||||
@@ -17767,12 +17822,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:
|
||||
@@ -17876,7 +17938,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();
|
||||
@@ -20710,6 +20773,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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -2175,7 +2175,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() {
|
||||
@@ -2250,7 +2251,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.
|
||||
@@ -2936,6 +2941,27 @@ impl Game<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
|
||||
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
|
||||
/// tells the management protocol's clients).
|
||||
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
if self.options.dedicated.is_some() {
|
||||
let (value_type, json) = match value {
|
||||
Value::Bool(b) => ("boolean", serde_json::json!(b)),
|
||||
Value::Int(i) => ("integer", serde_json::json!(i)),
|
||||
};
|
||||
super::dedicated::events::record_server(
|
||||
"gamerule",
|
||||
serde_json::json!({
|
||||
"key": format!("minecraft:{}", rule.name),
|
||||
"valueType": value_type,
|
||||
"value": json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
return Ok(0);
|
||||
@@ -2958,8 +2984,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),
|
||||
|
||||
@@ -233,6 +233,14 @@ impl Game<'_> {
|
||||
self.sign_input(None, Some(text));
|
||||
return Ok(());
|
||||
}
|
||||
// The title's screens: the world name, the seed, a server's address.
|
||||
// A screen opened over the title (Options, say) keeps its own text.
|
||||
if title_owns_text(&self.screen, self.title.is_some())
|
||||
&& let Some(title) = &mut self.title
|
||||
{
|
||||
title.type_text(text);
|
||||
return Ok(());
|
||||
}
|
||||
match self.screen {
|
||||
Screen::Chat => self.chat_type(text),
|
||||
Screen::CommandBlock => self.command_block_type(text),
|
||||
@@ -1459,10 +1467,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The title's own screens (not one opened over it, as Options is) take the
|
||||
/// text: the world name, the seed, a server's address.
|
||||
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
|
||||
title_up && *screen == Screen::Playing
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_title_takes_text_only_on_its_own_screens() {
|
||||
assert!(title_owns_text(&Screen::Playing, true));
|
||||
assert!(!title_owns_text(&Screen::Playing, false));
|
||||
assert!(!title_owns_text(&Screen::Options, true));
|
||||
assert!(!title_owns_text(&Screen::Chat, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_names_the_protocol_the_release_and_the_command_groups() {
|
||||
let reply = hello();
|
||||
|
||||
@@ -263,6 +263,10 @@ pub(super) fn named_mob_pickable(
|
||||
alive && Some(id) != riding && Some(id) != camera
|
||||
}
|
||||
|
||||
/// What an entity drew on the GPU path: the skin frame's mark before its draws, and their
|
||||
/// camera-relative bounds (`cull_skinned`).
|
||||
pub(super) type Skinned = Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn player_view(&self) -> Option<PlayerView> {
|
||||
let p = self.player.as_ref()?;
|
||||
@@ -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 {
|
||||
@@ -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,
|
||||
@@ -6284,7 +6319,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 +6571,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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -1804,6 +1841,13 @@ impl Game<'_> {
|
||||
self.store_property("max-players", max_players.to_string());
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().enforce_whitelist = on;
|
||||
self.store_property("enforce-whitelist", on.to_string());
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
pub(super) fn store_property(&mut self, key: &str, value: String) {
|
||||
let path = PathBuf::from("server.properties");
|
||||
|
||||
@@ -21,11 +21,21 @@ static EVENTS: Mutex<Events> = Mutex::new(Events {
|
||||
|
||||
/// Records one event: its kind, the player's name and UUID, and what else it says.
|
||||
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save; an IP
|
||||
/// ban), and what else it says.
|
||||
pub(in crate::game) fn record_server(kind: &str, fields: Value) {
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
fn push(kind: &str, mut fields: Value) {
|
||||
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
fields["seq"] = events.next.into();
|
||||
fields["type"] = kind.into();
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
fields["time"] = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
@@ -52,6 +62,7 @@ pub(super) fn since(since: u64) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// One test: the events are one process-wide list, so two tests recording at once would count each other's.
|
||||
#[test]
|
||||
fn events_are_numbered_and_read_from_a_number() {
|
||||
let first = since(u64::MAX)["next"].as_u64().unwrap();
|
||||
@@ -65,5 +76,11 @@ mod tests {
|
||||
assert_eq!(events[1]["message"], "hi");
|
||||
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
|
||||
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
|
||||
// An event of the server itself (a world save) carries no player.
|
||||
record_server("saving", json!({}));
|
||||
let event = &since(first + 2)["events"][0];
|
||||
assert_eq!(event["type"], "saving");
|
||||
assert_eq!(event["seq"].as_u64(), Some(first + 3));
|
||||
assert!(event.get("name").is_none() && event.get("uuid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
|
||||
use crate::game::Game;
|
||||
use horizon_client::{
|
||||
command::vanilla::DIFFICULTIES,
|
||||
gamerules::{self, Value as GameRuleValue},
|
||||
server::{
|
||||
profiles,
|
||||
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
|
||||
@@ -29,6 +30,15 @@ struct Op {
|
||||
bypass: Option<bool>,
|
||||
}
|
||||
|
||||
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
|
||||
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
|
||||
let (kind, value) = match value {
|
||||
GameRuleValue::Bool(b) => ("boolean", json!(b)),
|
||||
GameRuleValue::Int(i) => ("integer", json!(i)),
|
||||
};
|
||||
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
|
||||
}
|
||||
|
||||
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
|
||||
fn shown(user: &NameAndId) -> String {
|
||||
format!(
|
||||
@@ -49,6 +59,48 @@ 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) });
|
||||
}
|
||||
@@ -137,6 +189,46 @@ impl Game<'_> {
|
||||
self.set_max_players(state, max_players);
|
||||
return json!({ "value": state.max_players });
|
||||
}
|
||||
"serversettings/use_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/use_allowlist/set" => {
|
||||
let Some(use_allowlist) = body["value"].as_bool() else {
|
||||
return json!({ "error": "using allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().use_whitelist;
|
||||
rpc(format!(
|
||||
"Update using allowlist from {} to {}",
|
||||
current, use_allowlist
|
||||
));
|
||||
self.set_using_whitelist(&lists, use_allowlist);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist/set" => {
|
||||
let Some(enforce) = body["value"].as_bool() else {
|
||||
return json!({ "error": "enforce allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().enforce_whitelist;
|
||||
rpc(format!(
|
||||
"Update enforce allowlist from {} to {}",
|
||||
current, enforce
|
||||
));
|
||||
self.set_enforce_whitelist(&lists, enforce);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
@@ -468,3 +560,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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -763,10 +763,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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -2419,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,
|
||||
@@ -2445,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
|
||||
@@ -2535,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
|
||||
@@ -2573,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`
|
||||
@@ -2675,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`).
|
||||
@@ -2721,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
|
||||
@@ -2743,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
|
||||
@@ -4518,6 +4543,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,
|
||||
@@ -5893,6 +5939,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
|
||||
@@ -7355,6 +7414,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.
|
||||
@@ -9164,9 +9232,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 {
|
||||
@@ -9175,6 +9246,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -716,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(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -356,6 +356,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
|
||||
@@ -2688,6 +2701,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];
|
||||
@@ -3829,6 +3853,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,
|
||||
@@ -3999,6 +4024,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]
|
||||
|
||||
@@ -324,6 +324,14 @@ pub struct Menu {
|
||||
/// a level) runs the rest of its `onTake` (the anvil's wear and level
|
||||
/// event 1030 or 1029, the smithing table's 1044).
|
||||
pub combined: bool,
|
||||
/// The caller fires `AnvilCraftEvent.Pre` for a taken anvil result: its
|
||||
/// `onTake` waits in `anvil_craft` for the caller to run `anvil_take`, or
|
||||
/// to refuse the take with `anvil_cancel` (from an `anvil_undo` made
|
||||
/// before the click).
|
||||
pub anvil_hook: bool,
|
||||
/// The anvil result taken while `anvil_hook` is set, as it was before
|
||||
/// crafting.
|
||||
pub anvil_craft: Option<AnvilCraft>,
|
||||
/// The enchanting table's item changed to an enchantable one: the caller
|
||||
/// (with a level) fills the costs and clues (`slotsChanged`'s
|
||||
/// `access.execute`).
|
||||
@@ -349,6 +357,26 @@ pub struct Anvil {
|
||||
pub only_renaming: bool,
|
||||
}
|
||||
|
||||
/// An anvil craft as `AnvilCraftEvent` reports it: the inputs and the result
|
||||
/// taken, before the crafting changed anything.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnvilCraft {
|
||||
pub left: Option<ItemStack>,
|
||||
pub right: Option<ItemStack>,
|
||||
pub output: ItemStack,
|
||||
}
|
||||
|
||||
/// An anvil menu before a click, for `Menu::anvil_cancel`: the anvil's three
|
||||
/// slots and cost, the player's slots and the cursor.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnvilUndo {
|
||||
inputs: Vec<Option<ItemStack>>,
|
||||
result: Option<ItemStack>,
|
||||
cost: i32,
|
||||
player: Vec<Option<ItemStack>>,
|
||||
carried: Option<ItemStack>,
|
||||
}
|
||||
|
||||
/// `StonecutterMenu`'s state besides its slots and `selectedRecipeIndex`.
|
||||
#[derive(Clone, Default, Debug, PartialEq)]
|
||||
pub struct Stonecutter {
|
||||
@@ -742,6 +770,8 @@ impl Menu {
|
||||
level_change: 0,
|
||||
anvil: Anvil::default(),
|
||||
combined: false,
|
||||
anvil_hook: false,
|
||||
anvil_craft: None,
|
||||
enchant_changed: false,
|
||||
enchant_button: None,
|
||||
may_build: true,
|
||||
@@ -1594,8 +1624,33 @@ impl Menu {
|
||||
out
|
||||
}
|
||||
|
||||
/// The anvil menu as it is before a click that may take its result.
|
||||
pub fn anvil_undo(&self) -> AnvilUndo {
|
||||
AnvilUndo {
|
||||
inputs: self.containers[MAIN].clone(),
|
||||
result: self.containers[RESULT][0].clone(),
|
||||
cost: self.data[0],
|
||||
player: self.containers[PLAYER].clone(),
|
||||
carried: self.carried.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses a take that `anvil_hook` deferred (a cancelled
|
||||
/// `AnvilCraftEvent.Pre`): the result goes back in its slot and the
|
||||
/// inputs, cost, inventory and cursor are as `undo` held them, so nothing
|
||||
/// the click did (cursor, shift-click, swap, drop) is left.
|
||||
pub fn anvil_cancel(&mut self, undo: AnvilUndo) {
|
||||
self.containers[MAIN] = undo.inputs;
|
||||
self.containers[RESULT][0] = undo.result;
|
||||
self.data[0] = undo.cost;
|
||||
self.containers[PLAYER] = undo.player;
|
||||
self.carried = undo.carried;
|
||||
self.anvil_craft = None;
|
||||
self.dropped.clear();
|
||||
}
|
||||
|
||||
/// `AnvilMenu.onTake`: the levels, the material used up, then the input.
|
||||
fn anvil_take(&mut self, env: &Env) {
|
||||
pub fn anvil_take(&mut self, env: &Env) {
|
||||
let cost = self.data[0];
|
||||
if !self.creative {
|
||||
// Player.giveExperienceLevels(-cost).
|
||||
@@ -1680,6 +1735,14 @@ impl Menu {
|
||||
return;
|
||||
}
|
||||
if self.slots[s].role == Role::CombineResult && self.kind == Kind::Anvil {
|
||||
if self.anvil_hook {
|
||||
self.anvil_craft = Some(AnvilCraft {
|
||||
left: self.containers[MAIN][0].clone(),
|
||||
right: self.containers[MAIN][1].clone(),
|
||||
output: taken.clone(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.anvil_take(env);
|
||||
return;
|
||||
}
|
||||
@@ -3492,6 +3555,71 @@ pub(crate) mod tests {
|
||||
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
|
||||
}
|
||||
|
||||
/// A cancelled `AnvilCraftEvent.Pre` refuses the take: the result sits in
|
||||
/// its slot, both inputs and the cost are as they were, and neither the
|
||||
/// cursor nor the inventory holds the item (no dupe).
|
||||
#[test]
|
||||
fn a_cancelled_anvil_craft_leaves_the_menu_as_it_was() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-anvil-cancel-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
let items = Items::for_tests(Default::default());
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (banners, station_recipes) = (Banners::default(), Default::default());
|
||||
let (enchantments, names) = (Default::default(), Default::default());
|
||||
let env = &Env {
|
||||
items: &items,
|
||||
recipes: &recipes,
|
||||
brewing: &brewing,
|
||||
banners: &banners,
|
||||
station_recipes: &station_recipes,
|
||||
enchantments: &enchantments,
|
||||
names: &names,
|
||||
trade: None,
|
||||
maps: None,
|
||||
};
|
||||
// Both a pick-up onto the cursor and a shift-click into the inventory.
|
||||
for input in [Input::Pickup, Input::QuickMove] {
|
||||
let mut menu = Menu::new(Kind::Anvil);
|
||||
menu.creative = false;
|
||||
menu.levels = 10;
|
||||
menu.anvil_hook = true;
|
||||
menu.set_slot(env, 0, stack("iron_sword", 1));
|
||||
menu.set_slot(env, 1, stack("iron_ingot", 2));
|
||||
menu.set_slot(env, 2, stack("iron_sword", 1));
|
||||
menu.data[0] = 3;
|
||||
let before = (
|
||||
menu.slot(0).cloned(),
|
||||
menu.slot(1).cloned(),
|
||||
menu.slot(2).cloned(),
|
||||
);
|
||||
let undo = menu.anvil_undo();
|
||||
menu.click(env, 2, 0, input).unwrap();
|
||||
// The take waits for the mods, and the click moved the result.
|
||||
let craft = menu.anvil_craft.take().expect("the take is deferred");
|
||||
assert_eq!(craft.output, before.2.clone().unwrap());
|
||||
assert_eq!(menu.slot(2), None);
|
||||
assert_eq!(menu.data[0], 3);
|
||||
menu.anvil_craft = Some(craft);
|
||||
menu.anvil_cancel(undo);
|
||||
assert_eq!(
|
||||
(
|
||||
menu.slot(0).cloned(),
|
||||
menu.slot(1).cloned(),
|
||||
menu.slot(2).cloned()
|
||||
),
|
||||
before
|
||||
);
|
||||
assert_eq!(menu.data[0], 3);
|
||||
assert_eq!(menu.level_change, 0);
|
||||
assert_eq!(menu.carried, None);
|
||||
assert!((0..36).all(|i| menu.player_item(i).is_none()));
|
||||
assert!(menu.anvil_craft.is_none() && menu.dropped.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod menu type: three slots, then a result (slot 3).
|
||||
fn mod_menu() -> Kind {
|
||||
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
|
||||
|
||||
@@ -117,6 +117,8 @@ use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
pub struct Polygon {
|
||||
pub normal: [f32; 3],
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
/// The model's render type culls back faces (`entityCutoutCull`: the bat).
|
||||
pub cull_back: bool,
|
||||
}
|
||||
impl Polygon {
|
||||
fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] {
|
||||
@@ -127,7 +129,12 @@ impl Polygon {
|
||||
n.normalize_or_zero()
|
||||
};
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
[packed[0], packed[1], packed[2], -127]
|
||||
let w = if self.cull_back {
|
||||
crate::mesher::SHADED_CULL_W
|
||||
} else {
|
||||
-127
|
||||
};
|
||||
[packed[0], packed[1], packed[2], w]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +232,15 @@ pub struct Part {
|
||||
pub children: Vec<Part>,
|
||||
}
|
||||
impl Part {
|
||||
/// Marks every polygon as drawn by a back-face-culling render type.
|
||||
fn set_cull_back(&mut self) {
|
||||
for poly in &mut self.polygons {
|
||||
poly.cull_back = true;
|
||||
}
|
||||
for child in &mut self.children {
|
||||
child.set_cull_back();
|
||||
}
|
||||
}
|
||||
fn parse(name: &str, v: &Value) -> Self {
|
||||
let pose: Vec<f32> = v["pose"]
|
||||
.as_array()
|
||||
@@ -271,6 +287,7 @@ impl Part {
|
||||
|n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32),
|
||||
),
|
||||
vertices: [verts[0], verts[1], verts[2], verts[3]],
|
||||
cull_back: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -494,7 +511,14 @@ impl MobTypes {
|
||||
max_head_y_rot: def.max_head_y_rot(),
|
||||
ambient_interval: def.ambient_interval(),
|
||||
flags,
|
||||
model: Arc::new(Part::parse("root", model)),
|
||||
model: Arc::new({
|
||||
let mut part = Part::parse("root", model);
|
||||
// BatModel: `RenderTypes::entityCutoutCull`.
|
||||
if def.name == "bat" {
|
||||
part.set_cull_back();
|
||||
}
|
||||
part
|
||||
}),
|
||||
layers,
|
||||
texture: def.texture.to_owned(),
|
||||
brain: def.brain(),
|
||||
@@ -1538,6 +1562,16 @@ pub enum MobEvent {
|
||||
amount: f32,
|
||||
source: Damage,
|
||||
},
|
||||
/// `Pufferfish.touch` on a mob (`Pufferfish.aiStep`): the hit, then, only
|
||||
/// when `hurtServer` let it land, the fish's `entity.puffer_fish.sting`
|
||||
/// and the poison it gives (`poison_ticks`, `60 * getPuffState()`).
|
||||
PufferfishSting {
|
||||
mob: u32,
|
||||
target: u32,
|
||||
amount: f32,
|
||||
source: Damage,
|
||||
poison_ticks: i32,
|
||||
},
|
||||
/// `LivingEntity.stabAttack`'s `target.stopRiding()`: a spear knocks the
|
||||
/// target off its mount.
|
||||
StabDismount {
|
||||
|
||||
@@ -271,6 +271,8 @@ pub enum ArmPose {
|
||||
ThrowTrident,
|
||||
/// Playing a goat horn.
|
||||
TootHorn,
|
||||
/// Looking through a spyglass (`HumanoidModel.ArmPose.SPYGLASS`).
|
||||
Spyglass,
|
||||
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
|
||||
Spear,
|
||||
/// A brush being used (`HumanoidModel.ArmPose.BRUSH`).
|
||||
@@ -400,6 +402,8 @@ pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: boo
|
||||
let x = lerp(alpha, p.get(pull)[3], -PI / 2.0);
|
||||
p.xrot(pull, x);
|
||||
}
|
||||
/// `HumanoidModel.SPYGLASS_ARM_ROT_X`.
|
||||
const SPYGLASS_ARM_ROT_X: f32 = 1.919_862_2;
|
||||
/// `HumanoidModel.poseRightArm` / `poseLeftArm`.
|
||||
fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
|
||||
let arm = if right { "right_arm" } else { "left_arm" };
|
||||
@@ -436,6 +440,11 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
|
||||
p.xrot(arm, x * 0.5 - PI);
|
||||
p.yrot(arm, 0.0);
|
||||
}
|
||||
ArmPose::Spyglass => {
|
||||
let crouch = if h.crouching { 0.261_799_4 } else { 0.0 };
|
||||
p.xrot(arm, (hx - SPYGLASS_ARM_ROT_X - crouch).clamp(-2.4, 3.3));
|
||||
p.yrot(arm, hy + if right { -0.261_799_4 } else { 0.261_799_4 });
|
||||
}
|
||||
ArmPose::TootHorn => {
|
||||
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
|
||||
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
|
||||
@@ -818,6 +827,7 @@ pub fn setup(
|
||||
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
|
||||
Some("minecraft:bow") if using_main => ArmPose::Bow,
|
||||
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
|
||||
Some("minecraft:spyglass") if using_main => ArmPose::Spyglass,
|
||||
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
|
||||
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
|
||||
ArmPose::CrossbowCharge
|
||||
@@ -838,6 +848,8 @@ pub fn setup(
|
||||
ArmPose::Spear
|
||||
} else if using_off && m.extra.blocking {
|
||||
ArmPose::Block
|
||||
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:spyglass") {
|
||||
ArmPose::Spyglass
|
||||
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:goat_horn") {
|
||||
ArmPose::TootHorn
|
||||
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") {
|
||||
@@ -1067,6 +1079,41 @@ mod flight_pose_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// HumanoidModel.poseRightArm / poseLeftArm's SPYGLASS case: pitch, yaw,
|
||||
/// crouching, and the arm's pitch.
|
||||
#[test]
|
||||
fn a_spyglass_raises_the_arm_with_the_head_in_both_hands() {
|
||||
for right in [false, true] {
|
||||
for (pitch, yaw, crouching, expected_pitch) in [
|
||||
(0.0, 0.0, false, -1.919_862_2),
|
||||
(0.5, 0.3, false, -1.419_862_2),
|
||||
(0.0, 0.0, true, -2.181_661_6),
|
||||
// Looking up: limited to -2.4.
|
||||
(-1.5, 0.0, false, -2.4),
|
||||
] {
|
||||
let mut p = Poses {
|
||||
map: ["head", "right_arm", "left_arm"]
|
||||
.into_iter()
|
||||
.map(|n| (key(n), [0.0; 9]))
|
||||
.collect(),
|
||||
dummy: [0.0; 9],
|
||||
skipped: Default::default(),
|
||||
hidden: Default::default(),
|
||||
};
|
||||
let arm = if right { "right_arm" } else { "left_arm" };
|
||||
p.xrot("head", pitch);
|
||||
p.yrot("head", yaw);
|
||||
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
|
||||
h.crouching = crouching;
|
||||
pose_arm(&mut p, ArmPose::Spyglass, right, &h);
|
||||
let pose = *p.get(arm);
|
||||
assert!((pose[3] - expected_pitch).abs() < 1e-6, "{pose:?}");
|
||||
let side = if right { -0.261_799_4 } else { 0.261_799_4 };
|
||||
assert!((pose[4] - (yaw + side)).abs() < 1e-6, "{pose:?}");
|
||||
assert_eq!(p.get(if right { "left_arm" } else { "right_arm" })[3], 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// EndermanModel.setupAnim (#4565): humanoid_pub forwards isPassenger so the
|
||||
/// riding leg y/z survive; only xRot is halved and clamped to ±0.4 after.
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! tropical fish and pufferfish (`AbstractFish`, puffing), and dolphins
|
||||
//! (breathing, smooth swimming).
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species,
|
||||
Damage, Mob, MobEvent, MobWorld, Species, Who,
|
||||
anim::{Poses, State},
|
||||
control::MoveKind,
|
||||
goals::{
|
||||
@@ -333,6 +333,9 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.sound("entity.puffer_fish.sting", events);
|
||||
}
|
||||
}
|
||||
if m.species() == S::Pufferfish {
|
||||
sting_touching_mobs(m, w, events);
|
||||
}
|
||||
}
|
||||
S::Dolphin => {
|
||||
// Dolphin.tick: moisture out of water, hop on land.
|
||||
@@ -364,6 +367,65 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EntityTypeTags.NOT_SCARY_FOR_PUFFERFISH`: the mobs a pufferfish leaves
|
||||
/// alone, its own kind and the water mobs it shares a tank with.
|
||||
pub fn not_scary_for_pufferfish(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Dolphin
|
||||
| Species::Turtle
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Tadpole
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
| Species::SulfurCube
|
||||
)
|
||||
}
|
||||
|
||||
/// `Pufferfish.aiStep` after `AbstractFish.aiStep`: a puffed pufferfish stings
|
||||
/// every mob that touches it, not only the player. The query is `Mob.class`
|
||||
/// in its own box inflated by 0.3, filtered by `TARGETING_CONDITIONS` (alive
|
||||
/// and not `#not_scary_for_pufferfish`; invisibility and line of sight are
|
||||
/// not tested), and each one takes `Pufferfish.touch`: `doTeamsAllowDamage`,
|
||||
/// then a `mobAttack` hit for `getPuffState() + 1`. The poison and the sting
|
||||
/// sound follow a hit that landed, which only the game layer knows.
|
||||
fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if !m.alive() || m.extra.size <= 0 {
|
||||
return;
|
||||
}
|
||||
let area = m.bounds().inflate(Vec3::new(0.3, 0.3, 0.3));
|
||||
let own = w.teams.of(Who::Mob(m.id));
|
||||
let stung: Vec<u32> = w
|
||||
.others_near(area)
|
||||
.filter(|o| {
|
||||
o.id != m.id
|
||||
&& o.alive
|
||||
&& !not_scary_for_pufferfish(o.species)
|
||||
&& o.bounds.intersects(area)
|
||||
&& crate::scoreboard::teams_allow_damage(own, w.teams.of(Who::Mob(o.id)))
|
||||
})
|
||||
.map(|o| o.id)
|
||||
.collect();
|
||||
let from = [m.position.x, m.position.y, m.position.z];
|
||||
for target in stung {
|
||||
events.push(MobEvent::PufferfishSting {
|
||||
mob: m.id,
|
||||
target,
|
||||
amount: 1.0 + m.extra.size as f32,
|
||||
source: Damage::mob(m.id, from),
|
||||
poison_ticks: 60 * m.extra.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Squid.aiStep: tentacle cycle, propulsion and body rotation.
|
||||
fn squid_ai_step(m: &mut Mob) {
|
||||
squid_step(m, false);
|
||||
@@ -881,23 +943,7 @@ impl Goal for PufferfishPuff {
|
||||
let mob = cx.w.others_near(area).any(|s| {
|
||||
s.id != m.id
|
||||
&& s.alive
|
||||
&& !matches!(
|
||||
s.species,
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Dolphin
|
||||
| Species::Turtle
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Tadpole
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
| Species::SulfurCube
|
||||
)
|
||||
&& !not_scary_for_pufferfish(s.species)
|
||||
&& area.intersects(s.bounds)
|
||||
});
|
||||
player || mob
|
||||
@@ -1099,3 +1145,85 @@ mod school_tests {
|
||||
assert!((100..=110).contains(&start));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pufferfish_sting_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a pufferfish puffed to `puff` stings in one `ai_step`, as
|
||||
/// `(target, damage)`, with `others` standing `x` blocks east of it.
|
||||
fn stings(puff: i32, others: &[(&str, f64)]) -> Option<Vec<(u32, f32)>> {
|
||||
let mut built = None;
|
||||
with_test_world(&[], |_, types| {
|
||||
let mob = |id: u32, name: &str, at: Vec3| {
|
||||
Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0)
|
||||
};
|
||||
let mut fish = mob(1, "minecraft:pufferfish", Vec3::new(0.5, 64.0, 0.5));
|
||||
fish.extra.size = puff;
|
||||
let seen: Vec<_> = others
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, x))| {
|
||||
mob(i as u32 + 2, name, Vec3::new(0.5 + x, 64.0, 0.5)).seen(None)
|
||||
})
|
||||
.collect();
|
||||
built = Some((fish, seen));
|
||||
});
|
||||
let (mut fish, seen) = built?;
|
||||
let mut events = Vec::new();
|
||||
with_test_world(&seen, |w, _| ai_step(&mut fish, w, &mut events));
|
||||
Some(
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::PufferfishSting {
|
||||
mob: 1,
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
poison_ticks,
|
||||
} => {
|
||||
assert_eq!(source.attacker, Some(Who::Mob(1)));
|
||||
assert_eq!(
|
||||
*poison_ticks,
|
||||
60 * puff,
|
||||
"Pufferfish.touch's POISON lasts 60 * getPuffState()"
|
||||
);
|
||||
Some((*target, *amount))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Pufferfish.aiStep`: a puffed pufferfish stings every mob whose box
|
||||
/// meets its own (inflated by 0.3), not only the player, for
|
||||
/// `getPuffState() + 1`.
|
||||
#[test]
|
||||
fn a_puffed_pufferfish_stings_the_mobs_that_touch_it() {
|
||||
let Some(half) = stings(1, &[("minecraft:zombie", 0.5)]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(half, vec![(2, 2.0)], "puff state 1 stings for 1 + 1");
|
||||
let full = stings(2, &[("minecraft:zombie", 0.5)]).unwrap();
|
||||
assert_eq!(full, vec![(2, 3.0)], "puff state 2 stings for 1 + 2");
|
||||
assert!(
|
||||
stings(0, &[("minecraft:zombie", 0.5)]).unwrap().is_empty(),
|
||||
"a deflated pufferfish stings nobody"
|
||||
);
|
||||
assert!(
|
||||
stings(2, &[("minecraft:zombie", 1.5)]).unwrap().is_empty(),
|
||||
"a zombie a block away never touches it"
|
||||
);
|
||||
// `#not_scary_for_pufferfish`: its tank mates are left alone.
|
||||
assert!(
|
||||
stings(2, &[("minecraft:cod", 0.4), ("minecraft:squid", 0.3)])
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"a cod and a squid are not scary"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap<String, String>) -> Pa
|
||||
.map(|q| Polygon {
|
||||
normal: q.normal,
|
||||
vertices: q.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: bone.children.iter().map(|c| part(c, count, ids)).collect(),
|
||||
@@ -473,6 +474,7 @@ mod tests {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
cull_back: false,
|
||||
vertices: [[0.0; 5]; 4],
|
||||
}],
|
||||
children,
|
||||
|
||||
@@ -2602,6 +2602,7 @@ mod tests {
|
||||
.map(|_| super::super::Polygon {
|
||||
normal: [0.0; 3],
|
||||
vertices: [corner(min), corner(max), corner(min), corner(max)],
|
||||
cull_back: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -3380,6 +3381,7 @@ mod emissive_tests {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
cull_back: false,
|
||||
vertices: [
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0, 1.0, 0.0],
|
||||
@@ -3670,6 +3672,7 @@ mod random_texture_tests {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
cull_back: false,
|
||||
vertices: [
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0, 1.0, 0.0],
|
||||
|
||||
@@ -22,6 +22,8 @@ struct Stored {
|
||||
/// The model's bounds in its parts' own space, per part: min and max of
|
||||
/// its own vertices (none: no polygons).
|
||||
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
|
||||
/// The model's render type culls back faces (the bat).
|
||||
cull: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -241,9 +243,15 @@ fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored
|
||||
first,
|
||||
parts,
|
||||
bounds,
|
||||
cull: has_cull(model),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the model's polygons cull back faces (all or none, set per model).
|
||||
fn has_cull(part: &Part) -> bool {
|
||||
part.polygons.iter().any(|p| p.cull_back) || part.children.iter().any(has_cull)
|
||||
}
|
||||
|
||||
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
|
||||
fn axis(n: [f32; 3]) -> Option<u32> {
|
||||
let mut found = None;
|
||||
@@ -415,7 +423,8 @@ impl Walk<'_> {
|
||||
overlay: paint.overlay,
|
||||
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
|
||||
item: 0,
|
||||
spare: [0; 2],
|
||||
cull: u32::from(self.stored.cull),
|
||||
spare: 0,
|
||||
});
|
||||
}
|
||||
*self.vertices += stored.count;
|
||||
@@ -533,6 +542,63 @@ mod tests {
|
||||
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
|
||||
}
|
||||
|
||||
/// `BatModel` draws with `entityCutoutCull`: its zero-depth ears, wings and
|
||||
/// feet put two quads in one place, and only culling tells them apart. The
|
||||
/// bat's vertices ask for the shaded cull encoding, other mobs keep -127,
|
||||
/// on both paths (the whole-mob test above holds them bit-equal).
|
||||
#[test]
|
||||
fn only_the_bat_asks_for_culled_back_faces() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let types = MobTypes::load(&root).unwrap();
|
||||
for (id, w) in [
|
||||
("minecraft:bat", crate::mesher::SHADED_CULL_W),
|
||||
("minecraft:cow", -127),
|
||||
("minecraft:cat", -127),
|
||||
] {
|
||||
let mut part = vec![&*types.types[id].model];
|
||||
let mut seen = 0;
|
||||
while let Some(p) = part.pop() {
|
||||
for poly in &p.polygons {
|
||||
let n = poly.vertex_normal(ModelNormals::new(glam::Mat4::IDENTITY));
|
||||
assert_eq!(n[3], w, "{id}");
|
||||
seen += 1;
|
||||
}
|
||||
part.extend(&p.children);
|
||||
}
|
||||
assert!(seen > 0, "{id} has polygons");
|
||||
}
|
||||
// The GPU path flags the job, the CPU path the polygons.
|
||||
let mut models = SkinModels::default();
|
||||
let mut frame = SkinFrame::default();
|
||||
for (id, cull) in [("minecraft:bat", 1), ("minecraft:cow", 0)] {
|
||||
models.begin(&mut frame, 0);
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
assert!(skin.draw(
|
||||
&types.types[id].model,
|
||||
glam::Mat4::IDENTITY,
|
||||
&Poses::default(),
|
||||
Paint {
|
||||
uv: [0.0, 0.0, 1.0, 1.0],
|
||||
scroll: 0.0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
overlay: 0
|
||||
},
|
||||
));
|
||||
assert!(frame.jobs.iter().all(|j| j.cull == cull), "{id}");
|
||||
frame.clear_draws();
|
||||
}
|
||||
}
|
||||
|
||||
/// The armor stand's models (`ArmorStandRenderer`: the stand, the small
|
||||
/// one, their armour layers) posed like a stand: turned arms and head, a
|
||||
/// hidden part (scale 0, as a stand without arms or base plate), the
|
||||
|
||||
@@ -110,7 +110,10 @@ pub struct NativeScreenView<'a> {
|
||||
/// on it. Every container, station and mod window draws on the inventory
|
||||
/// screen and so reports `inventory`: tell them apart by `menu` and
|
||||
/// `title` (both None on a window the client opened itself) and by
|
||||
/// `slots`.
|
||||
/// `slots`. The title's own screens report theirs (API @API:native_instance_dir@): `title`,
|
||||
/// `world_select`, `world_create`, `world_create_flat`, `world_edit`,
|
||||
/// `multiplayer`, `add_server`, `direct_connect`…, with no menu, no title
|
||||
/// and no slots.
|
||||
pub id: &'static str,
|
||||
/// The type id of the menu the server opened for this screen
|
||||
/// (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id; a mount's
|
||||
@@ -492,3 +495,101 @@ mod generated;
|
||||
pub use generated::Hooks;
|
||||
#[doc(hidden)]
|
||||
pub use generated::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A double with the three `Host` methods the probe calls.
|
||||
struct Reader {
|
||||
block: &'static str,
|
||||
lines: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Host for Reader {
|
||||
fn block(&self, _pos: [i32; 3]) -> String {
|
||||
self.block.to_string()
|
||||
}
|
||||
fn acting_player(&self) -> Option<NativeServerPlayer> {
|
||||
Some(NativeServerPlayer {
|
||||
uuid: 7,
|
||||
name: "Probe".to_string(),
|
||||
permission: 0,
|
||||
dimension: "minecraft:overworld".to_string(),
|
||||
position: [0.0; 3],
|
||||
game_mode: 0,
|
||||
health: 20.0,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
})
|
||||
}
|
||||
fn action_bar(&mut self, _uuid: u128, line: Text) -> bool {
|
||||
self.lines.push(line);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims a use on stone, after telling the player what the block is.
|
||||
struct Probe;
|
||||
|
||||
impl Hooks for Probe {
|
||||
fn use_item_on(&self, host: &mut dyn Host, event: &mut UseItemOnEvent<'_>) -> Flow {
|
||||
let block = host.block(event.pos);
|
||||
let player = host.acting_player().expect("a player acts");
|
||||
host.action_bar(player.uuid, Text::literal(block.clone()));
|
||||
if block == "minecraft:stone" {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the host to tell a player without anyone having implemented it.
|
||||
struct Chatty;
|
||||
|
||||
impl Hooks for Chatty {
|
||||
fn use_item_on(&self, host: &mut dyn Host, _event: &mut UseItemOnEvent<'_>) -> Flow {
|
||||
host.tell(7, Text::literal("hello"));
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn stick() -> ItemStack {
|
||||
ItemStack {
|
||||
item: Arc::from("minecraft:stick"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_host_implements_only_what_its_hook_calls() {
|
||||
let mods = [Box::new(Probe) as Box<dyn Hooks>];
|
||||
let stack = stick();
|
||||
for (block, expected) in [("minecraft:stone", true), ("minecraft:dirt", false)] {
|
||||
let mut host = Reader {
|
||||
block,
|
||||
lines: Vec::new(),
|
||||
};
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut host, &mods, &stack, [1, 2, 3], [0, 1, 0], &mut claimed);
|
||||
assert_eq!(claimed, expected, "{block}");
|
||||
let told: Vec<String> = host.lines.iter().map(Text::plain).collect();
|
||||
assert_eq!(told, [block], "{block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Host::tell")]
|
||||
fn an_unexpected_host_call_names_the_method() {
|
||||
let mods = [Box::new(Chatty) as Box<dyn Hooks>];
|
||||
let mut host = Reader {
|
||||
block: "minecraft:stone",
|
||||
lines: Vec::new(),
|
||||
};
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut host, &mods, &stick(), [0; 3], [0, 1, 0], &mut claimed);
|
||||
}
|
||||
}
|
||||
+127
-18
@@ -1187,13 +1187,15 @@ pub trait Hooks: Send + Sync {
|
||||
/// it is. Once a frame on the window, never while playing with no screen;
|
||||
/// `&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.
|
||||
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
|
||||
let _ = host;
|
||||
@@ -1225,10 +1227,11 @@ pub trait Hooks: Send + Sync {
|
||||
/// alt 4, super 8). Set `canceled` and the press goes no further: no slot,
|
||||
/// 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
|
||||
@@ -1418,9 +1421,10 @@ pub trait Hooks: Send + Sync {
|
||||
/// changes either.
|
||||
/// 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
|
||||
@@ -1488,10 +1492,10 @@ pub trait Hooks: Send + Sync {
|
||||
/// 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`
|
||||
@@ -3046,6 +3050,15 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = event;
|
||||
None
|
||||
}
|
||||
/// 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@.
|
||||
fn item_expire(&self, host: &mut dyn Host, event: &mut ItemExpireEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// A dropped item entity was successfully added to the server level.
|
||||
/// @API:item_spawn@.
|
||||
fn item_spawn(&self, host: &mut dyn Host, event: &mut ItemSpawnEvent<'_>) {
|
||||
@@ -3111,6 +3124,19 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// 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@.
|
||||
fn anvil_craft_post(&self, host: &mut dyn Host, event: &mut AnvilCraftPostEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// 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@.
|
||||
///
|
||||
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
|
||||
fn anvil_craft_pre(&self, host: &mut dyn Host, event: &mut AnvilCraftPreEvent<'_>) -> Flow {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
Flow::Continue
|
||||
}
|
||||
/// A player takes an item from an armor stand or puts one on it
|
||||
/// (`ArmorStand.interactAt`, after the stand's disabled-slot checks and
|
||||
/// `swapItem`'s take/put locks, before anything moves): `stand_id` is the
|
||||
@@ -10179,6 +10205,30 @@ pub fn dispatch_item_energy_buffer(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::item_expire`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ItemExpireEvent<'a> {
|
||||
pub entity: u32,
|
||||
pub stack: &'a ItemStack,
|
||||
pub extra_life: i32,
|
||||
}
|
||||
pub fn dispatch_item_expire(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
entity: u32,
|
||||
stack: &ItemStack,
|
||||
extra_life: &mut i32,
|
||||
) {
|
||||
let mut event = ItemExpireEvent {
|
||||
entity,
|
||||
stack,
|
||||
extra_life: *extra_life,
|
||||
};
|
||||
for mod_hook in mods {
|
||||
mod_hook.item_expire(host, &mut event);
|
||||
}
|
||||
*extra_life = event.extra_life;
|
||||
}
|
||||
/// What [`Hooks::item_spawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ItemSpawnEvent<'a> {
|
||||
@@ -10324,6 +10374,65 @@ pub fn dispatch_moisture_change(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::anvil_craft_post`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct AnvilCraftPostEvent<'a> {
|
||||
pub player: u128,
|
||||
pub name: &'a str,
|
||||
pub pos: [i32; 3],
|
||||
pub craft: &'a horizon_client::menu::AnvilCraft,
|
||||
}
|
||||
pub fn dispatch_anvil_craft_post(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
player: u128,
|
||||
name: &str,
|
||||
pos: [i32; 3],
|
||||
craft: &horizon_client::menu::AnvilCraft,
|
||||
) {
|
||||
let mut event = AnvilCraftPostEvent {
|
||||
player,
|
||||
name,
|
||||
pos,
|
||||
craft,
|
||||
};
|
||||
for mod_hook in mods {
|
||||
mod_hook.anvil_craft_post(host, &mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::anvil_craft_pre`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct AnvilCraftPreEvent<'a> {
|
||||
pub player: u128,
|
||||
pub name: &'a str,
|
||||
pub pos: [i32; 3],
|
||||
pub craft: &'a horizon_client::menu::AnvilCraft,
|
||||
}
|
||||
pub fn dispatch_anvil_craft_pre(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
player: u128,
|
||||
name: &str,
|
||||
pos: [i32; 3],
|
||||
craft: &horizon_client::menu::AnvilCraft,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
if *cancel {
|
||||
return;
|
||||
}
|
||||
let mut event = AnvilCraftPreEvent {
|
||||
player,
|
||||
name,
|
||||
pos,
|
||||
craft,
|
||||
};
|
||||
for mod_hook in mods {
|
||||
if matches!(mod_hook.anvil_craft_pre(host, &mut event), Flow::Cancel) {
|
||||
*cancel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::armor_stand_manipulate`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ArmorStandManipulateEvent<'a> {
|
||||
|
||||
+581
-111
File diff suppressed because it is too large.
Load diff
@@ -138,6 +138,24 @@ pub enum Outcome {
|
||||
Back,
|
||||
Connect { address: String, lan: bool },
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// The id native mods see for this screen (`NativeScreenView.id`, the
|
||||
/// screen hooks): one per variant, a hook's contract, so it stays the
|
||||
/// same from release to release. A new variant must add one.
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::List { .. } => "multiplayer",
|
||||
Self::Edit { index: None, .. } => "add_server",
|
||||
Self::Edit { index: Some(_), .. } => "edit_server",
|
||||
Self::Direct { .. } => "direct_connect",
|
||||
Self::ConfirmDelete { .. } => "server_delete",
|
||||
Self::Friend { .. } => "friends_join",
|
||||
Self::Disconnected { .. } => "disconnected",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A button: x, y, width, label, enabled, id.
|
||||
pub type ButtonSpec = (f32, f32, f32, String, bool, u8);
|
||||
|
||||
|
||||
@@ -910,6 +910,17 @@ fn configure(
|
||||
data: hash.to_vec(),
|
||||
}))?;
|
||||
}
|
||||
// The server names its mods; the answer is ours, and the server judges.
|
||||
cfg::Clientbound::CustomPayload(p)
|
||||
if p.channel == horizon_protocol::mod_list::CHANNEL =>
|
||||
{
|
||||
reply(cfg::Serverbound::CustomPayload(cfg::CustomPayload {
|
||||
channel: horizon_protocol::mod_list::CHANNEL.into(),
|
||||
data: horizon_protocol::mod_list::encode(&horizon_protocol::mod_list::shared(
|
||||
&horizon_protocol::mod_list::local(),
|
||||
)),
|
||||
}))?;
|
||||
}
|
||||
cfg::Clientbound::CustomPayload(p) => receive_brand(&p, send),
|
||||
cfg::Clientbound::Ping(p) => reply(cfg::Serverbound::Pong(p))?,
|
||||
// Know no packs: the server then sends every registry entry in full.
|
||||
|
||||
@@ -835,11 +835,15 @@ impl Settings {
|
||||
let mut displays = 0;
|
||||
let mut added = Vec::new();
|
||||
for id in ids {
|
||||
if !self.recipes.contains(id) && !index.special(id) {
|
||||
// The whole id: the crafting copy names a `minecraft:` recipe
|
||||
// bare, and every reader of the book (the network copy, the
|
||||
// advancements' JSON, the saved file) knows it qualified.
|
||||
let id = crate::crafting::qualified(id);
|
||||
if !self.recipes.contains(&id) && !index.special(&id) {
|
||||
self.recipes.insert(id.clone());
|
||||
self.to_be_displayed.insert(id.clone());
|
||||
displays += index.displays(id);
|
||||
added.push(id.clone());
|
||||
displays += index.displays(&id);
|
||||
added.push(id);
|
||||
}
|
||||
}
|
||||
(displays, added)
|
||||
@@ -879,9 +883,10 @@ impl Settings {
|
||||
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
|
||||
let mut displays = Vec::new();
|
||||
for id in ids {
|
||||
if self.recipes.remove(id) {
|
||||
self.to_be_displayed.remove(id);
|
||||
displays.extend(index.display_ids(id));
|
||||
let id = crate::crafting::qualified(id);
|
||||
if self.recipes.remove(&id) {
|
||||
self.to_be_displayed.remove(&id);
|
||||
displays.extend(index.display_ids(&id));
|
||||
}
|
||||
}
|
||||
displays
|
||||
@@ -959,6 +964,24 @@ impl Settings {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A book saved before #7186's fix holds bare names: reading fills the
|
||||
/// namespace in, so those recipes show in the book too.
|
||||
#[test]
|
||||
fn a_book_saved_with_bare_names_reads_qualified() {
|
||||
use horizon_world::nbt::{Compound, Tag};
|
||||
let list = || Tag::List(vec![Tag::String("folder_recipe".into())]);
|
||||
let mut compound = Compound::new();
|
||||
compound.insert("recipes".into(), list());
|
||||
compound.insert("toBeDisplayed".into(), list());
|
||||
let tag = Tag::Compound(compound);
|
||||
let read = Settings::read_valid(Some(&tag), &|_| true);
|
||||
assert_eq!(
|
||||
read.recipes,
|
||||
["minecraft:folder_recipe".to_owned()].into_iter().collect()
|
||||
);
|
||||
assert!(read.to_be_displayed.contains("minecraft:folder_recipe"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ing(items: &[&str]) -> Ingredient {
|
||||
|
||||
@@ -776,7 +776,7 @@ fn players(id: &str, ctx: &Context, env: &mut dyn Env) -> CmdResult<i32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `OperationArgument`: the new (target, source) values.
|
||||
/// `OperationArgument.getSimpleOperation`: the new (target, source) values.
|
||||
pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
|
||||
let div0 = || err("arguments.operation.div0", &[]);
|
||||
Ok(match op {
|
||||
@@ -789,7 +789,7 @@ pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
|
||||
return Err(div0());
|
||||
}
|
||||
(
|
||||
a.wrapping_div(b) - i32::from((a % b != 0) && ((a < 0) != (b < 0))),
|
||||
a.wrapping_div(b) - i32::from((a.wrapping_rem(b) != 0) && ((a < 0) != (b < 0))),
|
||||
b,
|
||||
)
|
||||
}
|
||||
@@ -797,7 +797,14 @@ pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
|
||||
if b == 0 {
|
||||
return Err(div0());
|
||||
}
|
||||
(a.wrapping_rem(b).wrapping_add(b).wrapping_rem(b), b)
|
||||
let remainder = a.wrapping_rem(b);
|
||||
// Floor remainder has the divisor's sign; only correct opposite signs.
|
||||
let remainder = if remainder != 0 && ((remainder < 0) != (b < 0)) {
|
||||
remainder + b
|
||||
} else {
|
||||
remainder
|
||||
};
|
||||
(remainder, b)
|
||||
}
|
||||
"<" => (a.min(b), b),
|
||||
">" => (a.max(b), b),
|
||||
|
||||
@@ -189,6 +189,68 @@ fn operations_use_floor_semantics() {
|
||||
assert_eq!(apply_operation("*=", i32::MAX, 2).unwrap().0, -2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn division_wraps_minimum_integer_by_negative_one() {
|
||||
let mut m = mock();
|
||||
run(&mut m, "scoreboard objectives add poc dummy").unwrap();
|
||||
run(&mut m, "scoreboard players set target poc -2147483648").unwrap();
|
||||
run(&mut m, "scoreboard players set divisor poc -1").unwrap();
|
||||
run(
|
||||
&mut m,
|
||||
"scoreboard players operation target poc /= divisor poc",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
run(&mut m, "scoreboard players get target poc").unwrap(),
|
||||
i32::MIN
|
||||
);
|
||||
assert_eq!(
|
||||
run(&mut m, "scoreboard players get divisor poc").unwrap(),
|
||||
-1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remainder_handles_integer_boundaries() {
|
||||
for (dividend, divisor, expected) in [
|
||||
(i32::MAX - 1, i32::MAX, i32::MAX - 1),
|
||||
(7, -3, -2),
|
||||
(-7, 3, 2),
|
||||
(i32::MIN, -1, 0),
|
||||
(-1, i32::MIN, -1),
|
||||
(1, i32::MIN, i32::MIN + 1),
|
||||
(0, i32::MAX, 0),
|
||||
] {
|
||||
let mut m = mock();
|
||||
run(&mut m, "scoreboard objectives add boundary dummy").unwrap();
|
||||
run(
|
||||
&mut m,
|
||||
&format!("scoreboard players set left boundary {dividend}"),
|
||||
)
|
||||
.unwrap();
|
||||
run(
|
||||
&mut m,
|
||||
&format!("scoreboard players set right boundary {divisor}"),
|
||||
)
|
||||
.unwrap();
|
||||
run(
|
||||
&mut m,
|
||||
"scoreboard players operation left boundary %= right boundary",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
run(&mut m, "scoreboard players get left boundary").unwrap(),
|
||||
expected,
|
||||
"{dividend} %= {divisor}"
|
||||
);
|
||||
assert_eq!(
|
||||
run(&mut m, "scoreboard players get right boundary").unwrap(),
|
||||
divisor
|
||||
);
|
||||
}
|
||||
assert!(apply_operation("%=", 1, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers() {
|
||||
let mut m = mock();
|
||||
|
||||
@@ -699,6 +699,35 @@ fn rolls_user_splash(text: &str, seed: u64, date: (u32, u32)) -> bool {
|
||||
.is_ok_and(|size| size > 0 && LegacyRandom::new(seed as i64).next_int_bound(size) == Ok(42))
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The id native mods see for this screen (`NativeScreenView.id`, the
|
||||
/// screen hooks): one per variant, a hook's contract, so it stays the
|
||||
/// same from release to release. A new variant must add one.
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "title",
|
||||
Self::Select { .. } => "world_select",
|
||||
Self::Create { .. } => "world_create",
|
||||
Self::CreateFlat { .. } => "world_create_flat",
|
||||
Self::CreateBuffet { .. } => "world_create_buffet",
|
||||
Self::Edit { .. } => "world_edit",
|
||||
Self::Backup { .. } => "world_backup",
|
||||
Self::BackupBeforeUpgrade { .. } => "world_backup_confirm",
|
||||
Self::OptimizeConfirm { .. } => "world_optimize_confirm",
|
||||
Self::Optimize { .. } => "world_optimize",
|
||||
Self::LowDiskSpace { .. } => "low_disk_space",
|
||||
Self::Delete { .. } => "world_delete",
|
||||
Self::Recover { .. } => "world_recover",
|
||||
Self::Recovered { .. } => "world_recovered",
|
||||
Self::Error { .. } => "error",
|
||||
Self::Multiplayer(screen) => screen.id(),
|
||||
Self::MultiplayerWarning { .. } => "multiplayer_warning",
|
||||
Self::Onboarding(_) => "onboarding",
|
||||
Self::BanNotice(_) => "ban_notice",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Title {
|
||||
/// Names the player for the `<NAME> IS YOU` splash; `name` is only asked for when that splash was rolled.
|
||||
pub fn with_user(mut self, name: impl FnOnce() -> String) -> Self {
|
||||
|
||||
@@ -2278,3 +2278,76 @@ fn a_create_world_tab_label_wider_than_its_tab_stays_inside_it() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ids native mods see for the title's screens (`Mode::id`, the screen
|
||||
/// hooks' `NativeScreenView.id`): one per screen, stable across releases.
|
||||
#[test]
|
||||
fn the_title_screens_have_their_own_ids() {
|
||||
let mut f = Fixture::new();
|
||||
assert_eq!(f.title.mode.id(), "title");
|
||||
// An empty list opens Create New World (an empty list is why vanilla
|
||||
// starts one), a world in it opens the list.
|
||||
f.title.enter_singleplayer();
|
||||
assert!(matches!(f.title.mode, Mode::Create { .. }));
|
||||
assert_eq!(f.title.mode.id(), "world_create");
|
||||
f.title.escape();
|
||||
assert_eq!(f.title.mode.id(), "title");
|
||||
f.world("World", "World");
|
||||
f.select();
|
||||
assert_eq!(f.title.mode.id(), "world_select");
|
||||
f.click("Edit");
|
||||
assert_eq!(f.title.mode.id(), "world_edit");
|
||||
}
|
||||
|
||||
/// The multiplayer screens' ids, the ones the title's `Multiplayer` mode
|
||||
/// passes through.
|
||||
#[test]
|
||||
fn the_multiplayer_screens_have_their_own_ids() {
|
||||
use crate::multiplayer::Screen as M;
|
||||
assert_eq!(
|
||||
M::Direct {
|
||||
address: String::new()
|
||||
}
|
||||
.id(),
|
||||
"direct_connect"
|
||||
);
|
||||
assert_eq!(
|
||||
M::Edit {
|
||||
index: None,
|
||||
name: String::new(),
|
||||
address: String::new(),
|
||||
focus: 0,
|
||||
packs: None,
|
||||
}
|
||||
.id(),
|
||||
"add_server"
|
||||
);
|
||||
assert_eq!(
|
||||
M::Edit {
|
||||
index: Some(0),
|
||||
name: String::new(),
|
||||
address: String::new(),
|
||||
focus: 0,
|
||||
packs: None,
|
||||
}
|
||||
.id(),
|
||||
"edit_server"
|
||||
);
|
||||
assert_eq!(
|
||||
M::Friend {
|
||||
code: String::new(),
|
||||
relay: String::new(),
|
||||
focus: 0,
|
||||
}
|
||||
.id(),
|
||||
"friends_join"
|
||||
);
|
||||
assert_eq!(
|
||||
M::Disconnected {
|
||||
reason: String::new(),
|
||||
retry: None,
|
||||
}
|
||||
.id(),
|
||||
"disconnected"
|
||||
);
|
||||
}
|
||||
@@ -32,10 +32,12 @@ use std::{
|
||||
pub const FILE_NAME: &str = "horizon-data.bin";
|
||||
/// The file it checks its preparation against.
|
||||
const SOURCE: &str = "horizon-resources.json";
|
||||
/// The folders it holds, with their subfolders.
|
||||
const FOLDERS: [&str; 2] = ["data", "assets"];
|
||||
/// The folders it holds, with their subfolders: the resources, and the
|
||||
/// structure templates world generation loads one by one.
|
||||
const FOLDERS: [&str; 3] = ["data", "assets", "worldgen/structure"];
|
||||
const MAGIC: [u8; 8] = *b"HZDATA\0\0";
|
||||
const VERSION: u32 = 1;
|
||||
/// 2: `worldgen/structure` joined the folders (a version 1 file lacks it).
|
||||
const VERSION: u32 = 2;
|
||||
const HEADER: usize = 40;
|
||||
const ENTRY: usize = 24;
|
||||
|
||||
@@ -224,6 +226,21 @@ pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The files under `dir` (a folder of a prepared root) and its subfolders, as
|
||||
/// `/`-separated paths from `dir` in name order, from the root's data file
|
||||
/// without listing the disk: empty when it holds none there. `None` when no
|
||||
/// data file holds `dir` (walk the folder).
|
||||
pub fn files_under(dir: &Path) -> Option<Vec<String>> {
|
||||
let (files, rel) = find(dir)?;
|
||||
let names = files.list(&rel).unwrap_or_default();
|
||||
Some(
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| name[rel.len() + 1..].to_owned())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// At most the first `max` bytes of `path`, from where [`read`] takes them:
|
||||
/// a header, without reading the whole file.
|
||||
pub fn read_head(path: &Path, max: u64) -> io::Result<Vec<u8>> {
|
||||
@@ -392,6 +409,11 @@ mod tests {
|
||||
("data/minecraft/tags/item/logs.json", b"[1,2,3]"),
|
||||
("assets/minecraft/textures/block/stone.png", b"\x89PNG..."),
|
||||
("assets/minecraft/sounds/ambient/cave1.ogg", b"OggS"),
|
||||
(
|
||||
"worldgen/structure/minecraft/fossil/spine_1.nbt",
|
||||
b"\x1f\x8b",
|
||||
),
|
||||
("worldgen/biomes.json", b"{}"),
|
||||
("facts.json", b"{}"),
|
||||
] {
|
||||
let path = root.join(name);
|
||||
@@ -425,6 +447,14 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
assert!(files.read("facts.json").is_none());
|
||||
assert!(files.read("worldgen/biomes.json").is_none());
|
||||
assert_eq!(
|
||||
files
|
||||
.read("worldgen/structure/minecraft/fossil/spine_1.nbt")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
b"\x1f\x8b"
|
||||
);
|
||||
assert!(files.read("data/minecraft/recipe/missing.json").is_none());
|
||||
assert_eq!(
|
||||
files.list("data/minecraft/recipe").unwrap(),
|
||||
@@ -483,6 +513,31 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A folder's files come from the file, without the disk: one added since
|
||||
/// isn't listed (as `read` doesn't find it), and a folder it holds nothing
|
||||
/// in is empty. Outside its folders, the disk is walked.
|
||||
#[test]
|
||||
fn files_under_lists_a_folder_from_the_file() {
|
||||
let root = root("under");
|
||||
write(&root).unwrap();
|
||||
fs::write(root.join("assets/minecraft/textures/block/added.png"), b"x").unwrap();
|
||||
assert_eq!(
|
||||
files_under(&root.join("assets/minecraft/textures")).unwrap(),
|
||||
["block/stone.png"]
|
||||
);
|
||||
assert_eq!(
|
||||
files_under(&root.join("worldgen/structure")).unwrap(),
|
||||
["minecraft/fossil/spine_1.nbt"]
|
||||
);
|
||||
assert!(
|
||||
files_under(&root.join("assets/minecraft/textures/entity"))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(files_under(&root.join("worldgen")), None);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `find` gives the file and the path from its root for a folder under a
|
||||
/// root that has one, and nothing elsewhere.
|
||||
#[test]
|
||||
@@ -491,7 +546,11 @@ mod tests {
|
||||
write(&root).unwrap();
|
||||
let (_, rel) = find(&root.join("data/minecraft/recipe")).unwrap();
|
||||
assert_eq!(rel, "data/minecraft/recipe");
|
||||
assert!(find(&root.join("worldgen/structure")).is_none());
|
||||
let (_, rel) = find(&root.join("worldgen/structure/minecraft")).unwrap();
|
||||
assert_eq!(rel, "worldgen/structure/minecraft");
|
||||
// The top-level tables beside the structures stay on the disk.
|
||||
assert!(find(&root.join("worldgen")).is_none());
|
||||
assert!(find(&root.join("worldgen/biomes.json")).is_none());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ rust-version.workspace = true
|
||||
description = "OptiFine-format resource pack files: .properties, the name, range, biome and block matchers, paths and the optifine/ folders; CEM entity models and connected-texture rules, and the Fusion mod's connected-texture format"
|
||||
|
||||
[dependencies]
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
log = "0.4"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode-perl", "unicode-case"] }
|
||||
|
||||
|
||||
@@ -117,6 +117,18 @@ fn walk(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
out: &mut Vec<String>,
|
||||
) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if depth == 0
|
||||
&& let Some(files) = horizon_core::prepared_files::files_under(dir)
|
||||
{
|
||||
out.extend(
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|file| endings.iter().any(|end| file.ends_with(end)))
|
||||
.map(|file| format!("{rel}/{file}")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Links are followed (overlay folders are links into the packs and vanilla), so a link back
|
||||
// to a parent would loop: each real folder is read once, and never deeper than MAX_DEPTH.
|
||||
if depth > MAX_DEPTH || !fs::canonicalize(dir).is_ok_and(|real| seen.insert(real)) {
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod frame;
|
||||
pub mod hash_ops;
|
||||
pub mod ids;
|
||||
pub mod item;
|
||||
pub mod mod_list;
|
||||
pub mod mod_net;
|
||||
pub mod packets;
|
||||
pub mod quiet;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! The mod list: which content mods a Horizon server and a Horizon client
|
||||
//! have turned on, compared when a client joins (docs/mods.md, "Playing on a
|
||||
//! server with mods").
|
||||
//!
|
||||
//! In the configuration phase the server sends its list on [`CHANNEL`] and
|
||||
//! the client answers with its own on the same channel. The server then
|
||||
//! refuses a client whose mods differ, naming each one, which the registry
|
||||
//! hash of `horizon:block_registry` cannot do (it only sees ids): a mod
|
||||
//! whose loot, recipes or textures were edited keeps its ids, not its file's
|
||||
//! SHA-256. A vanilla client never hears the channel.
|
||||
use crate::{
|
||||
Error, Result,
|
||||
buf::{Reader, Writer},
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The custom payload channel, both ways.
|
||||
pub const CHANNEL: &str = "horizon:mod_list";
|
||||
/// Mods in a list at most; a list with more is refused whole.
|
||||
pub const MAX_ENTRIES: usize = 128;
|
||||
/// Characters in a mod's id or version at most.
|
||||
pub const MAX_TEXT: usize = 64;
|
||||
/// Mods named in a refusal's reason per kind of difference; the rest is counted.
|
||||
const SHOWN: usize = 6;
|
||||
|
||||
/// Where a mod runs (a manifest's `side`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Side {
|
||||
/// Both halves must have it: it changes what the world holds.
|
||||
Both,
|
||||
/// Only the server needs it.
|
||||
Server,
|
||||
/// Only the player's game: a HUD, keys, a minimap. Any server accepts it.
|
||||
Client,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn code(self) -> u8 {
|
||||
match self {
|
||||
Side::Both => 0,
|
||||
Side::Server => 1,
|
||||
Side::Client => 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_code(code: u8) -> Result<Self> {
|
||||
match code {
|
||||
0 => Ok(Side::Both),
|
||||
1 => Ok(Side::Server),
|
||||
2 => Ok(Side::Client),
|
||||
_ => Err(Error::new(format!("mod list: side {code}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A mod that is on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
/// SHA-256 of the mod's file; none for a mod run from a folder.
|
||||
pub sha256: Option<[u8; 32]>,
|
||||
pub side: Side,
|
||||
}
|
||||
|
||||
/// The mods this game has on, set once when they load (`game/mods.rs`), like
|
||||
/// the registries they fill, and read when a connection is set up, whatever
|
||||
/// side of it this game is.
|
||||
static LOCAL: OnceLock<Vec<Entry>> = OnceLock::new();
|
||||
|
||||
/// Sets the mods this game has on; false when they were set already (a pack
|
||||
/// reload builds another root, the registries keep the first).
|
||||
pub fn set_local(mut entries: Vec<Entry>) -> bool {
|
||||
entries.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
entries.truncate(MAX_ENTRIES);
|
||||
LOCAL.set(entries).is_ok()
|
||||
}
|
||||
|
||||
/// Whether `set_local` has run: a pack reload then skips reading and hashing
|
||||
/// the mods' files again, since the first list stays.
|
||||
pub fn is_set() -> bool {
|
||||
LOCAL.get().is_some()
|
||||
}
|
||||
|
||||
/// The mods this game has on, by id.
|
||||
pub fn local() -> Vec<Entry> {
|
||||
LOCAL.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The entries the other side compares (`side = "both"`): what a join sends. Client-only
|
||||
/// and server-only mods stay private, since any server can ask for the list.
|
||||
pub fn shared(entries: &[Entry]) -> Vec<Entry> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|e| e.side == Side::Both)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A list as a payload.
|
||||
pub fn encode(entries: &[Entry]) -> Vec<u8> {
|
||||
let cut = |s: &str| -> String { s.chars().take(MAX_TEXT).collect() };
|
||||
let entries = &entries[..entries.len().min(MAX_ENTRIES)];
|
||||
let mut w = Writer::new();
|
||||
w.varint(entries.len() as i32);
|
||||
for e in entries {
|
||||
w.string(&cut(&e.id));
|
||||
w.string(&cut(&e.version));
|
||||
w.u8(e.side.code());
|
||||
match &e.sha256 {
|
||||
Some(hash) => {
|
||||
w.bool(true);
|
||||
w.bytes(hash);
|
||||
}
|
||||
None => w.bool(false),
|
||||
}
|
||||
}
|
||||
w.data
|
||||
}
|
||||
|
||||
/// A payload's list; an error for one that breaks a cap.
|
||||
pub fn decode(data: &[u8]) -> Result<Vec<Entry>> {
|
||||
let mut r = Reader::new(data);
|
||||
let count = r.varint()?;
|
||||
if !(0..=MAX_ENTRIES as i32).contains(&count) {
|
||||
return Err(Error::new(format!("mod list: {count} mods")));
|
||||
}
|
||||
let entries = (0..count)
|
||||
.map(|_| {
|
||||
let id = r.string(MAX_TEXT)?;
|
||||
let version = r.string(MAX_TEXT)?;
|
||||
let side = Side::from_code(r.u8()?)?;
|
||||
let sha256 = match r.bool()? {
|
||||
true => Some(r.bytes(32)?.try_into().expect("32 bytes")),
|
||||
false => None,
|
||||
};
|
||||
Ok(Entry {
|
||||
id,
|
||||
version,
|
||||
sha256,
|
||||
side,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if r.remaining() > 0 {
|
||||
return Err(Error::new("mod list: trailing bytes"));
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// How a client's mods differ from a server's.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Difference {
|
||||
/// The server has it on and the client does not: `id version`.
|
||||
pub missing: Vec<String>,
|
||||
/// The client has it on, the server does not, and it changes the world.
|
||||
pub extra: Vec<String>,
|
||||
/// Both have it, not the same: `id (server version, yours version)`, or
|
||||
/// `id version (your file was changed)` for the same version.
|
||||
pub changed: Vec<String>,
|
||||
}
|
||||
|
||||
impl Difference {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.missing.is_empty() && self.extra.is_empty() && self.changed.is_empty()
|
||||
}
|
||||
|
||||
/// What the refused player reads.
|
||||
pub fn reason(&self) -> String {
|
||||
let mut parts = Vec::new();
|
||||
for (label, names) in [
|
||||
("Missing", &self.missing),
|
||||
("Not on this server", &self.extra),
|
||||
("Different version or file", &self.changed),
|
||||
] {
|
||||
if names.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut text = names[..names.len().min(SHOWN)].join(", ");
|
||||
if names.len() > SHOWN {
|
||||
text.push_str(&format!(" and {} more", names.len() - SHOWN));
|
||||
}
|
||||
parts.push(format!("{label}: {text}"));
|
||||
}
|
||||
format!("Your mods don't match this server's. {}", parts.join(". "))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client must change to join a server. Only `Both` mods count, on
|
||||
/// either side: a `Server` mod is the server's business, a `Client` one the
|
||||
/// player's.
|
||||
pub fn compare(server: &[Entry], client: &[Entry]) -> Difference {
|
||||
let mut difference = Difference::default();
|
||||
for s in server.iter().filter(|s| s.side == Side::Both) {
|
||||
match client.iter().find(|c| c.id == s.id) {
|
||||
None => difference.missing.push(format!("{} {}", s.id, s.version)),
|
||||
Some(c) => {
|
||||
let other_file = matches!((s.sha256, c.sha256), (Some(a), Some(b)) if a != b);
|
||||
if s.version != c.version {
|
||||
difference.changed.push(format!(
|
||||
"{} (server {}, yours {})",
|
||||
s.id, s.version, c.version
|
||||
));
|
||||
} else if other_file {
|
||||
difference
|
||||
.changed
|
||||
.push(format!("{} {} (your file was changed)", s.id, s.version));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in client.iter().filter(|c| c.side == Side::Both) {
|
||||
if !server.iter().any(|s| s.id == c.id) {
|
||||
difference.extra.push(format!("{} {}", c.id, c.version));
|
||||
}
|
||||
}
|
||||
difference
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn entry(id: &str, version: &str, hash: u8, side: Side) -> Entry {
|
||||
Entry {
|
||||
id: id.into(),
|
||||
version: version.into(),
|
||||
sha256: Some([hash; 32]),
|
||||
side,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_shared_mods_are_sent() {
|
||||
let list = [
|
||||
entry("ruby", "1.0", 1, Side::Both),
|
||||
entry("minimap", "2.0", 2, Side::Client),
|
||||
entry("admin", "3.0", 3, Side::Server),
|
||||
];
|
||||
let sent = shared(&list);
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0].id, "ruby");
|
||||
assert!(compare(&list, &sent).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_round_trips() {
|
||||
let list = vec![
|
||||
entry("ruby", "1.2.0", 7, Side::Both),
|
||||
Entry {
|
||||
sha256: None,
|
||||
..entry("dev", "0.1", 0, Side::Client)
|
||||
},
|
||||
];
|
||||
assert_eq!(decode(&encode(&list)).unwrap(), list);
|
||||
assert_eq!(decode(&encode(&[])).unwrap(), []);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_over_the_caps_are_refused() {
|
||||
let mut w = Writer::new();
|
||||
w.varint(MAX_ENTRIES as i32 + 1);
|
||||
assert!(decode(&w.data).is_err());
|
||||
let mut w = Writer::new();
|
||||
w.varint(1);
|
||||
w.string(&"x".repeat(MAX_TEXT + 1));
|
||||
assert!(decode(&w.data).is_err());
|
||||
let mut w = Writer::new();
|
||||
w.varint(1);
|
||||
w.string("a");
|
||||
w.string("1");
|
||||
w.u8(9);
|
||||
w.bool(false);
|
||||
assert!(decode(&w.data).is_err());
|
||||
let mut trailing = encode(&[]);
|
||||
trailing.push(0);
|
||||
assert!(decode(&trailing).is_err());
|
||||
assert!(decode(&[0xFF]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_mods_match_whatever_the_order() {
|
||||
let a = entry("a", "1", 1, Side::Both);
|
||||
let b = entry("b", "2", 2, Side::Both);
|
||||
assert!(compare(&[a.clone(), b.clone()], &[b, a]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_kind_of_difference_is_named() {
|
||||
let server = [
|
||||
entry("ruby", "1.0", 1, Side::Both),
|
||||
entry("pipes", "2.0", 2, Side::Both),
|
||||
entry("glass", "1.0", 3, Side::Both),
|
||||
];
|
||||
let client = [
|
||||
entry("ruby", "1.0", 1, Side::Both),
|
||||
entry("pipes", "2.0", 99, Side::Both),
|
||||
entry("glass", "0.9", 3, Side::Both),
|
||||
entry("xray_blocks", "1.0", 4, Side::Both),
|
||||
];
|
||||
let d = compare(&server, &client);
|
||||
assert_eq!(d.missing, Vec::<String>::new());
|
||||
assert_eq!(d.extra, ["xray_blocks 1.0"]);
|
||||
assert_eq!(
|
||||
d.changed,
|
||||
[
|
||||
"pipes 2.0 (your file was changed)",
|
||||
"glass (server 1.0, yours 0.9)"
|
||||
]
|
||||
);
|
||||
let d = compare(&server, &client[..1]);
|
||||
assert_eq!(d.missing, ["pipes 2.0", "glass 1.0"]);
|
||||
let reason = d.reason();
|
||||
assert!(reason.contains("Missing: pipes 2.0, glass 1.0"), "{reason}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_only_and_server_only_mods_never_block_a_join() {
|
||||
let server = [
|
||||
entry("anti_grief", "1", 1, Side::Server),
|
||||
entry("ruby", "1", 2, Side::Both),
|
||||
];
|
||||
let client = [
|
||||
entry("minimap", "3", 3, Side::Client),
|
||||
entry("ruby", "1", 2, Side::Both),
|
||||
];
|
||||
assert!(compare(&server, &client).is_empty());
|
||||
assert!(compare(&[], &[entry("minimap", "3", 3, Side::Client)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mod_run_from_a_folder_matches_by_version() {
|
||||
let mut folder = entry("ruby", "1", 0, Side::Both);
|
||||
folder.sha256 = None;
|
||||
assert!(compare(&[entry("ruby", "1", 5, Side::Both)], &[folder.clone()]).is_empty());
|
||||
assert!(!compare(&[entry("ruby", "2", 5, Side::Both)], &[folder]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_refusal_counts_the_rest() {
|
||||
let server: Vec<_> = (0..9)
|
||||
.map(|i| entry(&format!("m{i}"), "1", i, Side::Both))
|
||||
.collect();
|
||||
let reason = compare(&server, &[]).reason();
|
||||
assert!(reason.contains("m5 1 and 3 more"), "{reason}");
|
||||
assert!(!reason.contains("m6"), "{reason}");
|
||||
}
|
||||
}
|
||||
@@ -312,16 +312,39 @@ fn json_bool(v: &Json) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// What happened to a list, for the one that watches it: what vanilla's `StoredUserList` tells its
|
||||
/// `NotificationService` (`playerOped`/`playerDeoped`, `playerAddedToAllowlist`/`playerRemovedFromAllowlist`,
|
||||
/// `playerBanned`/`playerUnbanned`, `ipBanned`/`ipUnbanned`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Change<E> {
|
||||
/// An entry was added (or replaced by a different one).
|
||||
Added(E),
|
||||
/// An entry was removed; a `clear` says it once for each entry, in the map's order, before it empties.
|
||||
Removed(E),
|
||||
}
|
||||
|
||||
/// StoredUserList.
|
||||
pub struct UserList<E: Entry> {
|
||||
pub file: PathBuf,
|
||||
map: JavaHashMap<JKey, E>,
|
||||
watcher: Option<Box<dyn Fn(Change<E>) + Send>>,
|
||||
}
|
||||
impl<E: Entry> UserList<E> {
|
||||
pub fn new(file: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
file: file.into(),
|
||||
map: JavaHashMap::default(),
|
||||
watcher: None,
|
||||
}
|
||||
}
|
||||
/// Has `watcher` told of every change from now on (`add`, `remove` and `clear`, not a `load`, and not the
|
||||
/// expired entries `get` drops, as vanilla's don't).
|
||||
pub fn watch(&mut self, watcher: impl Fn(Change<E>) + Send + 'static) {
|
||||
self.watcher = Some(Box::new(watcher));
|
||||
}
|
||||
fn told(&self, change: Change<E>) {
|
||||
if let Some(watcher) = &self.watcher {
|
||||
watcher(change);
|
||||
}
|
||||
}
|
||||
/// The entries in the map's iteration order.
|
||||
@@ -354,23 +377,29 @@ impl<E: Entry> UserList<E> {
|
||||
if self.map.get(&key).is_some_and(|old| entry.same(old)) {
|
||||
return false;
|
||||
}
|
||||
self.map.put(key, entry);
|
||||
self.map.put(key, entry.clone());
|
||||
if let Err(e) = self.save() {
|
||||
log::warn!("Could not save the list after adding a user. {e}");
|
||||
}
|
||||
self.told(Change::Added(entry));
|
||||
true
|
||||
}
|
||||
pub fn remove(&mut self, user: &E::User) -> bool {
|
||||
if self.map.remove(&JKey(E::key(user))).is_none() {
|
||||
let Some(entry) = self.map.remove(&JKey(E::key(user))) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Err(e) = self.save() {
|
||||
log::warn!("Could not save the list after removing a user. {e}");
|
||||
}
|
||||
self.told(Change::Removed(entry));
|
||||
true
|
||||
}
|
||||
/// StoredUserList.clear: no entry left; saves.
|
||||
pub fn clear(&mut self) {
|
||||
let entries: Vec<E> = self.map.entries().map(|(_, e)| e.clone()).collect();
|
||||
for entry in entries {
|
||||
self.told(Change::Removed(entry));
|
||||
}
|
||||
self.map.clear();
|
||||
if let Err(e) = self.save() {
|
||||
log::warn!("Could not save the list after removing a user. {e}");
|
||||
@@ -448,6 +477,49 @@ mod tests {
|
||||
assert_eq!(parse_date("forever"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_watcher_hears_what_vanillas_notification_service_does() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-lists-watch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let heard = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut ops = OpList::new(dir.join("ops.json"));
|
||||
let sink = heard.clone();
|
||||
ops.watch(move |change| sink.lock().unwrap().push(change));
|
||||
let op = |name: &str, level| OpEntry {
|
||||
user: NameAndId::offline(name),
|
||||
level,
|
||||
bypasses_player_limit: false,
|
||||
};
|
||||
assert!(ops.add(op("Kim", 4)));
|
||||
assert!(ops.add(op("Lee", 2)));
|
||||
// A removal that finds nothing says nothing; one that finds an entry says which.
|
||||
assert!(!ops.remove(&NameAndId::offline("Nobody")));
|
||||
assert!(ops.remove(&NameAndId::offline("Kim")));
|
||||
// A clear says it for each entry that is left, and the list is empty after.
|
||||
ops.clear();
|
||||
assert!(ops.is_empty());
|
||||
assert_eq!(
|
||||
*heard.lock().unwrap(),
|
||||
[
|
||||
Change::Added(op("Kim", 4)),
|
||||
Change::Added(op("Lee", 2)),
|
||||
Change::Removed(op("Kim", 4)),
|
||||
Change::Removed(op("Lee", 2)),
|
||||
]
|
||||
);
|
||||
// A ban equal to the stored one is not added again, so nothing is said.
|
||||
let mut bans = BanList::new(dir.join("banned-players.json"));
|
||||
let said = Arc::new(Mutex::new(0));
|
||||
let counter = said.clone();
|
||||
bans.watch(move |_| *counter.lock().unwrap() += 1);
|
||||
let ban = BanEntry::new(NameAndId::offline("Kim"), None, None, None);
|
||||
assert!(bans.add(ban.clone()));
|
||||
assert!(!bans.add(ban));
|
||||
assert_eq!(*said.lock().unwrap(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cleared_list_saves_empty() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-lists-{}", std::process::id()));
|
||||
|
||||
@@ -87,9 +87,13 @@ pub fn poll_game(shared: Arc<Shared>) {
|
||||
continue;
|
||||
};
|
||||
// A new game numbers its events from 1 again.
|
||||
let mut history = false;
|
||||
if control.as_ref().is_none_or(|(p, _)| *p != pid) {
|
||||
control = engine::Control::connect(&shared.dir).ok().map(|c| (pid, c));
|
||||
since = 0;
|
||||
// What the game recorded before this first fetch (its saves while it started) happened before
|
||||
// the management protocol's clients could hear of it.
|
||||
history = true;
|
||||
}
|
||||
let Some((_, game)) = control.as_mut() else {
|
||||
continue;
|
||||
@@ -105,6 +109,9 @@ pub fn poll_game(shared: Arc<Shared>) {
|
||||
if let Some(fields) = event.as_object_mut() {
|
||||
fields.remove("seq");
|
||||
}
|
||||
if !(history && matches!(kind.as_str(), "saving" | "saved")) {
|
||||
crate::management::game_event(&shared, &kind, &event);
|
||||
}
|
||||
shared.emit(&kind, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,26 @@ pub fn started(shared: &Shared) {
|
||||
notify(shared, "notification/server/started", None);
|
||||
}
|
||||
|
||||
/// The notification a typed event of the game is, if vanilla sends one for it: a player who joined or left
|
||||
/// (`playerJoined`, `playerLeft`, with the player as a `PlayerDto`), a world save that began or ended
|
||||
/// (`serverSaveStarted`, `serverSaveCompleted`), and a change of the operators, the allowlist, the bans or the
|
||||
/// IP bans.
|
||||
pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
|
||||
let player = || Some(json!({ "id": event["uuid"], "name": event["name"] }));
|
||||
let (method, params) = match kind {
|
||||
"join" => ("notification/players/joined", player()),
|
||||
"leave" => ("notification/players/left", player()),
|
||||
"saving" => ("notification/server/saving", None),
|
||||
"saved" => ("notification/server/saved", None),
|
||||
// A change of a list, whoever made it: the console, a command in the game, the protocol's own methods.
|
||||
_ => match crate::management_methods::list_notification(kind, event) {
|
||||
Some((method, params)) => (method, Some(params)),
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
notify(shared, method, params);
|
||||
}
|
||||
|
||||
/// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all.
|
||||
pub fn stopping(shared: &Shared) {
|
||||
if !shared.management.stopping_told.swap(true, Ordering::SeqCst) {
|
||||
@@ -765,12 +785,20 @@ mod tests {
|
||||
stream
|
||||
}
|
||||
|
||||
/// The text of the next frame the server sends (unmasked, short enough for a 7-bit length).
|
||||
/// The text of the next frame the server sends (unmasked, shorter than 65536 bytes).
|
||||
fn next_text(stream: &mut TcpStream) -> String {
|
||||
let mut head = [0u8; 2];
|
||||
stream.read_exact(&mut head).unwrap();
|
||||
assert_eq!(head[0], 0x81);
|
||||
let mut payload = vec![0u8; usize::from(head[1])];
|
||||
let length = match head[1] {
|
||||
126 => {
|
||||
let mut b = [0u8; 2];
|
||||
stream.read_exact(&mut b).unwrap();
|
||||
usize::from(u16::from_be_bytes(b))
|
||||
}
|
||||
n => usize::from(n),
|
||||
};
|
||||
let mut payload = vec![0u8; length];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
String::from_utf8(payload).unwrap()
|
||||
}
|
||||
@@ -829,6 +857,59 @@ mod tests {
|
||||
assert_eq!(shared.management.sockets.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_games_events_become_the_notifications_vanilla_sends() {
|
||||
let shared = shared();
|
||||
let address = listen_on(shared.clone(), settings());
|
||||
let mut client = upgrade(address);
|
||||
registered(&shared, 1);
|
||||
let id = "0123abcd-0000-4000-8000-00000000000a";
|
||||
let joined = json!({ "type": "join", "name": "Kim0", "uuid": id });
|
||||
game_event(&shared, "join", &joined);
|
||||
game_event(
|
||||
&shared,
|
||||
"chat",
|
||||
&json!({ "name": "Kim0", "uuid": id, "message": "hi" }),
|
||||
);
|
||||
game_event(&shared, "saving", &json!({ "type": "saving" }));
|
||||
game_event(&shared, "saved", &json!({ "type": "saved" }));
|
||||
game_event(&shared, "leave", &json!({ "name": "Kim0", "uuid": id }));
|
||||
// The chat is not a notification; the rest come in order, the player as a `PlayerDto`.
|
||||
let player = format!(r#"[{{"id":"{id}","name":"Kim0"}}]"#);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
format!(
|
||||
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/joined","params":{player}}}"#
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saving"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saved"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
format!(
|
||||
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/left","params":{player}}}"#
|
||||
)
|
||||
);
|
||||
// A change of a list reaches the client with its one parameter (the player, for an allowlist).
|
||||
game_event(
|
||||
&shared,
|
||||
"allowlist_removed",
|
||||
&json!({ "name": "Kim0", "uuid": id }),
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
format!(
|
||||
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/allowlist/removed","params":{player}}}"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_notification_sent_is_one_discover_lists() {
|
||||
let listed = schema::discover();
|
||||
@@ -841,6 +922,18 @@ mod tests {
|
||||
for sent in [
|
||||
"notification/server/started",
|
||||
"notification/server/stopping",
|
||||
"notification/server/saving",
|
||||
"notification/server/saved",
|
||||
"notification/players/joined",
|
||||
"notification/players/left",
|
||||
"notification/operators/added",
|
||||
"notification/operators/removed",
|
||||
"notification/allowlist/added",
|
||||
"notification/allowlist/removed",
|
||||
"notification/bans/added",
|
||||
"notification/bans/removed",
|
||||
"notification/ip_bans/added",
|
||||
"notification/ip_bans/removed",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&format!("minecraft:{sent}").as_str()),
|
||||
|
||||
@@ -41,6 +41,8 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
|
||||
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
|
||||
),
|
||||
),
|
||||
"gamerules" => game.gamerules(client, None),
|
||||
"gamerules/update" => game.gamerules(client, Some(argument)),
|
||||
"serversettings/motd" => game.motd(client, None),
|
||||
"serversettings/motd/set" => game.motd(
|
||||
client,
|
||||
@@ -87,6 +89,28 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
|
||||
.ok_or_else(|| invalid_params(format!("Not a number: {argument}")))?,
|
||||
),
|
||||
),
|
||||
"serversettings/use_allowlist" => game.allowlist_setting(client, "use_allowlist", None),
|
||||
"serversettings/use_allowlist/set" => game.allowlist_setting(
|
||||
client,
|
||||
"use_allowlist",
|
||||
Some(
|
||||
argument
|
||||
.as_bool()
|
||||
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
|
||||
),
|
||||
),
|
||||
"serversettings/enforce_allowlist" => {
|
||||
game.allowlist_setting(client, "enforce_allowlist", None)
|
||||
}
|
||||
"serversettings/enforce_allowlist/set" => game.allowlist_setting(
|
||||
client,
|
||||
"enforce_allowlist",
|
||||
Some(
|
||||
argument
|
||||
.as_bool()
|
||||
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
|
||||
),
|
||||
),
|
||||
"allowlist" => Ok(lists.allowlist()),
|
||||
"bans" => Ok(lists.bans()),
|
||||
"ip_bans" => Ok(lists.ip_bans()),
|
||||
@@ -318,6 +342,59 @@ struct Game<'a> {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `MinecraftServerSettingsServiceImpl.isUsingWhitelist`, `setUsingWhitelist`,
|
||||
/// `isEnforceWhitelist` and `setEnforceWhitelist`.
|
||||
fn allowlist_setting(
|
||||
&self,
|
||||
client: u32,
|
||||
setting: &str,
|
||||
value: Option<bool>,
|
||||
) -> Result<Value, Failure> {
|
||||
let mut request = json!({
|
||||
"method": format!("serversettings/{setting}")
|
||||
});
|
||||
if let Some(value) = value {
|
||||
request["method"] = json!(format!("serversettings/{setting}/set"));
|
||||
request["value"] = json!(value);
|
||||
}
|
||||
let reply = self.ask(&format!("management {client} {request}"))?;
|
||||
if let Some(error) = reply["error"].as_str() {
|
||||
return Err((INTERNAL_ERROR, Some(error.to_owned())));
|
||||
}
|
||||
if let Some(value) = value {
|
||||
let property = match setting {
|
||||
"use_allowlist" => "whitelist",
|
||||
"enforce_allowlist" => "enforce_whitelist",
|
||||
_ => unreachable!("allowlist setting is validated by its callers"),
|
||||
};
|
||||
crate::config::update(
|
||||
&self.shared.dir,
|
||||
"properties",
|
||||
property,
|
||||
toml::Value::Boolean(value),
|
||||
)
|
||||
.map_err(|e| (INTERNAL_ERROR, Some(e)))?;
|
||||
}
|
||||
Ok(reply["value"].clone())
|
||||
}
|
||||
|
||||
/// `GameRulesService.get` and `update`: the game's rules as typed rules, or the one `update` set.
|
||||
fn gamerules(&self, client: u32, update: Option<&Value>) -> Result<Value, Failure> {
|
||||
let mut request = json!({ "method": "gamerules" });
|
||||
if let Some(update) = update {
|
||||
request["method"] = json!("gamerules/update");
|
||||
request["value"] = update.clone();
|
||||
}
|
||||
let reply = self.ask(&format!("management {client} {request}"))?;
|
||||
if let Some(invalid) = reply["invalid"].as_str() {
|
||||
return Err(invalid_params(invalid));
|
||||
}
|
||||
if let Some(error) = reply["error"].as_str() {
|
||||
return Err((INTERNAL_ERROR, Some(error.to_owned())));
|
||||
}
|
||||
Ok(reply["value"].clone())
|
||||
}
|
||||
|
||||
/// `MinecraftServerSettingsServiceImpl.isAutoSave`: read the game's live save flag.
|
||||
/// `MinecraftServerSettingsServiceImpl.setAutoSave`: update that flag and return its new value.
|
||||
fn autosave(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
|
||||
@@ -633,6 +710,46 @@ impl Lists<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification a change of one of the lists is, from the game's typed event (`operators_added`,
|
||||
/// `bans_removed`...): `JsonRpcNotificationService`'s `playerOped`/`playerDeoped` (an `OperatorDto`),
|
||||
/// `playerAddedToAllowlist`/`playerRemovedFromAllowlist` and `playerUnbanned` (a `PlayerDto`), `playerBanned`
|
||||
/// (a `UserBanDto`), `ipBanned` (an `IpBanDto`) and `ipUnbanned` (the address).
|
||||
pub fn list_notification(kind: &str, event: &Value) -> Option<(&'static str, Value)> {
|
||||
let player = || json!({ "id": event["uuid"], "name": event["name"] });
|
||||
let operator = || {
|
||||
json!({
|
||||
"player": player(),
|
||||
"permissionLevel": event["level"],
|
||||
"bypassesPlayerLimit": event["bypassesPlayerLimit"],
|
||||
})
|
||||
};
|
||||
let reason = event["reason"].as_str().map(str::to_owned);
|
||||
let source = event["source"].as_str().unwrap_or_default();
|
||||
let expires = event["expires"].as_i64();
|
||||
Some(match kind {
|
||||
"operators_added" => ("notification/operators/added", operator()),
|
||||
"operators_removed" => ("notification/operators/removed", operator()),
|
||||
"allowlist_added" => ("notification/allowlist/added", player()),
|
||||
"allowlist_removed" => ("notification/allowlist/removed", player()),
|
||||
"bans_added" => (
|
||||
"notification/bans/added",
|
||||
ban(("player", player()), &reason, source, expires),
|
||||
),
|
||||
// `MinecraftServer.onGameRuleChanged`: the rule as a typed rule (the event's own `type` is its kind).
|
||||
"gamerule" => (
|
||||
"notification/gamerules/updated",
|
||||
json!({ "key": event["key"], "type": event["valueType"], "value": event["value"] }),
|
||||
),
|
||||
"bans_removed" => ("notification/bans/removed", player()),
|
||||
"ip_bans_added" => (
|
||||
"notification/ip_bans/added",
|
||||
ban(("ip", event["ip"].clone()), &reason, source, expires),
|
||||
),
|
||||
"ip_bans_removed" => ("notification/ip_bans/removed", event["ip"].clone()),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A `UserBanDto` or `IpBanDto`: the reason when there is one, the end as an ISO-8601 instant.
|
||||
fn ban(who: (&str, Value), reason: &Option<String>, source: &str, expires: Option<i64>) -> Value {
|
||||
let mut b = json!({ "source": source });
|
||||
@@ -660,6 +777,84 @@ mod tests {
|
||||
Shared::with_install(&dir, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_a_list_is_the_notification_vanilla_sends() {
|
||||
let id = "93e029a5-04a0-349d-90b2-f7e145b2644f";
|
||||
let event = |fields: Value| {
|
||||
let mut event = json!({ "name": "Kim0", "uuid": id });
|
||||
event
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.extend(fields.as_object().unwrap().clone());
|
||||
event
|
||||
};
|
||||
let player = json!({ "id": id, "name": "Kim0" });
|
||||
let note = |kind, fields| list_notification(kind, &event(fields)).unwrap();
|
||||
assert_eq!(
|
||||
note(
|
||||
"operators_added",
|
||||
json!({ "level": 3, "bypassesPlayerLimit": true })
|
||||
),
|
||||
(
|
||||
"notification/operators/added",
|
||||
json!({ "player": player, "permissionLevel": 3, "bypassesPlayerLimit": true })
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
note(
|
||||
"operators_removed",
|
||||
json!({ "level": 4, "bypassesPlayerLimit": false })
|
||||
)
|
||||
.0,
|
||||
"notification/operators/removed"
|
||||
);
|
||||
assert_eq!(
|
||||
note("allowlist_added", json!({})),
|
||||
("notification/allowlist/added", player.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
note("bans_removed", json!({})),
|
||||
("notification/bans/removed", player.clone())
|
||||
);
|
||||
// A ban says why and until when, as the lists do; no reason or end leaves them out.
|
||||
assert_eq!(
|
||||
note(
|
||||
"bans_added",
|
||||
json!({ "reason": "griefing", "source": "Kim", "expires": 1_793_491_200_000_i64 })
|
||||
),
|
||||
(
|
||||
"notification/bans/added",
|
||||
json!({ "player": player, "source": "Kim", "reason": "griefing", "expires": "2026-11-01T00:00:00Z" })
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
note(
|
||||
"ip_bans_added",
|
||||
json!({ "ip": "10.0.0.1", "source": "Rcon", "reason": null, "expires": null })
|
||||
),
|
||||
(
|
||||
"notification/ip_bans/added",
|
||||
json!({ "ip": "10.0.0.1", "source": "Rcon" })
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
note("ip_bans_removed", json!({ "ip": "10.0.0.1" })),
|
||||
("notification/ip_bans/removed", json!("10.0.0.1"))
|
||||
);
|
||||
// A game rule changed: the rule as a typed rule, whose `type` the event keeps as `valueType`.
|
||||
assert_eq!(
|
||||
note(
|
||||
"gamerule",
|
||||
json!({ "key": "minecraft:pvp", "valueType": "boolean", "value": false })
|
||||
),
|
||||
(
|
||||
"notification/gamerules/updated",
|
||||
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
|
||||
)
|
||||
);
|
||||
assert!(list_notification("kick", &event(json!({}))).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lists_read_as_vanillas_dtos() {
|
||||
let shared = shared();
|
||||
|
||||
@@ -3,11 +3,15 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The Iris feature flags (`iris.features.*`) Horizon has.
|
||||
const FEATURES: [&str; 4] = [
|
||||
/// The Iris feature flags (`iris.features.*`) Horizon has. `SEPARATE_HARDWARE_SAMPLERS`
|
||||
/// (ShaderDoc, "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth
|
||||
/// samplers when hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks
|
||||
/// a comparison sampler by the declared type (`sampler2DShadow`), so they always are.
|
||||
const FEATURES: [&str; 5] = [
|
||||
"COMPUTE_SHADERS",
|
||||
"CUSTOM_IMAGES",
|
||||
"ENTITY_TRANSLUCENT",
|
||||
"SEPARATE_HARDWARE_SAMPLERS",
|
||||
"SSBO",
|
||||
];
|
||||
|
||||
@@ -413,13 +417,14 @@ mod tests {
|
||||
#[test]
|
||||
fn iris_features_are_defined_or_noted() {
|
||||
let properties = crate::Properties::parse(
|
||||
"iris.features.optional = COMPUTE_SHADERS NOT_A_FEATURE\n\
|
||||
iris.features.required = ENTITY_TRANSLUCENT NOT_A_FEATURE\n",
|
||||
"iris.features.optional = COMPUTE_SHADERS SEPARATE_HARDWARE_SAMPLERS NOT_A_FEATURE\n\
|
||||
iris.features.required = ENTITY_TRANSLUCENT SEPARATE_HARDWARE_SAMPLERS NOT_A_FEATURE\n",
|
||||
&Macros::standard(),
|
||||
);
|
||||
let mut m = Macros::standard();
|
||||
let notes = m.define_features(&properties);
|
||||
assert!(m.is_defined("IRIS_FEATURE_COMPUTE_SHADERS"));
|
||||
assert!(m.is_defined("IRIS_FEATURE_SEPARATE_HARDWARE_SAMPLERS"));
|
||||
assert!(!m.is_defined("IRIS_FEATURE_NOT_A_FEATURE"));
|
||||
assert_eq!(
|
||||
notes,
|
||||
|
||||
@@ -439,6 +439,57 @@ pub struct Post {
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Post {
|
||||
/// Every program that loaded: the passes', what draws the game's geometry, the shadow
|
||||
/// pass's and its composites.
|
||||
fn every_program(&self) -> impl Iterator<Item = &PostProgram> {
|
||||
let draws = [
|
||||
&self.terrain,
|
||||
&self.water,
|
||||
&self.sky,
|
||||
&self.sky_textured,
|
||||
&self.entities,
|
||||
&self.block,
|
||||
&self.entities_translucent,
|
||||
&self.particles,
|
||||
&self.damaged_block,
|
||||
&self.clouds,
|
||||
&self.weather,
|
||||
&self.hand,
|
||||
&self.shadow,
|
||||
&self.shadow_water,
|
||||
&self.shadow_entities,
|
||||
&self.shadow_block,
|
||||
];
|
||||
self.programs
|
||||
.iter()
|
||||
.chain(draws.into_iter().flatten())
|
||||
.chain(&self.shadow_comp)
|
||||
}
|
||||
|
||||
/// The fewest bytes each storage buffer (`bufferObject.<n>`, set 4) needs for every stage
|
||||
/// that binds it: its block's size as the stage lays it out (std430). OpenGL binds a smaller buffer and leaves what lies
|
||||
/// past its end undefined; wgpu refuses to draw or dispatch with one.
|
||||
pub fn buffer_minimums(&self) -> std::collections::BTreeMap<u32, u64> {
|
||||
let mut sizes = std::collections::BTreeMap::new();
|
||||
for p in self.every_program() {
|
||||
let stages = p.program.iter().flat_map(|s| [&s.vertex, &s.fragment]);
|
||||
for module in stages.chain(p.compute.iter().map(|(_, c)| &c.module)) {
|
||||
for (_, var) in module.global_variables.iter() {
|
||||
if let (naga::AddressSpace::Storage { .. }, Some(b)) = (var.space, &var.binding)
|
||||
&& b.group == 4
|
||||
{
|
||||
let size = u64::from(module.types[var.ty].inner.size(module.to_ctx()));
|
||||
let least = sizes.entry(b.binding).or_insert(0);
|
||||
*least = size.max(*least);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sizes
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder a dimension reads its programs from: `world<id>` when the pack has it, else the
|
||||
/// base folder `""` (1.2). `id` is the legacy number: 0 the Overworld, -1 the Nether, 1 the End.
|
||||
pub fn dimension_folder(pack: &Pack, id: i32) -> String {
|
||||
@@ -1341,6 +1392,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A storage buffer declared smaller than the block a stage binds it as: the block's size
|
||||
/// as the stage lays it out is what the buffer needs (std430 rounds a block to its
|
||||
/// largest member's alignment), the most of every stage and program that binds it.
|
||||
#[test]
|
||||
fn storage_buffers_need_their_blocks_size() {
|
||||
let compute = "#version 430\nlayout(local_size_x = 1) in;\n\
|
||||
layout(std430, binding = 0) buffer Sums { vec4 a; vec4 b; float c; } sums;\n\
|
||||
layout(std430, binding = 1) buffer Cells { uint count; uvec4 cells[3]; } grid;\n\
|
||||
void main() {\n sums.c = sums.a.x + float(grid.count + grid.cells[0].x);\n}\n";
|
||||
let fsh = "#version 430\nlayout(std430, binding = 0) buffer Sums { vec4 a; } sums;\n\
|
||||
uniform sampler2D colortex0;\nvarying vec2 uv;\n\
|
||||
void main() {\n/* DRAWBUFFERS:3 */\n\
|
||||
gl_FragData[0] = texture2D(colortex0, uv) + sums.a;\n}\n";
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"t",
|
||||
zip(&[
|
||||
("shaders/composite.vsh", VSH),
|
||||
("shaders/composite.fsh", fsh),
|
||||
("shaders/composite.csh", compute),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let properties = Properties::parse("bufferObject.0 = 40\nbufferObject.1 = 4\n", ¯os);
|
||||
let post = load_post(&pack, "", &properties, ¯os, &GameInputs::default());
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
assert_eq!(post.programs[0].compute.len(), 1);
|
||||
// `Sums`: 36 bytes rounded to vec4's 16; `Cells`: a uint padded to the array's 16,
|
||||
// then three elements.
|
||||
assert_eq!(
|
||||
post.buffer_minimums(),
|
||||
std::collections::BTreeMap::from([(0, 48), (1, 64)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_sizes_are_bounded() {
|
||||
let relative = BufferSize::parse("0.5 0.25").unwrap();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! name isn't stored again with every session. `extra` holds the later hardware fields
|
||||
//! (cores, clocks, GPU maker and kind, VRAM, drive, screen, power, form) as JSON.
|
||||
//! - `perf`: one row per play session, in columns. A frame or tick summary is an 80-byte blob
|
||||
//! ([`pack`]), not JSON.
|
||||
//! ([`pack`]), not JSON; `slow_frames` (the frames of 250 ms or more by the step they spent
|
||||
//! their time in) is JSON, empty for most sessions.
|
||||
//! - `reports`: crash and bug reports and benchmark uploads, their `data` zstd-compressed.
|
||||
//! - `perf_daily`: the anonymous totals per day, version, OS, GPU vendor and render distance
|
||||
//! that `/v1/aggregate` reads. Rebuilt from `perf` when a day changes (new sessions, a
|
||||
@@ -39,7 +40,7 @@ CREATE TABLE IF NOT EXISTS perf (
|
||||
render_distance INTEGER NOT NULL, simulation_distance INTEGER NOT NULL, max_fps INTEGER NOT NULL,
|
||||
vsync INTEGER NOT NULL, fullscreen INTEGER NOT NULL, graphics TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, frames BLOB NOT NULL, ticks BLOB,
|
||||
peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER
|
||||
peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER, slow_frames TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS perf_install ON perf (install);
|
||||
CREATE INDEX IF NOT EXISTS perf_day ON perf (day);
|
||||
@@ -160,13 +161,16 @@ impl Store {
|
||||
tx.execute(
|
||||
"INSERT INTO perf (install, day, version, system, render_distance, simulation_distance,
|
||||
max_fps, vsync, fullscreen, graphics, seconds, frames, ticks, peak_ram_mb,
|
||||
to_title_ms, to_world_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
to_title_ms, to_world_ms, slow_frames) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
install, day, item.version, system,
|
||||
p.settings.render_distance, p.settings.simulation_distance, p.settings.max_fps,
|
||||
p.settings.vsync, p.settings.fullscreen, p.settings.graphics,
|
||||
p.seconds, pack(&p.frames), p.ticks.as_ref().map(pack), p.peak_ram_mb,
|
||||
p.to_title_ms, p.to_world_ms,
|
||||
(!p.slow_frames.is_empty())
|
||||
.then(|| serde_json::to_string(&p.slow_frames).ok())
|
||||
.flatten(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -366,14 +370,67 @@ impl Store {
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
let breakdowns = self.breakdowns(from, to)?;
|
||||
let slow_frames = self.slow_frames(from, to, k)?;
|
||||
Ok(serde_json::json!({
|
||||
"from_day": from, "to_day": to, "min_install_days": k,
|
||||
"bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0),
|
||||
"totals": { "sessions": sessions, "hours": (hours * 10.0).round() / 10.0 },
|
||||
"groups": out, "crashes_by_version": crashes, "breakdowns": breakdowns,
|
||||
"slow_frames": slow_frames,
|
||||
}))
|
||||
}
|
||||
|
||||
/// What froze in `from..=to`: per release and step (`span`, `""` when no step was timed),
|
||||
/// the frames of 250 ms or more spent mostly in it, the sessions and installs that had one,
|
||||
/// and the longest frame and step. Only steps seen by at least `k` installs. From the raw
|
||||
/// rows, so only within the retention period.
|
||||
fn slow_frames(&self, from: u32, to: u32, k: u64) -> rusqlite::Result<serde_json::Value> {
|
||||
#[derive(Default)]
|
||||
struct Step {
|
||||
frames: u64,
|
||||
sessions: u64,
|
||||
installs: HashSet<Vec<u8>>,
|
||||
frame_max_ms: f32,
|
||||
span_max_ms: f32,
|
||||
}
|
||||
let mut steps: HashMap<(String, String), Step> = HashMap::new();
|
||||
let mut q = self.db.prepare(
|
||||
"SELECT install, version, slow_frames FROM perf
|
||||
WHERE day BETWEEN ?1 AND ?2 AND slow_frames IS NOT NULL",
|
||||
)?;
|
||||
let mut rows = q.query([from, to])?;
|
||||
while let Some(r) = rows.next()? {
|
||||
let install: Vec<u8> = r.get(0)?;
|
||||
let version: String = r.get(1)?;
|
||||
let list: Vec<horizon_telemetry::payload::SlowFrames> =
|
||||
serde_json::from_str(&r.get::<_, String>(2)?).unwrap_or_default();
|
||||
for entry in list {
|
||||
let step = steps.entry((version.clone(), entry.span)).or_default();
|
||||
step.frames += u64::from(entry.frames);
|
||||
step.sessions += 1;
|
||||
step.installs.insert(install.clone());
|
||||
step.frame_max_ms = step.frame_max_ms.max(entry.frame_max_ms);
|
||||
step.span_max_ms = step.span_max_ms.max(entry.span_max_ms);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<((String, String), Step)> = steps
|
||||
.into_iter()
|
||||
.filter(|(_, s)| s.installs.len() as u64 >= k)
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.1.frames.cmp(&a.1.frames).then(a.0.cmp(&b.0)));
|
||||
let round = |ms: f32| (f64::from(ms) * 10.0).round() / 10.0;
|
||||
Ok(out
|
||||
.into_iter()
|
||||
.map(|((version, span), s)| {
|
||||
serde_json::json!({
|
||||
"version": version, "span": span, "frames": s.frames, "sessions": s.sessions,
|
||||
"installs": s.installs.len(), "frame_max_ms": round(s.frame_max_ms),
|
||||
"span_max_ms": round(s.span_max_ms),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Play sessions in `from..=to` per hardware class (`classes`): for each RAM, VRAM, drive,
|
||||
/// OS and GPU kind class with at least `MIN_INSTALLS` different installs, its installs,
|
||||
/// sessions, hours and mean p50 and p99 frame times. From the raw rows, so only within the
|
||||
@@ -516,12 +573,17 @@ impl Extra {
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases from before `systems.extra`: the table rebuilt with it (ids kept), once.
|
||||
/// Databases from before a column: `perf.slow_frames` added, and `systems` rebuilt with
|
||||
/// `extra` (ids kept), once each.
|
||||
fn migrate(db: &mut Connection) -> rusqlite::Result<()> {
|
||||
let has_extra = db
|
||||
.prepare("SELECT 1 FROM pragma_table_info('systems') WHERE name = 'extra'")?
|
||||
.exists([])?;
|
||||
if has_extra {
|
||||
let has = |db: &Connection, table: &str, column: &str| {
|
||||
db.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")?
|
||||
.exists([table, column])
|
||||
};
|
||||
if !has(db, "perf", "slow_frames")? {
|
||||
db.execute_batch("ALTER TABLE perf ADD COLUMN slow_frames TEXT")?;
|
||||
}
|
||||
if has(db, "systems", "extra")? {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = db.transaction()?;
|
||||
@@ -582,6 +644,42 @@ pub fn inflate(body: &[u8]) -> serde_json::Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A database from before `perf.slow_frames` gains the column and keeps its sessions.
|
||||
#[test]
|
||||
fn perf_from_before_slow_frames_gains_the_column() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"horizon-telemetry-slow-{}.sqlite",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
{
|
||||
let db = Connection::open(&path).unwrap();
|
||||
db.execute_batch(
|
||||
"CREATE TABLE perf (
|
||||
install BLOB NOT NULL, day INTEGER NOT NULL, version TEXT NOT NULL, system INTEGER NOT NULL,
|
||||
render_distance INTEGER NOT NULL, simulation_distance INTEGER NOT NULL, max_fps INTEGER NOT NULL,
|
||||
vsync INTEGER NOT NULL, fullscreen INTEGER NOT NULL, graphics TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, frames BLOB NOT NULL, ticks BLOB,
|
||||
peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER
|
||||
);
|
||||
INSERT INTO perf VALUES (x'00', 1, 'v1', 1, 12, 12, 0, 0, 0, 'fancy', 60, x'00', NULL, 1, NULL, NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let path_str = path.to_str().unwrap();
|
||||
let store = Store::open(path_str).unwrap();
|
||||
let old: Option<String> = store
|
||||
.db
|
||||
.query_row("SELECT slow_frames FROM perf", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(old, None, "the old session is kept, with none");
|
||||
drop(store);
|
||||
Store::open(path_str).unwrap(); // a second open adds nothing
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path_str}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A database from before `systems.extra` opens, keeps its machines' ids and takes new ones.
|
||||
#[test]
|
||||
fn systems_from_before_extra_are_kept() {
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use horizon_telemetry::batch::{self, Batch};
|
||||
use horizon_telemetry::payload::{Crash, Payload, Perf, Settings, Source, System, Timings};
|
||||
use horizon_telemetry::payload::{
|
||||
Crash, Payload, Perf, Settings, SlowFrames, Source, System, Timings,
|
||||
};
|
||||
use horizon_telemetry::{Item, outbox};
|
||||
use horizon_telemetry_server::store::{self, Store};
|
||||
use horizon_telemetry_server::{App, Config, router};
|
||||
@@ -196,6 +198,55 @@ async fn totals_break_down_by_hardware_class() {
|
||||
}
|
||||
}
|
||||
|
||||
/// What froze: slow frames by release and step, summed over sessions; a step shows only once
|
||||
/// enough installs had it, and never with an install or a machine.
|
||||
#[tokio::test]
|
||||
async fn slow_frames_show_what_froze_with_enough_installs() {
|
||||
let s = server(Config {
|
||||
min_install_days: 2,
|
||||
..generous()
|
||||
});
|
||||
let slow = |span: &str, frames, frame_max_ms, span_max_ms| {
|
||||
let mut item = perf("NVIDIA GeForce RTX 4070", 12);
|
||||
if let Payload::Perf(p) = &mut item.payload {
|
||||
p.slow_frames = vec![SlowFrames {
|
||||
span: span.into(),
|
||||
frames,
|
||||
frame_max_ms,
|
||||
span_max_ms,
|
||||
}];
|
||||
}
|
||||
item
|
||||
};
|
||||
for (n, frame_max_ms) in [(0, 900.0), (1, 400.0)] {
|
||||
let item = slow("renderer/r_uploads", 3, frame_max_ms, 300.0);
|
||||
assert_eq!(
|
||||
s.post(&install(n), vec![item]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let lone = slow("tick/tick_mobs", 1, 260.0, 200.0);
|
||||
assert_eq!(
|
||||
s.post(&install(2), vec![lone]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let quiet = perf("NVIDIA GeForce RTX 4070", 12);
|
||||
assert_eq!(
|
||||
s.post(&install(3), vec![quiet]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let totals = s.aggregate().await;
|
||||
assert_eq!(
|
||||
totals["slow_frames"],
|
||||
serde_json::json!([{
|
||||
"version": "v2026.10.15", "span": "renderer/r_uploads", "frames": 6, "sessions": 2,
|
||||
"installs": 2, "frame_max_ms": 900.0, "span_max_ms": 300.0,
|
||||
}]),
|
||||
"one install is too few for tick_mobs: {totals}"
|
||||
);
|
||||
assert!(!totals.to_string().contains(&install(0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_batches_are_refused() {
|
||||
let s = server(generous());
|
||||
|
||||
@@ -124,6 +124,19 @@ impl Item {
|
||||
Payload::Benchmark(report) if !report.is_object() => {
|
||||
Err("a benchmark report is a JSON object".into())
|
||||
}
|
||||
Payload::Perf(perf)
|
||||
if perf.slow_frames.len() > SlowFrames::MAX_SPANS
|
||||
|| perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.any(|s| s.span.len() > SlowFrames::MAX_NAME) =>
|
||||
{
|
||||
Err(format!(
|
||||
"at most {} slow-frame spans of {} bytes",
|
||||
SlowFrames::MAX_SPANS,
|
||||
SlowFrames::MAX_NAME
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -217,6 +230,69 @@ pub struct Perf {
|
||||
/// From choosing a world to playing in it, the first time this session.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub to_world_ms: Option<u32>,
|
||||
/// The frames of [`SLOW_FRAME_MS`] or more while playing, by what took longest in each:
|
||||
/// what froze. At most [`SlowFrames::MAX_SPANS`] entries.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub slow_frames: Vec<SlowFrames>,
|
||||
}
|
||||
|
||||
/// A frame this long or longer is a freeze worth explaining ([`Perf::slow_frames`]): the
|
||||
/// edge of [`Summary::buckets`]' last bucket.
|
||||
pub const SLOW_FRAME_MS: f32 = 250.0;
|
||||
|
||||
/// The slow frames whose longest step was one span: a named step of the render thread's frame
|
||||
/// (`spans.rs` in the client: `render.upload`, `tick`, `world.stream`…), the same names in
|
||||
/// every game of a release. `span` is empty when no step was timed in the frame (it waited
|
||||
/// outside them).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SlowFrames {
|
||||
pub span: String,
|
||||
pub frames: u32,
|
||||
/// The longest of these frames and the longest this span took in one of them.
|
||||
pub frame_max_ms: f32,
|
||||
pub span_max_ms: f32,
|
||||
}
|
||||
|
||||
impl SlowFrames {
|
||||
/// Distinct spans a session keeps; the later ones count under [`SlowFrames::OTHER`].
|
||||
pub const MAX_SPANS: usize = 16;
|
||||
pub const MAX_NAME: usize = 64;
|
||||
pub const OTHER: &'static str = "other";
|
||||
|
||||
/// Counts a slow frame of `frame_ms` whose longest span was `span` (`""`: none) for
|
||||
/// `span_ms`, in a session's list.
|
||||
pub fn record(list: &mut Vec<Self>, frame_ms: f32, span: &str, span_ms: f32) {
|
||||
let span = if span.len() > Self::MAX_NAME {
|
||||
Self::OTHER
|
||||
} else {
|
||||
span
|
||||
};
|
||||
let at = match list.iter().position(|s| s.span == span) {
|
||||
Some(at) => at,
|
||||
None if list.len() + 1 < Self::MAX_SPANS => {
|
||||
list.push(Self {
|
||||
span: span.to_owned(),
|
||||
..Self::default()
|
||||
});
|
||||
list.len() - 1
|
||||
}
|
||||
None => match list.iter().position(|s| s.span == Self::OTHER) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
list.push(Self {
|
||||
span: Self::OTHER.to_owned(),
|
||||
..Self::default()
|
||||
});
|
||||
list.len() - 1
|
||||
}
|
||||
},
|
||||
};
|
||||
let entry = &mut list[at];
|
||||
entry.frames = entry.frames.saturating_add(1);
|
||||
entry.frame_max_ms = entry.frame_max_ms.max(frame_ms);
|
||||
entry.span_max_ms = entry.span_max_ms.max(span_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// A crash, scrubbed: the error and its stack, the thread, and the details that say what the
|
||||
@@ -453,4 +529,50 @@ mod tests {
|
||||
assert!(size < 1024, "{size} bytes");
|
||||
assert!(perf.validate().is_ok());
|
||||
}
|
||||
|
||||
/// Slow frames count under their longest span; past the cap, new spans share one entry,
|
||||
/// so a session full of different freezes still fits its item.
|
||||
#[test]
|
||||
fn slow_frames_are_counted_by_span_within_a_cap() {
|
||||
let mut list = Vec::new();
|
||||
SlowFrames::record(&mut list, 300.0, "render.upload", 200.0);
|
||||
SlowFrames::record(&mut list, 900.0, "render.upload", 120.0);
|
||||
SlowFrames::record(&mut list, 260.0, "", 0.0);
|
||||
assert_eq!(
|
||||
list[0],
|
||||
SlowFrames {
|
||||
span: "render.upload".into(),
|
||||
frames: 2,
|
||||
frame_max_ms: 900.0,
|
||||
span_max_ms: 200.0,
|
||||
}
|
||||
);
|
||||
assert_eq!((list[1].span.as_str(), list[1].frames), ("", 1));
|
||||
for i in 0..40 {
|
||||
SlowFrames::record(&mut list, 400.0, &format!("step.{i}"), 300.0);
|
||||
}
|
||||
SlowFrames::record(&mut list, 400.0, &"x".repeat(200), 300.0);
|
||||
assert_eq!(list.len(), SlowFrames::MAX_SPANS);
|
||||
let other = list.iter().find(|s| s.span == SlowFrames::OTHER).unwrap();
|
||||
// 40 spans, 13 of which found room after the first two.
|
||||
assert_eq!(other.frames, 40 - 13 + 1);
|
||||
let perf = Item::new(
|
||||
Source::Game,
|
||||
"v0.4.2",
|
||||
Payload::Perf(Perf {
|
||||
slow_frames: list.clone(),
|
||||
..Perf::default()
|
||||
}),
|
||||
);
|
||||
assert!(perf.validate().is_ok());
|
||||
let too_many = Item::new(
|
||||
Source::Game,
|
||||
"v0.4.2",
|
||||
Payload::Perf(Perf {
|
||||
slow_frames: [list.clone(), list].concat(),
|
||||
..Perf::default()
|
||||
}),
|
||||
);
|
||||
assert!(too_many.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,13 @@ impl<'r> BlockCollisionRegistry<'r> {
|
||||
empty_shape: flags & 4 != 0,
|
||||
})
|
||||
}
|
||||
/// Whether `state` can have a collision shape: false for states whose shape is always
|
||||
/// empty, whatever the context. States outside the registry count.
|
||||
pub fn may_collide(&self, state: BlockStateId) -> bool {
|
||||
self.scan_flags
|
||||
.get(state.0 as usize)
|
||||
.is_none_or(|flags| flags & 4 == 0)
|
||||
}
|
||||
/// Whether a scan keeps `state` in the cells around its box: a large
|
||||
/// collision shape or a moving piston. States outside the registry count.
|
||||
pub fn reaches_out(&self, state: BlockStateId) -> bool {
|
||||
|
||||
@@ -368,18 +368,20 @@ impl StructureResources {
|
||||
}
|
||||
let (namespace, path) = id.split_once(':').unwrap();
|
||||
let file = self.dir.join(namespace).join(format!("{path}.nbt"));
|
||||
let loaded = std::fs::read(&file).ok().and_then(|bytes| {
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes.as_slice())
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
|
||||
Some(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.registry,
|
||||
&self.transforms,
|
||||
))
|
||||
});
|
||||
let loaded = horizon_core::prepared_files::read(&file)
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes.as_slice())
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
|
||||
Some(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.registry,
|
||||
&self.transforms,
|
||||
))
|
||||
});
|
||||
// StructureTemplateManager.getOrCreate: missing templates are empty.
|
||||
let mut loaded = loaded.unwrap_or_else(StructureTemplate::empty);
|
||||
loaded.location = id.as_str().into();
|
||||
|
||||
+3
-3
@@ -79,9 +79,9 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
- **Backups**: restore a single dimension.
|
||||
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
|
||||
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
|
||||
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications other than
|
||||
`server/started` and `server/stopping`, and the status heartbeat. The methods answer an internal error saying
|
||||
so; a kick's translatable message is sent as its key.
|
||||
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done), the notifications of
|
||||
the server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
|
||||
an internal error saying so; a kick's translatable message is sent as its key.
|
||||
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
|
||||
|
||||
## Changelog
|
||||
|
||||
@@ -77,6 +77,15 @@ effects, entity types and menu types (each below) in `creative_tab/`, `mob_effec
|
||||
them. LAN and dedicated server players play with them when they run the same mods: a client whose mods add different
|
||||
content is refused when it joins, with the reason.
|
||||
|
||||
A server that has content mods on also checks them one by one (`horizon_protocol::mod_list`, channel
|
||||
`horizon:mod_list`): in the configuration phase it sends the id, version and SHA-256 of each `.hmod` with
|
||||
`side = "both"`, the client answers with its own, and a client that differs is refused with the mods named: missing,
|
||||
not on this server, or another version or file. Only `side = "both"` mods are compared and sent: a `client` mod (a
|
||||
HUD, a minimap) never blocks a join and is never told to a server, and a `server` mod stays private to it. A manifest
|
||||
with no `side` line is `client`, so a content mod meant for both halves must say `side = "both"`, or this check
|
||||
ignores it. A mod run from a folder (`--dev-mod`) has no file hash and matches by version. Native mods are not in the
|
||||
list yet: their manifest has no side.
|
||||
|
||||
A block names its template and what differs from it:
|
||||
|
||||
```json
|
||||
|
||||
+98
-3
@@ -1045,7 +1045,8 @@ trusting native code is the player's decision, never an agent's.
|
||||
7. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
|
||||
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
|
||||
saying which release or Nightly it started from (`base`) and which commit, mods, horizon-mixins and Rust made
|
||||
it. The instance the mods are installed in plays it, the default **Horizon** instance included: the build
|
||||
it. The game lists its `mods` with their versions in a crash report's details (`Native Mods: id version, …`;
|
||||
they stay in the local report, not in crash telemetry). The instance the mods are installed in plays it, the default **Horizon** instance included: the build
|
||||
pins its version, as picking a version does. Updates of the launcher's version leave a pinned instance where
|
||||
it is, and their clean-up never removes a native build. An instance built before this (the **Native mods**
|
||||
instance the default home's mods used to go to) keeps its worlds and its build.
|
||||
@@ -1587,6 +1588,8 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
|
||||
- **`native_enchant_item(player, name, pos, item, levels)`**: a player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. Guest state is not swapped in.
|
||||
- **`native_prepare_item_enchant(player, name, pos, costs)`**: enchanting offers were calculated at `pos`; change the three offer costs before they are sent to the player's menu. Guest state is not swapped in.
|
||||
- **`native_prepare_anvil(player, name, pos, result, cost)`**: an anvil result was prepared for a player at `pos`; change the result or level cost before it is sent to their menu. Guest state is not swapped in.
|
||||
- **`native_anvil_craft_pre(player, name, pos, craft, cancel)`**: a player took an anvil's result at `pos` (NeoForge's `AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` has the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Fires on the server only; the window's predicted take does not call it.
|
||||
- **`native_anvil_craft_post(player, name, pos, craft)`**: the levels, inputs and anvil wear of that take are done (NeoForge's `AnvilCraftEvent.Post`); a broken anvil is already air. `craft` is the same as in `native_anvil_craft_pre`. Not called when it cancelled.
|
||||
- **`native_prepare_grindstone(player, name, pos, result)`**: a grindstone result was prepared for a player at `pos`; change it before it is sent to their menu. Guest state is not swapped in.
|
||||
- **`native_prepare_smithing(player, name, pos, result)`**: a smithing result was prepared for a player at `pos`; change it before it is sent to their menu. Guest state is not swapped in.
|
||||
- **`native_prepare_item_craft(player, name, pos, result)`**: a crafting-grid result was prepared; `pos` is the crafting table, or None for the player's inventory grid. Change it before it is sent to the menu. Guest state is not swapped in.
|
||||
@@ -2220,7 +2223,7 @@ anything else a mod may change, a game struct or a list, is a `&mut` field. A ca
|
||||
the return value; a collection hook's list is a field (`event.lines`). One event goes through every mod, so each
|
||||
sees the changes before it. Because no mod can build or match the whole
|
||||
struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that
|
||||
a mod's own tests can't build an event yet.
|
||||
a mod's own tests drive a hook through its dispatch function ([Testing a trait mod](#testing-a-trait-mod)).
|
||||
|
||||
Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both
|
||||
the window's game and the server-thread game. A hook can also fire again inside
|
||||
@@ -2244,6 +2247,96 @@ renderer list is installed once alongside the mod list. `render_needs` collects
|
||||
run on the render thread while the frame is built, before any deferred GPU submission.
|
||||
The renderer module re-exports the game's wgpu types.
|
||||
|
||||
### The typed API from mixin code
|
||||
|
||||
A helper is compiled inside its target, as a [Sponge Mixin](https://github.com/SpongePowered/Mixin/wiki) handler is merged
|
||||
into its target class, so it can use the typed API. On a `Game` method, bind the game and coerce it to `dyn Host`:
|
||||
|
||||
```toml
|
||||
[[injection]]
|
||||
id = "count"
|
||||
target = "horizon_client::game::Game<'_>::tick_mobs"
|
||||
kind = "HEAD"
|
||||
helper = "count.rs"
|
||||
bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }]
|
||||
```
|
||||
|
||||
```rust
|
||||
fn count(game: &mut crate::game::Game<'_>) {
|
||||
let host: &mut dyn horizon_client::mod_api::Host = game;
|
||||
let holder = horizon_client::mod_api::Holder::Level;
|
||||
if let Ok(old) = crate::game::example::COUNTER.get(&mut *host, holder) {
|
||||
let _ = crate::game::example::COUNTER.set(host, holder, old + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`COUNTER` is the `AttachmentType<i32>` of [Server attachments](#server-attachments), kept in a `[[module]]` of the
|
||||
bundle (horizon-mixins' README): one with `file = "crates/horizon-client/src/game.rs"` and `name = "example"` is
|
||||
`crate::game::example`. `Game` is not an `AttachmentHost` itself, so the `dyn Host` coercion is the step that matters;
|
||||
any `Host` helper works on `host` too.
|
||||
|
||||
On a `GameLevel` method that takes `&mut self` (`native_random_tick`, `native_tick_block`, `native_perform_bonemeal`,
|
||||
`native_is_bonemeal_success`), bind `{ name = "level", ty = "&mut dyn crate::mod_api::LevelRandomHost", value = "&mut *self" }`
|
||||
for attachments and `random()`. Scheduling and `set_block_property` stay on the `LevelTicksHost` and `LevelWriteHost` the
|
||||
hook points pass; `is_bonemeal_success` gets only a `LevelRandomHost`.
|
||||
|
||||
A trait injection gives the mod's `[api]` crate the same `Host` at that method. The `[api]` crate depends on
|
||||
horizon-client's library, not on the game executable that helpers and modules are compiled into, so it declares its
|
||||
own `AttachmentType<i32>` with the same `namespace` and `name`: the pair is the key, so both statics read one value.
|
||||
|
||||
```toml
|
||||
[[injection]]
|
||||
id = "count"
|
||||
target = "horizon_client::game::Game<'_>::tick_mobs"
|
||||
kind = "HEAD"
|
||||
trait_method = "count"
|
||||
bindings = [{ name = "host", ty = "&mut dyn crate::mod_api::Host", value = "&mut *self" }]
|
||||
```
|
||||
|
||||
```rust
|
||||
fn inject_example_count(&self, event: &mut InjectExampleCountEvent<'_>) {
|
||||
let _ = COUNTER.get(&mut *event.host, Holder::Level);
|
||||
}
|
||||
```
|
||||
|
||||
The `[api]` crate reads `event.host` at any `Game` method horizon-mixins can inject into at HEAD (its README lists the
|
||||
limits). `hook` and `hook_version` work in place of `target`.
|
||||
|
||||
The helper runs in every `Game` that calls its target, and attachments answer `MissingHolder` outside the server's game,
|
||||
as they do in hooks.
|
||||
|
||||
A catalog hook's trait method, event and return are part of its `hook_version` contract ([Named hook
|
||||
points](#named-hook-points), [Mods on the hook points](#mods-on-the-hook-points)). Later Horizons only add event fields
|
||||
(`#[non_exhaustive]`), trait methods with default bodies, and `Host` methods. So a mod whose `[api]` package uses only catalog
|
||||
hooks (trait methods, or injections by `hook`) and `Host` can set `"hooks"` in its `.hnmod`'s `horizon-native.json` to the
|
||||
`NATIVE_HOOKS_API` it was made on.
|
||||
A raw `target` like `tick_mobs` is not a hook point and may change in any commit: such a mod pins `horizon_revision` and
|
||||
leaves `hooks` out.
|
||||
|
||||
### Testing a trait mod
|
||||
|
||||
Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements
|
||||
only what its hook calls, and an unexpected call fails loudly. Drive the hook through
|
||||
`horizon_client::mod_api::dispatch_<hook>(&mut host, &mods, args…)`, which builds the event as the game does.
|
||||
If a `Host` method returns a game value a test can't build, a double can return `None` instead, as with
|
||||
`acting_player() -> Option<NativeServerPlayer>`. If the dispatch function itself takes an unconstructible value,
|
||||
such as `dispatch_perform_command`'s `&NativeServerPlayer`, test the logic beneath the hook instead. The level
|
||||
hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart.
|
||||
|
||||
```rust
|
||||
struct Stone;
|
||||
impl Host for Stone {
|
||||
fn block(&self, _pos: [i32; 3]) -> String {
|
||||
"minecraft:stone".to_string()
|
||||
}
|
||||
}
|
||||
let mods = [Box::new(MyMod) as Box<dyn Hooks>];
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut claimed);
|
||||
assert!(claimed);
|
||||
```
|
||||
|
||||
### Adding a hook
|
||||
|
||||
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
|
||||
@@ -2287,13 +2380,15 @@ static COUNTER: AttachmentType<i32> = AttachmentType {
|
||||
}),
|
||||
copy_on_death: false,
|
||||
};
|
||||
// Inside a server hook with `host: &mut dyn Host`:
|
||||
// Inside a server hook, or mixin code, with `host: &mut dyn Host`:
|
||||
if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ }
|
||||
let old = COUNTER.get(host, Holder::Chunk([0, 0]))?;
|
||||
COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?;
|
||||
Ok::<(), AttachmentError>(())
|
||||
```
|
||||
|
||||
For mixin code, see [The typed API from mixin code](#the-typed-api-from-mixin-code).
|
||||
|
||||
Use `Holder::Level`, `Holder::Chunk([x, z])`, `Holder::BlockEntity([x, y, z])`,
|
||||
`Holder::Entity(id)`, or `Holder::Player(uuid)`. Entity IDs cover mobs, item
|
||||
entities, boats, minecarts, armor stands, cushions, item frames, paintings and the
|
||||
|
||||
+13
-2
@@ -509,8 +509,19 @@ runtime or new dependency. Server-sent events can replace the polling later if a
|
||||
- notifications: the supervisor pushes them to every connected client as vanilla's `JsonRpcNotificationService`
|
||||
does (a JSON-RPC request without an `id`, its one parameter in an array), through a queue and a thread so a slow
|
||||
client never holds the game's output. Built: `server/started` (the game's `Done`) and `server/stopping`
|
||||
(when the supervisor or the game's own `stop` begins the shutdown, once per run);
|
||||
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
|
||||
(when the supervisor or the game's own `stop` begins the shutdown, once per run), `server/saving` and
|
||||
`server/saved` (around every world save of the running game: `save-all`, the autosave, the pause when
|
||||
empty; a save the game makes while it stops is after the last one the supervisor hears of), and
|
||||
`players/joined` and `players/left` (from the game's typed events, within a quarter of a second), and
|
||||
`operators/*`, `allowlist/*`, `bans/*` and `ip_bans/*` `added`/`removed`: each of the game's lists tells a
|
||||
watcher what `add`, `remove` and `clear` do, as vanilla's `StoredUserList` tells its `NotificationService`,
|
||||
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
|
||||
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
|
||||
- game rules: `gamerules` lists every rule the game has as a typed rule (`{key, type, value}`), `gamerules/update`
|
||||
sets one (checked against its type and bounds, logged and set only when the value changes) and
|
||||
`notification/gamerules/updated` follows every change, by that method or by `/gamerule`
|
||||
(`Game::set_game_rule`);
|
||||
- planned: the rest of `serversettings/*`, the activity and world-upgrade notifications and the heartbeat.
|
||||
|
||||
## 9. Benchmark plan (bots against vanilla and Paper)
|
||||
|
||||
|
||||
+4
-2
@@ -29,7 +29,7 @@ Every item has `day` (days since 1970, UTC: no finer time), `source` (`launcher`
|
||||
|
||||
| Kind | Switch | `data` | Largest | A day |
|
||||
|---|---|---|---|---|
|
||||
| `perf` | performance | `system`, `settings` (render and simulation distance, max FPS, vsync, fullscreen, graphics mode), `seconds` played, `frames` and `ticks` summaries, `peak_ram_mb`, `to_title_ms`, `to_world_ms` | 4 KiB | 12 |
|
||||
| `perf` | performance | `system`, `settings` (render and simulation distance, max FPS, vsync, fullscreen, graphics mode), `seconds` played, `frames` and `ticks` summaries, `peak_ram_mb`, `to_title_ms`, `to_world_ms`, `slow_frames` | 4 KiB | 12 |
|
||||
| `crash` | crash reports | `system`, `error`, `stack` (one frame a line), `thread`, the graphics and settings details, all scrubbed. No log, no world, no server | 32 KiB | 5 |
|
||||
| `bug` | bug reports | `system`, the player's `text` (up to 4000 characters), and the `log` only if they add it after reading it | 80 KiB | 5 |
|
||||
| `benchmark` | benchmark uploads | the benchmark report as `docs/benchmark-report.md` defines it, as it is, with its own schema field | 128 KiB | 10 |
|
||||
@@ -55,6 +55,8 @@ No serial number, host name, user name, MAC address or path is read. Settings
|
||||
|
||||
A summary (`frames`, `ticks`) is the count, the 50th, 95th and 99th percentile and the maximum in milliseconds (to the tenth), and 14 bucket counts: under 4 ms, 4–6, 6–8, 8–10, 10–12, 12–14, 14–16.7, 16.7–20, 20–25, 25–33.3, 33.3–50, 50–100, 100–250, and 250 ms and more. Raw frame times never leave: `Timings` records them in tenths of a millisecond (20 KB however long the session) and only its summary is kept. A perf item is under 1 KiB.
|
||||
|
||||
`slow_frames` says what froze: the frames of 250 ms or more while playing, counted by the step of the frame they spent most of their time in. A step is a span of the render thread (`spans.rs`), named in the code and the same in every game of a release: the slowest outer span, then the span inside it that took at least half of it, as a path (`renderer/r_uploads`, `tick/tick_mobs`; empty when no step was timed). Each entry has the step, its frame count, the longest of those frames and the longest the step took. At most 16 entries; later steps count under `other`. With performance statistics on, the render thread times its steps (wall time only, two clock reads a span); with them off it times nothing. Most sessions have no slow frame and send no `slow_frames`.
|
||||
|
||||
Never sent: account names or UUIDs, world names, server or LAN addresses, chat, file paths with the user's name, IP addresses. `scrub::Scrubber` replaces the user folder in paths (`/Users/<user>/…`, `C:\Users\<user>\…`), the user name, e-mail and IP addresses and UUIDs. It also replaces the terms the caller passes (the world's name, the server's address, account names). The server does not store the sender's IP address.
|
||||
|
||||
## Storage and sending
|
||||
@@ -106,7 +108,7 @@ The launcher reads the count when it opens and every minute after, and shows "N
|
||||
|
||||
- `POST /v1/batch`
|
||||
- `DELETE /v1/installs/<install_id>`: deletes everything sent under that ID.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times. Its `slow_frames` list what froze, per release and step: the slow frames, the sessions and installs that had one, and the longest frame and step, for steps at least as many installs had as the groups need.
|
||||
- `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, exact memory or screen. Uploads are never published one by one. Each group's `breakdowns` count its installs per hardware class.
|
||||
- `GET /v1/export/benchmarks` and `GET /v1/export/hardware`: for the maintainers' performance work only, behind a bearer token the public cannot use (404 while the server has none). The raw benchmark reports and the play sessions per machine description, including CPU threads and physical cores, with no minimum group size. Nothing identifying: no install ID, and only what a player already agreed to send (this page's lists).
|
||||
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
|
||||
|
||||
+2
-2
@@ -1758,7 +1758,7 @@
|
||||
{
|
||||
"name": "menu_slot_rules",
|
||||
"version": 1,
|
||||
"target": "horizon_client::menu::native_menu_slot_rules",
|
||||
"target": "horizon_client_lib::menu::native_menu_slot_rules",
|
||||
"side": "server",
|
||||
"cancellable": false,
|
||||
"mod_api": {
|
||||
@@ -1993,7 +1993,7 @@
|
||||
{
|
||||
"name": "living_breathe",
|
||||
"version": 1,
|
||||
"target": "horizon_client::breathing::native_living_breathe",
|
||||
"target": "horizon_client_lib::breathing::native_living_breathe",
|
||||
"neoforge": "LivingBreatheEvent",
|
||||
"side": "server",
|
||||
"cancellable": false,
|
||||
|
||||
@@ -348,6 +348,7 @@ class StressSceneTests(unittest.TestCase):
|
||||
summons = lambda name, kind: sum(c.startswith(f"summon {kind} ") for c in stress_scenes.setup(name))
|
||||
self.assertEqual(sum(summons("stress_mobs", k) for k in stress_scenes.MOBS), 1000)
|
||||
self.assertEqual(summons("stress_items", "item"), 2000)
|
||||
self.assertEqual(sum(summons("stress_held", k) for k in stress_scenes.HOLDERS), 1000)
|
||||
self.assertEqual(summons("stress_frames", "item_frame"), 512)
|
||||
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
|
||||
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
|
||||
|
||||
+22
-7
@@ -424,14 +424,29 @@ impl super::Device {
|
||||
key.shader_model.to_str()
|
||||
);
|
||||
|
||||
let compiled_shader = self.compiler_container.compile(
|
||||
self,
|
||||
&key.source,
|
||||
source_name,
|
||||
// Horizon: a shader compiled in an earlier run comes from the disk.
|
||||
let disk_key = super::disk_cache::key(
|
||||
self.compiler_container.cache_name(),
|
||||
key.shader_model.to_str(),
|
||||
naga::back::hlsl::shader_stage_to_hlsl_str(naga_stage),
|
||||
&key.entry_point,
|
||||
stage_bit,
|
||||
&full_stage,
|
||||
)?;
|
||||
&key.source,
|
||||
);
|
||||
let compiled_shader = match super::disk_cache::get(disk_key) {
|
||||
Some(bytes) => super::CompiledShader::Precompiled(bytes),
|
||||
None => {
|
||||
let compiled = self.compiler_container.compile(
|
||||
self,
|
||||
&key.source,
|
||||
source_name,
|
||||
&key.entry_point,
|
||||
stage_bit,
|
||||
&full_stage,
|
||||
)?;
|
||||
super::disk_cache::put(disk_key, compiled.bytes());
|
||||
compiled
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut shader_cache = self.shader_cache.lock();
|
||||
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
//! Horizon: compiled shaders kept on disk between runs.
|
||||
//!
|
||||
//! FXC takes about 2.6 s to compile Horizon's ~65 pipelines on every start
|
||||
//! (DXC about 1 s), while the in-memory [`super::ShaderCache`] only helps
|
||||
//! within one device. The application names one file with [`set_file`]. A
|
||||
//! shader compiled once is appended there, and the next run reads its bytecode
|
||||
//! back as [`super::CompiledShader::Precompiled`] instead of compiling it.
|
||||
//!
|
||||
//! The key is a 128-bit FNV-1a hash of the compiler, the shader model, the
|
||||
//! stage, the entry point and the HLSL source naga wrote. Any change to
|
||||
//! naga's output, the shader, or the compiler choice is a different key, so
|
||||
//! the file never needs explicit invalidation. Each record carries a checksum
|
||||
//! of its bytes; reading stops at the first record that is short or does not
|
||||
//! match (a run killed mid-write, two games writing at once), and the next
|
||||
//! append starts there. A file past [`MAX_BYTES`] is started over.
|
||||
//!
|
||||
//! File: `MAGIC`, then records of `key: u128 LE`, `len: u32 LE`,
|
||||
//! `check: u64 LE` (FNV-1a 64 of the bytes), `bytes`.
|
||||
|
||||
use alloc::vec::Vec;
|
||||
use std::{
|
||||
fs,
|
||||
io::{Seek as _, SeekFrom, Write as _},
|
||||
path::PathBuf,
|
||||
sync::{Mutex, OnceLock},
|
||||
};
|
||||
|
||||
use hashbrown::HashMap;
|
||||
|
||||
const MAGIC: &[u8; 8] = b"HZDXSC1\n";
|
||||
const HEADER: usize = 16 + 4 + 8;
|
||||
/// Past this the file is started over: Horizon's shaders take about 1 MB.
|
||||
pub const MAX_BYTES: u64 = 64 << 20;
|
||||
|
||||
static FILE: OnceLock<PathBuf> = OnceLock::new();
|
||||
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
|
||||
|
||||
/// Keep compiled shaders in `path` for the rest of the run. Only the first
|
||||
/// call counts; without one nothing is kept on disk.
|
||||
pub fn set_file(path: PathBuf) {
|
||||
let _ = FILE.set(path);
|
||||
}
|
||||
|
||||
pub(super) struct Cache {
|
||||
path: PathBuf,
|
||||
entries: HashMap<u128, Vec<u8>>,
|
||||
/// Where the next record goes: the end of the last good record.
|
||||
end: u64,
|
||||
}
|
||||
|
||||
fn cache() -> Option<&'static Mutex<Cache>> {
|
||||
let path = FILE.get()?;
|
||||
Some(CACHE.get_or_init(|| Mutex::new(Cache::load(path.clone()))))
|
||||
}
|
||||
|
||||
/// The bytecode compiled for `key` in an earlier run.
|
||||
pub(super) fn get(key: u128) -> Option<Vec<u8>> {
|
||||
let cache = cache()?.lock().ok()?;
|
||||
cache.entries.get(&key).cloned()
|
||||
}
|
||||
|
||||
/// Keep `bytes` for `key` for the next runs.
|
||||
pub(super) fn put(key: u128, bytes: &[u8]) {
|
||||
let Some(cache) = cache() else { return };
|
||||
let Ok(mut cache) = cache.lock() else { return };
|
||||
if let Err(error) = cache.append(key, bytes) {
|
||||
log::warn!("DX12 shader cache {}: {error}", cache.path.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// The key of one compile.
|
||||
pub(super) fn key(
|
||||
compiler: &str,
|
||||
shader_model: &str,
|
||||
stage: &str,
|
||||
entry: &str,
|
||||
source: &str,
|
||||
) -> u128 {
|
||||
let mut hash = Fnv128::new();
|
||||
for part in [compiler, shader_model, stage, entry, source] {
|
||||
hash.write(&(part.len() as u64).to_le_bytes());
|
||||
hash.write(part.as_bytes());
|
||||
}
|
||||
hash.0
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
fn load(path: PathBuf) -> Self {
|
||||
let bytes = match fs::metadata(&path) {
|
||||
Ok(meta) if meta.len() <= MAX_BYTES => fs::read(&path).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (entries, end) = parse(&bytes);
|
||||
Self { path, entries, end }
|
||||
}
|
||||
|
||||
fn append(&mut self, key: u128, bytes: &[u8]) -> std::io::Result<()> {
|
||||
if self.entries.contains_key(&key) {
|
||||
return Ok(());
|
||||
}
|
||||
let len = u32::try_from(bytes.len()).map_err(std::io::Error::other)?;
|
||||
if self.end + (HEADER + bytes.len()) as u64 > MAX_BYTES {
|
||||
self.end = 0;
|
||||
self.entries.clear();
|
||||
}
|
||||
if let Some(dir) = self.path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&self.path)?;
|
||||
let mut record = Vec::with_capacity(MAGIC.len() + HEADER + bytes.len());
|
||||
if self.end == 0 {
|
||||
record.extend_from_slice(MAGIC);
|
||||
}
|
||||
record.extend_from_slice(&key.to_le_bytes());
|
||||
record.extend_from_slice(&len.to_le_bytes());
|
||||
record.extend_from_slice(&fnv64(bytes).to_le_bytes());
|
||||
record.extend_from_slice(bytes);
|
||||
// Drop whatever follows the last good record, then write after it.
|
||||
file.set_len(self.end)?;
|
||||
file.seek(SeekFrom::Start(self.end))?;
|
||||
file.write_all(&record)?;
|
||||
self.end += record.len() as u64;
|
||||
self.entries.insert(key, bytes.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The records of a cache file and where its good part ends.
|
||||
fn parse(bytes: &[u8]) -> (HashMap<u128, Vec<u8>>, u64) {
|
||||
let mut entries = HashMap::new();
|
||||
if bytes.get(..MAGIC.len()) != Some(MAGIC.as_slice()) {
|
||||
return (entries, 0);
|
||||
}
|
||||
let mut at = MAGIC.len();
|
||||
while let Some(header) = bytes.get(at..at + HEADER) {
|
||||
let key = u128::from_le_bytes(header[..16].try_into().unwrap());
|
||||
let len = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize;
|
||||
let check = u64::from_le_bytes(header[20..28].try_into().unwrap());
|
||||
let Some(body) = bytes.get(at + HEADER..at + HEADER + len) else {
|
||||
break;
|
||||
};
|
||||
if fnv64(body) != check {
|
||||
break;
|
||||
}
|
||||
entries.insert(key, body.to_vec());
|
||||
at += HEADER + len;
|
||||
}
|
||||
(entries, at as u64)
|
||||
}
|
||||
|
||||
struct Fnv128(u128);
|
||||
|
||||
impl Fnv128 {
|
||||
fn new() -> Self {
|
||||
Self(0x6c62272e07bb014262b821756295c58d)
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.0 ^= u128::from(b);
|
||||
self.0 = self.0.wrapping_mul(0x0000000001000000000000000000013b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fnv64(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for &b in bytes {
|
||||
hash ^= u64::from(b);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("hz-dx12-cache-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
dir.join("cache/dx12-shaders.bin")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shader_kept_in_one_run_is_read_back_in_the_next() {
|
||||
let path = scratch("next");
|
||||
let vs = key(
|
||||
"fxc",
|
||||
"5_1",
|
||||
"vs",
|
||||
"main",
|
||||
"float4 main() : SV_Position { return 0; }",
|
||||
);
|
||||
let fs_ = key(
|
||||
"fxc",
|
||||
"5_1",
|
||||
"ps",
|
||||
"main",
|
||||
"float4 main() : SV_Target { return 1; }",
|
||||
);
|
||||
let mut run = Cache::load(path.clone());
|
||||
run.append(vs, b"DXBC vertex").unwrap();
|
||||
run.append(fs_, b"DXBC fragment").unwrap();
|
||||
let next = Cache::load(path.clone());
|
||||
assert_eq!(
|
||||
next.entries.get(&vs).map(Vec::as_slice),
|
||||
Some(&b"DXBC vertex"[..])
|
||||
);
|
||||
assert_eq!(
|
||||
next.entries.get(&fs_).map(Vec::as_slice),
|
||||
Some(&b"DXBC fragment"[..])
|
||||
);
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_part_of_the_compile_changes_the_key() {
|
||||
let base = key("fxc", "5_1", "vs", "main", "src");
|
||||
assert_ne!(base, key("dxc", "5_1", "vs", "main", "src"));
|
||||
assert_ne!(base, key("fxc", "6_0", "vs", "main", "src"));
|
||||
assert_ne!(base, key("fxc", "5_1", "ps", "main", "src"));
|
||||
assert_ne!(base, key("fxc", "5_1", "vs", "main2", "src"));
|
||||
assert_ne!(base, key("fxc", "5_1", "vs", "main", "src "));
|
||||
// Lengths are hashed, so moving a byte between parts changes the key.
|
||||
assert_ne!(key("ab", "c", "", "", ""), key("a", "bc", "", "", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_torn_record_is_dropped_and_the_next_one_written_over_it() {
|
||||
let path = scratch("torn");
|
||||
let a = key("fxc", "5_1", "vs", "a", "a");
|
||||
let b = key("fxc", "5_1", "vs", "b", "b");
|
||||
let c = key("fxc", "5_1", "vs", "c", "c");
|
||||
let mut run = Cache::load(path.clone());
|
||||
run.append(a, b"first").unwrap();
|
||||
run.append(b, b"second").unwrap();
|
||||
// The run is killed while it writes `b`: its last bytes never land.
|
||||
let full = fs::read(&path).unwrap();
|
||||
fs::write(&path, &full[..full.len() - 3]).unwrap();
|
||||
let mut next = Cache::load(path.clone());
|
||||
assert_eq!(next.entries.len(), 1);
|
||||
assert!(next.entries.contains_key(&a));
|
||||
next.append(c, b"third").unwrap();
|
||||
let last = Cache::load(path.clone());
|
||||
assert_eq!(last.entries.len(), 2);
|
||||
assert_eq!(last.entries.get(&c).map(Vec::as_slice), Some(&b"third"[..]));
|
||||
|
||||
// A flipped byte fails the checksum the same way.
|
||||
let mut bytes = fs::read(&path).unwrap();
|
||||
let at = bytes.len() - 1;
|
||||
bytes[at] ^= 1;
|
||||
fs::write(&path, bytes).unwrap();
|
||||
assert_eq!(Cache::load(path.clone()).entries.len(), 1);
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_is_not_a_cache_is_started_over() {
|
||||
let path = scratch("foreign");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, b"not a shader cache at all").unwrap();
|
||||
let mut run = Cache::load(path.clone());
|
||||
assert!(run.entries.is_empty());
|
||||
let k = key("dxc", "6_0", "cs", "main", "x");
|
||||
run.append(k, b"DXIL").unwrap();
|
||||
let next = Cache::load(path.clone());
|
||||
assert_eq!(next.entries.get(&k).map(Vec::as_slice), Some(&b"DXIL"[..]));
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap().parent().unwrap());
|
||||
}
|
||||
}
|
||||
Vendored
+11
@@ -83,6 +83,7 @@ mod conv;
|
||||
mod dcomp;
|
||||
mod descriptor;
|
||||
mod device;
|
||||
pub mod disk_cache;
|
||||
mod device_creation;
|
||||
mod instance;
|
||||
mod pipeline_desc;
|
||||
@@ -1353,6 +1354,16 @@ pub(super) enum CompiledShader {
|
||||
}
|
||||
|
||||
impl CompiledShader {
|
||||
/// The bytecode, as [`disk_cache`] keeps it.
|
||||
fn bytes(&self) -> &[u8] {
|
||||
let code = self.create_native_shader();
|
||||
if code.BytecodeLength == 0 {
|
||||
return &[];
|
||||
}
|
||||
// SAFETY: the blob or vector `code` points into lives as long as `self`.
|
||||
unsafe { core::slice::from_raw_parts(code.pShaderBytecode.cast(), code.BytecodeLength) }
|
||||
}
|
||||
|
||||
fn create_native_shader(&self) -> Direct3D12::D3D12_SHADER_BYTECODE {
|
||||
match self {
|
||||
CompiledShader::Dxc(shader) => Direct3D12::D3D12_SHADER_BYTECODE {
|
||||
|
||||
@@ -104,6 +104,15 @@ impl CompilerContainer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which compiler made a shader, for [`super::disk_cache`]'s keys.
|
||||
pub(super) fn cache_name(&self) -> &'static str {
|
||||
match self {
|
||||
CompilerContainer::Fxc(_) => "fxc",
|
||||
CompilerContainer::DynamicDxc(_) => "dxc",
|
||||
CompilerContainer::StaticDxc(_) => "static-dxc",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn compile(
|
||||
&self,
|
||||
device: &super::Device,
|
||||
|
||||
@@ -1191,8 +1191,21 @@ def compute():
|
||||
]
|
||||
|
||||
|
||||
def scoreboard_remainder_boundaries():
|
||||
operations = setup('/scoreboard objectives add boundary dummy')
|
||||
for dividend, divisor in [(2147483646, 2147483647), (7, -3), (-2147483648, -1)]:
|
||||
operations += setup(f'/scoreboard players set left boundary {dividend}',
|
||||
f'/scoreboard players set right boundary {divisor}')
|
||||
operations += [cmd('/scoreboard players operation left boundary %= right boundary'),
|
||||
cmd('/scoreboard players get left boundary'),
|
||||
cmd('/scoreboard players get right boundary')]
|
||||
operations += setup('/scoreboard objectives remove boundary')
|
||||
return operations
|
||||
|
||||
|
||||
def social():
|
||||
return [
|
||||
*scoreboard_remainder_boundaries(),
|
||||
*setup('/scoreboard objectives add trig trigger', '/scoreboard objectives add plain dummy'),
|
||||
cmd('/trigger trig'),
|
||||
cmd('/scoreboard players enable @s trig'),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "item_expire",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_expire",
|
||||
"neoforge": "ItemExpireEvent",
|
||||
"side": "server",
|
||||
"cancellable": false,
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "a dropped item reaching its lifetime (`native_item_expire`).",
|
||||
"doc_summary": "`native_item_expire(entity, stack, extra_life)`, a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`): every mod may set or add to `extra_life`, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Extensions are capped at 32766 ticks. Bundles need \"hooks\": @API:item_expire@."
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "anvil_craft_post",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_craft_post",
|
||||
"side": "server",
|
||||
"neoforge": "AnvilCraftEvent.Post",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "A player finished taking an anvil's result (`native_anvil_craft_post`).",
|
||||
"doc_summary": "A player took an anvil's result: `native_anvil_craft_post(player, name, pos, craft)` (NeoForge's `AnvilCraftEvent.Post`) is heard after the levels, the inputs and the anvil wear are done, so a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not heard when `native_anvil_craft_pre` cancelled. Bundles need \"hooks\": @API:native_anvil_craft_post@."
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "anvil_craft_pre",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_craft_pre",
|
||||
"side": "server",
|
||||
"cancellable": true,
|
||||
"neoforge": "AnvilCraftEvent.Pre",
|
||||
"mod_api": {
|
||||
"contract": "cancel",
|
||||
"out": "cancel"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "A player took an anvil's result (`native_anvil_craft_pre`).",
|
||||
"doc_summary": "A player took an anvil's result: `native_anvil_craft_pre(player, name, pos, craft, cancel)` (NeoForge's `AnvilCraftEvent.Pre`) runs before the levels are charged, the inputs used up and the anvil worn. `craft` holds the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip the matching `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Bundles need \"hooks\": @API:native_anvil_craft_pre@."
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"hooks": [],
|
||||
"helpers": [
|
||||
{
|
||||
"name": "instance_dir",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_instance_dir"
|
||||
}
|
||||
],
|
||||
"rust_summary": "the title's own screens to the screen hooks, and the instance folder helper (`native_instance_dir`).",
|
||||
"doc_summary": "the title's own screens to the screen hooks. `NativeScreenView.id` gains one id per title state (`title`, `world_select`, `world_create`, `world_create_flat`, `world_create_buffet`, `world_edit`, `world_backup`, `world_backup_confirm`, `world_optimize_confirm`, `world_optimize`, `low_disk_space`, `world_delete`, `world_recover`, `world_recovered`, `error`, `multiplayer`, `add_server`, `edit_server`, `direct_connect`, `server_delete`, `friends_join`, `disconnected`, `multiplayer_warning`, `onboarding`, `ban_notice`), with no `menu`, no `title` and no `slots`, and the whole window as `rect`. `native_screen_render_post` fires after the title draws its screen (before its toasts), and the `_pre` input hooks — mouse buttons, drag, wheel, key press and release, character typed — fire on it and can cancel it as on any screen, so a mod draws its own button or browser over Create New World and takes the clicks. A dialog, a prompt or a server's pack prompt over the title keeps them away, and typing still goes to the title's own boxes (the key's text stays on the key there, as on Options, so `native_screen_char_typed_pre` does not fire from the keyboard on the title's own screens; the control socket's `type` now types into them too). `native_instance_dir()` is the instance folder, where `options.txt`, `saves/`, `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are; a world's `datapacks/` is in that world's folder. Bundles that use them need `\"hooks\": @API:native_instance_dir@`."
|
||||
}
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user