Merge main into #7245

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 14:55:48 +01:00
commit 73f3e0e888
154 files changed
+6768 -530

No files matched your search

+3 -2
View File
@@ -96,8 +96,9 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
Generated
+1
View File
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"horizon-bench",
"horizon-core",
"log",
"regex",
]
@@ -688,4 +688,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
// A prepared root's: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
out.extend(
files
.iter()
.filter_map(|file| file.strip_suffix(".png"))
.map(|rel| format!("{namespace}:{rel}")),
);
continue;
}
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
+192 -80
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -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)
}
+168 -7
View File
@@ -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| &section.blocks);
blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
})
}
}
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
type Error = io::Error;
/// The resident column, looked up once per chunk the scan enters.
@@ -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| &section.blocks);
Ok(blocks.is_none_or(|blocks| {
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
}))
Ok(self.section_reaches_out(chunk, section_y))
}
fn is_suffocating(
&mut self,
@@ -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"
);
}
}
+2 -1
View File
@@ -147,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
+59 -38
View File
@@ -23,7 +23,12 @@ use horizon_optifine::fusion::{
};
use horizon_world::block_state_properties::BlockProperties;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
use std::{
collections::HashMap,
fs,
num::NonZeroU16,
path::{Path, PathBuf},
};
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
struct Sheet {
@@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
for ns in namespaces.flatten() {
let ns_name = ns.file_name().to_string_lossy().into_owned();
let textures = ns.path().join("textures");
let mut dirs = vec![textures.clone()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
// Linked folders too: mods' content links into the packs' tree.
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
continue;
}
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
for path in mcmeta_files(&textures) {
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
continue;
};
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
continue;
};
if !text.contains("fusion") {
continue;
}
let Some(rel) = Path::new(png)
.strip_prefix(&textures)
.ok()
.and_then(|p| p.to_str())
else {
continue;
};
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
match Meta::from_mcmeta(&text) {
Ok(Some(meta)) => {
out.insert(name, meta);
}
Ok(None) => {}
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
}
}
}
out
}
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
/// from the folders (linked folders too: mods' content links into the packs' tree).
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
return files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| textures.join(file))
.collect();
}
let mut out = Vec::new();
let mut dirs = vec![textures.to_owned()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
let dir = match entry.file_type() {
Ok(t) if t.is_symlink() => path.is_dir(),
Ok(t) => t.is_dir(),
Err(_) => path.is_dir(),
};
if dir {
dirs.push(path);
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
out.push(path);
}
}
}
+12
View File
@@ -1447,6 +1447,13 @@ fn instance_descriptor(
}
descriptor.backends = backends.with_env();
let dx12 = cfg!(windows) && descriptor.backends.contains(wgpu::Backends::DX12);
// FXC spends about 2.6 s on every start compiling the pipelines (DXC about
// 1 s); what it compiled once is kept in the game folder and read back
// after that (third_party/wgpu-hal/src/dx12/disk_cache.rs).
#[cfg(windows)]
if dx12 {
wgpu::hal::dx12::disk_cache::set_file(dx12_shader_cache(&crate::logging::game_home()));
}
if !cfg!(debug_assertions) && !dx12 {
descriptor
.flags
@@ -1460,6 +1467,11 @@ fn instance_descriptor(
}
descriptor
}
/// Where DX12 keeps the shaders it compiled, inside a game folder.
pub fn dx12_shader_cache(home: &std::path::Path) -> std::path::PathBuf {
home.join("cache").join("dx12-shaders.bin")
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
/// one, wgpu-hal falls back to `EGL_MESA_platform_surfaceless`, whose configs
@@ -70,8 +70,11 @@ pub struct SkinJob {
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
pub item: u32,
/// Always 0: the shader reads the last as a zero it cannot fold away.
pub spare: [u32; 2],
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
pub cull: u32,
/// Always 0: the shader reads it as a zero it cannot fold away.
pub spare: u32,
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
@@ -376,7 +379,12 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} else {
(job.color, [packed[0], packed[1], packed[2], -127])
let w = if job.cull != 0 {
crate::mesher::SHADED_CULL_W
} else {
-127
};
(job.color, [packed[0], packed[1], packed[2], w])
};
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
@@ -490,6 +498,8 @@ mod tests {
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
// The bat's encoding: the shader must write -126 like `expand`.
cull: 1,
..Default::default()
},
SkinJob {
@@ -16,8 +16,8 @@ const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
// it draws an item's quads (`SkinJob::item`), 2 spare (0: the last one is
// `keep`'s).
// it draws an item's quads (`SkinJob::item`), whether its quads cull back faces
// (`SkinJob::cull`), 1 spare (0: `keep`'s).
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
@@ -89,6 +89,7 @@ fn skin(
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let item = jobs[j + 13u] != 0u;
let cull = jobs[j + 14u] != 0u;
keep = jobs[j + 15u];
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
@@ -110,6 +111,10 @@ fn skin(
// An item's colour by axis, shaded on the CPU, and `mesher::CULL_BACK`.
var vertex_color = color;
var normal = pack(n);
if cull {
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
}
if item {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
@@ -273,16 +273,20 @@ const ZEROS: u64 = 4 << 20;
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
objects: BTreeMap<u32, BufferObject>,
/// The fewest bytes each one's shaders bind it as (`Post::buffer_minimums`).
minimums: BTreeMap<u32, u64>,
pub(super) group: Option<wgpu::BindGroup>,
buffers: Vec<wgpu::Buffer>,
made_for: [u32; 2],
}
impl Buffers {
/// The layout of `objects`, bound in `stages`.
/// The layout of `objects`, bound in `stages`; each buffer is at least its entry in
/// `minimums` long.
pub(super) fn new(
device: &wgpu::Device,
objects: BTreeMap<u32, BufferObject>,
minimums: BTreeMap<u32, u64>,
stages: wgpu::ShaderStages,
) -> Self {
let entries: Vec<_> = objects
@@ -305,6 +309,7 @@ impl Buffers {
Self {
layout,
objects,
minimums,
group: None,
buffers: Vec::new(),
made_for: [0; 2],
@@ -330,7 +335,8 @@ impl Buffers {
.objects
.iter()
.map(|(binding, o)| {
let wanted = o.size(size).next_multiple_of(4);
let least = self.minimums.get(binding).copied().unwrap_or(0);
let wanted = buffer_size(*o, size, least);
if wanted > largest {
log::warn!(
"shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
@@ -368,6 +374,13 @@ impl Buffers {
}
}
/// The bytes a storage buffer is made with for a render size of `render`: what the pack
/// declares, or more when a shader binds it as a larger block (`least`), which wgpu needs where
/// OpenGL binds the smaller buffer; a multiple of 4.
fn buffer_size(object: BufferObject, render: [u32; 2], least: u64) -> u64 {
object.size(render).max(least).next_multiple_of(4)
}
/// A debug readback of one of the pack's images (`HORIZON_SHADER_PACK_DUMP=<name>`): every 60
/// frames, how many of its texels are not zero, logged.
pub(super) struct Dump {
@@ -458,3 +471,27 @@ impl Dump {
self.pending = Some((buffer, texel, dims, row));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A buffer the pack declares smaller than a shader's block grows to the block (40 bytes
/// bound as a 48-byte block failed wgpu's validation, #7178); a larger one keeps its size.
#[test]
fn a_buffer_is_at_least_its_shaders_block() {
assert_eq!(buffer_size(BufferObject::Bytes(40), [1, 1], 48), 48);
assert_eq!(
buffer_size(BufferObject::Bytes(40980), [1, 1], 40992),
40992
);
assert_eq!(buffer_size(BufferObject::Bytes(100), [1, 1], 48), 100);
assert_eq!(buffer_size(BufferObject::Bytes(41), [1, 1], 0), 44);
let per_pixel = BufferObject::PerPixel {
bytes: 4,
scale: [0.5, 0.5],
};
assert_eq!(buffer_size(per_pixel, [10, 10], 64), 100);
assert_eq!(buffer_size(per_pixel, [4, 4], 64), 64);
}
}
@@ -1686,6 +1686,8 @@ impl PackPost {
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Self {
// What the pack's shaders need of its storage buffers, before its parts move out.
let minimums = post.buffer_minimums();
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] =
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
@@ -1769,7 +1771,7 @@ impl PackPost {
if gpu.storage_stages().is_empty() {
objects.clear();
}
let buffers = pack_images::Buffers::new(device, objects, gpu.storage_stages());
let buffers = pack_images::Buffers::new(device, objects, minimums, gpu.storage_stages());
let storable = (0..BUFFERS)
.filter(|&i| gpu.storable(adapter, formats[i], features))
.fold(0u16, |m, i| m | 1 << i);
+53 -6
View File
@@ -115,6 +115,10 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
/// enough for a world map's 1024 blocks at a block a pixel.
pub const MOD_IMAGE_SPRITE: &str = "horizon/modimage";
pub const MOD_IMAGE_SIZE: u32 = 1024;
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
/// Vanilla registers a texture per skin. Here the slots are shared
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
@@ -193,6 +197,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
.then(|| (field(16), field(20)))
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
// A prepared root's folder: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
out.extend(
files
.iter()
.filter(|file| file.ends_with(".png"))
.map(|file| dir.join(file)),
);
return;
}
let Ok(entries) = fs::read_dir(dir) else {
return;
};
@@ -671,13 +685,21 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
Self::build_over_with_image(root, defaults, false)
}
/// The atlas above, with a slot for a native mod's own image
/// (`horizon/modimage`) only when `mod_image` says a mod is loaded that
/// draws one: the slot costs 4 MiB of pixels, so a game without native
/// mods holds none of them.
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
/// minimap, a native mod's own image, downloaded skins, world and pack
/// icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
}
@@ -688,6 +710,15 @@ impl HudAtlas {
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
));
// A mod's own image (native `mod_image`), only when a mod is loaded.
if mod_image {
images.push((
MOD_IMAGE_SPRITE.into(),
MOD_IMAGE_SIZE,
MOD_IMAGE_SIZE,
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
));
}
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
@@ -730,7 +761,7 @@ impl HudAtlas {
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, root, &["font"], false)?;
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
@@ -876,7 +907,13 @@ impl HudAtlas {
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
fn pack(
root: &Path,
defaults: &Path,
folders: &[&str],
full: bool,
mod_image: bool,
) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let default_textures =
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
@@ -1107,7 +1144,7 @@ impl HudAtlas {
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images, crate::trims::slot_size(root));
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
let missing = (0..256)
@@ -2622,6 +2659,16 @@ impl HudRenderer {
mod tests {
use super::*;
/// The mod image slot is paid only by a game with a native mod loaded.
#[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
let root = Path::new("/nonexistent/horizon-test-assets");
let plain = HudAtlas::build(root).unwrap();
assert!(!plain.sprites.contains_key(MOD_IMAGE_SPRITE));
let with_mod = HudAtlas::build_over_with_image(root, root, true).unwrap();
assert!(with_mod.sprites.contains_key(MOD_IMAGE_SPRITE));
}
#[test]
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
@@ -50,6 +50,9 @@ pub struct Vertex {
/// `Vertex::normal` of a baked-colour quad of an `entitySolid` model: the render type culls
/// back faces, which the entity pipelines here do not, so `entity.wgsl` discards them.
pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
pub const SHADED_CULL_W: i8 = -126;
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
@@ -996,7 +996,14 @@ impl ParticleLevel<'_> {
binding: self.collision,
subject: None,
};
match scene.resolve(bb, Vec3::new(d[0], d[1], d[2]), 0.0, false) {
let movement = Vec3::new(d[0], d[1], d[2]);
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
// block of the swept box's cells can collide, it finds no shape and the
// movement stands, as the resolution below would return it.
if scene.cells_without_collision(bb.expand_towards(movement)) {
return d;
}
match scene.resolve(bb, movement, 0.0, false) {
Ok(v) => [v.x, v.y, v.z],
Err(_) => d,
}
+173 -12
View File
@@ -6,7 +6,7 @@
//! or a core on a busy machine) shows apart from one that worked. Costs two
//! clock reads per span when enabled and a relaxed load otherwise.
use std::{
cell::RefCell,
cell::{Cell, RefCell},
sync::atomic::{AtomicBool, Ordering},
time::{Duration, Instant},
};
@@ -14,6 +14,10 @@ use std::{
static ENABLED: AtomicBool = AtomicBool::new(false);
thread_local! {
static SPANS: RefCell<Vec<Span>> = const { RefCell::new(Vec::new()) };
/// Spans recorded on this thread alone, wall time only ([`record_here`]).
static HERE: Cell<bool> = const { Cell::new(false) };
/// How many timed spans the thread is inside: the next span's [`Span::depth`].
static DEPTH: Cell<u8> = const { Cell::new(0) };
}
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
@@ -22,6 +26,8 @@ pub struct Span {
pub name: &'static str,
pub ms: f32,
pub cpu_ms: f32,
/// How many timed spans it ran inside (0: none): `tick_mobs` is inside `tick`.
pub depth: u8,
}
pub fn enable(on: bool) {
@@ -31,19 +37,49 @@ pub fn enable(on: bool) {
pub fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
/// what the render thread's slow frames spent their time in, for the opt-in performance
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
pub fn record_here(on: bool) {
HERE.with(|h| h.set(on));
}
/// Whether this thread records spans: all of them on, or [`record_here`].
#[inline]
pub fn recording_here() -> bool {
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
}
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
/// print it.
#[inline]
fn cpu_if_enabled() -> Option<Duration> {
if ENABLED.load(Ordering::Relaxed) {
thread_cpu()
} else {
None
}
}
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
if !ENABLED.load(Ordering::Relaxed) {
if !recording_here() {
return f();
}
let (started, cpu) = (Instant::now(), thread_cpu());
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
let (started, cpu) = (Instant::now(), cpu_if_enabled());
let out = f();
let ms = started.elapsed().as_secs_f32() * 1000.0;
let cpu_ms = thread_cpu()
.zip(cpu)
let cpu_ms = cpu
.and_then(|start| Some((thread_cpu()?, start)))
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
DEPTH.with(|d| d.set(depth));
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
out
}
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
@@ -52,7 +88,7 @@ pub struct Laps(Option<(Instant, Option<Duration>)>);
/// Start timing the steps of a function (see [`Laps`]).
#[inline]
pub fn laps() -> Laps {
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
}
impl Laps {
/// Record the step that just ended under `name`, and start the next.
@@ -61,12 +97,21 @@ impl Laps {
let Some((started, cpu)) = &mut self.0 else {
return;
};
let (now, now_cpu) = (Instant::now(), thread_cpu());
let now = Instant::now();
let now_cpu = cpu.and_then(|_| thread_cpu());
let ms = (now - *started).as_secs_f32() * 1000.0;
let cpu_ms = now_cpu
.zip(*cpu)
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
let depth = DEPTH.with(Cell::get);
SPANS.with(|s| {
s.borrow_mut().push(Span {
name,
ms,
cpu_ms,
depth,
})
});
(*started, *cpu) = (now, now_cpu);
}
}
@@ -83,11 +128,62 @@ pub fn thread_cpu() -> Option<Duration> {
horizon_threads::thread_cpu()
}
/// The frame's spans as they were recorded, each after the spans inside it; clears them.
pub fn take_raw() -> Vec<Span> {
SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()))
}
/// The frame's spans, summed by name, slowest first; clears them.
pub fn take() -> Vec<Span> {
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
merge(&take_raw())
}
/// What a frame spent its time in, from its spans as recorded ([`take_raw`]): the slowest of
/// the outermost spans (summed by name, as a frame can tick several times), then the slowest
/// span inside it while that one took at least half of it, and so on. The path of names
/// (`tick/tick_mobs`) and the last one's milliseconds; `None` without spans.
pub fn culprit(raw: &[Span]) -> Option<(String, f32)> {
// The spans directly inside each one: recorded just before it, one level deeper.
let mut inside: Vec<Vec<usize>> = vec![Vec::new(); raw.len()];
let mut outer: Vec<usize> = Vec::new();
for (i, span) in raw.iter().enumerate() {
while let Some(&j) = outer.last()
&& raw[j].depth > span.depth
{
outer.pop();
inside[i].push(j);
}
outer.push(i);
}
let slowest = |group: &[usize]| {
let mut sums: Vec<(&'static str, f32, Vec<usize>)> = Vec::new();
for &i in group {
match sums.iter_mut().find(|s| s.0 == raw[i].name) {
Some(sum) => {
sum.1 += raw[i].ms;
sum.2.push(i);
}
None => sums.push((raw[i].name, raw[i].ms, vec![i])),
}
}
sums.into_iter().max_by(|a, b| a.1.total_cmp(&b.1))
};
let (name, mut ms, mut at) = slowest(&outer)?;
let mut path = name.to_owned();
loop {
let next: Vec<usize> = at.iter().flat_map(|&i| inside[i].iter().copied()).collect();
match slowest(&next) {
Some((name, inner_ms, spans)) if inner_ms >= ms * 0.5 => {
path.push('/');
path.push_str(name);
(ms, at) = (inner_ms, spans);
}
_ => return Some((path, ms)),
}
}
}
/// Spans summed by name, slowest first.
pub fn merge(spans: &[Span]) -> Vec<Span> {
let mut sums: Vec<Span> = Vec::new();
for span in spans {
for &span in spans {
match sums.iter_mut().find(|s| s.name == span.name) {
Some(sum) => {
sum.ms += span.ms;
@@ -309,21 +405,25 @@ mod tests {
name: "tick",
ms: 2.0,
cpu_ms: 2.0,
depth: 0,
},
Span {
name: "submit",
ms: 30.0,
cpu_ms: 0.2,
depth: 0,
},
Span {
name: "tick",
ms: 3.0,
cpu_ms: 2.9,
depth: 0,
},
Span {
name: "hud",
ms: 0.3,
cpu_ms: 0.3,
depth: 0,
},
]);
});
@@ -333,7 +433,8 @@ mod tests {
Span {
name: "submit",
ms: 30.0,
cpu_ms: 0.2
cpu_ms: 0.2,
depth: 0,
}
);
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
@@ -341,6 +442,66 @@ mod tests {
assert!(super::take().is_empty());
}
/// A thread that records on its own keeps its spans with spans off, each with how many
/// spans it ran inside, after them.
#[test]
fn a_recording_thread_keeps_its_spans_and_their_nesting() {
std::thread::spawn(|| {
super::record_here(true);
super::time("tick", || {
super::time("tick_mobs", || ());
super::time("tick_items", || ());
});
super::time("renderer", || ());
let raw = super::take_raw();
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
assert_eq!(
seen,
[
("tick_mobs", 1),
("tick_items", 1),
("tick", 0),
("renderer", 0)
]
);
})
.join()
.unwrap();
}
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
/// at least half of it; repeated spans (several ticks a frame) add up.
#[test]
fn a_frame_is_put_down_to_the_step_that_took_most_of_it() {
let span = |name, ms, depth| Span {
name,
ms,
cpu_ms: ms,
depth,
};
let frame = [
span("tick_mobs", 90.0, 1),
span("tick_items", 5.0, 1),
span("tick", 100.0, 0),
span("tick_mobs", 90.0, 1),
span("tick", 100.0, 0),
span("r_uploads", 120.0, 1),
span("renderer", 150.0, 0),
];
assert_eq!(
super::culprit(&frame),
Some(("tick/tick_mobs".to_owned(), 180.0))
);
// Spread over many steps: the outer span alone.
let spread = [
span("tick_mobs", 40.0, 1),
span("tick_items", 40.0, 1),
span("tick", 300.0, 0),
];
assert_eq!(super::culprit(&spread), Some(("tick".to_owned(), 300.0)));
assert_eq!(super::culprit(&[]), None);
}
/// Laps record the steps between them while spans are on, and nothing while off.
#[test]
fn laps_time_consecutive_steps() {
@@ -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();
+52 -7
View File
@@ -223,6 +223,7 @@ pub struct Session {
title_measured: bool,
to_world_ms: Option<u32>,
opening: Option<Instant>,
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
}
impl Session {
@@ -250,8 +251,21 @@ impl Session {
self.opening = None;
}
/// A frame drawn while playing (not in menus or loading).
pub fn frame(&mut self, ms: f32) {
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
if ms >= SLOW_FRAME_MS {
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
// A path too long for the item keeps its last steps.
while path.len() > SlowFrames::MAX_NAME
&& let Some((_, rest)) = path.split_once('/')
{
path = rest.to_owned();
}
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
}
if let Some(opening) = self.opening.take() {
crate::spans::startup::mark("world.first_frame");
if self.to_world_ms.is_none() {
@@ -281,6 +295,7 @@ impl Session {
to_world_ms: self.to_world_ms,
to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)),
to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)),
slow_frames: self.slow_frames.clone(),
})
}
@@ -490,10 +505,10 @@ mod tests {
assert!(session.opening.is_some());
session.cancel_world_opening();
assert!(session.opening.is_none());
session.frame(16.0);
session.frame(16.0, &[]);
assert!(session.to_world_ms.is_none());
session.world_opening();
session.frame(16.0);
session.frame(16.0, &[]);
assert!(session.to_world_ms.is_some());
}
@@ -502,7 +517,7 @@ mod tests {
let home = home("session");
let mut session = Session::default();
session.world_opening();
(0..2000).for_each(|_| session.frame(16.6));
(0..2000).for_each(|_| session.frame(16.6, &[]));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
allow(
@@ -512,11 +527,11 @@ mod tests {
..Choices::NONE
},
);
(0..2000).for_each(|_| session.frame(16.6));
(0..2000).for_each(|_| session.frame(16.6, &[]));
session.finish(&home, Settings::default());
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
session.world_opening();
(0..4000).for_each(|_| session.frame(16.6));
(0..4000).for_each(|_| session.frame(16.6, &[]));
(0..1200).for_each(|_| session.tick(4.0));
session.finish(
&home,
@@ -537,4 +552,34 @@ mod tests {
assert!(perf.to_world_ms.is_some());
assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024);
}
/// Frames of 250 ms or more count under the step that took most of them; a frame with no
/// span counts under none. Faster frames leave no entry.
#[test]
fn slow_frames_are_put_down_to_their_step() {
use crate::spans::Span;
let span = |name, ms, depth| Span {
name,
ms,
cpu_ms: ms,
depth,
};
let mut session = Session::default();
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
session.frame(320.0, &frozen);
session.frame(900.0, &frozen);
session.frame(260.0, &[]);
session.frame(249.0, &frozen);
let perf = session.summary(Settings::default()).unwrap();
let seen: Vec<_> = perf
.slow_frames
.iter()
.map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms))
.collect();
assert_eq!(
seen,
[("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)]
);
}
}
+51 -30
View File
@@ -5,7 +5,12 @@ use crate::{
hud::{HudAtlas, HudBatch, Rect, rgba},
text::Text,
};
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::RwLock,
time::Instant,
};
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
/// its texture's resolution, its borders (left, top, right, bottom) in the
@@ -64,37 +69,53 @@ pub fn set_metadata(metadata: UiMetadata) {
pub fn read_metadata(root: &Path) -> UiMetadata {
let mut map = HashMap::new();
let base = root.join("assets/minecraft/textures");
let mut stack = vec![base.join("gui/sprites")];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
let sprites = base.join("gui/sprites");
// A prepared root's: from its data file, without listing the disk.
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
Some(files) => files
.iter()
.filter(|file| file.ends_with(".png.mcmeta"))
.map(|file| sprites.join(file))
.collect(),
None => {
let mut files = Vec::new();
let mut stack = vec![sprites];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
files.push(p);
}
}
}
files
}
};
for p in files {
let name = p.to_string_lossy().into_owned();
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
let name = p.to_string_lossy().into_owned();
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
continue;
};
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
else {
continue;
};
let key = Path::new(stripped)
.strip_prefix(&base)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
map.insert(key, nine_slice);
}
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
continue;
};
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
else {
continue;
};
let key = Path::new(stripped)
.strip_prefix(&base)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
map.insert(key, nine_slice);
}
UiMetadata(map)
}
+23
View File
@@ -1425,6 +1425,29 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
/// the second half of #7186.
#[test]
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
let w = Flat;
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
assert!(
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
"the crafting copy's bare name never matched the criterion"
);
assert!(
id_matches(
&criterion["recipes"],
"recipe",
&crate::crafting::qualified("folder_recipe"),
&w
),
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
+12
View File
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
augment(i, ingredients, stacks, &mut owner, &mut seen)
})
}
/// A resource id with its default namespace filled in — the whole id the
/// book, the network copy and the advancements' JSON know a recipe by,
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
/// on purpose (vanilla's own ids); this puts the namespace back.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let sources = Self::sources(root, overlay)?;
+82 -21
View File
@@ -91,6 +91,11 @@ pub struct Creative {
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
/// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines
/// of every search item in one language (keyed by the `itemGroup.search`
/// string), built on the first search and kept until the language or the
/// item list changes.
lines_cache: Mutex<Option<(String, Arc<[Vec<String>]>)>>,
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
@@ -373,6 +378,7 @@ impl Creative {
exported_search,
permissions: false,
shown: Mutex::new(None),
lines_cache: Mutex::new(None),
};
creative.rebuild();
creative
@@ -398,6 +404,7 @@ impl Creative {
})
})
.collect();
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
(self.search_items, self.search_index) = self
.exported_search
.iter()
@@ -531,6 +538,46 @@ impl Creative {
/// the name tree, both over vanilla's suffix arrays (substring matches,
/// results in the tab's order).
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
let english =
names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search");
let local = if term.is_empty() || term.starts_with('#') {
Arc::from([])
} else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) {
// The exported English lines are the whole index.
Arc::from([])
} else {
let language = names.display_get("itemGroup.search").unwrap_or_default();
let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner());
match cache.as_ref().filter(|(l, _)| *l == language) {
Some((_, lines)) => lines.clone(),
None => {
let lines = self.localized_lines(&|k| names.display_get(k));
*cache = Some((language, lines.clone()));
lines
}
}
};
self.search_results_in(term, names, &local, english)
}
/// Every search item's tooltip lines through `translate`, lower-cased.
fn localized_lines(&self, translate: &dyn Fn(&str) -> Option<String>) -> Arc<[Vec<String>]> {
self.search_items
.iter()
.map(|s| {
crate::item_tooltip::lines(s, None, translate)
.iter()
.map(|l| java_lower(l))
.collect()
})
.collect()
}
fn search_results_in(
&self,
term: &str,
names: &Names,
local: &[Vec<String>],
english: bool,
) -> Vec<usize> {
let all = 0..self.search_index.len();
if term.is_empty() {
return all.collect();
@@ -552,18 +599,13 @@ impl Creative {
})
.collect();
}
// FullTextSearchTree: tooltip lines, and the item id after a colon.
// The export's lines are English (they come from the vanilla jars);
// the hover name in the selected language is matched too, so a
// localized search finds the item. It is the whole name (a tipped
// arrow's potion, the ominous banner), as the first line is.
// FullTextSearchTree: the creative tooltip lines in the selected
// language, and the item id after a colon. The export's lines are
// English (they come from the vanilla jars), so they stand in only
// while the language is English; otherwise every line (name, lore,
// effects, ...) is rebuilt through the language, as vanilla's
// getTooltipLines does.
let text = java_lower(term);
let localized = |i: usize| {
java_lower(&crate::item_tooltip::name_line(
&self.search_items[i],
&|k| names.display_get(k),
))
};
all.filter(|&i| {
let e = &self.search_index[i];
let fallback;
@@ -573,16 +615,17 @@ impl Creative {
} else {
&e.lines
};
let exported = english || e.lines.is_empty();
let local = local.get(i).map_or(&[][..], Vec::as_slice);
let hit = |t: &str| {
(exported && any(lines.iter().map(String::as_str), t))
|| any(local.iter().map(String::as_str), t)
};
match text.split_once(':') {
None => {
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
}
None => hit(&text),
Some((ns, path)) => {
let (ns, path) = (java_trim(ns), java_trim(path));
e.namespace.contains(ns)
&& (e.path.contains(path)
|| any(lines.iter().map(String::as_str), path)
|| localized(i).contains(path))
e.namespace.contains(ns) && (e.path.contains(path) || hit(path))
}
}
})
@@ -1396,15 +1439,33 @@ mod tests {
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
]),
};
let q = |s: &str| c.search_results(s, &names);
let tr = |k: &str| names.get(k).map(str::to_owned);
let local = c.localized_lines(&tr);
let q = |s: &str| c.search_results_in(s, &names, &local, false);
assert_eq!(q("铁砧"), [0]);
assert_eq!(q("木锄"), [2]);
// The English tooltip lines the export indexed still match.
assert_eq!(q("anvil"), [0]);
// Only the selected language's lines count outside English.
assert!(q("anvil").is_empty());
assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]);
// A colon's path searches the display name too.
assert_eq!(q("minecraft:铁砧"), [0]);
}
#[test]
fn search_matches_every_localized_tooltip_line() {
// Vanilla's FullTextSearchTree indexes the selected language's
// tooltip lines; the export's English lines do not apply.
let c = creative(0);
let names = Names::default();
let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]);
let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned());
let local = c.localized_lines(&tr);
let q = |s: &str, english| c.search_results_in(s, &names, &local, english);
assert_eq!(q("наков", false), [0]);
assert!(q("anvil", false).is_empty());
assert_eq!(q("anvil", true), [0]);
}
#[test]
fn scrollbar_drag_follows_the_mouse_while_held() {
let mut c = creative(90);
+19 -10
View File
@@ -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(
+5 -3
View File
@@ -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,
+82 -15
View File
@@ -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
+19 -4
View File
@@ -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);
}
}
}
+18 -2
View File
@@ -1121,6 +1121,16 @@ impl Game<'_> {
);
self.world.modified.insert(key);
self.boats.push(boat);
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_vehicle_create(
self.boats.last().unwrap().entity_type().as_str(),
[
self.boats.last().unwrap().position.x,
self.boats.last().unwrap().position.y,
self.boats.last().unwrap().position.z,
],
);
}
if !self.creative {
self.consume_held();
}
@@ -1345,18 +1355,24 @@ impl Game<'_> {
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
let mut cancel = false;
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
if cancel {
return false;
}
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
b.hurt_time = 10;
b.damage += damage * 10.0;
let destroyed = creative || b.damage > 40.0;
if destroyed {
let at = [b.position.x, b.position.y, b.position.z];
self.native_vehicle_destroy("boat", at);
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
self.boat_removed(&b);
// `AbstractChestBoat.remove` spills the chest through
+12
View File
@@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec<String> {
/// The `namespace:` ids of the `extension` files under `root`, sorted.
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
let suffix = format!(".{extension}");
// A prepared root's folder: from its data file, without listing the disk.
if let Some(files) = horizon_core::prepared_files::files_under(root) {
let mut out: Vec<String> = files
.iter()
.filter_map(|file| file.strip_suffix(&suffix))
.filter(|path| !path.is_empty() && !path.ends_with('/'))
.map(|path| format!("{namespace}:{path}"))
.collect();
out.sort();
return out;
}
let mut out = Vec::new();
let mut stack = vec![root.to_owned()];
while let Some(dir) = stack.pop() {
+42 -3
View File
@@ -159,6 +159,17 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
i32::try_from(id).ok()
}
impl Game<'_> {
/// `native_player_level_change` for a level that was `from` before an experience change.
pub(super) fn native_player_level_changed(&mut self, from: i32) {
let to = self.experience.level;
if from != to
&& !horizon_client::mod_api::mods().is_empty()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_level_change(player, &name, from, to);
}
}
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.client_mod_command(command) {
@@ -2175,7 +2186,8 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob_spawned(&short, pos);
self.spawn_mob_spawned(&short, pos)
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
@@ -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();
}
+22
View File
@@ -233,6 +233,14 @@ impl Game<'_> {
self.sign_input(None, Some(text));
return Ok(());
}
// The title's screens: the world name, the seed, a server's address.
// A screen opened over the title (Options, say) keeps its own text.
if title_owns_text(&self.screen, self.title.is_some())
&& let Some(title) = &mut self.title
{
title.type_text(text);
return Ok(());
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
@@ -1459,10 +1467,24 @@ fn key_code(name: &str) -> Option<KeyCode> {
})
}
/// The title's own screens (not one opened over it, as Options is) take the
/// text: the world name, the seed, a server's address.
pub(super) fn title_owns_text(screen: &Screen, title_up: bool) -> bool {
title_up && *screen == Screen::Playing
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_title_takes_text_only_on_its_own_screens() {
assert!(title_owns_text(&Screen::Playing, true));
assert!(!title_owns_text(&Screen::Playing, false));
assert!(!title_owns_text(&Screen::Options, true));
assert!(!title_owns_text(&Screen::Chat, true));
}
#[test]
fn hello_names_the_protocol_the_release_and_the_command_groups() {
let reply = hello();
+33 -16
View File
@@ -263,6 +263,10 @@ pub(super) fn named_mob_pickable(
alive && Some(id) != riding && Some(id) != camera
}
/// What an entity drew on the GPU path: the skin frame's mark before its draws, and their
/// camera-relative bounds (`cull_skinned`).
pub(super) type Skinned = Option<((usize, usize, u32), Option<(glam::Vec3, glam::Vec3)>)>;
impl Game<'_> {
pub(super) fn player_view(&self) -> Option<PlayerView> {
let p = self.player.as_ref()?;
@@ -427,6 +431,9 @@ impl Game<'_> {
let id = mob.id;
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
if !self.mobs.iter().any(|m| m.id == id) {
return None;
}
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
if !persistent && !scripted {
@@ -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);
}
+56 -2
View File
@@ -15,8 +15,8 @@ use horizon_client::{
properties::{GAME_MODES, ServerProperties, SystemEntropy},
user_cache::UserCache,
user_lists::{
BanEntry, BanList, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry, WhiteList,
format_date,
BanEntry, BanList, Change, IpBanList, NameAndId, OpEntry, OpList, WhiteEntry,
WhiteList, format_date,
},
},
text::Text,
@@ -408,6 +408,19 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
Ok(true)
}
/// The event a change of a list is (`operators_added`, `bans_removed`...), and the entry it is about.
fn listed<E>(list: &str, change: Change<E>) -> (String, E) {
match change {
Change::Added(e) => (format!("{list}_added"), e),
Change::Removed(e) => (format!("{list}_removed"), e),
}
}
/// What a ban says besides who it is for: the reason and the source, and when it ends (milliseconds, or none).
fn ban_fields<T>(ban: &BanEntry<T>) -> serde_json::Value {
serde_json::json!({ "reason": ban.reason, "source": ban.source, "expires": ban.expires })
}
/// The operator, whitelist and ban lists (DedicatedPlayerList), shared with
/// the connection threads that check logins.
pub(super) struct Lists {
@@ -431,6 +444,30 @@ impl Lists {
enforce_whitelist: false,
op_level: 4,
};
// Each list tells the event stream what changes in it, as vanilla's tell their `NotificationService`.
l.ops.watch(|change| {
let (kind, e) = listed("operators", change);
events::record(
&kind,
&e.user.name,
e.user.id,
serde_json::json!({ "level": e.level, "bypassesPlayerLimit": e.bypasses_player_limit }),
);
});
l.white.watch(|change| {
let (kind, e) = listed("allowlist", change);
events::record(&kind, &e.user.name, e.user.id, serde_json::json!({}));
});
l.bans.watch(|change| {
let (kind, e) = listed("bans", change);
events::record(&kind, &e.user.name, e.user.id, ban_fields(&e));
});
l.ip_bans.watch(|change| {
let (kind, e) = listed("ip_bans", change);
let mut fields = ban_fields(&e);
fields["ip"] = e.user.clone().into();
events::record_server(&kind, fields);
});
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
let warn = |what: &str, r: std::result::Result<(), String>| {
if let Err(e) = r {
@@ -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],
+212 -74
View File
@@ -36,6 +36,7 @@ pub(super) struct Hook {
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
tick_count: i32,
life: i32,
on_ground: bool,
nibble: i32,
@@ -64,6 +65,67 @@ fn bounds(p: Vec3) -> Aabb {
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
)
}
fn segment_entry(start: Vec3, end: Vec3, bounds: Aabb) -> Option<f64> {
let min = bounds.min_position();
let max = bounds.max_position();
let start = [start.x, start.y, start.z];
let min = [min.x, min.y, min.z];
let max = [max.x, max.y, max.z];
if (0..3).all(|axis| start[axis] >= min[axis] && start[axis] < max[axis]) {
return None;
}
let delta = [end.x - start[0], end.y - start[1], end.z - start[2]];
let mut entry: f64 = 0.0;
let mut exit: f64 = 1.0;
for axis in 0..3 {
if delta[axis] == 0.0 {
if start[axis] < min[axis] || start[axis] > max[axis] {
return None;
}
continue;
}
let a = (min[axis] - start[axis]) / delta[axis];
let b = (max[axis] - start[axis]) / delta[axis];
entry = entry.max(a.min(b));
exit = exit.min(a.max(b));
if entry > exit {
return None;
}
}
(entry <= 1.0 && exit >= 0.0).then_some(entry)
}
/// `ProjectileUtil.computeMargin`: the fishing hook's target-box margin grows
/// to 0.3 blocks over its first eight ticks.
fn entity_hit_margin(tick_count: i32) -> f64 {
f64::from((tick_count - 2).clamp(0, 6)) / 20.0
}
fn first_entity_hit(
start: Vec3,
end: Vec3,
margin: f64,
targets: impl IntoIterator<Item = (u32, Aabb)>,
) -> Option<u32> {
let mut hit = None;
let mut hit_at = f64::INFINITY;
for (id, target_bounds) in targets {
if let Some(at) = segment_entry(
start,
end,
target_bounds.inflate(Vec3::new(margin, margin, margin)),
) && at < hit_at
{
hit = Some(id);
hit_at = at;
}
}
hit
}
fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
item.velocity = item.velocity.add_vector(delta);
// The change is made outside ItemEntity::tick, so its velocity comparison
// cannot request the immediate motion update for tracking clients.
item.needs_sync = true;
}
impl Hook {
pub(super) fn bounds(&self) -> Aabb {
bounds(self.position)
@@ -170,6 +232,7 @@ impl Game<'_> {
previous: start,
velocity: v,
state: State::Flying,
tick_count: 0,
life: 0,
on_ground: false,
nibble: 0,
@@ -221,7 +284,7 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = pulled(m.velocity);
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
pull_item(item, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1));
hooked_item = true;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
t.velocity = pulled(t.velocity);
@@ -566,6 +629,7 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
h.tick_count += 1;
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
@@ -600,58 +664,67 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
let (a, z) = (b.min_position(), b.max_position());
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
})
.ok()
.flatten()
};
let hit = self
.mobs
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
// ArmorStand.isPickable is false for a marker.
self.armor_stands
.iter()
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
.map(|a| a.id)
})
.or_else(|| {
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
// boat the owner rides (approximating `leftOwner`, as the
// players above skip their owner).
let own = self.own_vehicle();
self.boats
.iter()
.find(|b| Some(b.id) != own && touches(b.bounds()))
.map(|b| b.id)
})
.or_else(|| {
self.dropped
.iter()
.find(|i| touches(i.bounds()))
.map(|i| i.id)
})
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && touches(t.bounds()))
.map(|t| t.id)
});
let entity_end = block_hit.map_or(to, |hit| hit.location);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
let hit = first_entity_hit(
h.position,
entity_end,
entity_hit_margin(h.tick_count),
self.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (m.id, m.bounds()))
.chain(
self.creatures
.hook_targets
.iter()
.filter(|t| t.conn != owner)
.map(|t| (t.id, t.bounds)),
)
.chain(
self.armor_stands
.iter()
.filter(|a| a.health > 0.0 && !a.marker)
.map(|a| (a.id, a.bounds())),
)
.chain(
self.boats
.iter()
.filter(|b| Some(b.id) != own_vehicle)
.map(|b| (b.id, b.bounds())),
)
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
.chain(
self.tnts
.iter()
.filter(|t| t.id != 0)
.map(|t| (t.id, t.bounds())),
),
);
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
self.projectile_event(
@@ -664,26 +737,7 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: the block the path hits before the move, then
// `onHitBlock`'s rebound.
let to = h.position.add_vector(h.velocity);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
if let Some(hit) = block_hit {
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
@@ -1124,6 +1178,7 @@ impl Hook {
previous: at,
velocity: Vec3::ZERO,
state: State::Flying,
tick_count: 0,
life: 0,
on_ground: false,
nibble: 0,
@@ -1419,8 +1474,9 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::rebound;
use horizon_core::geometry::Vec3;
use super::{entity_hit_margin, first_entity_hit, pull_item, rebound};
use horizon_client::{entities::ItemEntity, items::ItemStack};
use horizon_core::geometry::{Aabb, Vec3};
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
#[test]
@@ -1429,4 +1485,86 @@ mod tests {
let hit = Vec3::new(0.0, 0.0, -0.5);
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
}
#[test]
fn reeling_an_item_requests_an_immediate_motion_update() {
let mut item = ItemEntity {
no_gravity: false,
health: 5,
fire_ticks: 0,
id: 1,
stack: ItemStack::new("minecraft:stone", 1),
thrower: None,
target: None,
position: Vec3::ZERO,
previous: Vec3::ZERO,
velocity: Vec3::new(0.2, 0.0, -0.1),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
};
pull_item(&mut item, Vec3::new(0.5, 0.1, -0.2));
assert!((item.velocity.x - 0.7).abs() < 1.0e-12);
assert!((item.velocity.y - 0.1).abs() < 1.0e-12);
assert!((item.velocity.z + 0.3).abs() < 1.0e-12);
assert!(item.needs_sync);
}
#[test]
fn a_flying_hook_hits_a_small_target_between_ticks() {
let target = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.8, 0.0, 0.0), target).is_some());
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.4, 0.0, 0.0), target).is_none());
}
#[test]
fn a_flying_hook_uses_vanillas_growing_entity_hit_margin() {
let start = Vec3::ZERO;
let end = Vec3::new(1.0, 0.0, 0.0);
let target = Aabb::new(Vec3::new(0.5, 0.31, -0.1), Vec3::new(0.7, 0.5, 0.1));
assert_eq!(entity_hit_margin(2), 0.0);
assert_eq!(entity_hit_margin(8), 0.3);
assert_eq!(
first_entity_hit(start, end, entity_hit_margin(8), [(1, target)]),
None
);
let within_margin = Aabb::new(Vec3::new(0.5, 0.29, -0.1), Vec3::new(0.7, 0.5, 0.1));
assert_eq!(
first_entity_hit(start, end, entity_hit_margin(8), [(1, within_margin)]),
Some(1)
);
}
#[test]
fn a_flying_hook_does_not_hit_a_target_it_starts_inside() {
let target = Aabb::new(Vec3::new(-0.1, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
assert_eq!(
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, [(1, target)]),
None
);
}
#[test]
fn a_flying_hook_uses_the_first_target_along_its_path() {
let start = Vec3::ZERO;
let end = Vec3::new(1.0, 0.0, 0.0);
let near = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
let far = Aabb::new(Vec3::new(0.8, -0.1, -0.1), Vec3::new(0.95, 0.15, 0.1));
assert_eq!(
first_entity_hit(start, end, 0.0, [(2, far), (1, near)]),
Some(1)
);
}
}
+12 -7
View File
@@ -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();
+46 -7
View File
@@ -478,20 +478,59 @@ impl Game<'_> {
frame: &mut frame,
bounds: None,
};
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
for &pose in poses {
let renderer = &self.pack.item_renderer;
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
if !self.skin_item(state, pose, light, lights, &mut skin) {
// Decided before any draw, the same for every pose.
return None;
}
}
Some((mark, skin.bounds))
}
/// One pose of [`Self::submit_item_skinned`] into an entity's open
/// `skin` (its bounds grow by the item's): false, with nothing drawn,
/// when the item cannot take the GPU path.
pub(super) fn skin_item(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
lights: (glam::Vec3, glam::Vec3),
skin: &mut horizon_client::mobs::skin::Skin,
) -> bool {
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
let renderer = &self.pack.item_renderer;
state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, skin)
}
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
/// nothing drawn, when the state cannot take it.
pub(super) fn skin_item_into(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
skinned: &mut super::creatures::Skinned,
) -> bool {
let lights = diffuse_lights(self.dim == Dim::Nether);
let Some((mark, bounds)) = self.submit_item_skinned(state, &[pose], light, lights) else {
return false;
};
*skinned = Some(match skinned.take() {
Some((first, before)) => (
first,
match (before, bounds) {
(Some((a, b)), Some((c, d))) => Some((a.min(c), b.max(d))),
(one, other) => one.or(other),
},
),
None => (mark, bounds),
});
true
}
/// `ItemEntityRenderer.submit` for every dropped item.
pub(super) fn dropped_item_geometry(
&self,
+62
View File
@@ -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);
+54 -11
View File
@@ -13,7 +13,7 @@ use super::{
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
menu::{Kind, Menu},
menu::{AnvilCraft, Kind, Menu},
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -80,6 +80,8 @@ impl Game<'_> {
return true;
};
let mut menu = Box::new(Menu::new(kind));
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& kind == Kind::Enchanting
{
@@ -136,14 +138,42 @@ impl Game<'_> {
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
(slots, gs.menu.data[0])
});
self.guest_menu_op(host, gi, &mut gs.menu, op);
let recomputed = before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
let mut undo = None;
let hook = gs.menu.anvil_hook;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
undo = hook.then(|| menu.anvil_undo());
op(menu, env);
});
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
let mut anvil_craft = gs.menu.anvil_craft.take();
let mut cancelled = false;
if let Some(craft) = &anvil_craft {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut cancel = false;
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
if cancel && let Some(undo) = undo {
// The take is refused: the result, inputs, cost, inventory
// and cursor are as before the click (the resync below
// tells the client).
anvil_craft = None;
cancelled = true;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
} else {
gs.menu.anvil_take(&self.menu_env());
let dropped = std::mem::take(&mut gs.menu.dropped);
self.drop_guest_items(host, gi, dropped);
}
}
// A cancelled take runs no createResult: the inputs are untouched.
let recomputed = !cancelled
&& before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
});
if recomputed && gs.kind == StationKind::Anvil {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
@@ -172,7 +202,7 @@ impl Game<'_> {
if let Some(amount) = gs.menu.ground.take() {
self.grindstone_taken(gs.pos, amount);
}
self.lan_station_combined(host, conn, &mut gs);
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
if let Some(button) = gs.menu.enchant_button.take() {
self.lan_enchant(host, gi, &mut gs, button);
}
@@ -305,8 +335,15 @@ impl Game<'_> {
/// The rest of a taken anvil or smithing result's `onTake`: the guest
/// pays the levels, the anvil wears (level event 1030 or 1029), the
/// smithing table sounds (1044).
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
/// smithing table sounds (1044). An anvil's `craft` is then reported to
/// the mods (`AnvilCraftEvent.Post`).
fn lan_station_combined(
&mut self,
host: &mut LanHost,
conn: u32,
gs: &mut GuestStation,
craft: Option<&AnvilCraft>,
) {
let change = std::mem::take(&mut gs.menu.level_change);
if change != 0 {
self.as_guest(host, conn, |g| g.give_levels(change));
@@ -320,6 +357,12 @@ impl Game<'_> {
let creative = gs.menu.creative;
// A broken anvil closes the menu on the next stillValid check.
self.anvil_used(gs.pos, creative);
if let Some(craft) = craft
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
{
let (uuid, name) = (guest.uuid, guest.name.clone());
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
}
}
}
+1
View File
@@ -146,6 +146,7 @@ impl Game<'_> {
}) else {
return;
};
self.native_entity_unleash(id, &kind);
if used
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
+89 -3
View File
@@ -740,6 +740,7 @@ impl Game<'_> {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
@@ -763,10 +764,10 @@ impl Game<'_> {
let mut i = 0;
while i < self.dropped.len() {
let item = &self.dropped[i];
// `ItemEntity.tick` moves 1 of 4 ticks while resting (age was
// counted already); `checkInsideBlocks` only follows a move.
// `ItemEntity.tick` moves 1 of 4 ticks while resting;
// `checkInsideBlocks` only follows a move.
let moved = item.position != item.previous
|| (item.age.wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4);
|| item.ticks.wrapping_add(item.id).is_multiple_of(4);
if !moved || item.stack.count == 0 {
i += 1;
continue;
@@ -866,6 +867,11 @@ impl Game<'_> {
let front = offset(pos, facing);
let face = facing.opposite();
if let Some(target) = c.view(front, false) {
self.native_hopper_inventory_search(
[pos.0, pos.1, pos.2],
Some([front.0, front.1, front.2]),
None,
);
return self.eject_into(pos, hopper, &target, face);
}
let Some(id) = self.entity_container_at([
@@ -875,15 +881,62 @@ impl Game<'_> {
]) else {
return false;
};
self.native_hopper_inventory_search([pos.0, pos.1, pos.2], None, Some(id));
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
.unwrap_or(false)
}
/// Where a container is in the world for the mods: its block, or the block a chest
/// boat or minecart is in (its view sits under a placeholder key).
fn container_world_pos(&self, view: &View) -> [i32; 3] {
let (x, y, z) = view.parts[0].0;
if x == i32::MAX - 15 && y == super::minecarts::CART_KEY_Y {
let id = z as u32;
let at = self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| c.position)
.or_else(|| self.boats.iter().find(|b| b.id == id).map(|b| b.position));
if let Some(p) = at {
return [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
}
}
[x, y, z]
}
/// `InventoryMoveItemEvent` for the item in `slot` of `holder` going from `source` to
/// `destination`: whether a mod canceled the move. Nothing is looked up with no mod loaded.
fn hopper_move_canceled(
&self,
holder: &View,
slot: usize,
source: &View,
destination: &View,
) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return false;
}
let containers = self.containers();
let Some(stack) = containers.get(holder, slot) else {
return false;
};
let mut cancel = false;
self.native_inventory_move_item(
self.container_world_pos(source),
self.container_world_pos(destination),
&stack.item,
&mut cancel,
);
cancel
}
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
if self.containers().is_full(target, face) {
return false;
}
for slot in 0..hopper.len() {
if self.hopper_move_canceled(hopper, slot, hopper, target) {
continue;
}
let Some(one) = self.take_one(hopper, slot) else {
continue;
};
@@ -946,9 +999,27 @@ impl Game<'_> {
let c = self.containers();
let Some(source) = c.view(above, false) else {
let id = self.entity_container_at(at)?;
self.native_hopper_inventory_search(
[
hopper.parts[0].0.0,
hopper.parts[0].0.1,
hopper.parts[0].0.2,
],
None,
Some(id),
);
return self
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
};
self.native_hopper_inventory_search(
[
hopper.parts[0].0.0,
hopper.parts[0].0.1,
hopper.parts[0].0.2,
],
Some([above.0, above.1, above.2]),
None,
);
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
@@ -959,6 +1030,18 @@ impl Game<'_> {
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
[above.0, above.1, above.2],
self.container_world_pos(hopper),
&bone_meal.item,
&mut cancel,
);
if cancel {
return Some(false);
}
}
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
@@ -984,6 +1067,9 @@ impl Game<'_> {
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
}
let Some(one) = self.take_one(source, slot) else {
continue;
};
+22 -1
View File
@@ -1875,6 +1875,13 @@ impl Game<'_> {
.modified
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
cart.kind.id(),
[cart.position.x, cart.position.y, cart.position.z],
);
}
if !self.creative {
self.consume_held();
}
@@ -2134,7 +2141,13 @@ impl Game<'_> {
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
/// once for creative players); TNT minecarts prime instead when moving
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
pub(super) fn hurt_cart(&mut self, i: usize, mut damage: f32, creative: bool, fire: bool) {
let kind = self.carts[i].kind.id();
let mut cancel = false;
self.native_vehicle_damage(kind, &mut damage, &mut cancel);
if cancel {
return;
}
let destroyed = {
let c = &mut self.carts[i];
c.hurt_dir = -c.hurt_dir;
@@ -2154,6 +2167,14 @@ impl Game<'_> {
if !tnt_prime {
self.release_vehicle_container(id);
}
if !tnt_prime {
let at = [
self.carts[i].position.x,
self.carts[i].position.y,
self.carts[i].position.z,
];
self.native_vehicle_destroy(self.carts[i].kind.id(), at);
}
let mut c = self.carts.remove(i);
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
+10 -1
View File
@@ -187,10 +187,11 @@ impl Game<'_> {
camera: glam::DVec3,
alpha: f64,
light: u32,
skin: Option<&mut super::creatures::Skinned>,
out: &mut Vec<Vertex>,
) {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.held_item_geometry(mob, camera, alpha, light, true, skin, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
self.head_item_geometry(mob, camera, alpha, light, true, out);
self.golem_flower_geometry(mob, camera, alpha, light, out);
@@ -376,6 +377,7 @@ impl Game<'_> {
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
/// applies the level's diffuse lighting (off for GUI previews, which
/// light the projected quads themselves).
/// `skin`: the mob's GPU path draws, which a lit item joins when it can.
pub(super) fn held_item_geometry(
&self,
mob: &Mob,
@@ -383,6 +385,7 @@ impl Game<'_> {
alpha: f64,
light: u32,
lit: bool,
mut skin: Option<&mut super::creatures::Skinned>,
out: &mut Vec<Vertex>,
) {
// ItemInHandLayer submits with OverlayTexture.NO_OVERLAY: no hurt tint.
@@ -483,6 +486,12 @@ impl Game<'_> {
horizon_client::cit::Hand::Main
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, hand);
if lit
&& let Some(skinned) = skin.as_deref_mut()
&& self.skin_item_into(&state, t, light, skinned)
{
continue;
}
if lit {
self.submit_item_world(&state, t, light, out);
} else {
@@ -483,4 +483,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()
}
}
+69 -6
View File
@@ -80,6 +80,9 @@ pub(super) fn content_root(
paths.extend(dev.iter().cloned());
let mut known = None;
let mut mods: Vec<ContentMod> = Vec::new();
// The first load's list stays (`set_local`): a reload does not hash the files again.
let announce = !horizon_protocol::mod_list::is_set();
let mut announced = Vec::new();
for path in &paths {
let Ok(package) = Package::read(path) else {
continue;
@@ -121,8 +124,10 @@ pub(super) fn content_root(
id: m.id.clone(),
content,
};
if let Err(e) = mod_content::accept_mod(&mut mods, candidate, known) {
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
match mod_content::accept_mod(&mut mods, candidate, known) {
Ok(_) if announce => announced.push(announced_entry(m, path)),
Ok(_) => {}
Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id),
}
}
let (blocks, items) = native_declared();
@@ -146,6 +151,9 @@ pub(super) fn content_root(
}
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
Ok(out) => {
if announce {
horizon_protocol::mod_list::set_local(announced);
}
if out != root {
log::info!(
"Horizon: content from {} mod(s) in {}",
@@ -168,6 +176,34 @@ pub(super) fn content_root(
}
}
/// A loaded mod as a server tells joining clients (`horizon_protocol::mod_list`): its manifest's
/// id, version and side, and the SHA-256 of its `.hmod` (none for a folder).
fn announced_entry(
m: &horizon_mods::Manifest,
path: &std::path::Path,
) -> horizon_protocol::mod_list::Entry {
use horizon_mods::Side;
use horizon_protocol::mod_list;
use sha2::{Digest, Sha256};
let sha256 = if path.is_file() {
std::fs::read(path)
.ok()
.map(|bytes| Sha256::digest(bytes).into())
} else {
None
};
mod_list::Entry {
id: m.id.clone(),
version: m.version.clone(),
sha256,
side: match m.side {
Side::Both => mod_list::Side::Both,
Side::Server => mod_list::Side::Server,
Side::Client => mod_list::Side::Client,
},
}
}
/// The mods' mob effects join vanilla's (`brewing::effect`), once the root
/// holding their network ids is built. The startup root's effects, none
/// included, stay until restart: a pack reload after a Mods switch builds a
@@ -237,10 +273,20 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
/// The native mods the launcher built this game with, `id version, id version`, for the crash
/// report (`built`: its [`BUILD_INFO`]). None for a release or Nightly, which have no mods.
fn native_mods_detail(built: &str) -> Option<String> {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
let list = info["mods"]
.as_array()?
.iter()
.map(|m| {
let text = |key: &str| m[key].as_str().unwrap_or("?").to_owned();
format!("{} {}", text("id"), text("version"))
})
.collect::<Vec<_>>()
.join(", ");
(!list.is_empty()).then_some(list)
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
@@ -330,8 +376,13 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
let built_mods = native_mods_detail(&built);
let built_with_mods = built_mods.is_some();
if let Some(list) = built_mods {
horizon_client::crash::set_detail("Native Mods", list);
}
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
built_with_mods
|| self
.settings
.mods
@@ -392,6 +443,18 @@ mod tests {
zip.finish().unwrap();
}
#[test]
fn the_crash_report_lists_the_builds_native_mods() {
let built = r#"{"mods":[{"id":"machine_sample","version":"0.1.0","sha256":null},{"id":"pipes","version":"2.0"}]}"#;
assert_eq!(
native_mods_detail(built).as_deref(),
Some("machine_sample 0.1.0, pipes 2.0")
);
// A release has no file; a build without mods has an empty list.
assert_eq!(native_mods_detail(""), None);
assert_eq!(native_mods_detail(r#"{"mods":[]}"#), None);
}
#[test]
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
+366 -24
View File
@@ -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));
}
}
+5 -2
View File
@@ -461,11 +461,14 @@ pub(super) fn stage_pack(
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
// each other: about a second each, side by side.
let mod_image = !horizon_client::mod_api::mods().is_empty();
// The sound events, the GUI sprites' metadata and the language are read here too, not in
// the frame that applies the reload (about 20 ms of it).
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
let atlas =
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
let atlas = s.spawn(|| {
HudAtlas::build_over_with_image(&root, vanilla_root, mod_image)
.map_err(|e| e.to_string())
});
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
let small = s.spawn(|| {
(
@@ -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);
+17
View File
@@ -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 {
+1
View File
@@ -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)
}
}
+18 -5
View File
@@ -1260,10 +1260,10 @@ mod tests {
assert!((transform_normal(r, Vec3::Z) - Vec3::X).length() < 1e-6);
}
/// Every vanilla item on the ground, turned, shaded and unshaded: the GPU path's
/// vertices (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each state it takes. Needs the prepared
/// resources.
/// Every vanilla item on the ground, held in either hand, framed and worn on the
/// head, turned, shaded and unshaded: the GPU path's vertices (its jobs expanded as
/// `entity_skin.wgsl` does) are the CPU path's, to the bit and in the same order, for
/// each state it takes. Needs the prepared resources.
#[test]
fn items_posed_on_the_gpu_draw_what_the_cpu_draws() {
use crate::gfx::entity_skin::{SkinFrame, expand};
@@ -1281,11 +1281,24 @@ mod tests {
let view = StackView::new(id, 1);
r.resolve(&view, DisplayContext::Ground, None, None, 7, &NoEnv)
};
// Each display context's transforms (`ItemTransform`s) pose the same quads.
let contexts = [
DisplayContext::Ground,
DisplayContext::ThirdPersonRightHand,
DisplayContext::ThirdPersonLeftHand,
DisplayContext::Fixed,
DisplayContext::Head,
];
let states: Vec<_> = r
.models
.definitions
.keys()
.map(|id| (id, resolve(id)))
.flat_map(|id| {
contexts.map(|context| {
let view = StackView::new(id, 1);
(id, r.resolve(&view, context, None, None, 7, &NoEnv))
})
})
.collect();
// Each sprite its own rect, so a run drawn with another's shows.
let mut sprites: HashMap<String, Rect> = HashMap::new();
+47
View File
@@ -1102,4 +1102,51 @@ mod tests {
other(&item, "minecraft:custom_model_data")
);
}
#[test]
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
use horizon_protocol::buf::{Reader, Writer};
use horizon_protocol::item::Slot;
use horizon_protocol::registry::Ids;
let mut components = vec![String::new(); component::RARITY as usize + 1];
components[component::RARITY as usize] = "minecraft:rarity".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
item.enchant("minecraft:aqua_affinity", 1);
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|_, _| None,
&Some(item.clone()),
);
let mut w = Writer::new();
Slot::write(&wire, &mut w);
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
let back = from_network(&received, &builtin, &dynamic);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:rarity")),
Some(&Tag::String("epic".into()))
);
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
}
}
+39 -5
View File
@@ -1053,7 +1053,7 @@ pub fn lines(
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
/// the component's own styles (including explicit `false`) winning on children.
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
let lines: Vec<crate::text::Text> = match raw {
Tag::List(entries) => entries
.iter()
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
.collect(),
Tag::Compound(_) | Tag::String(_) => {
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
}
_ => Vec::new(),
};
lines
.into_iter()
.map(|mut text| {
for span in &mut text.spans {
span.style.inherit(&base);
}
@@ -1360,6 +1369,31 @@ mod tests {
assert_eq!(rarity(&sword, "common"), "rare");
}
#[test]
fn lore_texts_handles_compound_lists() {
// Direct command components from issue #6405
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
let comp = horizon_items::item_data::command_components(cmd).unwrap();
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
let stack = ItemStack {
item: "minecraft:stick".into(),
count: 1,
damage,
extra,
};
assert_eq!(rarity(&stack, "common"), "epic");
let name = styled_name(&stack, || "fallback".into());
assert_eq!(name.plain(), "Simple Custom Name");
assert_eq!(name.spans[0].style.italic, Some(false));
let lore = lore_texts(&stack);
assert_eq!(lore.len(), 1);
assert_eq!(lore[0].plain(), "Simple Custom Lore");
assert_eq!(lore[0].spans[0].style.italic, Some(false));
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn name_line_translates_items_of_any_namespace() {
let translate = |key: &str| match key {
@@ -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(
+40
View File
@@ -1003,6 +1003,46 @@ fn login_and_configure(
}),
)?;
}
// The mods that must be the same on both sides, named when they are not
// (horizon_protocol::mod_list); the registry hash below only sees ids.
let server_mods = horizon_protocol::mod_list::local();
if brand == "horizon" && !server_mods.is_empty() {
send(
c,
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
channel: horizon_protocol::mod_list::CHANNEL.into(),
data: horizon_protocol::mod_list::encode(&horizon_protocol::mod_list::shared(
&server_mods,
)),
}),
)?;
send(c, cfg::Clientbound::Ping(cfg::Ping { id: 1 }))?;
// A client that does not answer has no mods to offer.
let mut client_mods = Vec::new();
loop {
let (id, payload) = c.recv().map_err(e)?;
match decode_config(id, &payload).map_err(e)? {
cfg::Serverbound::CustomPayload(p)
if p.channel == horizon_protocol::mod_list::CHANNEL =>
{
client_mods = horizon_protocol::mod_list::decode(&p.data).unwrap_or_default();
}
cfg::Serverbound::Pong(p) if p.id == 1 => break,
cfg::Serverbound::ClientInformation(i) => info = i,
_ => {}
}
}
let difference = horizon_protocol::mod_list::compare(&server_mods, &client_mods);
if !difference.is_empty() {
let text = difference.reason();
let reason = horizon_world::nbt::Tag::String(horizon_world::nbt::JavaString::from(
text.as_str(),
));
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
return Ok(None);
}
}
if brand == "horizon"
&& let Some(hash) = sync.content_identity
{
+1 -8
View File
@@ -82,14 +82,7 @@ pub struct SyncData {
pub advancements: super::advancements::ServerAdvancements,
}
/// A resource id with its default namespace filled in.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
use crate::crafting::qualified;
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
+70 -6
View File
@@ -276,6 +276,9 @@ pub struct Guest {
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
/// last sent about itself.
pub sent_shared: i8,
/// The shoulder parrots (Player.DATA_SHOULDER_PARROT_LEFT and _RIGHT) the
/// player's client was last sent about itself.
pub sent_shoulders: [Option<i32>; 2],
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
pub last_death: Option<(String, [i32; 3])>,
/// The last action sequence to acknowledge at the next tick (-1: none).
@@ -450,6 +453,7 @@ impl Guest {
sent_xp: None,
sent_air: (300, 0.0),
sent_shared: 0,
sent_shoulders: [None; 2],
last_death: None,
ack_up_to: std::cell::Cell::new(-1),
sent_selected: 0,
@@ -1102,13 +1106,55 @@ fn network_stack(
}
// Lore lines as loaded or as a server mod wrote them (a list of text
// components), so a server menu's items can say more than their name.
if let Some(horizon_world::nbt::Tag::List(lines)) =
s.data().and_then(|d| d.other("minecraft:lore"))
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
let lines = match lore_tag {
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
// A string is one literal line, as ComponentSerialization decodes a string tag.
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
Some(vec![lore_tag.clone()])
}
_ => None,
};
if let Some(lines) = lines {
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:lore").is_some())
{
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines.clone()),
));
stack.removed.push(horizon_protocol::item::component::LORE);
}
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
let index = match r {
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
"common" => Some(0),
"uncommon" => Some(1),
"rare" => Some(2),
"epic" => Some(3),
_ => None,
},
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
_ => None,
};
if let Some(index) = index {
stack.added.push((
horizon_protocol::item::component::RARITY,
horizon_protocol::item::Component::Int(index),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
{
stack
.removed
.push(horizon_protocol::item::component::RARITY);
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
@@ -1237,6 +1283,24 @@ mod tests {
);
}
#[test]
fn rarity_reaches_the_client() {
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
let mut writer = Writer::new();
horizon_protocol::item::Slot::write(&sent, &mut writer);
let mut reader = Reader::new(&writer.data);
let received = horizon_protocol::item::Slot::read(&mut reader)
.unwrap()
.unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
}
#[test]
fn styled_custom_name_survives_inventory_sync() {
let name = Tag::compound([
+105 -3
View File
@@ -135,6 +135,32 @@ pub enum HostEvent {
MainHandChanged(u32, bool),
/// A guest changed their client language option.
LocaleChanged(u32, String),
/// A guest's client finished loading the level around them.
PlayerLoaded(u32),
/// A guest changed recipe book settings.
RecipeBookSettings {
conn: u32,
book: i32,
open: bool,
filtering: bool,
},
/// A guest changed sneaking in a player input packet, already applied.
Sneak {
conn: u32,
sneaking: bool,
},
/// A loaded guest sent their current player input flags.
Input {
conn: u32,
flags: i32,
},
/// A guest was kicked, with the reason rendered by the hub.
Kicked {
conn: u32,
player: u128,
name: String,
reason: String,
},
/// A guest's client started (`flying`) or stopped flying (their abilities packet), applied to
/// the guest already: the game tells the mods, which may refuse it.
Flight {
@@ -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]
+4
View File
@@ -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);
}
+129 -1
View File
@@ -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 {
+27 -2
View File
@@ -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],
+47
View File
@@ -271,6 +271,8 @@ pub enum ArmPose {
ThrowTrident,
/// Playing a goat horn.
TootHorn,
/// Looking through a spyglass (`HumanoidModel.ArmPose.SPYGLASS`).
Spyglass,
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
Spear,
/// A brush being used (`HumanoidModel.ArmPose.BRUSH`).
@@ -400,6 +402,8 @@ pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: boo
let x = lerp(alpha, p.get(pull)[3], -PI / 2.0);
p.xrot(pull, x);
}
/// `HumanoidModel.SPYGLASS_ARM_ROT_X`.
const SPYGLASS_ARM_ROT_X: f32 = 1.919_862_2;
/// `HumanoidModel.poseRightArm` / `poseLeftArm`.
fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
let arm = if right { "right_arm" } else { "left_arm" };
@@ -436,6 +440,11 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
p.xrot(arm, x * 0.5 - PI);
p.yrot(arm, 0.0);
}
ArmPose::Spyglass => {
let crouch = if h.crouching { 0.261_799_4 } else { 0.0 };
p.xrot(arm, (hx - SPYGLASS_ARM_ROT_X - crouch).clamp(-2.4, 3.3));
p.yrot(arm, hy + if right { -0.261_799_4 } else { 0.261_799_4 });
}
ArmPose::TootHorn => {
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
@@ -818,6 +827,7 @@ pub fn setup(
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
Some("minecraft:bow") if using_main => ArmPose::Bow,
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
Some("minecraft:spyglass") if using_main => ArmPose::Spyglass,
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
ArmPose::CrossbowCharge
@@ -838,6 +848,8 @@ pub fn setup(
ArmPose::Spear
} else if using_off && m.extra.blocking {
ArmPose::Block
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:spyglass") {
ArmPose::Spyglass
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:goat_horn") {
ArmPose::TootHorn
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") {
@@ -1067,6 +1079,41 @@ mod flight_pose_tests {
}
}
}
/// HumanoidModel.poseRightArm / poseLeftArm's SPYGLASS case: pitch, yaw,
/// crouching, and the arm's pitch.
#[test]
fn a_spyglass_raises_the_arm_with_the_head_in_both_hands() {
for right in [false, true] {
for (pitch, yaw, crouching, expected_pitch) in [
(0.0, 0.0, false, -1.919_862_2),
(0.5, 0.3, false, -1.419_862_2),
(0.0, 0.0, true, -2.181_661_6),
// Looking up: limited to -2.4.
(-1.5, 0.0, false, -2.4),
] {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
let arm = if right { "right_arm" } else { "left_arm" };
p.xrot("head", pitch);
p.yrot("head", yaw);
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
h.crouching = crouching;
pose_arm(&mut p, ArmPose::Spyglass, right, &h);
let pose = *p.get(arm);
assert!((pose[3] - expected_pitch).abs() < 1e-6, "{pose:?}");
let side = if right { -0.261_799_4 } else { 0.261_799_4 };
assert!((pose[4] - (yaw + side)).abs() < 1e-6, "{pose:?}");
assert_eq!(p.get(if right { "left_arm" } else { "right_arm" })[3], 0.0);
}
}
}
/// EndermanModel.setupAnim (#4565): humanoid_pub forwards isPassenger so the
/// riding leg y/z survive; only xRot is halved and clamped to ±0.4 after.
#[test]
+2
View File
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -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],
+1
View File
@@ -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],
+3
View File
@@ -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],
+67 -1
View File
@@ -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
+4 -1
View File
@@ -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
File diff suppressed because it is too large. Load diff
+22
View File
@@ -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")
}
}
+18
View File
@@ -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);
+11
View File
@@ -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.
+29 -6
View File
@@ -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 {
+10 -3
View File
@@ -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();
+29
View File
@@ -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 {
+73
View File
@@ -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"
);
}
+63 -4
View File
@@ -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);
}
+1
View File
@@ -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"] }
+12
View File
@@ -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)) {
+1
View File
@@ -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;
+348
View File
@@ -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}");
}
}
+75 -3
View File
@@ -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()));
+28 -3
View File
@@ -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();
+9 -4
View File
@@ -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,
+86
View File
@@ -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", &macros);
let post = load_post(&pack, "", &properties, &macros, &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();
+106 -8
View File
@@ -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());
+122
View File
@@ -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