mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #7245
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
73f3e0e888
154 files changed
+6768
-530
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",
|
||||
]
|
||||
|
||||
@@ -688,4 +688,15 @@ mod tests {
|
||||
let (v, _) = parse_snbt("{n:1.5}").unwrap();
|
||||
assert_eq!(v["n"], Value::from(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_text_preserves_legacy_and_decodes_components() {
|
||||
let legacy = Tag::String("§6Golden Name".into());
|
||||
let text = name_text(&legacy);
|
||||
assert_eq!(text.plain(), "Golden Name");
|
||||
|
||||
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
|
||||
let text = name_text(&comp);
|
||||
assert_eq!(text.plain(), "Custom Name");
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(".png"))
|
||||
.map(|rel| format!("{namespace}:{rel}")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
|
||||
@@ -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:
|
||||
@@ -156,9 +203,121 @@ impl Input<'_> {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Mesh {
|
||||
key: Option<MeshKey>,
|
||||
cells: Vec<(i32, i32)>,
|
||||
cached_radius: Option<i32>,
|
||||
pub vertices: Vec<crate::mesher::Vertex>,
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn push_quad(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
p: [[f32; 3]; 4],
|
||||
shade: u32,
|
||||
normal: [i8; 4],
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
for pos in p {
|
||||
vertices.push(crate::mesher::Vertex {
|
||||
normal,
|
||||
pos,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_face(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
direction: usize,
|
||||
inside: bool,
|
||||
x0: f32,
|
||||
z0: f32,
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
let x1 = x0 + 12.0;
|
||||
let z1 = z0 + 12.0;
|
||||
const Y0: f32 = 0.0;
|
||||
const Y1: f32 = 4.0;
|
||||
let (p, shade, normal) = match direction {
|
||||
0 => (
|
||||
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
|
||||
1,
|
||||
[0, -127, 0, 0],
|
||||
),
|
||||
1 => (
|
||||
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
|
||||
0,
|
||||
[0, 127, 0, 0],
|
||||
),
|
||||
2 => (
|
||||
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
|
||||
2,
|
||||
[0, 0, -127, 0],
|
||||
),
|
||||
3 => (
|
||||
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
|
||||
2,
|
||||
[0, 0, 127, 0],
|
||||
),
|
||||
4 => (
|
||||
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
|
||||
3,
|
||||
[-127, 0, 0, 0],
|
||||
),
|
||||
_ => (
|
||||
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
|
||||
3,
|
||||
[127, 0, 0, 0],
|
||||
),
|
||||
};
|
||||
if inside {
|
||||
push_quad(
|
||||
vertices,
|
||||
[p[3], p[2], p[1], p[0]],
|
||||
shade,
|
||||
normal.map(|c| -c),
|
||||
uv,
|
||||
);
|
||||
} else {
|
||||
push_quad(vertices, p, shade, normal, uv);
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_faces(
|
||||
vertices: &mut Vec<crate::mesher::Vertex>,
|
||||
mask: u16,
|
||||
x0: f32,
|
||||
z0: f32,
|
||||
uv: [f32; 2],
|
||||
) {
|
||||
for d in [1, 0, 2, 3, 4, 5] {
|
||||
if mask & (1 << d) != 0 {
|
||||
emit_face(vertices, d, false, x0, z0, uv);
|
||||
}
|
||||
}
|
||||
if mask >> 6 != 0 {
|
||||
for d in 0..6 {
|
||||
if mask & (1 << (d + 6)) != 0 {
|
||||
emit_face(vertices, d, true, x0, z0, uv);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
|
||||
let x1 = x0 + 12.0;
|
||||
let z1 = z0 + 12.0;
|
||||
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
|
||||
push_quad(vertices, p, 0, [0; 4], uv);
|
||||
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Returns true only when vertices must be uploaded. Sub-cell motion and
|
||||
/// color changes update only the uniform, as in CloudRenderer.prepare.
|
||||
@@ -167,80 +326,39 @@ impl Mesh {
|
||||
if self.key.as_ref() == Some(&key) {
|
||||
return (false, uniform);
|
||||
}
|
||||
if self.cached_radius != Some(key.radius) {
|
||||
self.cells = self::cells(key.radius).collect();
|
||||
self.cached_radius = Some(key.radius);
|
||||
}
|
||||
self.vertices.clear();
|
||||
let (w, h, cells) = input.texture;
|
||||
let (w, h) = (i64::from(*w), i64::from(*h));
|
||||
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
|
||||
|
||||
let (w, h, tex_cells) = input.texture;
|
||||
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
|
||||
let bottom = uniform.offset[1];
|
||||
let uv = input.uv;
|
||||
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
|
||||
for (dx, dz) in self::cells(radius) {
|
||||
let (dx, dz) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx, cell_z + dz);
|
||||
|
||||
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
|
||||
let filled = |cx: i64, cz: i64| {
|
||||
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
|
||||
};
|
||||
|
||||
for &(dx, dz) in &self.cells {
|
||||
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
|
||||
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
|
||||
if !filled(cx, cz) {
|
||||
continue;
|
||||
}
|
||||
let x0 = dx as f32 * 12.0;
|
||||
let z0 = dz as f32 * 12.0;
|
||||
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
|
||||
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
|
||||
// `shade`.
|
||||
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
|
||||
for v in p {
|
||||
// Overlay 4: clouds (unlit, faded by the cloud fog end).
|
||||
self.vertices.push(crate::mesher::Vertex {
|
||||
normal,
|
||||
pos: v,
|
||||
uv,
|
||||
layer: 4,
|
||||
color: u32::MAX,
|
||||
light: shade,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
}
|
||||
};
|
||||
if input.fast {
|
||||
for p in flat_cell(x0, z0, 0.0) {
|
||||
quad(p, 0, [0; 4]);
|
||||
}
|
||||
emit_flat_cell(&mut self.vertices, x0, z0, uv);
|
||||
continue;
|
||||
}
|
||||
let quads = [
|
||||
(
|
||||
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
|
||||
1,
|
||||
[0, -127, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
|
||||
0,
|
||||
[0, 127, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
|
||||
2,
|
||||
[0, 0, -127, 0],
|
||||
),
|
||||
(
|
||||
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
|
||||
2,
|
||||
[0, 0, 127, 0],
|
||||
),
|
||||
(
|
||||
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
|
||||
3,
|
||||
[-127, 0, 0, 0],
|
||||
),
|
||||
(
|
||||
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
|
||||
3,
|
||||
[127, 0, 0, 0],
|
||||
),
|
||||
];
|
||||
|
||||
let mask = face_mask(
|
||||
dx as i32,
|
||||
dz as i32,
|
||||
dx,
|
||||
dz,
|
||||
bottom,
|
||||
[
|
||||
!filled(cx, cz - 1),
|
||||
@@ -249,15 +367,9 @@ impl Mesh {
|
||||
!filled(cx + 1, cz),
|
||||
],
|
||||
);
|
||||
for (direction, inside) in faces(mask) {
|
||||
let (p, shade, n) = quads[usize::from(direction)];
|
||||
if inside {
|
||||
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
|
||||
} else {
|
||||
quad(p, shade, n);
|
||||
}
|
||||
}
|
||||
emit_faces(&mut self.vertices, mask, x0, z0, uv);
|
||||
}
|
||||
|
||||
self.key = Some(key);
|
||||
(true, uniform)
|
||||
}
|
||||
|
||||
@@ -327,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.
|
||||
@@ -340,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,
|
||||
@@ -819,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);
|
||||
|
||||
@@ -115,6 +115,10 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
|
||||
pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
|
||||
/// enough for a world map's 1024 blocks at a block a pixel.
|
||||
pub const MOD_IMAGE_SPRITE: &str = "horizon/modimage";
|
||||
pub const MOD_IMAGE_SIZE: u32 = 1024;
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
/// Vanilla registers a texture per skin. Here the slots are shared
|
||||
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
|
||||
@@ -193,6 +197,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png"))
|
||||
.map(|file| dir.join(file)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
@@ -671,13 +685,21 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
|
||||
Self::build_over_with_image(root, defaults, false)
|
||||
}
|
||||
/// The atlas above, with a slot for a native mod's own image
|
||||
/// (`horizon/modimage`) only when `mod_image` says a mod is loaded that
|
||||
/// draws one: the slot costs 4 MiB of pixels, so a game without native
|
||||
/// mods holds none of them.
|
||||
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
/// Empty slots written at run time: map textures (game/maps.rs), the
|
||||
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
|
||||
/// minimap, a native mod's own image, downloaded skins, world and pack
|
||||
/// icons and the on-screen keyboard's text.
|
||||
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
|
||||
for i in 0..MAP_SLOTS {
|
||||
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
|
||||
}
|
||||
@@ -688,6 +710,15 @@ impl HudAtlas {
|
||||
crate::minimap::SIZE as u32,
|
||||
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
|
||||
));
|
||||
// A mod's own image (native `mod_image`), only when a mod is loaded.
|
||||
if mod_image {
|
||||
images.push((
|
||||
MOD_IMAGE_SPRITE.into(),
|
||||
MOD_IMAGE_SIZE,
|
||||
MOD_IMAGE_SIZE,
|
||||
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
|
||||
));
|
||||
}
|
||||
// Downloaded skins (game/scoreboard.rs).
|
||||
for i in 0..SKIN_SLOTS {
|
||||
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
|
||||
@@ -730,7 +761,7 @@ impl HudAtlas {
|
||||
/// holding its textures. The pixels are dropped once the glyph widths are
|
||||
/// read, since nothing uploads them.
|
||||
pub fn fonts(root: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, root, &["font"], false)?;
|
||||
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
|
||||
atlas.pixels = Vec::new();
|
||||
Ok(atlas)
|
||||
}
|
||||
@@ -876,7 +907,13 @@ impl HudAtlas {
|
||||
}
|
||||
/// Pack the textures under these folders of `textures/`, and for the
|
||||
/// `full` atlas the generated sprites and the run-time slots too.
|
||||
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
|
||||
fn pack(
|
||||
root: &Path,
|
||||
defaults: &Path,
|
||||
folders: &[&str],
|
||||
full: bool,
|
||||
mod_image: bool,
|
||||
) -> Result<Self> {
|
||||
let textures = root.join("assets/minecraft/textures");
|
||||
let default_textures =
|
||||
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
|
||||
@@ -1107,7 +1144,7 @@ impl HudAtlas {
|
||||
}
|
||||
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
|
||||
if full {
|
||||
Self::slots(&mut images, crate::trims::slot_size(root));
|
||||
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
|
||||
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
|
||||
// and black quarters.
|
||||
let missing = (0..256)
|
||||
@@ -2622,6 +2659,16 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The mod image slot is paid only by a game with a native mod loaded.
|
||||
#[test]
|
||||
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
|
||||
let root = Path::new("/nonexistent/horizon-test-assets");
|
||||
let plain = HudAtlas::build(root).unwrap();
|
||||
assert!(!plain.sprites.contains_key(MOD_IMAGE_SPRITE));
|
||||
let with_mod = HudAtlas::build_over_with_image(root, root, true).unwrap();
|
||||
assert!(with_mod.sprites.contains_key(MOD_IMAGE_SPRITE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
|
||||
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1987,6 +1987,14 @@ impl Streaming {
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let positions = if positions.len() > INLINE_RELIGHT_MAX {
|
||||
self.relight_on_workers(world, positions)
|
||||
} else {
|
||||
positions
|
||||
};
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut changed = LightChanges::default();
|
||||
crate::light::relight(
|
||||
&mut world.chunks,
|
||||
@@ -2005,6 +2013,25 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Hands a big batch of edits (a `/fill` arriving block by block) to the
|
||||
/// workers: the columns they touch and their neighbours get full light
|
||||
/// jobs, as `World::edit_runs` does, instead of relighting every changed
|
||||
/// cell in the frame (clearing a 64x64x16 volume in the air relit the
|
||||
/// sky below it: 43 ms of one frame). The sections wait for those jobs
|
||||
/// before they are meshed again. Returns the edits left to light in the
|
||||
/// frame: those in columns that can't be lit whole yet (the edge of the
|
||||
/// loaded area).
|
||||
fn relight_on_workers(
|
||||
&self,
|
||||
world: &mut World,
|
||||
positions: Vec<(i32, i32, i32)>,
|
||||
) -> Vec<(i32, i32, i32)> {
|
||||
let (columns, inline) = whole_relight_columns(positions, |x, z| {
|
||||
self.lit.contains(&(x, z)) && self.light_region_ready(world, x, z)
|
||||
});
|
||||
world.dirty_light.extend(columns);
|
||||
inline
|
||||
}
|
||||
/// A light job computed from blocks older than some edits: redo those
|
||||
/// edits on top of its result.
|
||||
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
|
||||
@@ -2693,6 +2720,36 @@ fn same_values(
|
||||
x == y
|
||||
}
|
||||
|
||||
/// The most changed blocks one frame relights itself (`Streaming::relight_edits`): a 16^3
|
||||
/// section's worth. Past it, the columns are lit again whole on the workers.
|
||||
const INLINE_RELIGHT_MAX: usize = 4096;
|
||||
|
||||
/// Splits edits at `positions` into the columns to light again whole and the
|
||||
/// edits to relight in place. An edited column is lit whole with its
|
||||
/// neighbours (light reaches 15 blocks, never past the next column) when all
|
||||
/// nine are `ready` to be lit whole; otherwise its edits stay in place.
|
||||
fn whole_relight_columns(
|
||||
positions: Vec<(i32, i32, i32)>,
|
||||
ready: impl Fn(i32, i32) -> bool,
|
||||
) -> (FxHashSet<(i32, i32)>, Vec<(i32, i32, i32)>) {
|
||||
let around =
|
||||
|(x, z): (i32, i32)| (-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)));
|
||||
let touched: FxHashSet<(i32, i32)> = positions
|
||||
.iter()
|
||||
.map(|&(x, _, z)| (x >> 4, z >> 4))
|
||||
.collect();
|
||||
let whole: FxHashSet<(i32, i32)> = touched
|
||||
.into_iter()
|
||||
.filter(|&column| around(column).all(|(x, z)| ready(x, z)))
|
||||
.collect();
|
||||
let columns = whole.iter().flat_map(|&column| around(column)).collect();
|
||||
let inline = positions
|
||||
.into_iter()
|
||||
.filter(|&(x, _, z)| !whole.contains(&(x >> 4, z >> 4)))
|
||||
.collect();
|
||||
(columns, inline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2840,6 +2897,31 @@ mod tests {
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
/// Edits in two neighbouring columns light those and the ten around
|
||||
/// them again; a column next to one that can't be lit whole keeps its
|
||||
/// edits in the frame.
|
||||
#[test]
|
||||
fn whole_relights_cover_the_edited_columns_and_their_neighbours() {
|
||||
// Blocks in columns (0, 0) and (1, 0).
|
||||
let positions = vec![(3, 70, 4), (15, 64, 15), (16, 80, 0), (31, 64, 9)];
|
||||
let (columns, inline) = whole_relight_columns(positions.clone(), |_, _| true);
|
||||
let mut columns: Vec<_> = columns.into_iter().collect();
|
||||
columns.sort();
|
||||
let expected: Vec<(i32, i32)> = (-1..=2)
|
||||
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
|
||||
.collect();
|
||||
assert_eq!(columns, expected);
|
||||
assert!(inline.is_empty());
|
||||
// (2, 1) is next to (1, 0) only: (0, 0) is still lit whole.
|
||||
let (columns, inline) = whole_relight_columns(positions, |x, z| (x, z) != (2, 1));
|
||||
assert_eq!(inline, vec![(16, 80, 0), (31, 64, 9)]);
|
||||
assert_eq!(columns.len(), 9);
|
||||
assert!(columns.contains(&(-1, -1)) && columns.contains(&(1, 1)));
|
||||
// Negative coordinates floor to their column.
|
||||
let (west, _) = whole_relight_columns(vec![(-1, 64, -17)], |_, _| true);
|
||||
assert!(west.contains(&(-2, -3)) && west.contains(&(0, -1)) && west.len() == 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -223,6 +223,7 @@ pub struct Session {
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -250,8 +251,21 @@ impl Session {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
{
|
||||
path = rest.to_owned();
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
@@ -281,6 +295,7 @@ impl Session {
|
||||
to_world_ms: self.to_world_ms,
|
||||
to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)),
|
||||
to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)),
|
||||
slow_frames: self.slow_frames.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,10 +505,10 @@ mod tests {
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
}
|
||||
|
||||
@@ -502,7 +517,7 @@ mod tests {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
@@ -512,11 +527,11 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6));
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
@@ -537,4 +552,34 @@ mod tests {
|
||||
assert!(perf.to_world_ms.is_some());
|
||||
assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024);
|
||||
}
|
||||
|
||||
/// Frames of 250 ms or more count under the step that took most of them; a frame with no
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ use crate::{
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
text::Text,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::RwLock,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
@@ -64,37 +69,53 @@ pub fn set_metadata(metadata: UiMetadata) {
|
||||
pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let mut map = HashMap::new();
|
||||
let base = root.join("assets/minecraft/textures");
|
||||
let mut stack = vec![base.join("gui/sprites")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
let sprites = base.join("gui/sprites");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
|
||||
Some(files) => files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| sprites.join(file))
|
||||
.collect(),
|
||||
None => {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![sprites];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
};
|
||||
for p in files {
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
UiMetadata(map)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -292,7 +292,10 @@ 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) {
|
||||
if !self.on_ground
|
||||
|| horizontal > 1.0e-5
|
||||
|| self.ticks.wrapping_add(self.id).is_multiple_of(4)
|
||||
{
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
@@ -439,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
|
||||
@@ -546,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),
|
||||
@@ -683,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
|
||||
@@ -727,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);
|
||||
@@ -1776,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,
|
||||
@@ -1800,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,
|
||||
}
|
||||
@@ -2052,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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2011,7 +2011,10 @@ impl UiResources {
|
||||
horizon_client::gfx::panorama::Faces::load(root, defaults)
|
||||
})
|
||||
});
|
||||
let atlas = startup::time("ui.atlas", || HudAtlas::build_over(root, defaults));
|
||||
let mod_image = !horizon_client::mod_api::mods().is_empty();
|
||||
let atlas = startup::time("ui.atlas", || {
|
||||
HudAtlas::build_over_with_image(root, defaults, mod_image)
|
||||
});
|
||||
let panorama = panorama
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
@@ -4009,9 +4012,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());
|
||||
@@ -4086,6 +4086,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,
|
||||
@@ -4095,6 +4098,22 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let item = &mut items[i];
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
|
||||
let mut extra_life = 0;
|
||||
self.native_item_expire(item.id, &item.stack, &mut extra_life);
|
||||
if extra_life > 0 {
|
||||
item.age = 6000 - extra_life.min(32766);
|
||||
alive = true;
|
||||
} else if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_item_despawn(
|
||||
item.id,
|
||||
&item.stack.item,
|
||||
item.stack.count as i32,
|
||||
[item.position.x, item.position.y, item.position.z],
|
||||
);
|
||||
}
|
||||
}
|
||||
if !alive || removed[i] {
|
||||
removed[i] = true;
|
||||
cells.remove(i, item.position);
|
||||
@@ -4106,8 +4125,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",
|
||||
@@ -8087,6 +8105,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);
|
||||
@@ -8432,6 +8454,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.
|
||||
@@ -8746,9 +8783,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);
|
||||
@@ -13328,9 +13371,12 @@ impl Game<'_> {
|
||||
/// sound every fifth level (at most every 100 ticks).
|
||||
pub(super) fn give_orb_points(&mut self, gained: u32) {
|
||||
let at = self.eye_position();
|
||||
let old_level = self.experience.level;
|
||||
let gave = self.experience.give(gained as i32);
|
||||
self.native_player_level_changed(old_level);
|
||||
// Player.giveExperienceLevels runs per level, so the first multiple of 5
|
||||
// crossed plays the sound (and starts the cooldown for the rest).
|
||||
if let Some(level) = self.experience.give(gained as i32)
|
||||
if let Some(level) = gave
|
||||
&& self.ticks > self.last_level_up + 100
|
||||
{
|
||||
self.last_level_up = self.ticks;
|
||||
@@ -14818,14 +14864,14 @@ impl Game<'_> {
|
||||
// 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");
|
||||
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");
|
||||
dedicated::events::record_server("saved", serde_json::json!({}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -16799,14 +16845,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 {
|
||||
@@ -17784,12 +17835,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:
|
||||
@@ -17893,7 +17951,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();
|
||||
@@ -20727,6 +20786,14 @@ pub fn run_with() -> Result<()> {
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
if options.execution_role.is_client()
|
||||
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
|
||||
.allows(horizon_telemetry::Kind::Perf)
|
||||
{
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
|
||||
@@ -614,6 +614,11 @@ impl Game<'_> {
|
||||
let criterion = &a.criteria[ci].name;
|
||||
if self.adv.progress.grant(&a.id, criterion, &date) {
|
||||
self.advancement_progress(&a.id, criterion, true);
|
||||
if let (Some(player), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_advancement_criterion_grant(player, &name, &a.id, criterion);
|
||||
}
|
||||
}
|
||||
self.progress_changed(ai);
|
||||
if !was && self.adv.progress.done(a) {
|
||||
@@ -740,7 +745,9 @@ impl Game<'_> {
|
||||
}
|
||||
// AdvancementRewards.grant
|
||||
if a.rewards.experience > 0 {
|
||||
let old_level = self.experience.level;
|
||||
self.experience.give(a.rewards.experience);
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
|
||||
let rewards: Vec<String> = a
|
||||
|
||||
@@ -1067,7 +1067,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
self.armor_stand_items(a, model, base, &posed, packed, out);
|
||||
self.armor_stand_items(a, model, base, &posed, packed, skin.as_deref_mut(), out);
|
||||
// HumanoidArmorLayer: head, chest, legs, feet. One pose map for every layer
|
||||
// (`reset` keeps its capacity).
|
||||
let mut layer_poses = Poses::default();
|
||||
@@ -1380,7 +1380,9 @@ impl Game<'_> {
|
||||
impl Game<'_> {
|
||||
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
|
||||
/// items in the third-person contexts, and a non-armour head item on the
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
|
||||
/// head (`translateToHead`: down a quarter, turned, 0.625 scale). With
|
||||
/// `skin`, items that can are posed on the GPU with the stand.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn armor_stand_items(
|
||||
&self,
|
||||
a: &ArmorStand,
|
||||
@@ -1388,8 +1390,21 @@ impl Game<'_> {
|
||||
base: glam::Mat4,
|
||||
poses: &Poses,
|
||||
light: u32,
|
||||
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
out: &mut Vec<Vertex>| {
|
||||
let on_gpu = skin
|
||||
.as_deref_mut()
|
||||
.is_some_and(|skin| self.skin_item(state, pose, light, lights, skin));
|
||||
if !on_gpu {
|
||||
self.submit_item_world(state, pose, light, out);
|
||||
}
|
||||
};
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::mobs::render::part_matrix;
|
||||
let owner = super::item_draw::owner_at(
|
||||
@@ -1429,7 +1444,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.resolve_item(stack, context, Some(&owner), seed)
|
||||
};
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
submit(&state, pose, out);
|
||||
}
|
||||
// `CustomHeadLayer`: a worn skull draws the skull model.
|
||||
if let Some(stack) = &a.equipment[5]
|
||||
@@ -1490,7 +1505,7 @@ impl Game<'_> {
|
||||
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
|
||||
let state =
|
||||
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
|
||||
self.submit_item_world(&state, pose, light, out);
|
||||
submit(&state, pose, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1121,6 +1121,16 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert(key);
|
||||
self.boats.push(boat);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_vehicle_create(
|
||||
self.boats.last().unwrap().entity_type().as_str(),
|
||||
[
|
||||
self.boats.last().unwrap().position.x,
|
||||
self.boats.last().unwrap().position.y,
|
||||
self.boats.last().unwrap().position.z,
|
||||
],
|
||||
);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -1345,18 +1355,24 @@ impl Game<'_> {
|
||||
/// reason that destroys the boat (DISCARDED included), while the boat item
|
||||
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
|
||||
/// a creative hit discards it. True when the boat was removed.
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
|
||||
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return false;
|
||||
}
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_dir = -b.hurt_dir;
|
||||
b.hurt_time = 10;
|
||||
b.damage += damage * 10.0;
|
||||
let destroyed = creative || b.damage > 40.0;
|
||||
if destroyed {
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
self.native_vehicle_destroy("boat", at);
|
||||
self.eject_boat_mobs(i, true);
|
||||
let id = self.boats[i].id;
|
||||
self.release_vehicle_container(id);
|
||||
let b = self.boats.remove(i);
|
||||
let at = [b.position.x, b.position.y, b.position.z];
|
||||
// AbstractBoat.remove: a destroyed boat drops its lead.
|
||||
self.boat_removed(&b);
|
||||
// `AbstractChestBoat.remove` spills the chest through
|
||||
|
||||
@@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec<String> {
|
||||
|
||||
/// The `namespace:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
let suffix = format!(".{extension}");
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(root) {
|
||||
let mut out: Vec<String> = files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(&suffix))
|
||||
.filter(|path| !path.is_empty() && !path.ends_with('/'))
|
||||
.map(|path| format!("{namespace}:{path}"))
|
||||
.collect();
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
|
||||
@@ -159,6 +159,17 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
i32::try_from(id).ok()
|
||||
}
|
||||
impl Game<'_> {
|
||||
/// `native_player_level_change` for a level that was `from` before an experience change.
|
||||
pub(super) fn native_player_level_changed(&mut self, from: i32) {
|
||||
let to = self.experience.level;
|
||||
if from != to
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
self.native_player_level_change(player, &name, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands.performCommand for a line without its slash.
|
||||
pub(super) fn run_command(&mut self, command: &str) -> bool {
|
||||
if self.client_mod_command(command) {
|
||||
@@ -2175,7 +2186,8 @@ impl Game<'_> {
|
||||
if self.ui.difficulty == 0 && t.category == "monster" {
|
||||
return Err(err("commands.summon.failed.peaceful", &[]));
|
||||
}
|
||||
self.spawn_mob_spawned(&short, pos);
|
||||
self.spawn_mob_spawned(&short, pos)
|
||||
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
|
||||
name = Text::translate(&entity_name_key(&kind), &[]);
|
||||
} else {
|
||||
match short.as_str() {
|
||||
@@ -2250,7 +2262,11 @@ impl Game<'_> {
|
||||
let empty = horizon_world::nbt::Tag::Compound(Default::default());
|
||||
cart.spawner = Some(Box::new(Spawner::load(&empty)));
|
||||
}
|
||||
let id = cart.id;
|
||||
self.carts.push(cart);
|
||||
if !self.native_joins_flush(false, id) {
|
||||
return Err(err("commands.summon.failed.uuid", &[]));
|
||||
}
|
||||
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
|
||||
}
|
||||
// SummonCommand.createEntity: any other type from an empty compound.
|
||||
@@ -2731,6 +2747,7 @@ impl Game<'_> {
|
||||
Ok(count)
|
||||
}
|
||||
/// Player.giveExperiencePoints including negative amounts.
|
||||
/// The caller reports the level change to the mods.
|
||||
fn give_points(&mut self, amount: i32) {
|
||||
let e = &mut self.experience;
|
||||
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
|
||||
@@ -2916,6 +2933,7 @@ impl Game<'_> {
|
||||
/// ExperienceCommand's add and set on the player in place: false when
|
||||
/// the points to set do not fit the level.
|
||||
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
|
||||
let old_level = self.experience.level;
|
||||
if add {
|
||||
if levels {
|
||||
self.experience.level = (self.experience.level + amount).max(0);
|
||||
@@ -2934,8 +2952,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.experience.progress = amount as f32 / needed as f32;
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
true
|
||||
}
|
||||
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
|
||||
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
|
||||
/// tells the management protocol's clients).
|
||||
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
if self.options.dedicated.is_some() {
|
||||
let (value_type, json) = match value {
|
||||
Value::Bool(b) => ("boolean", serde_json::json!(b)),
|
||||
Value::Int(i) => ("integer", serde_json::json!(i)),
|
||||
};
|
||||
super::dedicated::events::record_server(
|
||||
"gamerule",
|
||||
serde_json::json!({
|
||||
"key": format!("minecraft:{}", rule.name),
|
||||
"valueType": value_type,
|
||||
"value": json,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
|
||||
let Some(name) = ctx.literals.get(1).cloned() else {
|
||||
return Ok(0);
|
||||
@@ -2958,8 +2998,7 @@ impl Game<'_> {
|
||||
if current == value {
|
||||
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
|
||||
}
|
||||
self.ui.rules.set(rule.name, value);
|
||||
self.sync_rules();
|
||||
self.set_game_rule(rule, value);
|
||||
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
|
||||
Ok(match value {
|
||||
Value::Bool(b) => i32::from(b),
|
||||
|
||||
@@ -276,7 +276,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(in crate::game) fn tick_worldborder(&mut self) {
|
||||
let remaining = self.border.remaining_ticks();
|
||||
self.border.tick();
|
||||
if remaining > 0
|
||||
&& self.border.remaining_ticks() == 0
|
||||
&& (self.serving.is_some() || self.options.dedicated.is_some())
|
||||
{
|
||||
self.native_world_border_bounds_change_finish();
|
||||
}
|
||||
self.player_border();
|
||||
self.border_damage();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -2305,21 +2312,29 @@ impl Game<'_> {
|
||||
thrown.thrower = Some(thrower);
|
||||
}
|
||||
}
|
||||
MobEvent::DropItem { item, count, at } => self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
MobEvent::DropItem {
|
||||
mob,
|
||||
item,
|
||||
count,
|
||||
at,
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
),
|
||||
} => {
|
||||
self.native_entity_drop_item(mob, &item, count as i32, at);
|
||||
self.spawn_item(
|
||||
ItemStack {
|
||||
item,
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
// The ItemEntity constructor's motion, from the entity's
|
||||
// own random (not the level's), so spawnAtLocation drops
|
||||
// (a fox's berries, a panda's held food, a goat's horn)
|
||||
// land apart.
|
||||
dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
MobEvent::DropGoatHorn {
|
||||
mob,
|
||||
screaming,
|
||||
@@ -6312,7 +6327,7 @@ impl Game<'_> {
|
||||
&self,
|
||||
out: &mut Vec<Vertex>,
|
||||
entity_start: usize,
|
||||
skinned: Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>,
|
||||
skinned: Skinned,
|
||||
) {
|
||||
let corners = skinned.and_then(|(_, bounds)| bounds);
|
||||
if let Some((lo, hi)) = corners {
|
||||
@@ -6564,7 +6579,9 @@ impl Game<'_> {
|
||||
}
|
||||
// CustomHeadLayer (a pumpkin, skull or head item) included.
|
||||
if layers {
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, out);
|
||||
// Held items join the GPU path's draws unless an outline reads their vertices.
|
||||
let skin = (skinning && outline.is_none()).then_some(&mut skinned);
|
||||
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
|
||||
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
|
||||
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -1794,6 +1831,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setMaxPlayers`: the live login limit, and `max-players` in
|
||||
/// server.properties.
|
||||
fn set_max_players(&mut self, state: &mut ServerState, max_players: usize) {
|
||||
state.max_players = max_players;
|
||||
if let Some(lan) = &self.lan {
|
||||
lan.hub.lan.shared.set_max_players(max_players);
|
||||
}
|
||||
self.store_property("max-players", max_players.to_string());
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().enforce_whitelist = on;
|
||||
self.store_property("enforce-whitelist", on.to_string());
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
pub(super) fn store_property(&mut self, key: &str, value: String) {
|
||||
let path = PathBuf::from("server.properties");
|
||||
|
||||
@@ -26,9 +26,10 @@ pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Va
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save).
|
||||
pub(in crate::game) fn record_server(kind: &str) {
|
||||
push(kind, json!({}));
|
||||
/// 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) {
|
||||
@@ -76,7 +77,7 @@ mod tests {
|
||||
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");
|
||||
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));
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -120,6 +172,63 @@ impl Game<'_> {
|
||||
self.set_force_game_mode(force);
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/max_players" => {
|
||||
return json!({ "value": state.max_players });
|
||||
}
|
||||
"serversettings/max_players/set" => {
|
||||
let Some(max_players) = body["value"]
|
||||
.as_i64()
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
else {
|
||||
return json!({ "error": "max players must be a non-negative integer" });
|
||||
};
|
||||
rpc(format!(
|
||||
"Update max players from {} to {}",
|
||||
state.max_players, max_players
|
||||
));
|
||||
self.set_max_players(state, max_players);
|
||||
return json!({ "value": state.max_players });
|
||||
}
|
||||
"serversettings/use_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/use_allowlist/set" => {
|
||||
let Some(use_allowlist) = body["value"].as_bool() else {
|
||||
return json!({ "error": "using allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().use_whitelist;
|
||||
rpc(format!(
|
||||
"Update using allowlist from {} to {}",
|
||||
current, use_allowlist
|
||||
));
|
||||
self.set_using_whitelist(&lists, use_allowlist);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist/set" => {
|
||||
let Some(enforce) = body["value"].as_bool() else {
|
||||
return json!({ "error": "enforce allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().enforce_whitelist;
|
||||
rpc(format!(
|
||||
"Update enforce allowlist from {} to {}",
|
||||
current, enforce
|
||||
));
|
||||
self.set_enforce_whitelist(&lists, enforce);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
@@ -451,3 +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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1216,6 +1216,13 @@ impl Game<'_> {
|
||||
}
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.boats.push(boat);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let boat = self.boats.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
boat.entity_type().as_str(),
|
||||
[boat.position.x, boat.position.y, boat.position.z],
|
||||
);
|
||||
}
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
/// `MinecartDispenseItemBehavior`.
|
||||
@@ -1255,6 +1262,13 @@ impl Game<'_> {
|
||||
);
|
||||
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
|
||||
self.carts.push(cart);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
cart.kind.id(),
|
||||
[cart.position.x, cart.position.y, cart.position.z],
|
||||
);
|
||||
}
|
||||
(shrink(stack), CLICK)
|
||||
}
|
||||
|
||||
|
||||
@@ -856,6 +856,9 @@ impl Game<'_> {
|
||||
damage_type: &str,
|
||||
resists: Option<([i32; 3], f32)>,
|
||||
) -> Result<()> {
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
self.native_explosion_prime(center, radius);
|
||||
}
|
||||
if let Some(p) = &self.player {
|
||||
let d = [
|
||||
p.position.x - center[0],
|
||||
|
||||
@@ -36,6 +36,7 @@ pub(super) struct Hook {
|
||||
pub(super) previous: Vec3,
|
||||
velocity: Vec3,
|
||||
state: State,
|
||||
tick_count: i32,
|
||||
life: i32,
|
||||
on_ground: bool,
|
||||
nibble: i32,
|
||||
@@ -64,6 +65,67 @@ fn bounds(p: Vec3) -> Aabb {
|
||||
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
|
||||
)
|
||||
}
|
||||
fn segment_entry(start: Vec3, end: Vec3, bounds: Aabb) -> Option<f64> {
|
||||
let min = bounds.min_position();
|
||||
let max = bounds.max_position();
|
||||
let start = [start.x, start.y, start.z];
|
||||
let min = [min.x, min.y, min.z];
|
||||
let max = [max.x, max.y, max.z];
|
||||
if (0..3).all(|axis| start[axis] >= min[axis] && start[axis] < max[axis]) {
|
||||
return None;
|
||||
}
|
||||
let delta = [end.x - start[0], end.y - start[1], end.z - start[2]];
|
||||
let mut entry: f64 = 0.0;
|
||||
let mut exit: f64 = 1.0;
|
||||
for axis in 0..3 {
|
||||
if delta[axis] == 0.0 {
|
||||
if start[axis] < min[axis] || start[axis] > max[axis] {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let a = (min[axis] - start[axis]) / delta[axis];
|
||||
let b = (max[axis] - start[axis]) / delta[axis];
|
||||
entry = entry.max(a.min(b));
|
||||
exit = exit.min(a.max(b));
|
||||
if entry > exit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
(entry <= 1.0 && exit >= 0.0).then_some(entry)
|
||||
}
|
||||
/// `ProjectileUtil.computeMargin`: the fishing hook's target-box margin grows
|
||||
/// to 0.3 blocks over its first eight ticks.
|
||||
fn entity_hit_margin(tick_count: i32) -> f64 {
|
||||
f64::from((tick_count - 2).clamp(0, 6)) / 20.0
|
||||
}
|
||||
fn first_entity_hit(
|
||||
start: Vec3,
|
||||
end: Vec3,
|
||||
margin: f64,
|
||||
targets: impl IntoIterator<Item = (u32, Aabb)>,
|
||||
) -> Option<u32> {
|
||||
let mut hit = None;
|
||||
let mut hit_at = f64::INFINITY;
|
||||
for (id, target_bounds) in targets {
|
||||
if let Some(at) = segment_entry(
|
||||
start,
|
||||
end,
|
||||
target_bounds.inflate(Vec3::new(margin, margin, margin)),
|
||||
) && at < hit_at
|
||||
{
|
||||
hit = Some(id);
|
||||
hit_at = at;
|
||||
}
|
||||
}
|
||||
hit
|
||||
}
|
||||
fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
|
||||
item.velocity = item.velocity.add_vector(delta);
|
||||
// The change is made outside ItemEntity::tick, so its velocity comparison
|
||||
// cannot request the immediate motion update for tracking clients.
|
||||
item.needs_sync = true;
|
||||
}
|
||||
impl Hook {
|
||||
pub(super) fn bounds(&self) -> Aabb {
|
||||
bounds(self.position)
|
||||
@@ -170,6 +232,7 @@ impl Game<'_> {
|
||||
previous: start,
|
||||
velocity: v,
|
||||
state: State::Flying,
|
||||
tick_count: 0,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
@@ -221,7 +284,7 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.velocity = pulled(m.velocity);
|
||||
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
|
||||
item.velocity = pulled(item.velocity);
|
||||
pull_item(item, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1));
|
||||
hooked_item = true;
|
||||
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
|
||||
t.velocity = pulled(t.velocity);
|
||||
@@ -566,6 +629,7 @@ impl Game<'_> {
|
||||
if !self.holds_rod() || d2 > 1024.0 {
|
||||
return;
|
||||
}
|
||||
h.tick_count += 1;
|
||||
// The players it can catch, as they stand now (the host's own hook;
|
||||
// guests' hooks use the ones taken as their turns start).
|
||||
if self.lan_acting.is_none()
|
||||
@@ -600,58 +664,67 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: hook the first living mob or other player it
|
||||
// touches, or (via `canHitEntity`) a dropped item.
|
||||
let bb = bounds(h.position);
|
||||
let (lo, hi) = (bb.min_position(), bb.max_position());
|
||||
let touches = |b: Aabb| {
|
||||
let (a, z) = (b.min_position(), b.max_position());
|
||||
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
|
||||
// checkCollision: find the first block and entity on the movement
|
||||
// segment so a fast hook cannot tunnel or catch through a wall.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let block_hit = {
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
};
|
||||
let hit = self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.alive() && touches(m.bounds()))
|
||||
.map(|m| m.id)
|
||||
.or_else(|| {
|
||||
let owner = self.lan_acting;
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.find(|t| t.conn != owner && touches(t.bounds))
|
||||
.map(|t| t.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
|
||||
// ArmorStand.isPickable is false for a marker.
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
|
||||
.map(|a| a.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
|
||||
// boat the owner rides (approximating `leftOwner`, as the
|
||||
// players above skip their owner).
|
||||
let own = self.own_vehicle();
|
||||
self.boats
|
||||
.iter()
|
||||
.find(|b| Some(b.id) != own && touches(b.bounds()))
|
||||
.map(|b| b.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.dropped
|
||||
.iter()
|
||||
.find(|i| touches(i.bounds()))
|
||||
.map(|i| i.id)
|
||||
})
|
||||
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
|
||||
.or_else(|| {
|
||||
self.tnts
|
||||
.iter()
|
||||
.find(|t| t.id != 0 && touches(t.bounds()))
|
||||
.map(|t| t.id)
|
||||
});
|
||||
let entity_end = block_hit.map_or(to, |hit| hit.location);
|
||||
let owner = self.lan_acting;
|
||||
let own_vehicle = self.own_vehicle();
|
||||
let hit = first_entity_hit(
|
||||
h.position,
|
||||
entity_end,
|
||||
entity_hit_margin(h.tick_count),
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.map(|m| (m.id, m.bounds()))
|
||||
.chain(
|
||||
self.creatures
|
||||
.hook_targets
|
||||
.iter()
|
||||
.filter(|t| t.conn != owner)
|
||||
.map(|t| (t.id, t.bounds)),
|
||||
)
|
||||
.chain(
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.filter(|a| a.health > 0.0 && !a.marker)
|
||||
.map(|a| (a.id, a.bounds())),
|
||||
)
|
||||
.chain(
|
||||
self.boats
|
||||
.iter()
|
||||
.filter(|b| Some(b.id) != own_vehicle)
|
||||
.map(|b| (b.id, b.bounds())),
|
||||
)
|
||||
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
|
||||
.chain(
|
||||
self.tnts
|
||||
.iter()
|
||||
.filter(|t| t.id != 0)
|
||||
.map(|t| (t.id, t.bounds())),
|
||||
),
|
||||
);
|
||||
if let Some(id) = hit {
|
||||
// Projectile.onHit on an entity: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
@@ -664,26 +737,7 @@ impl Game<'_> {
|
||||
self.hook = Some(h);
|
||||
return;
|
||||
}
|
||||
// checkCollision: the block the path hits before the move, then
|
||||
// `onHitBlock`'s rebound.
|
||||
let to = h.position.add_vector(h.velocity);
|
||||
let mut scene = SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
if self.res.facts().is_air(state) {
|
||||
return None;
|
||||
}
|
||||
let shape = self
|
||||
.collision
|
||||
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
|
||||
.ok()?;
|
||||
shape.clip(h.position, to, p)
|
||||
});
|
||||
if let Some(hit) = hit.ok().flatten() {
|
||||
if let Some(hit) = block_hit {
|
||||
// Projectile.onHit on a block: PROJECTILE_LAND.
|
||||
self.projectile_event(
|
||||
"minecraft:projectile_land",
|
||||
@@ -1124,6 +1178,7 @@ impl Hook {
|
||||
previous: at,
|
||||
velocity: Vec3::ZERO,
|
||||
state: State::Flying,
|
||||
tick_count: 0,
|
||||
life: 0,
|
||||
on_ground: false,
|
||||
nibble: 0,
|
||||
@@ -1419,8 +1474,9 @@ impl Game<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rebound;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use super::{entity_hit_margin, first_entity_hit, pull_item, rebound};
|
||||
use horizon_client::{entities::ItemEntity, items::ItemStack};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
|
||||
#[test]
|
||||
@@ -1429,4 +1485,86 @@ mod tests {
|
||||
let hit = Vec3::new(0.0, 0.0, -0.5);
|
||||
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reeling_an_item_requests_an_immediate_motion_update() {
|
||||
let mut item = ItemEntity {
|
||||
no_gravity: false,
|
||||
health: 5,
|
||||
fire_ticks: 0,
|
||||
id: 1,
|
||||
stack: ItemStack::new("minecraft:stone", 1),
|
||||
thrower: None,
|
||||
target: None,
|
||||
position: Vec3::ZERO,
|
||||
previous: Vec3::ZERO,
|
||||
velocity: Vec3::new(0.2, 0.0, -0.1),
|
||||
on_ground: true,
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
age: 0,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: 0,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
};
|
||||
|
||||
pull_item(&mut item, Vec3::new(0.5, 0.1, -0.2));
|
||||
|
||||
assert!((item.velocity.x - 0.7).abs() < 1.0e-12);
|
||||
assert!((item.velocity.y - 0.1).abs() < 1.0e-12);
|
||||
assert!((item.velocity.z + 0.3).abs() < 1.0e-12);
|
||||
assert!(item.needs_sync);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_hits_a_small_target_between_ticks() {
|
||||
let target = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
|
||||
|
||||
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.8, 0.0, 0.0), target).is_some());
|
||||
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.4, 0.0, 0.0), target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_uses_vanillas_growing_entity_hit_margin() {
|
||||
let start = Vec3::ZERO;
|
||||
let end = Vec3::new(1.0, 0.0, 0.0);
|
||||
let target = Aabb::new(Vec3::new(0.5, 0.31, -0.1), Vec3::new(0.7, 0.5, 0.1));
|
||||
|
||||
assert_eq!(entity_hit_margin(2), 0.0);
|
||||
assert_eq!(entity_hit_margin(8), 0.3);
|
||||
assert_eq!(
|
||||
first_entity_hit(start, end, entity_hit_margin(8), [(1, target)]),
|
||||
None
|
||||
);
|
||||
let within_margin = Aabb::new(Vec3::new(0.5, 0.29, -0.1), Vec3::new(0.7, 0.5, 0.1));
|
||||
assert_eq!(
|
||||
first_entity_hit(start, end, entity_hit_margin(8), [(1, within_margin)]),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_does_not_hit_a_target_it_starts_inside() {
|
||||
let target = Aabb::new(Vec3::new(-0.1, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
|
||||
|
||||
assert_eq!(
|
||||
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, [(1, target)]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flying_hook_uses_the_first_target_along_its_path() {
|
||||
let start = Vec3::ZERO;
|
||||
let end = Vec3::new(1.0, 0.0, 0.0);
|
||||
let near = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
|
||||
let far = Aabb::new(Vec3::new(0.8, -0.1, -0.1), Vec3::new(0.95, 0.15, 0.1));
|
||||
|
||||
assert_eq!(
|
||||
first_entity_hit(start, end, 0.0, [(2, far), (1, near)]),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -623,6 +623,9 @@ impl Game<'_> {
|
||||
items.push((stack, f.id, m.as_mat4(), item_light));
|
||||
}
|
||||
// `extractRenderState`: FIXED, seeded by the frame entity.
|
||||
let skinning = self.skinning();
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
for (stack, id, m, light) in items {
|
||||
let entity_start = out.len();
|
||||
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
|
||||
@@ -632,13 +635,15 @@ impl Game<'_> {
|
||||
Some(&owner),
|
||||
id as i32,
|
||||
);
|
||||
self.submit_item_world(
|
||||
&state,
|
||||
m,
|
||||
horizon_client::block_entity_render::vertex_light(light),
|
||||
out,
|
||||
);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
let light = horizon_client::block_entity_render::vertex_light(light);
|
||||
// Posed on the GPU when every layer can be.
|
||||
let skinned = skinning
|
||||
.then(|| self.submit_item_skinned(&state, &[m], light, lights))
|
||||
.flatten();
|
||||
if skinned.is_none() {
|
||||
self.submit_item_world(&state, m, light, out);
|
||||
}
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
for (id, m, light) in maps {
|
||||
let entity_start = out.len();
|
||||
|
||||
@@ -478,20 +478,59 @@ impl Game<'_> {
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
let sink = (
|
||||
item_hdr_light(state, light),
|
||||
(state.hdr_light == 0).then_some(lights),
|
||||
state.hdr_tier,
|
||||
);
|
||||
for &pose in poses {
|
||||
let renderer = &self.pack.item_renderer;
|
||||
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
|
||||
if !self.skin_item(state, pose, light, lights, &mut skin) {
|
||||
// Decided before any draw, the same for every pose.
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some((mark, skin.bounds))
|
||||
}
|
||||
/// One pose of [`Self::submit_item_skinned`] into an entity's open
|
||||
/// `skin` (its bounds grow by the item's): false, with nothing drawn,
|
||||
/// when the item cannot take the GPU path.
|
||||
pub(super) fn skin_item(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
lights: (glam::Vec3, glam::Vec3),
|
||||
skin: &mut horizon_client::mobs::skin::Skin,
|
||||
) -> bool {
|
||||
let sink = (
|
||||
item_hdr_light(state, light),
|
||||
(state.hdr_light == 0).then_some(lights),
|
||||
state.hdr_tier,
|
||||
);
|
||||
let renderer = &self.pack.item_renderer;
|
||||
state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, skin)
|
||||
}
|
||||
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
|
||||
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
|
||||
/// nothing drawn, when the state cannot take it.
|
||||
pub(super) fn skin_item_into(
|
||||
&self,
|
||||
state: &ItemRenderState,
|
||||
pose: glam::Mat4,
|
||||
light: u32,
|
||||
skinned: &mut super::creatures::Skinned,
|
||||
) -> bool {
|
||||
let lights = diffuse_lights(self.dim == Dim::Nether);
|
||||
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
|
||||
return false;
|
||||
};
|
||||
*skinned = Some(match skinned.take() {
|
||||
Some((first, before)) => (
|
||||
first,
|
||||
match (before, bounds) {
|
||||
(Some((a, b)), Some((c, d))) => Some((a.min(c), b.max(d))),
|
||||
(one, other) => one.or(other),
|
||||
},
|
||||
),
|
||||
None => (mark, bounds),
|
||||
});
|
||||
true
|
||||
}
|
||||
/// `ItemEntityRenderer.submit` for every dropped item.
|
||||
pub(super) fn dropped_item_geometry(
|
||||
&self,
|
||||
|
||||
@@ -1939,6 +1939,47 @@ impl Game<'_> {
|
||||
HostEvent::Flight { conn, flying } => {
|
||||
self.lan_flight_toggled(&mut host, conn, flying);
|
||||
}
|
||||
HostEvent::Sneak { conn, sneaking } => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_toggle_sneak(uuid, &name, sneaking);
|
||||
});
|
||||
}
|
||||
}
|
||||
HostEvent::Kicked {
|
||||
conn,
|
||||
player,
|
||||
name,
|
||||
reason,
|
||||
} => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The same poll can have removed the guest (their `Left`): the mods hear the
|
||||
// kick anyway, without the guest's state swapped in.
|
||||
if host.hub.guest(conn).is_some() {
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_kicked(player, &name, &reason);
|
||||
});
|
||||
} else {
|
||||
self.native_player_kicked(player, &name, &reason);
|
||||
}
|
||||
}
|
||||
HostEvent::Input { conn, flags } => {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.as_guest(&mut host, conn, |game| {
|
||||
game.native_player_input(uuid, &name, flags);
|
||||
});
|
||||
}
|
||||
}
|
||||
HostEvent::MainHandChanged(conn, left) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
@@ -1951,6 +1992,25 @@ impl Game<'_> {
|
||||
self.native_player_locale_change(uuid, &name, &locale);
|
||||
}
|
||||
}
|
||||
HostEvent::PlayerLoaded(conn) => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_client_loaded_world(uuid, &name);
|
||||
}
|
||||
}
|
||||
HostEvent::RecipeBookSettings {
|
||||
conn,
|
||||
book,
|
||||
open,
|
||||
filtering,
|
||||
} => {
|
||||
if let Some(g) = host.hub.guest(conn) {
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_recipe_book_settings_change(
|
||||
uuid, &name, book, open, filtering,
|
||||
);
|
||||
}
|
||||
}
|
||||
HostEvent::Packet(conn, packet) => {
|
||||
self.lan_guest_packet(&mut host, conn, *packet);
|
||||
if !host.commands.is_empty() {
|
||||
@@ -2002,6 +2062,7 @@ impl Game<'_> {
|
||||
self.ui.rules.bool("player_movement_check"),
|
||||
self.ui.rules.bool("elytra_movement_check"),
|
||||
);
|
||||
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first {
|
||||
host.hub.poll(&mut ctx)
|
||||
@@ -2184,6 +2245,7 @@ impl Game<'_> {
|
||||
// (`tick_server`): the network is read once, before the first.
|
||||
// Player packets may already have been drained before
|
||||
// scheduled block ticks (`tick_lan_packets`).
|
||||
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
|
||||
let pass = self.level_pass;
|
||||
let mut events = if pass.first && !pass.packets_drained {
|
||||
host.hub.poll(&mut ctx)
|
||||
|
||||
@@ -769,6 +769,34 @@ impl Game<'_> {
|
||||
p.guest = Some(conn);
|
||||
}
|
||||
}
|
||||
let guest = &host.hub.guests[i];
|
||||
let listening = !horizon_client::mod_api::mods().is_empty();
|
||||
for projectile in projectiles..self.creatures.projectiles.len() {
|
||||
if !listening {
|
||||
break;
|
||||
}
|
||||
let item = match &self.creatures.projectiles[projectile].kind {
|
||||
horizon_client::projectiles::ProjectileKind::Snowball => {
|
||||
Some("minecraft:snowball".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Egg => {
|
||||
Some("minecraft:egg".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::EnderPearl => {
|
||||
Some("minecraft:ender_pearl".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Trident => {
|
||||
Some("minecraft:trident".to_owned())
|
||||
}
|
||||
horizon_client::projectiles::ProjectileKind::Potion(stack, _) => {
|
||||
Some(stack.item.to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(item) = item {
|
||||
self.native_player_launch_projectile(guest.uuid, &guest.name, &item);
|
||||
}
|
||||
}
|
||||
// The guest's swings are theirs: the host's own count stays the host's.
|
||||
play.swings += self.swings_started.wrapping_sub(swings);
|
||||
self.swings_started = swings;
|
||||
@@ -1836,6 +1864,8 @@ impl Game<'_> {
|
||||
}),
|
||||
Some(conn),
|
||||
);
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_block_damage_abort(guest.uuid, &guest.name, a.pos);
|
||||
}
|
||||
4 | 5 => {
|
||||
if !spectator {
|
||||
@@ -1845,6 +1875,13 @@ impl Game<'_> {
|
||||
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
|
||||
// hand of the item in use acting.
|
||||
6 => {
|
||||
let listening = !horizon_client::mod_api::mods().is_empty();
|
||||
let (uuid, name) = if listening {
|
||||
let guest = &host.hub.guests[gi];
|
||||
(guest.uuid, guest.name.clone())
|
||||
} else {
|
||||
(0, String::new())
|
||||
};
|
||||
self.as_guest(host, conn, |g| {
|
||||
let Some(hand) = g.used_hand() else {
|
||||
return;
|
||||
@@ -1853,7 +1890,18 @@ impl Game<'_> {
|
||||
if let Some((slot, ticks)) = g.using.take()
|
||||
&& let Some(stack) = g.stack_in(slot).cloned()
|
||||
{
|
||||
if listening
|
||||
&& &*stack.item == "minecraft:bow"
|
||||
&& let Some(arrow) = g
|
||||
.projectile_slot("minecraft:bow")
|
||||
.and_then(|slot| g.stack_in(slot).map(|s| s.item.clone()))
|
||||
{
|
||||
g.native_player_ready_arrow(uuid, &name, &arrow);
|
||||
}
|
||||
g.release_used_item(&stack, ticks, false);
|
||||
if listening {
|
||||
g.native_player_stop_using_item(uuid, &name);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1957,6 +2005,7 @@ impl Game<'_> {
|
||||
g.reset_attack_strength();
|
||||
});
|
||||
self.native_player_attack_cooldown_reset(player, &name);
|
||||
self.native_player_arm_swing(player, &name, "mainhand");
|
||||
}
|
||||
S::UseItemOn(p) => {
|
||||
host.hub.ack(conn, p.sequence);
|
||||
@@ -2291,6 +2340,8 @@ impl Game<'_> {
|
||||
if !spectator && let Some(slot) = slot {
|
||||
host.hub
|
||||
.select_bundle_item(conn, slot, p.index, &mut self.world);
|
||||
let guest = &host.hub.guests[gi];
|
||||
self.native_player_bundle_item_select(guest.uuid, &guest.name, p.slot, p.index);
|
||||
}
|
||||
}
|
||||
S::ContainerClick(p) => {
|
||||
@@ -4023,6 +4074,7 @@ impl Game<'_> {
|
||||
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
|
||||
let riding = g.vehicle.is_some();
|
||||
let mut soul = None;
|
||||
let mut jumps = 0;
|
||||
let events = self.as_guest(host, conn, |game| {
|
||||
let (rules, motion) = (game.world.health_rules, game.motion);
|
||||
let health = &motion.health;
|
||||
@@ -4083,6 +4135,7 @@ impl Game<'_> {
|
||||
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
|
||||
// the server's copy jumps with the player, so the velocity
|
||||
// it sends them keeps the jump.
|
||||
jumps += 1;
|
||||
let _ = w.jump_from_ground();
|
||||
}
|
||||
let landed = (|| -> horizon_client::Result<()> {
|
||||
@@ -4197,6 +4250,16 @@ impl Game<'_> {
|
||||
let Some((events, marked)) = events else {
|
||||
return;
|
||||
};
|
||||
if jumps > 0
|
||||
&& !horizon_client::mod_api::mods().is_empty()
|
||||
&& let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
|
||||
{
|
||||
self.as_guest(host, conn, |game| {
|
||||
for _ in 0..jumps {
|
||||
game.native_player_jump(uuid, &name);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some((at, velocity)) = soul {
|
||||
super::lan_particles::send_soul_particle(host, at, velocity);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,13 @@ impl Game<'_> {
|
||||
if let Some(maps) = maps {
|
||||
self.register_made_maps(maps);
|
||||
}
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
// A taken anvil result waits for AnvilCraftEvent.Pre: what the click
|
||||
// dropped waits with it (lan_station_op throws it or refuses the take).
|
||||
let dropped = if menu.anvil_craft.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
std::mem::take(&mut menu.dropped)
|
||||
};
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
use horizon_client::{
|
||||
containers::BlockEntity,
|
||||
items::ItemStack,
|
||||
menu::{Kind, Menu},
|
||||
menu::{AnvilCraft, Kind, Menu},
|
||||
};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
@@ -80,6 +80,8 @@ impl Game<'_> {
|
||||
return true;
|
||||
};
|
||||
let mut menu = Box::new(Menu::new(kind));
|
||||
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
|
||||
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
|
||||
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
|
||||
&& kind == Kind::Enchanting
|
||||
{
|
||||
@@ -136,14 +138,42 @@ impl Game<'_> {
|
||||
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
|
||||
(slots, gs.menu.data[0])
|
||||
});
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, op);
|
||||
let recomputed = before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
let mut undo = None;
|
||||
let hook = gs.menu.anvil_hook;
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
|
||||
undo = hook.then(|| menu.anvil_undo());
|
||||
op(menu, env);
|
||||
});
|
||||
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
|
||||
let mut anvil_craft = gs.menu.anvil_craft.take();
|
||||
let mut cancelled = false;
|
||||
if let Some(craft) = &anvil_craft {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
let mut cancel = false;
|
||||
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
|
||||
if cancel && let Some(undo) = undo {
|
||||
// The take is refused: the result, inputs, cost, inventory
|
||||
// and cursor are as before the click (the resync below
|
||||
// tells the client).
|
||||
anvil_craft = None;
|
||||
cancelled = true;
|
||||
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
|
||||
} else {
|
||||
gs.menu.anvil_take(&self.menu_env());
|
||||
let dropped = std::mem::take(&mut gs.menu.dropped);
|
||||
self.drop_guest_items(host, gi, dropped);
|
||||
}
|
||||
}
|
||||
// A cancelled take runs no createResult: the inputs are untouched.
|
||||
let recomputed = !cancelled
|
||||
&& before.is_some_and(|(slots, cost)| {
|
||||
cost != gs.menu.data[0]
|
||||
|| slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
|
||||
});
|
||||
if recomputed && gs.kind == StationKind::Anvil {
|
||||
let guest = &host.hub.guests[gi];
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
@@ -172,7 +202,7 @@ impl Game<'_> {
|
||||
if let Some(amount) = gs.menu.ground.take() {
|
||||
self.grindstone_taken(gs.pos, amount);
|
||||
}
|
||||
self.lan_station_combined(host, conn, &mut gs);
|
||||
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
|
||||
if let Some(button) = gs.menu.enchant_button.take() {
|
||||
self.lan_enchant(host, gi, &mut gs, button);
|
||||
}
|
||||
@@ -305,8 +335,15 @@ impl Game<'_> {
|
||||
|
||||
/// The rest of a taken anvil or smithing result's `onTake`: the guest
|
||||
/// pays the levels, the anvil wears (level event 1030 or 1029), the
|
||||
/// smithing table sounds (1044).
|
||||
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
|
||||
/// smithing table sounds (1044). An anvil's `craft` is then reported to
|
||||
/// the mods (`AnvilCraftEvent.Post`).
|
||||
fn lan_station_combined(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
conn: u32,
|
||||
gs: &mut GuestStation,
|
||||
craft: Option<&AnvilCraft>,
|
||||
) {
|
||||
let change = std::mem::take(&mut gs.menu.level_change);
|
||||
if change != 0 {
|
||||
self.as_guest(host, conn, |g| g.give_levels(change));
|
||||
@@ -320,6 +357,12 @@ impl Game<'_> {
|
||||
let creative = gs.menu.creative;
|
||||
// A broken anvil closes the menu on the next stillValid check.
|
||||
self.anvil_used(gs.pos, creative);
|
||||
if let Some(craft) = craft
|
||||
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
|
||||
{
|
||||
let (uuid, name) = (guest.uuid, guest.name.clone());
|
||||
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ impl Game<'_> {
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
self.native_entity_unleash(id, &kind);
|
||||
if used
|
||||
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
|
||||
@@ -740,6 +740,7 @@ impl Game<'_> {
|
||||
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
|
||||
return;
|
||||
}
|
||||
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
|
||||
let mut changed = false;
|
||||
if !c.is_empty(&view) {
|
||||
changed = self.hopper_eject(pos, &view);
|
||||
@@ -763,10 +764,10 @@ impl Game<'_> {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let item = &self.dropped[i];
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting (age was
|
||||
// counted already); `checkInsideBlocks` only follows a move.
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting;
|
||||
// `checkInsideBlocks` only follows a move.
|
||||
let moved = item.position != item.previous
|
||||
|| (item.age.wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4);
|
||||
|| item.ticks.wrapping_add(item.id).is_multiple_of(4);
|
||||
if !moved || item.stack.count == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -866,6 +867,11 @@ impl Game<'_> {
|
||||
let front = offset(pos, facing);
|
||||
let face = facing.opposite();
|
||||
if let Some(target) = c.view(front, false) {
|
||||
self.native_hopper_inventory_search(
|
||||
[pos.0, pos.1, pos.2],
|
||||
Some([front.0, front.1, front.2]),
|
||||
None,
|
||||
);
|
||||
return self.eject_into(pos, hopper, &target, face);
|
||||
}
|
||||
let Some(id) = self.entity_container_at([
|
||||
@@ -875,15 +881,62 @@ impl Game<'_> {
|
||||
]) else {
|
||||
return false;
|
||||
};
|
||||
self.native_hopper_inventory_search([pos.0, pos.1, pos.2], None, Some(id));
|
||||
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// Where a container is in the world for the mods: its block, or the block a chest
|
||||
/// boat or minecart is in (its view sits under a placeholder key).
|
||||
fn container_world_pos(&self, view: &View) -> [i32; 3] {
|
||||
let (x, y, z) = view.parts[0].0;
|
||||
if x == i32::MAX - 15 && y == super::minecarts::CART_KEY_Y {
|
||||
let id = z as u32;
|
||||
let at = self
|
||||
.carts
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.map(|c| c.position)
|
||||
.or_else(|| self.boats.iter().find(|b| b.id == id).map(|b| b.position));
|
||||
if let Some(p) = at {
|
||||
return [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
}
|
||||
}
|
||||
[x, y, z]
|
||||
}
|
||||
/// `InventoryMoveItemEvent` for the item in `slot` of `holder` going from `source` to
|
||||
/// `destination`: whether a mod canceled the move. Nothing is looked up with no mod loaded.
|
||||
fn hopper_move_canceled(
|
||||
&self,
|
||||
holder: &View,
|
||||
slot: usize,
|
||||
source: &View,
|
||||
destination: &View,
|
||||
) -> bool {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let containers = self.containers();
|
||||
let Some(stack) = containers.get(holder, slot) else {
|
||||
return false;
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
self.container_world_pos(source),
|
||||
self.container_world_pos(destination),
|
||||
&stack.item,
|
||||
&mut cancel,
|
||||
);
|
||||
cancel
|
||||
}
|
||||
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
|
||||
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
|
||||
if self.containers().is_full(target, face) {
|
||||
return false;
|
||||
}
|
||||
for slot in 0..hopper.len() {
|
||||
if self.hopper_move_canceled(hopper, slot, hopper, target) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(hopper, slot) else {
|
||||
continue;
|
||||
};
|
||||
@@ -946,9 +999,27 @@ impl Game<'_> {
|
||||
let c = self.containers();
|
||||
let Some(source) = c.view(above, false) else {
|
||||
let id = self.entity_container_at(at)?;
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
hopper.parts[0].0.0,
|
||||
hopper.parts[0].0.1,
|
||||
hopper.parts[0].0.2,
|
||||
],
|
||||
None,
|
||||
Some(id),
|
||||
);
|
||||
return self
|
||||
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
|
||||
};
|
||||
self.native_hopper_inventory_search(
|
||||
[
|
||||
hopper.parts[0].0.0,
|
||||
hopper.parts[0].0.1,
|
||||
hopper.parts[0].0.2,
|
||||
],
|
||||
Some([above.0, above.1, above.2]),
|
||||
None,
|
||||
);
|
||||
if let Kind::Composter(level) = source.kind {
|
||||
if level != 8 {
|
||||
return Some(false);
|
||||
@@ -959,6 +1030,18 @@ impl Game<'_> {
|
||||
// room, the setItem putting the bone meal back into the detached
|
||||
// container (whose bone meal is lost with it).
|
||||
let bone_meal = c.bone_meal.clone();
|
||||
if !horizon_client::mod_api::mods().is_empty() {
|
||||
let mut cancel = false;
|
||||
self.native_inventory_move_item(
|
||||
[above.0, above.1, above.2],
|
||||
self.container_world_pos(hopper),
|
||||
&bone_meal.item,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
let at = BlockPos::new(above.0, above.1, above.2);
|
||||
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
|
||||
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
|
||||
@@ -984,6 +1067,9 @@ impl Game<'_> {
|
||||
if !c.can_take(source, slot, stack, Direction::Down) {
|
||||
continue;
|
||||
}
|
||||
if self.hopper_move_canceled(source, slot, source, hopper) {
|
||||
continue;
|
||||
}
|
||||
let Some(one) = self.take_one(source, slot) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -1875,6 +1875,13 @@ impl Game<'_> {
|
||||
.modified
|
||||
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
|
||||
self.carts.push(cart);
|
||||
if self.serving.is_some() || self.options.dedicated.is_some() {
|
||||
let cart = self.carts.last().unwrap();
|
||||
self.native_vehicle_create(
|
||||
cart.kind.id(),
|
||||
[cart.position.x, cart.position.y, cart.position.z],
|
||||
);
|
||||
}
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
@@ -2134,7 +2141,13 @@ impl Game<'_> {
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
|
||||
pub(super) fn hurt_cart(&mut self, i: usize, mut damage: f32, creative: bool, fire: bool) {
|
||||
let kind = self.carts[i].kind.id();
|
||||
let mut cancel = false;
|
||||
self.native_vehicle_damage(kind, &mut damage, &mut cancel);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
let destroyed = {
|
||||
let c = &mut self.carts[i];
|
||||
c.hurt_dir = -c.hurt_dir;
|
||||
@@ -2154,6 +2167,14 @@ impl Game<'_> {
|
||||
if !tnt_prime {
|
||||
self.release_vehicle_container(id);
|
||||
}
|
||||
if !tnt_prime {
|
||||
let at = [
|
||||
self.carts[i].position.x,
|
||||
self.carts[i].position.y,
|
||||
self.carts[i].position.z,
|
||||
];
|
||||
self.native_vehicle_destroy(self.carts[i].kind.id(), at);
|
||||
}
|
||||
let mut c = self.carts.remove(i);
|
||||
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
|
||||
// MinecartTNT.destroy: prime instead of breaking.
|
||||
|
||||
@@ -187,10 +187,11 @@ impl Game<'_> {
|
||||
camera: glam::DVec3,
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
skin: Option<&mut super::creatures::Skinned>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
self.captain_banner_geometry(mob, camera, alpha, light, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.held_item_geometry(mob, camera, alpha, light, true, skin, out);
|
||||
self.worn_block_geometry(mob, camera, alpha, light, out);
|
||||
self.head_item_geometry(mob, camera, alpha, light, true, out);
|
||||
self.golem_flower_geometry(mob, camera, alpha, light, out);
|
||||
@@ -376,6 +377,7 @@ impl Game<'_> {
|
||||
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
|
||||
/// applies the level's diffuse lighting (off for GUI previews, which
|
||||
/// light the projected quads themselves).
|
||||
/// `skin`: the mob's GPU path draws, which a lit item joins when it can.
|
||||
pub(super) fn held_item_geometry(
|
||||
&self,
|
||||
mob: &Mob,
|
||||
@@ -383,6 +385,7 @@ impl Game<'_> {
|
||||
alpha: f64,
|
||||
light: u32,
|
||||
lit: bool,
|
||||
mut skin: Option<&mut super::creatures::Skinned>,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
|
||||
@@ -483,6 +486,12 @@ impl Game<'_> {
|
||||
horizon_client::cit::Hand::Main
|
||||
};
|
||||
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, hand);
|
||||
if lit
|
||||
&& let Some(skinned) = skin.as_deref_mut()
|
||||
&& self.skin_item_into(&state, t, light, skinned)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if lit {
|
||||
self.submit_item_world(&state, t, light, out);
|
||||
} else {
|
||||
|
||||
@@ -483,4 +483,10 @@ impl Host for super::Game<'_> {
|
||||
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
|
||||
self.native_item_stack(batch, stack, at, size)
|
||||
}
|
||||
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
|
||||
self.native_mod_image(width, height, rgba)
|
||||
}
|
||||
fn instance_dir(&self) -> PathBuf {
|
||||
self.native_instance_dir()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -3137,6 +3162,11 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_player_toggle_flight(self, mod_api::mods(), player, name, flying, cancel);
|
||||
}
|
||||
/// A guest's client changed sneaking in its player input packet, after the hub applied the new state.
|
||||
/// The guest's state is swapped in. Server-side. @API:player_toggle_sneak@.
|
||||
pub(super) fn native_player_toggle_sneak(&mut self, player: u128, name: &str, sneaking: bool) {
|
||||
mod_api::dispatch_player_toggle_sneak(self, mod_api::mods(), player, name, sneaking);
|
||||
}
|
||||
/// A player finished editing a sign: `ServerGamePacketListenerImpl.updateSignText`
|
||||
/// has taken their update packet and every check has passed (they were
|
||||
/// editing that sign, within reach, the sign is not waxed, the chat filter
|
||||
@@ -4518,6 +4548,27 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
|
||||
}
|
||||
/// A player took an anvil's result at `pos` (`AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` holds the inputs and the result as they were before. Set `cancel` to refuse the take: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. @API:native_anvil_craft_pre@.
|
||||
pub(super) fn native_anvil_craft_pre(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
pos: [i32; 3],
|
||||
craft: &horizon_client::menu::AnvilCraft,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_anvil_craft_pre(self, mod_api::mods(), player, name, pos, craft, cancel);
|
||||
}
|
||||
/// A player took an anvil's result at `pos` and the levels, inputs and anvil wear are done (`AnvilCraftEvent.Post`); a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not called when `native_anvil_craft_pre` cancelled. @API:native_anvil_craft_post@.
|
||||
pub(super) fn native_anvil_craft_post(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
pos: [i32; 3],
|
||||
craft: &horizon_client::menu::AnvilCraft,
|
||||
) {
|
||||
mod_api::dispatch_anvil_craft_post(self, mod_api::mods(), player, name, pos, craft);
|
||||
}
|
||||
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
|
||||
pub(super) fn native_prepare_grindstone(
|
||||
&mut self,
|
||||
@@ -4839,6 +4890,223 @@ impl Game<'_> {
|
||||
mod_api::dispatch_entity_enter_block(self, mod_api::mods(), entity, kind, pos, block);
|
||||
}
|
||||
|
||||
/// A server player finished loading the world around them after joining,
|
||||
/// respawning or changing dimension. The guest's state is not swapped in,
|
||||
/// so helpers that look them up through `self.lan` may find nothing while
|
||||
/// a guest packet is handled. Server-side. @API:player_client_loaded_world@.
|
||||
pub(super) fn native_player_client_loaded_world(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_client_loaded_world(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A server player released the use-item key before the item finished
|
||||
/// being used. The guest's state is not swapped in, so helpers that look
|
||||
/// them up through `self.lan` may find nothing during packet handling.
|
||||
/// @API:player_stop_using_item@.
|
||||
pub(super) fn native_player_stop_using_item(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_stop_using_item(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A server player's recipe book open or filter setting changed. `book`
|
||||
/// is the book type's protocol id. The guest's state is not swapped in.
|
||||
/// @API:player_recipe_book_settings_change@.
|
||||
pub(super) fn native_player_recipe_book_settings_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
book: i32,
|
||||
open: bool,
|
||||
filtering: bool,
|
||||
) {
|
||||
mod_api::dispatch_player_recipe_book_settings_change(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
book,
|
||||
open,
|
||||
filtering,
|
||||
);
|
||||
}
|
||||
/// A server player earned one criterion of an advancement, after the
|
||||
/// progress was recorded. @API:player_advancement_criterion_grant@.
|
||||
pub(super) fn native_player_advancement_criterion_grant(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
advancement: &str,
|
||||
criterion: &str,
|
||||
) {
|
||||
mod_api::dispatch_player_advancement_criterion_grant(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
advancement,
|
||||
criterion,
|
||||
);
|
||||
}
|
||||
/// A server player released a drawn bow with `arrow` selected. The guest's
|
||||
/// state is not swapped in during packet handling. @API:player_ready_arrow@.
|
||||
pub(super) fn native_player_ready_arrow(&mut self, player: u128, name: &str, arrow: &str) {
|
||||
mod_api::dispatch_player_ready_arrow(self, mod_api::mods(), player, name, arrow);
|
||||
}
|
||||
/// A server player selected a bundle item in `slot` at `index`. The guest's
|
||||
/// state is not swapped in during packet handling. @API:player_bundle_item_select@.
|
||||
pub(super) fn native_player_bundle_item_select(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
slot: i32,
|
||||
index: i32,
|
||||
) {
|
||||
mod_api::dispatch_player_bundle_item_select(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
player,
|
||||
name,
|
||||
slot,
|
||||
index,
|
||||
);
|
||||
}
|
||||
/// A server player stopped digging `pos` before the block broke, after
|
||||
/// the server cleared the dig and sent the block back to the client.
|
||||
/// The player's state is not swapped in. @API:block_damage_abort@.
|
||||
pub(super) fn native_block_damage_abort(&mut self, player: u128, name: &str, pos: [i32; 3]) {
|
||||
mod_api::dispatch_block_damage_abort(self, mod_api::mods(), player, name, pos);
|
||||
}
|
||||
/// A hopper at `pos` began an item transfer attempt. This runs after its
|
||||
/// cooldown and enabled checks, before pushing or pulling any items.
|
||||
/// Server-side, including the single-player server. @API:prepare_hopper_transfer@.
|
||||
pub(super) fn native_prepare_hopper_transfer(&self, pos: [i32; 3]) {
|
||||
mod_api::dispatch_prepare_hopper_transfer(self, mod_api::mods(), pos);
|
||||
}
|
||||
/// A hopper is about to move one item between containers, a composter's
|
||||
/// bone meal included. Set `cancel` to leave it in its source slot. A chest
|
||||
/// boat or minecart is reported at the block it is in. @API:inventory_move_item@.
|
||||
pub(super) fn native_inventory_move_item(
|
||||
&self,
|
||||
source: [i32; 3],
|
||||
destination: [i32; 3],
|
||||
item: &str,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_inventory_move_item(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
source,
|
||||
destination,
|
||||
item,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
/// A hopper selected a container while searching for items to push or
|
||||
/// pull. Exactly one of `container` and `entity` identifies the target.
|
||||
/// @API:hopper_inventory_search@.
|
||||
pub(super) fn native_hopper_inventory_search(
|
||||
&self,
|
||||
hopper: [i32; 3],
|
||||
container: Option<[i32; 3]>,
|
||||
entity: Option<u32>,
|
||||
) {
|
||||
mod_api::dispatch_hopper_inventory_search(self, mod_api::mods(), hopper, container, entity);
|
||||
}
|
||||
/// A vault at `pos` is about to eject a loot item. @API:vault_loot@.
|
||||
pub(super) fn native_vault_loot(&self, pos: [i32; 3], item: &str) {
|
||||
mod_api::dispatch_vault_loot(self, mod_api::mods(), pos, item);
|
||||
}
|
||||
/// A timed world-border size change finished on the server, after its
|
||||
/// final size was applied. Server-side, including the single-player
|
||||
/// server. @API:world_border_bounds_change_finish@.
|
||||
pub(super) fn native_world_border_bounds_change_finish(&mut self) {
|
||||
mod_api::dispatch_world_border_bounds_change_finish(self, mod_api::mods());
|
||||
}
|
||||
|
||||
/// A server player launched a projectile item, after it was created.
|
||||
/// `item` is the projectile's held item. @API:player_launch_projectile@.
|
||||
pub(super) fn native_player_launch_projectile(&mut self, player: u128, name: &str, item: &str) {
|
||||
mod_api::dispatch_player_launch_projectile(self, mod_api::mods(), player, name, item);
|
||||
}
|
||||
/// A server explosion is about to begin at `center`, with `radius` (Bukkit's
|
||||
/// `ExplosionPrimeEvent`). This is listen-only: the explosion proceeds as
|
||||
/// usual. Server-side, including the single-player server. @API:explosion_prime@.
|
||||
pub(super) fn native_explosion_prime(&mut self, center: [f64; 3], radius: f32) {
|
||||
mod_api::dispatch_explosion_prime(self, mod_api::mods(), center, radius);
|
||||
}
|
||||
/// A dropped item entity reached its 6000-tick lifetime and is about to be
|
||||
/// removed (Bukkit's `ItemDespawnEvent`). @API:item_despawn@.
|
||||
pub(super) fn native_item_despawn(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
item: &str,
|
||||
count: i32,
|
||||
pos: [f64; 3],
|
||||
) {
|
||||
mod_api::dispatch_item_despawn(self, mod_api::mods(), entity, item, count, pos);
|
||||
}
|
||||
/// A mob is about to drop `count` of `item` at `pos` (Bukkit's
|
||||
/// `EntityDropItemEvent`). @API:entity_drop_item@.
|
||||
pub(super) fn native_entity_drop_item(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
item: &str,
|
||||
count: i32,
|
||||
pos: [f64; 3],
|
||||
) {
|
||||
mod_api::dispatch_entity_drop_item(self, mod_api::mods(), entity, item, count, pos);
|
||||
}
|
||||
/// A leashed mob's leash was removed, before any lead drop is handled
|
||||
/// (Bukkit's `EntityUnleashEvent`). @API:entity_unleash@.
|
||||
pub(super) fn native_entity_unleash(&mut self, entity: u32, kind: &str) {
|
||||
mod_api::dispatch_entity_unleash(self, mod_api::mods(), entity, kind);
|
||||
}
|
||||
/// A vehicle is about to take damage from a player's attack. `kind` is
|
||||
/// `boat` or the minecart type; mods may change `damage` or set `cancel`
|
||||
/// to leave its damage and wobble unchanged. The attack itself still
|
||||
/// completes. Server-side. @API:native_vehicle_damage@.
|
||||
pub(super) fn native_vehicle_damage(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
damage: &mut f32,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_vehicle_damage(self, mod_api::mods(), kind, damage, cancel);
|
||||
}
|
||||
/// A boat or minecart was created at `pos`, after it joined the level.
|
||||
/// `kind` is the vehicle type. Server-side. @API:native_vehicle_create@.
|
||||
pub(super) fn native_vehicle_create(&mut self, kind: &str, pos: [f64; 3]) {
|
||||
mod_api::dispatch_vehicle_create(self, mod_api::mods(), kind, pos);
|
||||
}
|
||||
/// A boat or minecart is about to be destroyed at `pos`. TNT minecarts
|
||||
/// that prime instead of breaking are not heard. Server-side.
|
||||
/// @API:native_vehicle_destroy@.
|
||||
pub(super) fn native_vehicle_destroy(&mut self, kind: &str, pos: [f64; 3]) {
|
||||
mod_api::dispatch_vehicle_destroy(self, mod_api::mods(), kind, pos);
|
||||
}
|
||||
/// A guest sent the server's current `PlayerInput` flags, after they were applied to the hub. The guest's state is swapped in. Server-side. @API:player_input@.
|
||||
pub(super) fn native_player_input(&mut self, player: u128, name: &str, flags: i32) {
|
||||
mod_api::dispatch_player_input(self, mod_api::mods(), player, name, flags);
|
||||
}
|
||||
/// A guest's arm swing was handled by the server. `hand` is `mainhand`. Server-side. @API:player_arm_swing@.
|
||||
pub(super) fn native_player_arm_swing(&mut self, player: u128, name: &str, hand: &str) {
|
||||
mod_api::dispatch_player_arm_swing(self, mod_api::mods(), player, name, hand);
|
||||
}
|
||||
/// A guest's accepted movement made the server jump them from the ground. Server-side. @API:player_jump@.
|
||||
pub(super) fn native_player_jump(&mut self, player: u128, name: &str) {
|
||||
mod_api::dispatch_player_jump(self, mod_api::mods(), player, name);
|
||||
}
|
||||
/// A guest was disconnected by a server kick, after the hub recorded the rendered `reason`. The guest's state is
|
||||
/// swapped in unless they were already removed in the same poll. @API:player_kicked@.
|
||||
pub(super) fn native_player_kicked(&mut self, player: u128, name: &str, reason: &str) {
|
||||
mod_api::dispatch_player_kicked(self, mod_api::mods(), player, name, reason);
|
||||
}
|
||||
/// A server player's experience level changed, after the new level was applied. @API:player_level_change@.
|
||||
pub(super) fn native_player_level_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
name: &str,
|
||||
from: i32,
|
||||
to: i32,
|
||||
) {
|
||||
mod_api::dispatch_player_level_change(self, mod_api::mods(), player, name, from, to);
|
||||
}
|
||||
/// A click in a menu a mod opened (`native_open_menu`), which moved
|
||||
/// nothing: the menu's `slot` (its own first, then the player's
|
||||
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
|
||||
@@ -5893,6 +6161,19 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_item_picked_up(self, mod_api::mods(), player, item, count, canceled);
|
||||
}
|
||||
/// A dropped item reaches its lifetime on the server (`ItemEntity.tick`,
|
||||
/// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`,
|
||||
/// in ticks, which starts at zero; a positive total keeps the item that much
|
||||
/// longer, zero or less lets it expire. Extensions are capped at 32766
|
||||
/// ticks. @API:item_expire@.
|
||||
pub(super) fn native_item_expire(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
stack: &ItemStack,
|
||||
extra_life: &mut i32,
|
||||
) {
|
||||
mod_api::dispatch_item_expire(self, mod_api::mods(), entity, stack, extra_life);
|
||||
}
|
||||
/// A player took a crafting grid's result (`ResultSlot.onTake`, NeoForge's
|
||||
/// `PlayerEvent.ItemCraftedEvent`): `player` is the player's uuid, `recipe`
|
||||
/// the recipe the grid matched (`minecraft:oak_planks`) and `stack` the
|
||||
@@ -7065,6 +7346,36 @@ impl Game<'_> {
|
||||
let [a, r, g, b] = argb.to_be_bytes();
|
||||
batch.fill(&self.gui.atlas, x, y, w, h, rgba(r, g, b, a));
|
||||
}
|
||||
/// A mod's own RGBA image, uploaded into the HUD atlas slot
|
||||
/// [`MOD_IMAGE_SPRITE`](crate::hud::MOD_IMAGE_SPRITE) and drawn with
|
||||
/// `native_sprite`: its own source rectangle crops and stretches it. `rgba`
|
||||
/// is `width * height` pixels of four bytes, each side at most
|
||||
/// [`MOD_IMAGE_SIZE`](crate::hud::MOD_IMAGE_SIZE); false when it is not that
|
||||
/// size. The upload costs a texture patch of the image, so send one when the
|
||||
/// image changed, not every frame. API @API:mod_image@.
|
||||
pub(super) fn native_mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
|
||||
let Some(rect) = self
|
||||
.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(crate::hud::MOD_IMAGE_SPRITE)
|
||||
.copied()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if !mod_image_fits(width, height, rgba.len(), rect.w, rect.h) {
|
||||
return false;
|
||||
}
|
||||
let (w, h) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
|
||||
self.map_patches.push(horizon_client::font::Patch {
|
||||
x: (rect.u0 * w).round() as u32,
|
||||
y: (rect.v0 * h).round() as u32,
|
||||
width,
|
||||
height,
|
||||
rgba: rgba.to_vec(),
|
||||
});
|
||||
true
|
||||
}
|
||||
/// Part `src` (`[x, y, w, h]` in the sprite's own pixels) of sprite
|
||||
/// `name` (vanilla's `gui/...`, or a content mod's GUI texture
|
||||
/// `mymod:gui/...`), drawn stretched into `dst` (`[x, y, w, h]` in GUI
|
||||
@@ -7355,6 +7666,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.
|
||||
@@ -8440,6 +8760,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `rgba` is a whole `width` x `height` image that fits a
|
||||
/// `slot_width` x `slot_height` atlas slot (a native mod's `mod_image`).
|
||||
fn mod_image_fits(width: u32, height: u32, rgba: usize, slot_width: u32, slot_height: u32) -> bool {
|
||||
width > 0
|
||||
&& height > 0
|
||||
&& width <= slot_width
|
||||
&& height <= slot_height
|
||||
&& rgba == width as usize * height as usize * 4
|
||||
}
|
||||
|
||||
/// Whether F3's debug screen, open when `debug`, covers `corner`
|
||||
/// (`DebugScreenOverlay.extractRenderState`): its left and right columns
|
||||
/// the top corners, and its charts the bottom corners `charts` (bottom left,
|
||||
@@ -9286,4 +9616,16 @@ mod tests {
|
||||
assert!(!debug_takes(Corner::BottomRight, true, [true, false]));
|
||||
assert!(debug_takes(Corner::BottomRight, true, [false, true]));
|
||||
}
|
||||
|
||||
/// An image is taken whole, and only when it fits its slot.
|
||||
#[test]
|
||||
fn a_mod_image_is_taken_whole_and_only_when_it_fits() {
|
||||
use super::mod_image_fits;
|
||||
assert!(mod_image_fits(4, 2, 32, 1024, 1024));
|
||||
assert!(mod_image_fits(1024, 1024, 1024 * 1024 * 4, 1024, 1024));
|
||||
assert!(!mod_image_fits(0, 2, 0, 1024, 1024));
|
||||
assert!(!mod_image_fits(4, 2, 31, 1024, 1024));
|
||||
assert!(!mod_image_fits(4, 2, 33, 1024, 1024));
|
||||
assert!(!mod_image_fits(2048, 1, 2048 * 4, 1024, 1024));
|
||||
}
|
||||
}
|
||||
@@ -461,11 +461,14 @@ pub(super) fn stage_pack(
|
||||
progress.set(1);
|
||||
// The world's models and sprites and the GUI atlas read the same root and nothing of
|
||||
// each other: about a second each, side by side.
|
||||
let mod_image = !horizon_client::mod_api::mods().is_empty();
|
||||
// The sound events, the GUI sprites' metadata and the language are read here too, not in
|
||||
// the frame that applies the reload (about 20 ms of it).
|
||||
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
|
||||
let atlas =
|
||||
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
|
||||
let atlas = s.spawn(|| {
|
||||
HudAtlas::build_over_with_image(&root, vanilla_root, mod_image)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
|
||||
let small = s.spawn(|| {
|
||||
(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7369,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))
|
||||
|
||||
@@ -979,6 +979,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Player.giveExperienceLevels`.
|
||||
pub(super) fn give_levels(&mut self, levels: i32) {
|
||||
let old_level = self.experience.level;
|
||||
let xp = &mut self.experience;
|
||||
xp.level = xp.level.saturating_add(levels);
|
||||
if xp.level < 0 {
|
||||
@@ -986,6 +987,7 @@ impl Game<'_> {
|
||||
xp.progress = 0.0;
|
||||
xp.total = 0;
|
||||
}
|
||||
self.native_player_level_changed(old_level);
|
||||
}
|
||||
/// Whether the result slot may be taken (`mayPickup`).
|
||||
fn may_take_result(&self) -> bool {
|
||||
|
||||
@@ -190,6 +190,7 @@ impl Game<'_> {
|
||||
}
|
||||
// Vec3.atBottomCenterOf(pos).relative(UP, 1.2), lowered by spawnItem.
|
||||
VaultEvent::Eject(stack, v) => {
|
||||
self.native_vault_loot(pos, &stack.item);
|
||||
self.spawn_item(stack, [center[0], center[1] + 0.7 - 0.125, center[2]], v, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1102,4 +1102,51 @@ mod tests {
|
||||
other(&item, "minecraft:custom_model_data")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
|
||||
use horizon_protocol::buf::{Reader, Writer};
|
||||
use horizon_protocol::item::Slot;
|
||||
use horizon_protocol::registry::Ids;
|
||||
let mut components = vec![String::new(); component::RARITY as usize + 1];
|
||||
components[component::RARITY as usize] = "minecraft:rarity".into();
|
||||
let builtin = Builtin {
|
||||
registries: [
|
||||
(
|
||||
"minecraft:item".into(),
|
||||
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
|
||||
),
|
||||
(
|
||||
"minecraft:data_component_type".into(),
|
||||
Ids::from_names(components),
|
||||
),
|
||||
]
|
||||
.into(),
|
||||
};
|
||||
let dynamic = Dynamic::default();
|
||||
let mut item = ItemStack::new("minecraft:stick", 1);
|
||||
item.data_mut()
|
||||
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
|
||||
item.enchant("minecraft:aqua_affinity", 1);
|
||||
|
||||
let wire = crate::lan::guest::stack_to_network(
|
||||
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|
||||
|_, _| None,
|
||||
&Some(item.clone()),
|
||||
);
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&wire, &mut w);
|
||||
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
received.component(component::RARITY),
|
||||
Some(&Component::Int(3))
|
||||
);
|
||||
|
||||
let back = from_network(&received, &builtin, &dynamic);
|
||||
assert_eq!(
|
||||
back.data().and_then(|d| d.other("minecraft:rarity")),
|
||||
Some(&Tag::String("epic".into()))
|
||||
);
|
||||
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
|
||||
}
|
||||
}
|
||||
@@ -1053,7 +1053,7 @@ pub fn lines(
|
||||
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
|
||||
/// the component's own styles (including explicit `false`) winning on children.
|
||||
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let base = crate::text::Style {
|
||||
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
|
||||
italic: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
|
||||
let lines: Vec<crate::text::Text> = match raw {
|
||||
Tag::List(entries) => entries
|
||||
.iter()
|
||||
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
|
||||
.collect(),
|
||||
Tag::Compound(_) | Tag::String(_) => {
|
||||
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|mut text| {
|
||||
for span in &mut text.spans {
|
||||
span.style.inherit(&base);
|
||||
}
|
||||
@@ -1360,6 +1369,31 @@ mod tests {
|
||||
assert_eq!(rarity(&sword, "common"), "rare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_texts_handles_compound_lists() {
|
||||
// Direct command components from issue #6405
|
||||
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
|
||||
let comp = horizon_items::item_data::command_components(cmd).unwrap();
|
||||
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
|
||||
let stack = ItemStack {
|
||||
item: "minecraft:stick".into(),
|
||||
count: 1,
|
||||
damage,
|
||||
extra,
|
||||
};
|
||||
assert_eq!(rarity(&stack, "common"), "epic");
|
||||
let name = styled_name(&stack, || "fallback".into());
|
||||
assert_eq!(name.plain(), "Simple Custom Name");
|
||||
assert_eq!(name.spans[0].style.italic, Some(false));
|
||||
|
||||
let lore = lore_texts(&stack);
|
||||
assert_eq!(lore.len(), 1);
|
||||
assert_eq!(lore[0].plain(), "Simple Custom Lore");
|
||||
assert_eq!(lore[0].spans[0].style.italic, Some(false));
|
||||
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
|
||||
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_line_translates_items_of_any_namespace() {
|
||||
let translate = |key: &str| match key {
|
||||
|
||||
@@ -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,
|
||||
@@ -1102,13 +1106,55 @@ fn network_stack(
|
||||
}
|
||||
// Lore lines as loaded or as a server mod wrote them (a list of text
|
||||
// components), so a server menu's items can say more than their name.
|
||||
if let Some(horizon_world::nbt::Tag::List(lines)) =
|
||||
s.data().and_then(|d| d.other("minecraft:lore"))
|
||||
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
|
||||
let lines = match lore_tag {
|
||||
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
|
||||
// A string is one literal line, as ComponentSerialization decodes a string tag.
|
||||
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
|
||||
Some(vec![lore_tag.clone()])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(lines) = lines {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::LORE,
|
||||
horizon_protocol::item::Component::Lore(lines),
|
||||
));
|
||||
}
|
||||
}
|
||||
if s.data()
|
||||
.is_some_and(|d| d.other("!minecraft:lore").is_some())
|
||||
{
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::LORE,
|
||||
horizon_protocol::item::Component::Lore(lines.clone()),
|
||||
));
|
||||
stack.removed.push(horizon_protocol::item::component::LORE);
|
||||
}
|
||||
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
|
||||
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
|
||||
let index = match r {
|
||||
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
|
||||
"common" => Some(0),
|
||||
"uncommon" => Some(1),
|
||||
"rare" => Some(2),
|
||||
"epic" => Some(3),
|
||||
_ => None,
|
||||
},
|
||||
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
|
||||
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
|
||||
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(index) = index {
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::RARITY,
|
||||
horizon_protocol::item::Component::Int(index),
|
||||
));
|
||||
}
|
||||
}
|
||||
if s.data()
|
||||
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
|
||||
{
|
||||
stack
|
||||
.removed
|
||||
.push(horizon_protocol::item::component::RARITY);
|
||||
}
|
||||
// The component as loaded, so a translatable name stays one.
|
||||
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
@@ -1237,6 +1283,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rarity_reaches_the_client() {
|
||||
let mut item = ItemStack::new("minecraft:stick", 1);
|
||||
item.data_mut()
|
||||
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
|
||||
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
|
||||
let mut writer = Writer::new();
|
||||
horizon_protocol::item::Slot::write(&sent, &mut writer);
|
||||
let mut reader = Reader::new(&writer.data);
|
||||
let received = horizon_protocol::item::Slot::read(&mut reader)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
received.component(component::RARITY),
|
||||
Some(&Component::Int(3))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_custom_name_survives_inventory_sync() {
|
||||
let name = Tag::compound([
|
||||
|
||||
@@ -135,6 +135,32 @@ pub enum HostEvent {
|
||||
MainHandChanged(u32, bool),
|
||||
/// A guest changed their client language option.
|
||||
LocaleChanged(u32, String),
|
||||
/// A guest's client finished loading the level around them.
|
||||
PlayerLoaded(u32),
|
||||
/// A guest changed recipe book settings.
|
||||
RecipeBookSettings {
|
||||
conn: u32,
|
||||
book: i32,
|
||||
open: bool,
|
||||
filtering: bool,
|
||||
},
|
||||
/// A guest changed sneaking in a player input packet, already applied.
|
||||
Sneak {
|
||||
conn: u32,
|
||||
sneaking: bool,
|
||||
},
|
||||
/// A loaded guest sent their current player input flags.
|
||||
Input {
|
||||
conn: u32,
|
||||
flags: i32,
|
||||
},
|
||||
/// A guest was kicked, with the reason rendered by the hub.
|
||||
Kicked {
|
||||
conn: u32,
|
||||
player: u128,
|
||||
name: String,
|
||||
reason: String,
|
||||
},
|
||||
/// A guest's client started (`flying`) or stopped flying (their abilities packet), applied to
|
||||
/// the guest already: the game tells the mods, which may refuse it.
|
||||
Flight {
|
||||
@@ -205,12 +231,17 @@ fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
|
||||
pub struct Hub {
|
||||
/// Clicks on and closings of native mods' menus, for the game.
|
||||
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
|
||||
/// Kicks waiting for the game thread to notify native mods.
|
||||
pending_kicks: Vec<HostEvent>,
|
||||
/// The entities players started and stopped tracking, collected by
|
||||
/// `collect_tracking`, for the game to tell the mods.
|
||||
pub native_tracking: Vec<TrackingBatch>,
|
||||
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
|
||||
/// tell the mods.
|
||||
pub native_advancement_grants: Vec<TickGrants>,
|
||||
/// Whether any native mod is loaded: `Input` and `Sneak` events only exist for them, so
|
||||
/// the hub queues none otherwise. The game sets it before each poll.
|
||||
pub mods_loaded: bool,
|
||||
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
|
||||
/// only players of at least that permission level see them, in the
|
||||
/// player list, the world and their joining and leaving.
|
||||
@@ -356,6 +387,19 @@ fn shoulder_meta(state: &mut EntityState, shoulders: [Option<i32>; 2]) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shoulder parrot slots that differ from what the player's client was
|
||||
/// last sent, as entity data: 0 for none, else the variant's id plus one.
|
||||
fn shoulder_changes(now: [Option<i32>; 2], sent: [Option<i32>; 2]) -> Vec<(u8, i32, Value)> {
|
||||
(19..)
|
||||
.zip(now.into_iter().zip(sent))
|
||||
.filter(|(_, (now, sent))| now != sent)
|
||||
.map(|(index, (now, _))| {
|
||||
let id = now.map_or(0, |v| v + 1);
|
||||
(index, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Player.getDisplayName: the name formatted for the player's team
|
||||
/// (PlayerTeam.formatNameForTeam), suggesting `/tell NAME `, showing the
|
||||
@@ -556,8 +600,10 @@ impl Hub {
|
||||
entity_gone: Vec::new(),
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
native_menu_events: Vec::new(),
|
||||
pending_kicks: Vec::new(),
|
||||
native_tracking: Vec::new(),
|
||||
native_advancement_grants: Vec::new(),
|
||||
mods_loaded: false,
|
||||
hidden: Default::default(),
|
||||
teams: Vec::new(),
|
||||
nicknames: std::collections::HashMap::new(),
|
||||
@@ -821,8 +867,15 @@ impl Hub {
|
||||
// The log's "lost connection" line gives the reason, as vanilla's.
|
||||
let text = self.english(&reason);
|
||||
if let Some(g) = self.guest_mut(conn) {
|
||||
g.left_reason = text;
|
||||
g.left_reason = text.clone();
|
||||
let (player, name) = (g.uuid, g.name.clone());
|
||||
g.sender.disconnect(reason);
|
||||
self.pending_kicks.push(HostEvent::Kicked {
|
||||
conn,
|
||||
player,
|
||||
name,
|
||||
reason: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1521,7 +1574,7 @@ impl Hub {
|
||||
pub fn poll(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
|
||||
self.ticks += 1;
|
||||
self.anticheat_tick(ctx.tick_rate);
|
||||
let mut events = Vec::new();
|
||||
let mut events = std::mem::take(&mut self.pending_kicks);
|
||||
for event in self.lan.poll() {
|
||||
match event {
|
||||
NetEvent::Joined(j) => {
|
||||
@@ -2688,6 +2741,17 @@ impl Hub {
|
||||
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
|
||||
}));
|
||||
}
|
||||
// ServerEntity.sendDirtyEntityData: the parrots on the player's
|
||||
// shoulders go to the player too, whose own model draws them.
|
||||
let g = &mut self.guests[i];
|
||||
if g.shoulders != g.sent_shoulders {
|
||||
let values = shoulder_changes(g.shoulders, g.sent_shoulders);
|
||||
g.sent_shoulders = g.shoulders;
|
||||
g.send(&C::SetEntityData(play::SetEntityData {
|
||||
id: g.entity_id,
|
||||
values,
|
||||
}));
|
||||
}
|
||||
// Sent when the health or the food level changes, or the saturation
|
||||
// reaches or leaves zero; a dead player's health of 0 too.
|
||||
let g = &mut self.guests[i];
|
||||
@@ -3010,6 +3074,7 @@ impl Hub {
|
||||
g.loaded = true;
|
||||
g.loading = false;
|
||||
g.load_timeout = 0;
|
||||
events.push(HostEvent::PlayerLoaded(conn));
|
||||
}
|
||||
// PlayerChunkSender.onChunkBatchReceivedByClient.
|
||||
S::ChunkBatchReceived(p) => {
|
||||
@@ -3055,8 +3120,18 @@ impl Hub {
|
||||
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
|
||||
{
|
||||
let settings = &mut self.guests[i].recipe_book;
|
||||
let changed = settings.open[book.index()] != p.open
|
||||
|| settings.filtering[book.index()] != p.filtering;
|
||||
settings.open[book.index()] = p.open;
|
||||
settings.filtering[book.index()] = p.filtering;
|
||||
if changed {
|
||||
events.push(HostEvent::RecipeBookSettings {
|
||||
conn,
|
||||
book: p.book_type,
|
||||
open: p.open,
|
||||
filtering: p.filtering,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
S::ClientInformation(info) => {
|
||||
@@ -3093,7 +3168,21 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
g.input = p.flags;
|
||||
if g.client_loaded() {
|
||||
g.sneaking = p.flags & 32 != 0;
|
||||
// The events only tell the mods: nothing is queued with none loaded.
|
||||
let listening = self.mods_loaded;
|
||||
if listening {
|
||||
events.push(HostEvent::Input {
|
||||
conn,
|
||||
flags: i32::from(p.flags),
|
||||
});
|
||||
}
|
||||
let sneaking = p.flags & 32 != 0;
|
||||
if g.sneaking != sneaking {
|
||||
g.sneaking = sneaking;
|
||||
if listening {
|
||||
events.push(HostEvent::Sneak { conn, sneaking });
|
||||
}
|
||||
}
|
||||
}
|
||||
// handlePlayerInput only records keys and updates crouching.
|
||||
// Sprinting persists after the sprint key is released (or
|
||||
@@ -3829,6 +3918,7 @@ impl Hub {
|
||||
g.sent_xp = None;
|
||||
g.sent_air = (300, 0.0);
|
||||
g.sent_shared = 0;
|
||||
g.sent_shoulders = [None; 2];
|
||||
let (cx, cz) = g.chunk();
|
||||
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
|
||||
x: cx,
|
||||
@@ -3999,6 +4089,18 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::fast_hash::FxHashSet;
|
||||
|
||||
/// The owner's own shoulder parrots are sent when a slot changes.
|
||||
#[test]
|
||||
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
|
||||
assert!(shoulder_changes([None; 2], [None; 2]).is_empty());
|
||||
let landed = shoulder_changes([Some(1), None], [None; 2]);
|
||||
assert_eq!(landed.len(), 1);
|
||||
assert_eq!((landed[0].0, &landed[0].2), (19, &Value::Id(2)));
|
||||
let left = shoulder_changes([None; 2], [Some(1), Some(4)]);
|
||||
assert_eq!(left.len(), 2);
|
||||
assert_eq!((left[1].0, &left[1].2), (20, &Value::Id(0)));
|
||||
}
|
||||
|
||||
/// The counts of what is stuck in a player are sent when set, and a
|
||||
/// count back at zero is sent as the default.
|
||||
#[test]
|
||||
|
||||
@@ -412,6 +412,10 @@ impl Shared {
|
||||
pub fn max_players(&self) -> usize {
|
||||
self.max_players.load(Ordering::Relaxed)
|
||||
}
|
||||
/// `PlayerList.setMaxPlayers`: change the limit used by new logins and status responses.
|
||||
pub fn set_max_players(&self, max_players: usize) {
|
||||
self.max_players.store(max_players, Ordering::Relaxed);
|
||||
}
|
||||
pub fn release(&self, uuid: u128) {
|
||||
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
@@ -1606,6 +1630,7 @@ pub enum MobEvent {
|
||||
params: conversion::Params,
|
||||
},
|
||||
DropItem {
|
||||
mob: u32,
|
||||
item: Arc<str>,
|
||||
count: u32,
|
||||
at: [f64; 3],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -958,6 +958,7 @@ impl EatBerries {
|
||||
}
|
||||
if count > 0 {
|
||||
cx.events.push(MobEvent::DropItem {
|
||||
mob: m.id,
|
||||
item: "minecraft:sweet_berries".into(),
|
||||
count: count as u32,
|
||||
at,
|
||||
@@ -975,6 +976,7 @@ impl EatBerries {
|
||||
} else {
|
||||
// CaveVines.use: one glow berry, the vine goes back to bare.
|
||||
cx.events.push(MobEvent::DropItem {
|
||||
mob: m.id,
|
||||
item: "minecraft:glow_berries".into(),
|
||||
count: 1,
|
||||
at,
|
||||
|
||||
@@ -866,6 +866,7 @@ impl Goal for PandaSit {
|
||||
if let Some(item) = m.extra.main_hand.clone() {
|
||||
let count = held_count(m);
|
||||
cx.events.push(MobEvent::DropItem {
|
||||
mob: m.id,
|
||||
item,
|
||||
count,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
|
||||
@@ -1711,6 +1711,7 @@ pub fn ai_step(
|
||||
|| m.extra.piglin.seen_admire > 0;
|
||||
if admiring && let Some(item) = m.extra.off_hand.take() {
|
||||
events.push(MobEvent::DropItem {
|
||||
mob: m.id,
|
||||
item,
|
||||
count: 1,
|
||||
at: [m.position.x, m.position.y, m.position.z],
|
||||
|
||||
@@ -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
|
||||
|
||||
+866
-18
File diff suppressed because it is too large.
Load diff
+22
@@ -1180,4 +1180,26 @@ pub trait Host {
|
||||
let _ = size;
|
||||
unimplemented!("Host::item_stack")
|
||||
}
|
||||
/// A mod's own RGBA image, uploaded into the HUD atlas slot
|
||||
/// [`MOD_IMAGE_SPRITE`](crate::hud::MOD_IMAGE_SPRITE) and drawn with
|
||||
/// `native_sprite`: its own source rectangle crops and stretches it. `rgba`
|
||||
/// is `width * height` pixels of four bytes, each side at most
|
||||
/// [`MOD_IMAGE_SIZE`](crate::hud::MOD_IMAGE_SIZE); false when it is not that
|
||||
/// size. The upload costs a texture patch of the image, so send one when the
|
||||
/// image changed, not every frame. API @API:mod_image@.
|
||||
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
|
||||
let _ = width;
|
||||
let _ = height;
|
||||
let _ = rgba;
|
||||
unimplemented!("Host::mod_image")
|
||||
}
|
||||
/// 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@.
|
||||
fn instance_dir(&self) -> PathBuf {
|
||||
unimplemented!("Host::instance_dir")
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -73,8 +73,9 @@ pub fn started(shared: &Shared) {
|
||||
}
|
||||
|
||||
/// 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`), and a world save that began or ended
|
||||
/// (`serverSaveStarted`, `serverSaveCompleted`).
|
||||
/// (`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 {
|
||||
@@ -82,7 +83,11 @@ pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
|
||||
"leave" => ("notification/players/left", player()),
|
||||
"saving" => ("notification/server/saving", None),
|
||||
"saved" => ("notification/server/saved", None),
|
||||
_ => return,
|
||||
// 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);
|
||||
}
|
||||
@@ -891,6 +896,18 @@ mod tests {
|
||||
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]
|
||||
@@ -909,6 +926,14 @@ mod tests {
|
||||
"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,
|
||||
@@ -77,6 +79,38 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
|
||||
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
|
||||
),
|
||||
),
|
||||
"serversettings/max_players" => game.max_players(client, None),
|
||||
"serversettings/max_players/set" => game.max_players(
|
||||
client,
|
||||
Some(
|
||||
argument
|
||||
.as_i64()
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.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()),
|
||||
@@ -308,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> {
|
||||
@@ -346,6 +433,29 @@ impl Game<'_> {
|
||||
Ok(reply["value"].clone())
|
||||
}
|
||||
|
||||
/// `MinecraftServerSettingsServiceImpl.getMaxPlayers` and `setMaxPlayers`.
|
||||
fn max_players(&self, client: u32, value: Option<usize>) -> Result<Value, Failure> {
|
||||
let mut request = json!({ "method": "serversettings/max_players" });
|
||||
if let Some(value) = value {
|
||||
request["method"] = json!("serversettings/max_players/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 {
|
||||
crate::config::update(
|
||||
&self.shared.dir,
|
||||
"server",
|
||||
"max_players",
|
||||
toml::Value::Integer(value as i64),
|
||||
)
|
||||
.map_err(|e| (INTERNAL_ERROR, Some(e)))?;
|
||||
}
|
||||
Ok(reply["value"].clone())
|
||||
}
|
||||
|
||||
/// `MinecraftServerSettingsServiceImpl.getGameMode` and `setGameMode`.
|
||||
fn game_mode(&self, client: u32, value: Option<&str>) -> Result<Value, Failure> {
|
||||
let mut request = json!({ "method": "serversettings/game_mode" });
|
||||
@@ -600,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 });
|
||||
@@ -627,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());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user