mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'upstream/main' into mixin-docs
# Conflicts: # docs/native-mods.md
This commit is contained in:
commit
c1649ac88e
60 files changed
+2664
-364
No files matched your search
Generated
+1
@@ -1583,6 +1583,7 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"horizon-core",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -180,6 +180,16 @@ pub fn list_textures(root: &Path) -> Vec<String> {
|
||||
for ns in namespaces.flatten() {
|
||||
let namespace = ns.file_name().to_string_lossy().into_owned();
|
||||
let base = ns.path().join("textures");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(&base) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(".png"))
|
||||
.map(|rel| format!("{namespace}:{rel}")),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let mut stack = vec![base.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = fs::read_dir(&dir) else {
|
||||
|
||||
@@ -182,12 +182,24 @@ where
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
) -> Result<Vec3> {
|
||||
let context = CollisionContext::position(bounds.min_position().y);
|
||||
self.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// [`Self::resolve`] with the entity's captured collision context.
|
||||
pub fn resolve_with_context(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
movement: Vec3,
|
||||
max_step: f32,
|
||||
on_ground: bool,
|
||||
context: CollisionContext<'_>,
|
||||
) -> Result<Vec3> {
|
||||
Ok(resolve_world_movement(
|
||||
self,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context: CollisionContext::position(bounds.min_position().y),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
@@ -223,23 +235,12 @@ impl SceneCollisions<'_, '_> {
|
||||
border: Option<(&BorderGeometry, [f64; 2])>,
|
||||
) -> Result<Vec3> {
|
||||
let context = self.context(bounds);
|
||||
let mut scene = EntityCollisions {
|
||||
EntityCollisions {
|
||||
blocks: self,
|
||||
entities: &[],
|
||||
border,
|
||||
};
|
||||
Ok(resolve_world_movement(
|
||||
&mut scene,
|
||||
MovementRequest {
|
||||
entity: EntityIdentity(0),
|
||||
context,
|
||||
bounds,
|
||||
movement,
|
||||
max_step,
|
||||
on_ground,
|
||||
candidate_budget: 1_000_000,
|
||||
},
|
||||
)?)
|
||||
}
|
||||
.resolve_with_context(bounds, movement, max_step, on_ground, context)
|
||||
}
|
||||
/// Resolve movement with the entity's captured collision context. Most
|
||||
/// callers only need the position context; entities such as falling blocks
|
||||
@@ -326,6 +327,88 @@ where
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Whether a `BlockCollisionScan` of `bounds` would find no shape, told without
|
||||
/// building one: no block that can collide in its inner cells, and no block that
|
||||
/// reaches out of its cell in the sections of its outer ones (where unsure, false:
|
||||
/// the scan decides). The same cells, blocks and section flags the scan reads.
|
||||
pub fn cells_without_collision(&self, bounds: Aabb) -> bool {
|
||||
let (min, max) = (bounds.min_position(), bounds.max_position());
|
||||
if !min.is_finite() || !max.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// The scan's cells: one past the box on every side (`block_collision_scan::cells`).
|
||||
let lo = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7))
|
||||
.offset(-1, -1, -1);
|
||||
let hi = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7))
|
||||
.offset(1, 1, 1);
|
||||
let size = |a: i32, b: i32| i64::from(b) - i64::from(a) + 1;
|
||||
let cells = size(lo.x, hi.x) * size(lo.y, hi.y) * size(lo.z, hi.z);
|
||||
if lo.x > hi.x || lo.y > hi.y || lo.z > hi.z || cells > 4096 {
|
||||
return false;
|
||||
}
|
||||
let mut column: Option<((i32, i32), Option<&crate::chunk::Chunk>)> = None;
|
||||
let mut section: Option<((i32, i32), i32, bool)> = None;
|
||||
for z in lo.z..=hi.z {
|
||||
for y in lo.y..=hi.y {
|
||||
for x in lo.x..=hi.x {
|
||||
let outer = [
|
||||
x == lo.x || x == hi.x,
|
||||
y == lo.y || y == hi.y,
|
||||
z == lo.z || z == hi.z,
|
||||
];
|
||||
let kind = outer.into_iter().filter(|&o| o).count();
|
||||
if kind == 3 {
|
||||
continue;
|
||||
}
|
||||
let at = (x >> 4, z >> 4);
|
||||
let chunk = match column {
|
||||
Some((c, chunk)) if c == at => chunk,
|
||||
_ => {
|
||||
let chunk = self.world.chunks.get(&at).map(|c| &**c);
|
||||
column = Some((at, chunk));
|
||||
chunk
|
||||
}
|
||||
};
|
||||
// The scan passes the cells of a column that is not resident.
|
||||
let Some(chunk) = chunk else {
|
||||
continue;
|
||||
};
|
||||
if kind != 0 {
|
||||
let reaches = match section {
|
||||
Some((c, sy, reaches)) if c == at && sy == y >> 4 => reaches,
|
||||
_ => {
|
||||
let reaches = self.section_reaches_out(chunk, y >> 4);
|
||||
section = Some((at, y >> 4, reaches));
|
||||
reaches
|
||||
}
|
||||
};
|
||||
if reaches {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if self.binding.may_collide(chunk.block(x, y, z)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `may_reach_out`: whether a section's palette holds a block that reaches out of
|
||||
/// its cell; a global palette, or a section outside the column, may.
|
||||
fn section_reaches_out(&self, chunk: &crate::chunk::Chunk, section_y: i32) -> bool {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
})
|
||||
}
|
||||
}
|
||||
impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
type Error = io::Error;
|
||||
/// The resident column, looked up once per chunk the scan enters.
|
||||
@@ -339,13 +422,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
|
||||
/// From the section's palette, without reading its blocks; a palette
|
||||
/// of the whole registry reads every cell.
|
||||
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
|
||||
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
|
||||
.ok()
|
||||
.and_then(|s| chunk.sections.get(s))
|
||||
.map(|section| §ion.blocks);
|
||||
Ok(blocks.is_none_or(|blocks| {
|
||||
blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v)))
|
||||
}))
|
||||
Ok(self.section_reaches_out(chunk, section_y))
|
||||
}
|
||||
fn is_suffocating(
|
||||
&mut self,
|
||||
@@ -818,3 +895,88 @@ mod shulker_collision_tests {
|
||||
assert!((delta2[4] - 2.0).abs() < 1.0e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod section_tests {
|
||||
use super::*;
|
||||
|
||||
/// Boxes swept through air, past torches (no collision shape), a fence (a shape
|
||||
/// reaching into the cell above) and the scene's stone: wherever the cells say no
|
||||
/// block can collide, the full resolution returns the movement, to the bit; the rest
|
||||
/// is left to it. Needs the prepared resources.
|
||||
#[test]
|
||||
fn cells_without_collision_leave_the_movement_as_resolving_does() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("cached-collision.nbt").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let state = |name: &str| assets.registry.default_state(name).unwrap();
|
||||
// Torches, and a fence at the bottom of a section above air.
|
||||
for x in 17..30 {
|
||||
world.set(x, 20, 20, state("minecraft:torch")).unwrap();
|
||||
}
|
||||
world.set(5, 32, 5, state("minecraft:oak_fence")).unwrap();
|
||||
let scene = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
// Under the fence: its section's outer cells may hold a shape reaching in.
|
||||
let under = Aabb::new(Vec3::new(5.4, 31.6, 5.4), Vec3::new(5.6, 31.8, 5.6));
|
||||
assert!(!scene.cells_without_collision(under.expand_towards(Vec3::new(0.0, 0.1, 0.0))));
|
||||
// A torch's cell, and the air just over the scene's stone floor (y 0..4).
|
||||
let torches = Aabb::new(Vec3::new(20.4, 20.1, 20.4), Vec3::new(20.6, 20.3, 20.6));
|
||||
assert!(scene.cells_without_collision(torches));
|
||||
let over_floor = Aabb::new(Vec3::new(30.4, 5.2, 30.4), Vec3::new(30.6, 5.4, 30.6));
|
||||
assert!(scene.cells_without_collision(over_floor));
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut next = || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let (mut skipped, mut resolved) = (0, 0);
|
||||
for i in 0..4000 {
|
||||
let size = if i % 2 == 0 { 0.2 } else { 0.6 };
|
||||
// Near the floor (y 0..4) and the stepped pyramid (y 4..11) for most, higher for the rest.
|
||||
let height = if i % 4 == 0 { 40.0 } else { 14.0 };
|
||||
let at = Vec3::new(next() * 80.0 - 40.0, next() * height, next() * 80.0 - 40.0);
|
||||
let bb = Aabb::new(at, Vec3::new(at.x + size, at.y + size, at.z + size));
|
||||
let reach = if i % 3 == 0 { 3.0 } else { 0.3 };
|
||||
let d = Vec3::new(
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
(next() - 0.5) * reach,
|
||||
);
|
||||
let fast = scene.cells_without_collision(bb.expand_towards(d));
|
||||
let mut full = SceneCollisions {
|
||||
world: &world,
|
||||
binding: &binding,
|
||||
subject: None,
|
||||
};
|
||||
let moved = full.resolve(bb, d, 0.0, false).unwrap();
|
||||
if fast {
|
||||
skipped += 1;
|
||||
assert_eq!(
|
||||
moved.components().map(f64::to_bits),
|
||||
d.components().map(f64::to_bits),
|
||||
"{bb:?} by {d:?}"
|
||||
);
|
||||
} else {
|
||||
resolved += 1;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
skipped > 500 && resolved > 500,
|
||||
"{skipped} skipped, {resolved} resolved"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,8 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
|
||||
var back = c;
|
||||
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
|
||||
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
|
||||
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
|
||||
// -126/127 shades like -127 and also culls (entityCutoutCull models such as the bat).
|
||||
out.cull = select(0u, 1u, (normal.w > 0.0 && normal.w < 0.5) || (normal.w < 0.0 && normal.w > -0.996));
|
||||
if abs(normal.w) >= 0.5 {
|
||||
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
|
||||
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
|
||||
|
||||
@@ -23,7 +23,12 @@ use horizon_optifine::fusion::{
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, fs, num::NonZeroU16, path::Path};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
num::NonZeroU16,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// A connecting texture as drawn: its layout and its tiles, `None` for an empty one.
|
||||
struct Sheet {
|
||||
@@ -172,44 +177,60 @@ fn connecting_textures(root: &Path) -> HashMap<String, Meta> {
|
||||
for ns in namespaces.flatten() {
|
||||
let ns_name = ns.file_name().to_string_lossy().into_owned();
|
||||
let textures = ns.path().join("textures");
|
||||
let mut dirs = vec![textures.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
// Linked folders too: mods' content links into the packs' tree.
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
continue;
|
||||
}
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
for path in mcmeta_files(&textures) {
|
||||
let Some(png) = path.to_str().and_then(|p| p.strip_suffix(".png.mcmeta")) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if !text.contains("fusion") {
|
||||
continue;
|
||||
}
|
||||
let Some(rel) = Path::new(png)
|
||||
.strip_prefix(&textures)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = format!("{ns_name}:{}", rel.replace('\\', "/"));
|
||||
match Meta::from_mcmeta(&text) {
|
||||
Ok(Some(meta)) => {
|
||||
out.insert(name, meta);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("Horizon: Fusion texture {name}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `.png.mcmeta` files under `textures`: a prepared root's from its data file, others
|
||||
/// from the folders (linked folders too: mods' content links into the packs' tree).
|
||||
fn mcmeta_files(textures: &Path) -> Vec<PathBuf> {
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(textures) {
|
||||
return files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| textures.join(file))
|
||||
.collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut dirs = vec![textures.to_owned()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
for entry in fs::read_dir(&dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
let dir = match entry.file_type() {
|
||||
Ok(t) if t.is_symlink() => path.is_dir(),
|
||||
Ok(t) => t.is_dir(),
|
||||
Err(_) => path.is_dir(),
|
||||
};
|
||||
if dir {
|
||||
dirs.push(path);
|
||||
} else if path.to_str().is_some_and(|p| p.ends_with(".png.mcmeta")) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -193,6 +193,16 @@ fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
.then(|| (field(16), field(20)))
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(dir) {
|
||||
out.extend(
|
||||
files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png"))
|
||||
.map(|file| dir.join(file)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -50,6 +50,9 @@ pub struct Vertex {
|
||||
/// `Vertex::normal` of a baked-colour quad of an `entitySolid` model: the render type culls
|
||||
/// back faces, which the entity pipelines here do not, so `entity.wgsl` discards them.
|
||||
pub const CULL_BACK: [i8; 4] = [0, 0, 0, 1];
|
||||
/// `Vertex::normal.w` of a shaded quad of an `entityCutoutCull` model (the bat): shaded by the
|
||||
/// normal like -127, and `entity.wgsl` also discards its back-facing pixels.
|
||||
pub const SHADED_CULL_W: i8 = -126;
|
||||
/// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour,
|
||||
/// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes
|
||||
/// rather than vanilla's 28: the UV is fixed point, which holds model UVs
|
||||
|
||||
@@ -996,7 +996,14 @@ impl ParticleLevel<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
match scene.resolve(bb, Vec3::new(d[0], d[1], d[2]), 0.0, false) {
|
||||
let movement = Vec3::new(d[0], d[1], d[2]);
|
||||
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
|
||||
// block of the swept box's cells can collide, it finds no shape and the
|
||||
// movement stands, as the resolution below would return it.
|
||||
if scene.cells_without_collision(bb.expand_towards(movement)) {
|
||||
return d;
|
||||
}
|
||||
match scene.resolve(bb, movement, 0.0, false) {
|
||||
Ok(v) => [v.x, v.y, v.z],
|
||||
Err(_) => d,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! or a core on a busy machine) shows apart from one that worked. Costs two
|
||||
//! clock reads per span when enabled and a relaxed load otherwise.
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
cell::{Cell, RefCell},
|
||||
sync::atomic::{AtomicBool, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -14,6 +14,10 @@ use std::{
|
||||
static ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
thread_local! {
|
||||
static SPANS: RefCell<Vec<Span>> = const { RefCell::new(Vec::new()) };
|
||||
/// Spans recorded on this thread alone, wall time only ([`record_here`]).
|
||||
static HERE: Cell<bool> = const { Cell::new(false) };
|
||||
/// How many timed spans the thread is inside: the next span's [`Span::depth`].
|
||||
static DEPTH: Cell<u8> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// A named timing: wall milliseconds and the thread's CPU milliseconds in it.
|
||||
@@ -22,6 +26,8 @@ pub struct Span {
|
||||
pub name: &'static str,
|
||||
pub ms: f32,
|
||||
pub cpu_ms: f32,
|
||||
/// How many timed spans it ran inside (0: none): `tick_mobs` is inside `tick`.
|
||||
pub depth: u8,
|
||||
}
|
||||
|
||||
pub fn enable(on: bool) {
|
||||
@@ -31,19 +37,49 @@ pub fn enable(on: bool) {
|
||||
pub fn enabled() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed)
|
||||
}
|
||||
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
|
||||
/// what the render thread's slow frames spent their time in, for the opt-in performance
|
||||
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
|
||||
pub fn record_here(on: bool) {
|
||||
HERE.with(|h| h.set(on));
|
||||
}
|
||||
/// Whether this thread records spans: all of them on, or [`record_here`].
|
||||
#[inline]
|
||||
pub fn recording_here() -> bool {
|
||||
ENABLED.load(Ordering::Relaxed) || HERE.with(Cell::get)
|
||||
}
|
||||
/// The thread's CPU time when spans are on for every thread (`HORIZON_SPIKES`): only they
|
||||
/// print it.
|
||||
#[inline]
|
||||
fn cpu_if_enabled() -> Option<Duration> {
|
||||
if ENABLED.load(Ordering::Relaxed) {
|
||||
thread_cpu()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
if !ENABLED.load(Ordering::Relaxed) {
|
||||
if !recording_here() {
|
||||
return f();
|
||||
}
|
||||
let (started, cpu) = (Instant::now(), thread_cpu());
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = thread_cpu()
|
||||
.zip(cpu)
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
@@ -52,7 +88,7 @@ pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
Laps(recording_here().then(|| (Instant::now(), cpu_if_enabled())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
@@ -61,12 +97,21 @@ impl Laps {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let now = Instant::now();
|
||||
let now_cpu = cpu.and_then(|_| thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
let depth = DEPTH.with(Cell::get);
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
@@ -83,11 +128,62 @@ pub fn thread_cpu() -> Option<Duration> {
|
||||
horizon_threads::thread_cpu()
|
||||
}
|
||||
|
||||
/// The frame's spans as they were recorded, each after the spans inside it; clears them.
|
||||
pub fn take_raw() -> Vec<Span> {
|
||||
SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()))
|
||||
}
|
||||
/// The frame's spans, summed by name, slowest first; clears them.
|
||||
pub fn take() -> Vec<Span> {
|
||||
let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut()));
|
||||
merge(&take_raw())
|
||||
}
|
||||
/// What a frame spent its time in, from its spans as recorded ([`take_raw`]): the slowest of
|
||||
/// the outermost spans (summed by name, as a frame can tick several times), then the slowest
|
||||
/// span inside it while that one took at least half of it, and so on. The path of names
|
||||
/// (`tick/tick_mobs`) and the last one's milliseconds; `None` without spans.
|
||||
pub fn culprit(raw: &[Span]) -> Option<(String, f32)> {
|
||||
// The spans directly inside each one: recorded just before it, one level deeper.
|
||||
let mut inside: Vec<Vec<usize>> = vec![Vec::new(); raw.len()];
|
||||
let mut outer: Vec<usize> = Vec::new();
|
||||
for (i, span) in raw.iter().enumerate() {
|
||||
while let Some(&j) = outer.last()
|
||||
&& raw[j].depth > span.depth
|
||||
{
|
||||
outer.pop();
|
||||
inside[i].push(j);
|
||||
}
|
||||
outer.push(i);
|
||||
}
|
||||
let slowest = |group: &[usize]| {
|
||||
let mut sums: Vec<(&'static str, f32, Vec<usize>)> = Vec::new();
|
||||
for &i in group {
|
||||
match sums.iter_mut().find(|s| s.0 == raw[i].name) {
|
||||
Some(sum) => {
|
||||
sum.1 += raw[i].ms;
|
||||
sum.2.push(i);
|
||||
}
|
||||
None => sums.push((raw[i].name, raw[i].ms, vec![i])),
|
||||
}
|
||||
}
|
||||
sums.into_iter().max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
};
|
||||
let (name, mut ms, mut at) = slowest(&outer)?;
|
||||
let mut path = name.to_owned();
|
||||
loop {
|
||||
let next: Vec<usize> = at.iter().flat_map(|&i| inside[i].iter().copied()).collect();
|
||||
match slowest(&next) {
|
||||
Some((name, inner_ms, spans)) if inner_ms >= ms * 0.5 => {
|
||||
path.push('/');
|
||||
path.push_str(name);
|
||||
(ms, at) = (inner_ms, spans);
|
||||
}
|
||||
_ => return Some((path, ms)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Spans summed by name, slowest first.
|
||||
pub fn merge(spans: &[Span]) -> Vec<Span> {
|
||||
let mut sums: Vec<Span> = Vec::new();
|
||||
for span in spans {
|
||||
for &span in spans {
|
||||
match sums.iter_mut().find(|s| s.name == span.name) {
|
||||
Some(sum) => {
|
||||
sum.ms += span.ms;
|
||||
@@ -309,21 +405,25 @@ mod tests {
|
||||
name: "tick",
|
||||
ms: 2.0,
|
||||
cpu_ms: 2.0,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "tick",
|
||||
ms: 3.0,
|
||||
cpu_ms: 2.9,
|
||||
depth: 0,
|
||||
},
|
||||
Span {
|
||||
name: "hud",
|
||||
ms: 0.3,
|
||||
cpu_ms: 0.3,
|
||||
depth: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -333,7 +433,8 @@ mod tests {
|
||||
Span {
|
||||
name: "submit",
|
||||
ms: 30.0,
|
||||
cpu_ms: 0.2
|
||||
cpu_ms: 0.2,
|
||||
depth: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!((spans[1].name, spans[1].ms), ("tick", 5.0));
|
||||
@@ -341,6 +442,66 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// A thread that records on its own keeps its spans with spans off, each with how many
|
||||
/// spans it ran inside, after them.
|
||||
#[test]
|
||||
fn a_recording_thread_keeps_its_spans_and_their_nesting() {
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
super::time("tick", || {
|
||||
super::time("tick_mobs", || ());
|
||||
super::time("tick_items", || ());
|
||||
});
|
||||
super::time("renderer", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("tick_mobs", 1),
|
||||
("tick_items", 1),
|
||||
("tick", 0),
|
||||
("renderer", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
fn a_frame_is_put_down_to_the_step_that_took_most_of_it() {
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let frame = [
|
||||
span("tick_mobs", 90.0, 1),
|
||||
span("tick_items", 5.0, 1),
|
||||
span("tick", 100.0, 0),
|
||||
span("tick_mobs", 90.0, 1),
|
||||
span("tick", 100.0, 0),
|
||||
span("r_uploads", 120.0, 1),
|
||||
span("renderer", 150.0, 0),
|
||||
];
|
||||
assert_eq!(
|
||||
super::culprit(&frame),
|
||||
Some(("tick/tick_mobs".to_owned(), 180.0))
|
||||
);
|
||||
// Spread over many steps: the outer span alone.
|
||||
let spread = [
|
||||
span("tick_mobs", 40.0, 1),
|
||||
span("tick_items", 40.0, 1),
|
||||
span("tick", 300.0, 0),
|
||||
];
|
||||
assert_eq!(super::culprit(&spread), Some(("tick".to_owned(), 300.0)));
|
||||
assert_eq!(super::culprit(&[]), None);
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
|
||||
@@ -223,6 +223,7 @@ pub struct Session {
|
||||
title_measured: bool,
|
||||
to_world_ms: Option<u32>,
|
||||
opening: Option<Instant>,
|
||||
slow_frames: Vec<horizon_telemetry::payload::SlowFrames>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -250,8 +251,21 @@ impl Session {
|
||||
self.opening = None;
|
||||
}
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
/// A frame drawn while playing (not in menus or loading), with the spans the render thread
|
||||
/// recorded in it ([`crate::spans::take_raw`]): a frame of `SLOW_FRAME_MS` or more counts
|
||||
/// under the step it spent most of its time in ([`crate::spans::culprit`]).
|
||||
pub fn frame(&mut self, ms: f32, spans: &[crate::spans::Span]) {
|
||||
use horizon_telemetry::payload::{SLOW_FRAME_MS, SlowFrames};
|
||||
if ms >= SLOW_FRAME_MS {
|
||||
let (mut path, span_ms) = crate::spans::culprit(spans).unwrap_or_default();
|
||||
// A path too long for the item keeps its last steps.
|
||||
while path.len() > SlowFrames::MAX_NAME
|
||||
&& let Some((_, rest)) = path.split_once('/')
|
||||
{
|
||||
path = rest.to_owned();
|
||||
}
|
||||
SlowFrames::record(&mut self.slow_frames, ms, &path, span_ms);
|
||||
}
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
@@ -281,6 +295,7 @@ impl Session {
|
||||
to_world_ms: self.to_world_ms,
|
||||
to_core_ms: self.to_title_ms.and_then(|_| readiness_ms(&CORE_READY_MS)),
|
||||
to_rest_ms: self.to_title_ms.and_then(|_| readiness_ms(&REST_READY_MS)),
|
||||
slow_frames: self.slow_frames.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -490,10 +505,10 @@ mod tests {
|
||||
assert!(session.opening.is_some());
|
||||
session.cancel_world_opening();
|
||||
assert!(session.opening.is_none());
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_none());
|
||||
session.world_opening();
|
||||
session.frame(16.0);
|
||||
session.frame(16.0, &[]);
|
||||
assert!(session.to_world_ms.is_some());
|
||||
}
|
||||
|
||||
@@ -502,7 +517,7 @@ mod tests {
|
||||
let home = home("session");
|
||||
let mut session = Session::default();
|
||||
session.world_opening();
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s, and no consent");
|
||||
allow(
|
||||
@@ -512,11 +527,11 @@ mod tests {
|
||||
..Choices::NONE
|
||||
},
|
||||
);
|
||||
(0..2000).for_each(|_| session.frame(16.6));
|
||||
(0..2000).for_each(|_| session.frame(16.6, &[]));
|
||||
session.finish(&home, Settings::default());
|
||||
assert!(outbox::pending(&home).is_empty(), "33 s is too short");
|
||||
session.world_opening();
|
||||
(0..4000).for_each(|_| session.frame(16.6));
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[]));
|
||||
(0..1200).for_each(|_| session.tick(4.0));
|
||||
session.finish(
|
||||
&home,
|
||||
@@ -537,4 +552,34 @@ mod tests {
|
||||
assert!(perf.to_world_ms.is_some());
|
||||
assert!(serde_json::to_vec(&pending[0].1).unwrap().len() < 1024);
|
||||
}
|
||||
|
||||
/// Frames of 250 ms or more count under the step that took most of them; a frame with no
|
||||
/// span counts under none. Faster frames leave no entry.
|
||||
#[test]
|
||||
fn slow_frames_are_put_down_to_their_step() {
|
||||
use crate::spans::Span;
|
||||
let span = |name, ms, depth| Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth,
|
||||
};
|
||||
let mut session = Session::default();
|
||||
(0..4000).for_each(|_| session.frame(16.6, &[span("tick", 10.0, 0)]));
|
||||
let frozen = [span("r_uploads", 280.0, 1), span("renderer", 300.0, 0)];
|
||||
session.frame(320.0, &frozen);
|
||||
session.frame(900.0, &frozen);
|
||||
session.frame(260.0, &[]);
|
||||
session.frame(249.0, &frozen);
|
||||
let perf = session.summary(Settings::default()).unwrap();
|
||||
let seen: Vec<_> = perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.map(|s| (s.span.as_str(), s.frames, s.frame_max_ms, s.span_max_ms))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[("renderer/r_uploads", 2, 900.0, 280.0), ("", 1, 260.0, 0.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,12 @@ use crate::{
|
||||
hud::{HudAtlas, HudBatch, Rect, rgba},
|
||||
text::Text,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::RwLock,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
|
||||
/// its texture's resolution, its borders (left, top, right, bottom) in the
|
||||
@@ -64,37 +69,53 @@ pub fn set_metadata(metadata: UiMetadata) {
|
||||
pub fn read_metadata(root: &Path) -> UiMetadata {
|
||||
let mut map = HashMap::new();
|
||||
let base = root.join("assets/minecraft/textures");
|
||||
let mut stack = vec![base.join("gui/sprites")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
let sprites = base.join("gui/sprites");
|
||||
// A prepared root's: from its data file, without listing the disk.
|
||||
let files: Vec<PathBuf> = match horizon_core::prepared_files::files_under(&sprites) {
|
||||
Some(files) => files
|
||||
.iter()
|
||||
.filter(|file| file.ends_with(".png.mcmeta"))
|
||||
.map(|file| sprites.join(file))
|
||||
.collect(),
|
||||
None => {
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![sprites];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.to_string_lossy().ends_with(".png.mcmeta") {
|
||||
files.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
};
|
||||
for p in files {
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let name = p.to_string_lossy().into_owned();
|
||||
let Some(stripped) = name.strip_suffix(".png.mcmeta") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
let Ok(text) = horizon_core::prepared_files::read_to_string(&p) else {
|
||||
continue;
|
||||
};
|
||||
let Some(nine_slice) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.as_ref()
|
||||
.and_then(parse_nine_slice)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let key = Path::new(stripped)
|
||||
.strip_prefix(&base)
|
||||
.map(|r| r.to_string_lossy().replace('\\', "/"))
|
||||
.unwrap_or_default();
|
||||
map.insert(key, nine_slice);
|
||||
}
|
||||
UiMetadata(map)
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ pub struct Creative {
|
||||
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
|
||||
/// search changes (selectTab, refreshSearchResults), not on every frame.
|
||||
shown: Mutex<Option<Shown>>,
|
||||
/// SessionSearchTrees.creativeByNameSearch: the lower-cased tooltip lines
|
||||
/// of every search item in one language (keyed by the `itemGroup.search`
|
||||
/// string), built on the first search and kept until the language or the
|
||||
/// item list changes.
|
||||
lines_cache: Mutex<Option<(String, Arc<[Vec<String>]>)>>,
|
||||
}
|
||||
/// The grid's stacks for one tab, search text and language.
|
||||
struct Shown {
|
||||
@@ -373,6 +378,7 @@ impl Creative {
|
||||
exported_search,
|
||||
permissions: false,
|
||||
shown: Mutex::new(None),
|
||||
lines_cache: Mutex::new(None),
|
||||
};
|
||||
creative.rebuild();
|
||||
creative
|
||||
@@ -398,6 +404,7 @@ impl Creative {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
(self.search_items, self.search_index) = self
|
||||
.exported_search
|
||||
.iter()
|
||||
@@ -531,6 +538,46 @@ impl Creative {
|
||||
/// the name tree, both over vanilla's suffix arrays (substring matches,
|
||||
/// results in the tab's order).
|
||||
pub fn search_results(&self, term: &str, names: &Names) -> Vec<usize> {
|
||||
let english =
|
||||
names.display_get("itemGroup.search").as_deref() == names.get("itemGroup.search");
|
||||
let local = if term.is_empty() || term.starts_with('#') {
|
||||
Arc::from([])
|
||||
} else if english && self.search_index.iter().all(|e| !e.lines.is_empty()) {
|
||||
// The exported English lines are the whole index.
|
||||
Arc::from([])
|
||||
} else {
|
||||
let language = names.display_get("itemGroup.search").unwrap_or_default();
|
||||
let mut cache = self.lines_cache.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match cache.as_ref().filter(|(l, _)| *l == language) {
|
||||
Some((_, lines)) => lines.clone(),
|
||||
None => {
|
||||
let lines = self.localized_lines(&|k| names.display_get(k));
|
||||
*cache = Some((language, lines.clone()));
|
||||
lines
|
||||
}
|
||||
}
|
||||
};
|
||||
self.search_results_in(term, names, &local, english)
|
||||
}
|
||||
/// Every search item's tooltip lines through `translate`, lower-cased.
|
||||
fn localized_lines(&self, translate: &dyn Fn(&str) -> Option<String>) -> Arc<[Vec<String>]> {
|
||||
self.search_items
|
||||
.iter()
|
||||
.map(|s| {
|
||||
crate::item_tooltip::lines(s, None, translate)
|
||||
.iter()
|
||||
.map(|l| java_lower(l))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn search_results_in(
|
||||
&self,
|
||||
term: &str,
|
||||
names: &Names,
|
||||
local: &[Vec<String>],
|
||||
english: bool,
|
||||
) -> Vec<usize> {
|
||||
let all = 0..self.search_index.len();
|
||||
if term.is_empty() {
|
||||
return all.collect();
|
||||
@@ -552,18 +599,13 @@ impl Creative {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// FullTextSearchTree: tooltip lines, and the item id after a colon.
|
||||
// The export's lines are English (they come from the vanilla jars);
|
||||
// the hover name in the selected language is matched too, so a
|
||||
// localized search finds the item. It is the whole name (a tipped
|
||||
// arrow's potion, the ominous banner), as the first line is.
|
||||
// FullTextSearchTree: the creative tooltip lines in the selected
|
||||
// language, and the item id after a colon. The export's lines are
|
||||
// English (they come from the vanilla jars), so they stand in only
|
||||
// while the language is English; otherwise every line (name, lore,
|
||||
// effects, ...) is rebuilt through the language, as vanilla's
|
||||
// getTooltipLines does.
|
||||
let text = java_lower(term);
|
||||
let localized = |i: usize| {
|
||||
java_lower(&crate::item_tooltip::name_line(
|
||||
&self.search_items[i],
|
||||
&|k| names.display_get(k),
|
||||
))
|
||||
};
|
||||
all.filter(|&i| {
|
||||
let e = &self.search_index[i];
|
||||
let fallback;
|
||||
@@ -573,16 +615,17 @@ impl Creative {
|
||||
} else {
|
||||
&e.lines
|
||||
};
|
||||
let exported = english || e.lines.is_empty();
|
||||
let local = local.get(i).map_or(&[][..], Vec::as_slice);
|
||||
let hit = |t: &str| {
|
||||
(exported && any(lines.iter().map(String::as_str), t))
|
||||
|| any(local.iter().map(String::as_str), t)
|
||||
};
|
||||
match text.split_once(':') {
|
||||
None => {
|
||||
any(lines.iter().map(String::as_str), &text) || localized(i).contains(&text)
|
||||
}
|
||||
None => hit(&text),
|
||||
Some((ns, path)) => {
|
||||
let (ns, path) = (java_trim(ns), java_trim(path));
|
||||
e.namespace.contains(ns)
|
||||
&& (e.path.contains(path)
|
||||
|| any(lines.iter().map(String::as_str), path)
|
||||
|| localized(i).contains(path))
|
||||
e.namespace.contains(ns) && (e.path.contains(path) || hit(path))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1396,15 +1439,33 @@ mod tests {
|
||||
("item.minecraft.wooden_hoe".to_owned(), "木锄".to_owned()),
|
||||
]),
|
||||
};
|
||||
let q = |s: &str| c.search_results(s, &names);
|
||||
let tr = |k: &str| names.get(k).map(str::to_owned);
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str| c.search_results_in(s, &names, &local, false);
|
||||
assert_eq!(q("铁砧"), [0]);
|
||||
assert_eq!(q("木锄"), [2]);
|
||||
// The English tooltip lines the export indexed still match.
|
||||
assert_eq!(q("anvil"), [0]);
|
||||
// Only the selected language's lines count outside English.
|
||||
assert!(q("anvil").is_empty());
|
||||
assert_eq!(c.search_results_in("anvil", &names, &local, true), [0]);
|
||||
// A colon's path searches the display name too.
|
||||
assert_eq!(q("minecraft:铁砧"), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_matches_every_localized_tooltip_line() {
|
||||
// Vanilla's FullTextSearchTree indexes the selected language's
|
||||
// tooltip lines; the export's English lines do not apply.
|
||||
let c = creative(0);
|
||||
let names = Names::default();
|
||||
let ru: HashMap<&str, &str> = HashMap::from([("item.minecraft.anvil", "Наковальня")]);
|
||||
let tr = |k: &str| ru.get(k).map(|v| (*v).to_owned());
|
||||
let local = c.localized_lines(&tr);
|
||||
let q = |s: &str, english| c.search_results_in(s, &names, &local, english);
|
||||
assert_eq!(q("наков", false), [0]);
|
||||
assert!(q("anvil", false).is_empty());
|
||||
assert_eq!(q("anvil", true), [0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrollbar_drag_follows_the_mouse_while_held() {
|
||||
let mut c = creative(90);
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape},
|
||||
geometry::{Aabb, Direction, Vec3, VoxelShape, border::BorderGeometry},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::{
|
||||
@@ -104,7 +104,7 @@ pub struct ItemEntity {
|
||||
pub previous: Vec3,
|
||||
pub velocity: Vec3,
|
||||
pub on_ground: bool,
|
||||
pub age: u32,
|
||||
pub age: i32,
|
||||
pub pickup_delay: u32,
|
||||
pub bob_offset: f32,
|
||||
/// `Entity.tickCount`: ticks since the item entered the level, which
|
||||
@@ -261,6 +261,7 @@ impl ItemEntity {
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -291,8 +292,11 @@ impl ItemEntity {
|
||||
// horizontal speed. The idle branch still runs bubble-column push
|
||||
// (`applyEffectsFromBlocksForLastMovements`), so lift builds up and
|
||||
// the item moves on the next wake-up (#4558).
|
||||
if !self.on_ground || horizontal > 1.0e-5 || (self.age + self.id).is_multiple_of(4) {
|
||||
let mut scene = SceneCollisions {
|
||||
if !self.on_ground
|
||||
|| horizontal > 1.0e-5
|
||||
|| self.ticks.wrapping_add(self.id).is_multiple_of(4)
|
||||
{
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
@@ -308,17 +312,14 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else if entity_boxes.is_empty() {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still), and
|
||||
// so does the world border (`Entity.collectColliders`).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
border: border.map(|b| (b, [self.position.x, self.position.z])),
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
@@ -441,7 +442,9 @@ impl ItemEntity {
|
||||
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
|
||||
self.needs_sync = true;
|
||||
}
|
||||
self.age += 1;
|
||||
if self.age != i16::MIN as i32 {
|
||||
self.age += 1;
|
||||
}
|
||||
// ItemEntity.tick refreshes immersion after moving.
|
||||
self.update_fluids(world, fluids);
|
||||
self.age < 6000 && self.position.y > -128.0
|
||||
@@ -548,8 +551,11 @@ impl ItemEntity {
|
||||
}
|
||||
/// `ItemEntity.isMergable` at `age`: a stack below its size whose pickup
|
||||
/// delay isn't infinite, before its lifetime ends.
|
||||
fn mergeable(&self, age: u32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
fn mergeable(&self, age: i32, max_stack: u32) -> bool {
|
||||
self.pickup_delay != u32::MAX
|
||||
&& age != i16::MIN as i32
|
||||
&& age < 6000
|
||||
&& self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
@@ -685,7 +691,7 @@ pub fn merge_with_neighbours(
|
||||
max_stack: &dyn Fn(&ItemStack) -> u32,
|
||||
) {
|
||||
let age = |items: &[ItemEntity], j: usize| {
|
||||
if j == i {
|
||||
if j == i && items[j].age != i16::MIN as i32 {
|
||||
items[j].age - 1
|
||||
} else {
|
||||
items[j].age
|
||||
@@ -729,7 +735,7 @@ pub fn merge_with_neighbours(
|
||||
let to_item = &mut items[to];
|
||||
to_item.pickup_delay = to_item.pickup_delay.max(delay);
|
||||
// This item's age goes up once its tick ends.
|
||||
to_item.age = to_age.min(from_age) + u32::from(to == i);
|
||||
to_item.age = to_age.min(from_age) + i32::from(to == i);
|
||||
if items[from].stack.count == 0 {
|
||||
removed[from] = true;
|
||||
cells.remove(from, items[from].position);
|
||||
@@ -852,6 +858,7 @@ impl ExperienceOrb {
|
||||
player: Option<[f64; 3]>,
|
||||
server: bool,
|
||||
next_float: &mut dyn FnMut() -> f32,
|
||||
border: Option<&BorderGeometry>,
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -916,8 +923,10 @@ impl ExperienceOrb {
|
||||
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
|
||||
// push and before the move.
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop the orb too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve(bounds, wanted, 0.0, self.on_ground)
|
||||
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -1775,7 +1784,7 @@ mod armor_stand_render_tests {
|
||||
mod merge_tests {
|
||||
use super::*;
|
||||
|
||||
fn item(id: &str, count: u32, x: f64, age: u32) -> ItemEntity {
|
||||
fn item(id: &str, count: u32, x: f64, age: i32) -> ItemEntity {
|
||||
let at = Vec3::new(x, 64.0, 0.5);
|
||||
ItemEntity {
|
||||
no_gravity: false,
|
||||
@@ -1799,7 +1808,7 @@ mod merge_tests {
|
||||
age,
|
||||
pickup_delay: 0,
|
||||
bob_offset: 0.0,
|
||||
ticks: age,
|
||||
ticks: age.max(0) as u32,
|
||||
needs_sync: false,
|
||||
stuck: Vec3::ZERO,
|
||||
}
|
||||
@@ -1954,6 +1963,73 @@ mod merge_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod world_border_tests {
|
||||
use super::*;
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops an
|
||||
/// item and an experience orb as it stops the player. A border of size
|
||||
/// 10 around (20, 20) has its east wall at x = 25; each body starts at
|
||||
/// x = 24 on the flat ground of the test scene, moving 1.5 east.
|
||||
#[test]
|
||||
fn the_world_border_stops_items_and_experience_orbs() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap();
|
||||
for bordered in [false, true] {
|
||||
let border = bordered.then_some(&border);
|
||||
let mut item = super::honey_slide_tests::falling_item(24.0, 4.0, 20.5, 1.5, 0.0, 0.0);
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
border,
|
||||
);
|
||||
let mut orb = ExperienceOrb {
|
||||
no_gravity: false,
|
||||
id: 2,
|
||||
position: Vec3::new(24.0, 4.0, 20.5),
|
||||
previous: Vec3::new(24.0, 4.0, 20.5),
|
||||
velocity: Vec3::new(1.5, 0.0, 0.0),
|
||||
value: 1,
|
||||
age: 0,
|
||||
on_ground: true,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
};
|
||||
orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border);
|
||||
for (what, east) in [
|
||||
("item", item.bounds().max_position().x),
|
||||
("orb", orb.bounds().max_position().x),
|
||||
] {
|
||||
if bordered {
|
||||
assert!(
|
||||
(east - 25.0).abs() < 1.0e-7,
|
||||
"the {what} stops on the wall at x = 25, got {east}"
|
||||
);
|
||||
} else {
|
||||
assert!(east > 25.0, "no border: the {what} goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod honey_slide_tests {
|
||||
use super::*;
|
||||
@@ -1984,7 +2060,8 @@ mod honey_slide_tests {
|
||||
let mut item = falling_item(at.x, at.y, at.z, 0.0, 0.0, 0.0);
|
||||
item.on_ground = true;
|
||||
item.age = 0;
|
||||
item.id = 1;
|
||||
item.ticks = 0;
|
||||
item.id = 0;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(
|
||||
@@ -1995,6 +2072,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
@@ -2059,6 +2137,7 @@ mod honey_slide_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
@@ -2355,6 +2434,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
@@ -2373,6 +2453,7 @@ mod slime_bounce_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
@@ -2465,6 +2546,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
@@ -2513,6 +2595,7 @@ mod entity_collision_tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
item.position.x
|
||||
|
||||
@@ -262,7 +262,8 @@ mod tests {
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
&[],
|
||||
None
|
||||
));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
@@ -275,7 +276,8 @@ mod tests {
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
&[],
|
||||
None
|
||||
));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
@@ -289,6 +291,7 @@ mod tests {
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
|
||||
@@ -2034,7 +2034,10 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
("Item", item_to_nbt(&e.stack)),
|
||||
("Health", Tag::Short(e.health as i16)),
|
||||
("Fire", Tag::Short(e.fire_ticks as i16)),
|
||||
("Age", Tag::Short(e.age.min(i16::MAX as u32) as i16)),
|
||||
(
|
||||
"Age",
|
||||
Tag::Short(e.age.clamp(i16::MIN as i32, i16::MAX as i32) as i16),
|
||||
),
|
||||
(
|
||||
"PickupDelay",
|
||||
Tag::Short(e.pickup_delay.min(i16::MAX as u32) as i16),
|
||||
@@ -2111,8 +2114,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
|
||||
on_ground: flag(tag, "OnGround"),
|
||||
vertical_collision: false,
|
||||
horizontal_collision: false,
|
||||
// Age -32768 marks items that never despawn; Horizon counts upwards.
|
||||
age: number(tag.get("Age")).unwrap_or(0).max(0) as u32,
|
||||
age: number(tag.get("Age")).unwrap_or(0) as i32,
|
||||
pickup_delay: number(tag.get("PickupDelay")).unwrap_or(0).clamp(0, 32767) as u32,
|
||||
bob_offset,
|
||||
ticks: 0,
|
||||
|
||||
@@ -4009,9 +4009,6 @@ impl Game<'_> {
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut removed = vec![false; items.len()];
|
||||
let mut cells = horizon_client::entities::ItemCells::new(&items);
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
// A LAN or server guest only shows the host's items.
|
||||
let merging = self.remote.is_none();
|
||||
let obstacles = horizon_client::entities::ObstacleGrid::new(self.item_entity_collisions());
|
||||
@@ -4060,6 +4057,7 @@ impl Game<'_> {
|
||||
no_physics,
|
||||
&self.res.motion().fluids,
|
||||
&nearby,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
cells.moved(i, item.previous, item.position);
|
||||
// Entity.handleOnAboveBubbleColumn: the server sends the particles of
|
||||
@@ -4085,6 +4083,9 @@ impl Game<'_> {
|
||||
&& (alive || item.age == 6000 && item.position.y > -128.0)
|
||||
&& item.ticks.is_multiple_of(rate)
|
||||
{
|
||||
let catalog = world_items!(self);
|
||||
let max_stack =
|
||||
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
|
||||
horizon_client::entities::merge_with_neighbours(
|
||||
&mut items,
|
||||
&mut removed,
|
||||
@@ -4094,6 +4095,15 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
let item = &mut items[i];
|
||||
let mut alive = alive || (item.age < 6000 && item.position.y > -128.0);
|
||||
if merging && !removed[i] && item.age >= 6000 && item.position.y > -128.0 {
|
||||
let mut extra_life = 0;
|
||||
self.native_item_expire(item.id, &item.stack, &mut extra_life);
|
||||
if extra_life > 0 {
|
||||
item.age = 6000 - extra_life.min(32766);
|
||||
alive = true;
|
||||
}
|
||||
}
|
||||
if !alive || removed[i] {
|
||||
removed[i] = true;
|
||||
cells.remove(i, item.position);
|
||||
@@ -4105,8 +4115,7 @@ impl Game<'_> {
|
||||
self.pack.assets.registry,
|
||||
world_items!(self),
|
||||
);
|
||||
// shortcut: age is the fresh-drop clock; use entity ticks for saved/frozen-age phase parity.
|
||||
if environment.should_play_lava_hurt_sound(item.age) {
|
||||
if environment.should_play_lava_hurt_sound(item.ticks) {
|
||||
let pitch = 2.0 + self.world.random.next_float() * 0.4;
|
||||
self.audio.play(
|
||||
"minecraft:entity.generic.burn",
|
||||
@@ -5535,7 +5544,7 @@ impl Game<'_> {
|
||||
f.stuck = Vec3::ZERO;
|
||||
f.velocity = Vec3::ZERO;
|
||||
}
|
||||
let mut scene = collisions::SceneCollisions {
|
||||
let scene = collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: Some(collisions::PowderSnowSubject {
|
||||
@@ -5554,7 +5563,13 @@ impl Game<'_> {
|
||||
false,
|
||||
);
|
||||
// On an error the caller keeps the block as it was before this tick.
|
||||
let movement = scene.resolve_with_context(
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
let movement = collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: &[],
|
||||
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
|
||||
}
|
||||
.resolve_with_context(
|
||||
horizon_client::falling_block::bounds(f.position),
|
||||
wanted,
|
||||
0.0,
|
||||
@@ -13243,6 +13258,7 @@ impl Game<'_> {
|
||||
target,
|
||||
self.remote.is_none(),
|
||||
&mut next_float,
|
||||
Some(self.border.geometry()),
|
||||
)
|
||||
});
|
||||
self.orbs = orbs;
|
||||
@@ -14806,10 +14822,19 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// `DedicatedServer.saveAllChunks` tells the management protocol's clients when a save starts and when
|
||||
// it ends without failing, for every save: the command, the autosave, the pause.
|
||||
let dedicated = self.options.dedicated.is_some();
|
||||
if dedicated {
|
||||
dedicated::events::record_server("saving");
|
||||
}
|
||||
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");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn save_game_metadata(&mut self) -> Result<()> {
|
||||
@@ -17767,12 +17792,19 @@ impl Game<'_> {
|
||||
result => result?,
|
||||
};
|
||||
self.apply_cursor_icon();
|
||||
// The frame's spans, taken every frame they are recorded so they never
|
||||
// pile up: the slow-frame line's, and the session's (`perf.frame`).
|
||||
let frame_spans = if horizon_client::spans::recording_here() {
|
||||
horizon_client::spans::take_raw()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(threshold) = self.options.spikes
|
||||
&& let Some(r) = self.renderer.as_ref()
|
||||
{
|
||||
let cpu = now.elapsed().saturating_sub(r.last_wait).as_secs_f32() * 1000.0;
|
||||
// Spans timed between frames (`idle`) come first.
|
||||
let spans = horizon_client::spans::take();
|
||||
let spans = horizon_client::spans::merge(&frame_spans);
|
||||
let between = between.as_secs_f32() * 1000.0;
|
||||
if cpu > threshold || between > threshold {
|
||||
// The render thread's own CPU time in the frame:
|
||||
@@ -17876,7 +17908,8 @@ impl Game<'_> {
|
||||
if self.title.is_some() {
|
||||
self.perf.title_shown();
|
||||
} else if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.perf.frame(elapsed.as_secs_f32() * 1000.0);
|
||||
self.perf
|
||||
.frame(elapsed.as_secs_f32() * 1000.0, &frame_spans);
|
||||
if startup::filling() {
|
||||
let mut fill: Vec<(&'static str, usize)> =
|
||||
self.streaming.stages().to_vec();
|
||||
@@ -20710,6 +20743,14 @@ pub fn run_with() -> Result<()> {
|
||||
options.spikes = Some(dedicated::SLOW_TICK_MS);
|
||||
}
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
// A player who allows performance statistics also says what their slow frames spent their
|
||||
// time in: this thread, which draws the frames, times its steps (wall time only).
|
||||
if options.execution_role.is_client()
|
||||
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
|
||||
.allows(horizon_telemetry::Kind::Perf)
|
||||
{
|
||||
horizon_client::spans::record_here(true);
|
||||
}
|
||||
let vanilla_root = options.resources.clone();
|
||||
if let Some(error) = missing_resources(&vanilla_root) {
|
||||
// The game build started by hand (the release's zip), not by the
|
||||
|
||||
@@ -381,6 +381,18 @@ fn templates_under(root: &Path) -> Vec<String> {
|
||||
|
||||
/// The `namespace:` ids of the `extension` files under `root`, sorted.
|
||||
fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
let suffix = format!(".{extension}");
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if let Some(files) = horizon_core::prepared_files::files_under(root) {
|
||||
let mut out: Vec<String> = files
|
||||
.iter()
|
||||
.filter_map(|file| file.strip_suffix(&suffix))
|
||||
.filter(|path| !path.is_empty() && !path.ends_with('/'))
|
||||
.map(|path| format!("{namespace}:{path}"))
|
||||
.collect();
|
||||
out.sort();
|
||||
return out;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_owned()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
|
||||
@@ -2103,6 +2103,34 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::PufferfishSting {
|
||||
mob,
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
poison_ticks,
|
||||
} => {
|
||||
// Pufferfish.touch: the poison and
|
||||
// `playSound(PUFFER_FISH_STING, 1, 1)` sit inside the
|
||||
// `hurtServer` branch, so only a hit that landed poisons
|
||||
// and stings, at the fish's own position as
|
||||
// `Entity.playSound` puts it, and not when it is silent.
|
||||
let landed = self.mob_hits_mob(target, amount, source);
|
||||
if landed && let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
|
||||
t.add_effect("minecraft:poison", poison_ticks, 0);
|
||||
}
|
||||
if landed && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
|
||||
let at = [m.position.x, m.position.y, m.position.z];
|
||||
if !m.silent {
|
||||
self.handle_mob_events(vec![MobEvent::Sound(
|
||||
"entity.puffer_fish.sting",
|
||||
at,
|
||||
1.0,
|
||||
1.0,
|
||||
)])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
MobEvent::SonicBoom {
|
||||
target,
|
||||
attacker,
|
||||
|
||||
@@ -1794,6 +1794,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
|
||||
/// server.properties.
|
||||
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
|
||||
lists.lock().unwrap().enforce_whitelist = on;
|
||||
self.store_property("enforce-whitelist", on.to_string());
|
||||
}
|
||||
|
||||
/// DedicatedServerSettings.update: change one key and store the file.
|
||||
pub(super) fn store_property(&mut self, key: &str, value: String) {
|
||||
let path = PathBuf::from("server.properties");
|
||||
|
||||
@@ -21,11 +21,20 @@ static EVENTS: Mutex<Events> = Mutex::new(Events {
|
||||
|
||||
/// Records one event: its kind, the player's name and UUID, and what else it says.
|
||||
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
push(kind, fields);
|
||||
}
|
||||
|
||||
/// Records an event of the server itself, with no player (`saving` and `saved`, around a world save).
|
||||
pub(in crate::game) fn record_server(kind: &str) {
|
||||
push(kind, json!({}));
|
||||
}
|
||||
|
||||
fn push(kind: &str, mut fields: Value) {
|
||||
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
fields["seq"] = events.next.into();
|
||||
fields["type"] = kind.into();
|
||||
fields["name"] = name.into();
|
||||
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
|
||||
fields["time"] = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_millis() as u64)
|
||||
@@ -52,6 +61,7 @@ pub(super) fn since(since: u64) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// One test: the events are one process-wide list, so two tests recording at once would count each other's.
|
||||
#[test]
|
||||
fn events_are_numbered_and_read_from_a_number() {
|
||||
let first = since(u64::MAX)["next"].as_u64().unwrap();
|
||||
@@ -65,5 +75,11 @@ mod tests {
|
||||
assert_eq!(events[1]["message"], "hi");
|
||||
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
|
||||
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
|
||||
// An event of the server itself (a world save) carries no player.
|
||||
record_server("saving");
|
||||
let event = &since(first + 2)["events"][0];
|
||||
assert_eq!(event["type"], "saving");
|
||||
assert_eq!(event["seq"].as_u64(), Some(first + 3));
|
||||
assert!(event.get("name").is_none() && event.get("uuid").is_none());
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,46 @@ impl Game<'_> {
|
||||
self.set_force_game_mode(force);
|
||||
return json!({ "value": state.force_game_mode });
|
||||
}
|
||||
"serversettings/use_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/use_allowlist/set" => {
|
||||
let Some(use_allowlist) = body["value"].as_bool() else {
|
||||
return json!({ "error": "using allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().use_whitelist;
|
||||
rpc(format!(
|
||||
"Update using allowlist from {} to {}",
|
||||
current, use_allowlist
|
||||
));
|
||||
self.set_using_whitelist(&lists, use_allowlist);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().use_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist" => {
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/enforce_allowlist/set" => {
|
||||
let Some(enforce) = body["value"].as_bool() else {
|
||||
return json!({ "error": "enforce allowlist must be a boolean" });
|
||||
};
|
||||
let current = lists.lock().unwrap().enforce_whitelist;
|
||||
rpc(format!(
|
||||
"Update enforce allowlist from {} to {}",
|
||||
current, enforce
|
||||
));
|
||||
self.set_enforce_whitelist(&lists, enforce);
|
||||
self.kick_unlisted_players(&lists);
|
||||
return json!({
|
||||
"value": lists.lock().unwrap().enforce_whitelist
|
||||
});
|
||||
}
|
||||
"serversettings/difficulty" => {
|
||||
return json!({ "value": DIFFICULTIES[self.level.difficulty_id() as usize] });
|
||||
}
|
||||
|
||||
@@ -327,6 +327,7 @@ mod tests {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms: ms,
|
||||
depth: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -763,10 +763,10 @@ impl Game<'_> {
|
||||
let mut i = 0;
|
||||
while i < self.dropped.len() {
|
||||
let item = &self.dropped[i];
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting (age was
|
||||
// counted already); `checkInsideBlocks` only follows a move.
|
||||
// `ItemEntity.tick` moves 1 of 4 ticks while resting;
|
||||
// `checkInsideBlocks` only follows a move.
|
||||
let moved = item.position != item.previous
|
||||
|| (item.age.wrapping_sub(1).wrapping_add(item.id)).is_multiple_of(4);
|
||||
|| item.ticks.wrapping_add(item.id).is_multiple_of(4);
|
||||
if !moved || item.stack.count == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
|
||||
@@ -5893,6 +5893,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
|
||||
@@ -9164,9 +9177,12 @@ mod tests {
|
||||
// split): horizon-mixins resolves the crate by its package.
|
||||
format!("horizon_client_lib::crafting::native_{name}")
|
||||
} else if menu_hooks.contains(name) {
|
||||
format!("horizon_client::menu::native_{name}")
|
||||
// menu.rs compiles into horizon-client-lib (#5446's
|
||||
// split): horizon-mixins resolves the crate by its package.
|
||||
format!("horizon_client_lib::menu::native_{name}")
|
||||
} else if breath_hooks.contains(name) {
|
||||
format!("horizon_client::breathing::native_{name}")
|
||||
// breathing.rs compiles into horizon-client-lib too.
|
||||
format!("horizon_client_lib::breathing::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
@@ -9175,6 +9191,32 @@ mod tests {
|
||||
assert_eq!(hook["target"].as_str().unwrap(), target);
|
||||
}
|
||||
assert_eq!(names, declared);
|
||||
// Every target's crate really compiles its module: #5446 moved
|
||||
// files between horizon-client and horizon-client-lib, and a
|
||||
// target left on the old crate applies to no bundle — the string
|
||||
// comparison above shares its expectation with the catalog, so it
|
||||
// alone cannot catch a move. The first path segment after the
|
||||
// crate must be a module of that crate's lib root.
|
||||
let client_root = include_str!("../lib.rs");
|
||||
let lib_root = include_str!("../client_lib.rs");
|
||||
for hook in &hooks {
|
||||
let target = hook["target"].as_str().unwrap();
|
||||
let (root, crate_name, rest) =
|
||||
if let Some(rest) = target.strip_prefix("horizon_client::") {
|
||||
(client_root, "horizon_client", rest)
|
||||
} else if let Some(rest) = target.strip_prefix("horizon_client_lib::") {
|
||||
(lib_root, "horizon_client_lib", rest)
|
||||
} else {
|
||||
// The other crates (render, world) are their own packages,
|
||||
// never split.
|
||||
continue;
|
||||
};
|
||||
let module = rest.split("::").next().unwrap_or(rest);
|
||||
assert!(
|
||||
root.contains(&format!("mod {module};")),
|
||||
"{crate_name} does not compile module `{module}` (target {target})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The damage hook gets a mob's source from its damage: the causing mob,
|
||||
|
||||
@@ -6468,6 +6468,7 @@ impl Game<'_> {
|
||||
// collisions and the pull of the nearest player between the server's
|
||||
// updates; the server removes it on pickup.
|
||||
let (world, facts, collision) = (&self.world, self.res.facts(), self.collision);
|
||||
let border = Some(self.border.geometry());
|
||||
let mut lava_random = None;
|
||||
let mut next_float = || {
|
||||
lava_random
|
||||
@@ -6501,7 +6502,15 @@ impl Game<'_> {
|
||||
l.steps -= 1;
|
||||
}
|
||||
let before = o.position;
|
||||
let kept = o.tick(world, facts, collision, target, false, &mut next_float);
|
||||
let kept = o.tick(
|
||||
world,
|
||||
facts,
|
||||
collision,
|
||||
target,
|
||||
false,
|
||||
&mut next_float,
|
||||
border,
|
||||
);
|
||||
// `tick` set `previous` to the stepped position: keep the
|
||||
// pre-step one, as `xo` is in vanilla.
|
||||
o.previous = old;
|
||||
@@ -6519,11 +6528,12 @@ impl Game<'_> {
|
||||
// ItemEntity.tick runs on a client too: gravity, collisions and
|
||||
// friction move the item between the server's updates (every 20
|
||||
// ticks), and only the server removes it.
|
||||
let (world, registry, facts, collision) = (
|
||||
let (world, registry, facts, collision, border) = (
|
||||
&self.world,
|
||||
self.pack.assets.registry,
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
for d in self.dropped.iter_mut().filter(|_| running) {
|
||||
d.tick(
|
||||
@@ -6535,6 +6545,7 @@ impl Game<'_> {
|
||||
&self.res.motion().fluids,
|
||||
// The server decides what an item rests on; this copy only moves between updates.
|
||||
&[],
|
||||
border,
|
||||
);
|
||||
}
|
||||
head_lerps.retain(|_, (_, steps)| *steps > 0);
|
||||
@@ -7358,6 +7369,23 @@ impl Game<'_> {
|
||||
// and the click ends.
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
return true;
|
||||
} else if let Some(stack) = held
|
||||
.as_ref()
|
||||
.filter(|s| s.item.ends_with("_spawn_egg"))
|
||||
.filter(|_| {
|
||||
self.may_build()
|
||||
|| self.with_hand(hand, |g| g.can_place_on_in_adventure(hit.pos))
|
||||
})
|
||||
{
|
||||
// SpawnEggItem.useOn on a client level: FAIL for a mob that
|
||||
// cannot spawn (the click ends, no swing), else SUCCESS (swing).
|
||||
// Either way no UseItem follows, so the server does not spawn
|
||||
// a second mob from the fluid ray (ItemStack.useOn passes in
|
||||
// adventure mode off can_place_on, which the filter above keeps).
|
||||
if self.spawn_egg_allowed(&stack.item) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return true;
|
||||
} else if let Some(stack) = held.as_ref()
|
||||
&& (self.predict_item_on_block(stack, &hit, hand)
|
||||
|| self.predict_map_on_banner(stack, &hit))
|
||||
|
||||
@@ -611,16 +611,17 @@ impl Game<'_> {
|
||||
// fuse down (the swell and flash follow it) between the server's
|
||||
// updates (every 10 ticks), and a client discards it when the fuse
|
||||
// runs out; only the server explodes it.
|
||||
let (world, collision, assets, facts, fluids) = (
|
||||
let (world, collision, assets, facts, fluids, border) = (
|
||||
&self.world,
|
||||
self.collision,
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
);
|
||||
self.tnts.retain_mut(|t| {
|
||||
// A fluid lookup that fails leaves it where its move put it.
|
||||
let _ = t.tick(world, collision, assets, facts, fluids);
|
||||
let _ = t.tick(world, collision, assets, facts, fluids, border);
|
||||
t.fuse > 0
|
||||
});
|
||||
// FallingBlockEntity.tick runs on a client too: the block falls by
|
||||
|
||||
@@ -50,17 +50,20 @@ impl PrimedTnt {
|
||||
assets: &horizon_client::assets::Assets,
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
fluids: &horizon_client::motion::FluidResources,
|
||||
border: Option<&horizon_core::geometry::border::BorderGeometry>,
|
||||
) -> Result<Vec<BlockPos>> {
|
||||
self.previous = self.position;
|
||||
self.velocity.y -= self.get_gravity();
|
||||
let mut scene = SceneCollisions {
|
||||
let scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
let wanted = self.velocity;
|
||||
// Entity.collide: the world border's walls stop it too.
|
||||
let border = border.map(|b| (b, [self.position.x, self.position.z]));
|
||||
let moved = scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.resolve_in_border(self.bounds(), wanted, 0.0, self.on_ground, border)
|
||||
.unwrap_or(Vec3::ZERO);
|
||||
if horizon_client::entities::move_applies(wanted, moved) {
|
||||
self.position = Vec3::new(
|
||||
@@ -246,6 +249,7 @@ impl Game<'_> {
|
||||
&self.pack.assets,
|
||||
self.res.facts(),
|
||||
&self.res.motion().fluids,
|
||||
Some(self.border.geometry()),
|
||||
)?;
|
||||
for pos in tops {
|
||||
self.bubble_column_particles(pos);
|
||||
@@ -541,6 +545,55 @@ mod tests {
|
||||
assert!(!tnt.on_ground);
|
||||
}
|
||||
|
||||
/// `Entity.collectColliders` (#7080): the world border's wall stops primed
|
||||
/// TNT. A border of size 10 around (20, 20) has its east wall at x = 25;
|
||||
/// the TNT starts at x = 24 on the test scene's flat ground, 1.5 east.
|
||||
#[test]
|
||||
fn the_world_border_stops_primed_tnt() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = horizon_client::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = horizon_client::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
|
||||
let world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
|
||||
let border =
|
||||
horizon_core::geometry::border::BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984)
|
||||
.unwrap();
|
||||
for bordered in [false, true] {
|
||||
let at = Vec3::new(24.0, 4.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
no_gravity: false,
|
||||
id: 0,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity: Vec3::new(1.5, 0.0, 0.0),
|
||||
fuse: 80,
|
||||
on_ground: true,
|
||||
owner: None,
|
||||
guest: None,
|
||||
power: 4.0,
|
||||
};
|
||||
let border = bordered.then_some(&border);
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, border)
|
||||
.unwrap();
|
||||
let east = tnt.bounds().max_position().x;
|
||||
if bordered {
|
||||
assert!(
|
||||
(east - 25.0).abs() < 1.0e-7,
|
||||
"stops on the wall, got {east}"
|
||||
);
|
||||
} else {
|
||||
assert!(east > 25.0, "no border: goes on, got {east}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primed_tnt_follows_water_currents_after_moving() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -580,7 +633,7 @@ mod tests {
|
||||
guest: None,
|
||||
power: 4.0,
|
||||
};
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.position.x, at.x,
|
||||
@@ -592,14 +645,14 @@ mod tests {
|
||||
tnt.velocity
|
||||
);
|
||||
assert_eq!(tnt.fuse, 79);
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert!((tnt.position.x - at.x - 0.014).abs() < 1e-12);
|
||||
assert!(tnt.on_ground);
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 1;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert_eq!(tnt.fuse, 0);
|
||||
assert_eq!(
|
||||
@@ -610,7 +663,7 @@ mod tests {
|
||||
tnt.position = at;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.fuse = 80;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
tnt.velocity.x, 0.0,
|
||||
@@ -626,7 +679,7 @@ mod tests {
|
||||
.unwrap();
|
||||
tnt.on_ground = false;
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
assert!(
|
||||
tnt.position.y < at.y && tnt.velocity.y < 0.0,
|
||||
@@ -683,7 +736,7 @@ mod tests {
|
||||
power: 4.0,
|
||||
};
|
||||
for _ in 0..10 {
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
@@ -704,7 +757,7 @@ mod tests {
|
||||
tnt.velocity = Vec3::ZERO;
|
||||
tnt.on_ground = false;
|
||||
for _ in 0..4 {
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(
|
||||
@@ -764,7 +817,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..30 {
|
||||
tops.extend(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
@@ -778,7 +831,7 @@ mod tests {
|
||||
let mut tnt = primed();
|
||||
for _ in 0..10 {
|
||||
assert!(
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids)
|
||||
tnt.tick(&world, &collision, &assets, &facts, &fluids, None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
@@ -117,6 +117,8 @@ use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
pub struct Polygon {
|
||||
pub normal: [f32; 3],
|
||||
pub vertices: [[f32; 5]; 4],
|
||||
/// The model's render type culls back faces (`entityCutoutCull`: the bat).
|
||||
pub cull_back: bool,
|
||||
}
|
||||
impl Polygon {
|
||||
fn vertex_normal(&self, pose: ModelNormals) -> [i8; 4] {
|
||||
@@ -127,7 +129,12 @@ impl Polygon {
|
||||
n.normalize_or_zero()
|
||||
};
|
||||
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
|
||||
[packed[0], packed[1], packed[2], -127]
|
||||
let w = if self.cull_back {
|
||||
crate::mesher::SHADED_CULL_W
|
||||
} else {
|
||||
-127
|
||||
};
|
||||
[packed[0], packed[1], packed[2], w]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +232,15 @@ pub struct Part {
|
||||
pub children: Vec<Part>,
|
||||
}
|
||||
impl Part {
|
||||
/// Marks every polygon as drawn by a back-face-culling render type.
|
||||
fn set_cull_back(&mut self) {
|
||||
for poly in &mut self.polygons {
|
||||
poly.cull_back = true;
|
||||
}
|
||||
for child in &mut self.children {
|
||||
child.set_cull_back();
|
||||
}
|
||||
}
|
||||
fn parse(name: &str, v: &Value) -> Self {
|
||||
let pose: Vec<f32> = v["pose"]
|
||||
.as_array()
|
||||
@@ -271,6 +287,7 @@ impl Part {
|
||||
|n| std::array::from_fn(|i| n[i].as_f64().unwrap_or(0.0) as f32),
|
||||
),
|
||||
vertices: [verts[0], verts[1], verts[2], verts[3]],
|
||||
cull_back: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -494,7 +511,14 @@ impl MobTypes {
|
||||
max_head_y_rot: def.max_head_y_rot(),
|
||||
ambient_interval: def.ambient_interval(),
|
||||
flags,
|
||||
model: Arc::new(Part::parse("root", model)),
|
||||
model: Arc::new({
|
||||
let mut part = Part::parse("root", model);
|
||||
// BatModel: `RenderTypes::entityCutoutCull`.
|
||||
if def.name == "bat" {
|
||||
part.set_cull_back();
|
||||
}
|
||||
part
|
||||
}),
|
||||
layers,
|
||||
texture: def.texture.to_owned(),
|
||||
brain: def.brain(),
|
||||
@@ -1538,6 +1562,16 @@ pub enum MobEvent {
|
||||
amount: f32,
|
||||
source: Damage,
|
||||
},
|
||||
/// `Pufferfish.touch` on a mob (`Pufferfish.aiStep`): the hit, then, only
|
||||
/// when `hurtServer` let it land, the fish's `entity.puffer_fish.sting`
|
||||
/// and the poison it gives (`poison_ticks`, `60 * getPuffState()`).
|
||||
PufferfishSting {
|
||||
mob: u32,
|
||||
target: u32,
|
||||
amount: f32,
|
||||
source: Damage,
|
||||
poison_ticks: i32,
|
||||
},
|
||||
/// `LivingEntity.stabAttack`'s `target.stopRiding()`: a spear knocks the
|
||||
/// target off its mount.
|
||||
StabDismount {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! tropical fish and pufferfish (`AbstractFish`, puffing), and dolphins
|
||||
//! (breathing, smooth swimming).
|
||||
use super::{
|
||||
Mob, MobEvent, MobWorld, Species,
|
||||
Damage, Mob, MobEvent, MobWorld, Species, Who,
|
||||
anim::{Poses, State},
|
||||
control::MoveKind,
|
||||
goals::{
|
||||
@@ -333,6 +333,9 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
m.sound("entity.puffer_fish.sting", events);
|
||||
}
|
||||
}
|
||||
if m.species() == S::Pufferfish {
|
||||
sting_touching_mobs(m, w, events);
|
||||
}
|
||||
}
|
||||
S::Dolphin => {
|
||||
// Dolphin.tick: moisture out of water, hop on land.
|
||||
@@ -364,6 +367,65 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EntityTypeTags.NOT_SCARY_FOR_PUFFERFISH`: the mobs a pufferfish leaves
|
||||
/// alone, its own kind and the water mobs it shares a tank with.
|
||||
pub fn not_scary_for_pufferfish(s: Species) -> bool {
|
||||
matches!(
|
||||
s,
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Dolphin
|
||||
| Species::Turtle
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Tadpole
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
| Species::SulfurCube
|
||||
)
|
||||
}
|
||||
|
||||
/// `Pufferfish.aiStep` after `AbstractFish.aiStep`: a puffed pufferfish stings
|
||||
/// every mob that touches it, not only the player. The query is `Mob.class`
|
||||
/// in its own box inflated by 0.3, filtered by `TARGETING_CONDITIONS` (alive
|
||||
/// and not `#not_scary_for_pufferfish`; invisibility and line of sight are
|
||||
/// not tested), and each one takes `Pufferfish.touch`: `doTeamsAllowDamage`,
|
||||
/// then a `mobAttack` hit for `getPuffState() + 1`. The poison and the sting
|
||||
/// sound follow a hit that landed, which only the game layer knows.
|
||||
fn sting_touching_mobs(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
|
||||
if !m.alive() || m.extra.size <= 0 {
|
||||
return;
|
||||
}
|
||||
let area = m.bounds().inflate(Vec3::new(0.3, 0.3, 0.3));
|
||||
let own = w.teams.of(Who::Mob(m.id));
|
||||
let stung: Vec<u32> = w
|
||||
.others_near(area)
|
||||
.filter(|o| {
|
||||
o.id != m.id
|
||||
&& o.alive
|
||||
&& !not_scary_for_pufferfish(o.species)
|
||||
&& o.bounds.intersects(area)
|
||||
&& crate::scoreboard::teams_allow_damage(own, w.teams.of(Who::Mob(o.id)))
|
||||
})
|
||||
.map(|o| o.id)
|
||||
.collect();
|
||||
let from = [m.position.x, m.position.y, m.position.z];
|
||||
for target in stung {
|
||||
events.push(MobEvent::PufferfishSting {
|
||||
mob: m.id,
|
||||
target,
|
||||
amount: 1.0 + m.extra.size as f32,
|
||||
source: Damage::mob(m.id, from),
|
||||
poison_ticks: 60 * m.extra.size,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Squid.aiStep: tentacle cycle, propulsion and body rotation.
|
||||
fn squid_ai_step(m: &mut Mob) {
|
||||
squid_step(m, false);
|
||||
@@ -881,23 +943,7 @@ impl Goal for PufferfishPuff {
|
||||
let mob = cx.w.others_near(area).any(|s| {
|
||||
s.id != m.id
|
||||
&& s.alive
|
||||
&& !matches!(
|
||||
s.species,
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::TropicalFish
|
||||
| Species::Pufferfish
|
||||
| Species::Squid
|
||||
| Species::GlowSquid
|
||||
| Species::Dolphin
|
||||
| Species::Turtle
|
||||
| Species::Guardian
|
||||
| Species::ElderGuardian
|
||||
| Species::Tadpole
|
||||
| Species::Nautilus
|
||||
| Species::ZombieNautilus
|
||||
| Species::SulfurCube
|
||||
)
|
||||
&& !not_scary_for_pufferfish(s.species)
|
||||
&& area.intersects(s.bounds)
|
||||
});
|
||||
player || mob
|
||||
@@ -1099,3 +1145,85 @@ mod school_tests {
|
||||
assert!((100..=110).contains(&start));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pufferfish_sting_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// What a pufferfish puffed to `puff` stings in one `ai_step`, as
|
||||
/// `(target, damage)`, with `others` standing `x` blocks east of it.
|
||||
fn stings(puff: i32, others: &[(&str, f64)]) -> Option<Vec<(u32, f32)>> {
|
||||
let mut built = None;
|
||||
with_test_world(&[], |_, types| {
|
||||
let mob = |id: u32, name: &str, at: Vec3| {
|
||||
Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0)
|
||||
};
|
||||
let mut fish = mob(1, "minecraft:pufferfish", Vec3::new(0.5, 64.0, 0.5));
|
||||
fish.extra.size = puff;
|
||||
let seen: Vec<_> = others
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, x))| {
|
||||
mob(i as u32 + 2, name, Vec3::new(0.5 + x, 64.0, 0.5)).seen(None)
|
||||
})
|
||||
.collect();
|
||||
built = Some((fish, seen));
|
||||
});
|
||||
let (mut fish, seen) = built?;
|
||||
let mut events = Vec::new();
|
||||
with_test_world(&seen, |w, _| ai_step(&mut fish, w, &mut events));
|
||||
Some(
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::PufferfishSting {
|
||||
mob: 1,
|
||||
target,
|
||||
amount,
|
||||
source,
|
||||
poison_ticks,
|
||||
} => {
|
||||
assert_eq!(source.attacker, Some(Who::Mob(1)));
|
||||
assert_eq!(
|
||||
*poison_ticks,
|
||||
60 * puff,
|
||||
"Pufferfish.touch's POISON lasts 60 * getPuffState()"
|
||||
);
|
||||
Some((*target, *amount))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// `Pufferfish.aiStep`: a puffed pufferfish stings every mob whose box
|
||||
/// meets its own (inflated by 0.3), not only the player, for
|
||||
/// `getPuffState() + 1`.
|
||||
#[test]
|
||||
fn a_puffed_pufferfish_stings_the_mobs_that_touch_it() {
|
||||
let Some(half) = stings(1, &[("minecraft:zombie", 0.5)]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(half, vec![(2, 2.0)], "puff state 1 stings for 1 + 1");
|
||||
let full = stings(2, &[("minecraft:zombie", 0.5)]).unwrap();
|
||||
assert_eq!(full, vec![(2, 3.0)], "puff state 2 stings for 1 + 2");
|
||||
assert!(
|
||||
stings(0, &[("minecraft:zombie", 0.5)]).unwrap().is_empty(),
|
||||
"a deflated pufferfish stings nobody"
|
||||
);
|
||||
assert!(
|
||||
stings(2, &[("minecraft:zombie", 1.5)]).unwrap().is_empty(),
|
||||
"a zombie a block away never touches it"
|
||||
);
|
||||
// `#not_scary_for_pufferfish`: its tank mates are left alone.
|
||||
assert!(
|
||||
stings(2, &[("minecraft:cod", 0.4), ("minecraft:squid", 0.3)])
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"a cod and a squid are not scary"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -456,6 +456,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap<String, String>) -> Pa
|
||||
.map(|q| Polygon {
|
||||
normal: q.normal,
|
||||
vertices: q.vertices,
|
||||
cull_back: false,
|
||||
})
|
||||
.collect(),
|
||||
children: bone.children.iter().map(|c| part(c, count, ids)).collect(),
|
||||
@@ -473,6 +474,7 @@ mod tests {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
cull_back: false,
|
||||
vertices: [[0.0; 5]; 4],
|
||||
}],
|
||||
children,
|
||||
|
||||
@@ -2602,6 +2602,7 @@ mod tests {
|
||||
.map(|_| super::super::Polygon {
|
||||
normal: [0.0; 3],
|
||||
vertices: [corner(min), corner(max), corner(min), corner(max)],
|
||||
cull_back: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -3380,6 +3381,7 @@ mod emissive_tests {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
cull_back: false,
|
||||
vertices: [
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0, 1.0, 0.0],
|
||||
@@ -3670,6 +3672,7 @@ mod random_texture_tests {
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: vec![Polygon {
|
||||
normal: [0.0, 0.0, -1.0],
|
||||
cull_back: false,
|
||||
vertices: [
|
||||
[0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0, 1.0, 0.0],
|
||||
|
||||
@@ -22,6 +22,8 @@ struct Stored {
|
||||
/// The model's bounds in its parts' own space, per part: min and max of
|
||||
/// its own vertices (none: no polygons).
|
||||
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
|
||||
/// The model's render type culls back faces (the bat).
|
||||
cull: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -241,9 +243,15 @@ fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored
|
||||
first,
|
||||
parts,
|
||||
bounds,
|
||||
cull: has_cull(model),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the model's polygons cull back faces (all or none, set per model).
|
||||
fn has_cull(part: &Part) -> bool {
|
||||
part.polygons.iter().any(|p| p.cull_back) || part.children.iter().any(has_cull)
|
||||
}
|
||||
|
||||
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
|
||||
fn axis(n: [f32; 3]) -> Option<u32> {
|
||||
let mut found = None;
|
||||
@@ -415,7 +423,8 @@ impl Walk<'_> {
|
||||
overlay: paint.overlay,
|
||||
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
|
||||
item: 0,
|
||||
spare: [0; 2],
|
||||
cull: u32::from(self.stored.cull),
|
||||
spare: 0,
|
||||
});
|
||||
}
|
||||
*self.vertices += stored.count;
|
||||
@@ -533,6 +542,63 @@ mod tests {
|
||||
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
|
||||
}
|
||||
|
||||
/// `BatModel` draws with `entityCutoutCull`: its zero-depth ears, wings and
|
||||
/// feet put two quads in one place, and only culling tells them apart. The
|
||||
/// bat's vertices ask for the shaded cull encoding, other mobs keep -127,
|
||||
/// on both paths (the whole-mob test above holds them bit-equal).
|
||||
#[test]
|
||||
fn only_the_bat_asks_for_culled_back_faces() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3");
|
||||
if !root.join("entity-models.json").exists() {
|
||||
eprintln!("no prepared resources: skipped");
|
||||
return;
|
||||
}
|
||||
let types = MobTypes::load(&root).unwrap();
|
||||
for (id, w) in [
|
||||
("minecraft:bat", crate::mesher::SHADED_CULL_W),
|
||||
("minecraft:cow", -127),
|
||||
("minecraft:cat", -127),
|
||||
] {
|
||||
let mut part = vec![&*types.types[id].model];
|
||||
let mut seen = 0;
|
||||
while let Some(p) = part.pop() {
|
||||
for poly in &p.polygons {
|
||||
let n = poly.vertex_normal(ModelNormals::new(glam::Mat4::IDENTITY));
|
||||
assert_eq!(n[3], w, "{id}");
|
||||
seen += 1;
|
||||
}
|
||||
part.extend(&p.children);
|
||||
}
|
||||
assert!(seen > 0, "{id} has polygons");
|
||||
}
|
||||
// The GPU path flags the job, the CPU path the polygons.
|
||||
let mut models = SkinModels::default();
|
||||
let mut frame = SkinFrame::default();
|
||||
for (id, cull) in [("minecraft:bat", 1), ("minecraft:cow", 0)] {
|
||||
models.begin(&mut frame, 0);
|
||||
let mut skin = Skin {
|
||||
models: &mut models,
|
||||
frame: &mut frame,
|
||||
bounds: None,
|
||||
};
|
||||
assert!(skin.draw(
|
||||
&types.types[id].model,
|
||||
glam::Mat4::IDENTITY,
|
||||
&Poses::default(),
|
||||
Paint {
|
||||
uv: [0.0, 0.0, 1.0, 1.0],
|
||||
scroll: 0.0,
|
||||
color: u32::MAX,
|
||||
light: 0,
|
||||
overlay: 0
|
||||
},
|
||||
));
|
||||
assert!(frame.jobs.iter().all(|j| j.cull == cull), "{id}");
|
||||
frame.clear_draws();
|
||||
}
|
||||
}
|
||||
|
||||
/// The armor stand's models (`ArmorStandRenderer`: the stand, the small
|
||||
/// one, their armour layers) posed like a stand: turned arms and head, a
|
||||
/// hidden part (scale 0, as a stand without arms or base plate), the
|
||||
|
||||
@@ -492,3 +492,101 @@ mod generated;
|
||||
pub use generated::Hooks;
|
||||
#[doc(hidden)]
|
||||
pub use generated::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A double with the three `Host` methods the probe calls.
|
||||
struct Reader {
|
||||
block: &'static str,
|
||||
lines: Vec<Text>,
|
||||
}
|
||||
|
||||
impl Host for Reader {
|
||||
fn block(&self, _pos: [i32; 3]) -> String {
|
||||
self.block.to_string()
|
||||
}
|
||||
fn acting_player(&self) -> Option<NativeServerPlayer> {
|
||||
Some(NativeServerPlayer {
|
||||
uuid: 7,
|
||||
name: "Probe".to_string(),
|
||||
permission: 0,
|
||||
dimension: "minecraft:overworld".to_string(),
|
||||
position: [0.0; 3],
|
||||
game_mode: 0,
|
||||
health: 20.0,
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
})
|
||||
}
|
||||
fn action_bar(&mut self, _uuid: u128, line: Text) -> bool {
|
||||
self.lines.push(line);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Claims a use on stone, after telling the player what the block is.
|
||||
struct Probe;
|
||||
|
||||
impl Hooks for Probe {
|
||||
fn use_item_on(&self, host: &mut dyn Host, event: &mut UseItemOnEvent<'_>) -> Flow {
|
||||
let block = host.block(event.pos);
|
||||
let player = host.acting_player().expect("a player acts");
|
||||
host.action_bar(player.uuid, Text::literal(block.clone()));
|
||||
if block == "minecraft:stone" {
|
||||
Flow::Cancel
|
||||
} else {
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the host to tell a player without anyone having implemented it.
|
||||
struct Chatty;
|
||||
|
||||
impl Hooks for Chatty {
|
||||
fn use_item_on(&self, host: &mut dyn Host, _event: &mut UseItemOnEvent<'_>) -> Flow {
|
||||
host.tell(7, Text::literal("hello"));
|
||||
Flow::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn stick() -> ItemStack {
|
||||
ItemStack {
|
||||
item: Arc::from("minecraft:stick"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_host_implements_only_what_its_hook_calls() {
|
||||
let mods = [Box::new(Probe) as Box<dyn Hooks>];
|
||||
let stack = stick();
|
||||
for (block, expected) in [("minecraft:stone", true), ("minecraft:dirt", false)] {
|
||||
let mut host = Reader {
|
||||
block,
|
||||
lines: Vec::new(),
|
||||
};
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut host, &mods, &stack, [1, 2, 3], [0, 1, 0], &mut claimed);
|
||||
assert_eq!(claimed, expected, "{block}");
|
||||
let told: Vec<String> = host.lines.iter().map(Text::plain).collect();
|
||||
assert_eq!(told, [block], "{block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Host::tell")]
|
||||
fn an_unexpected_host_call_names_the_method() {
|
||||
let mods = [Box::new(Chatty) as Box<dyn Hooks>];
|
||||
let mut host = Reader {
|
||||
block: "minecraft:stone",
|
||||
lines: Vec::new(),
|
||||
};
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut host, &mods, &stick(), [0; 3], [0, 1, 0], &mut claimed);
|
||||
}
|
||||
}
|
||||
+33
@@ -3046,6 +3046,15 @@ pub trait Hooks: Send + Sync {
|
||||
let _ = event;
|
||||
None
|
||||
}
|
||||
/// A dropped item reaches its lifetime on the server (`ItemEntity.tick`,
|
||||
/// NeoForge's `ItemExpireEvent`). Every mod may set or add to `extra_life`,
|
||||
/// in ticks, which starts at zero; a positive total keeps the item that much
|
||||
/// longer, zero or less lets it expire. Extensions are capped at 32766
|
||||
/// ticks. @API:item_expire@.
|
||||
fn item_expire(&self, host: &mut dyn Host, event: &mut ItemExpireEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
/// A dropped item entity was successfully added to the server level.
|
||||
/// @API:item_spawn@.
|
||||
fn item_spawn(&self, host: &mut dyn Host, event: &mut ItemSpawnEvent<'_>) {
|
||||
@@ -10179,6 +10188,30 @@ pub fn dispatch_item_energy_buffer(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::item_expire`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ItemExpireEvent<'a> {
|
||||
pub entity: u32,
|
||||
pub stack: &'a ItemStack,
|
||||
pub extra_life: i32,
|
||||
}
|
||||
pub fn dispatch_item_expire(
|
||||
host: &mut dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
entity: u32,
|
||||
stack: &ItemStack,
|
||||
extra_life: &mut i32,
|
||||
) {
|
||||
let mut event = ItemExpireEvent {
|
||||
entity,
|
||||
stack,
|
||||
extra_life: *extra_life,
|
||||
};
|
||||
for mod_hook in mods {
|
||||
mod_hook.item_expire(host, &mut event);
|
||||
}
|
||||
*extra_life = event.extra_life;
|
||||
}
|
||||
/// What [`Hooks::item_spawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct ItemSpawnEvent<'a> {
|
||||
|
||||
+572
-111
File diff suppressed because it is too large.
Load diff
@@ -32,10 +32,12 @@ use std::{
|
||||
pub const FILE_NAME: &str = "horizon-data.bin";
|
||||
/// The file it checks its preparation against.
|
||||
const SOURCE: &str = "horizon-resources.json";
|
||||
/// The folders it holds, with their subfolders.
|
||||
const FOLDERS: [&str; 2] = ["data", "assets"];
|
||||
/// The folders it holds, with their subfolders: the resources, and the
|
||||
/// structure templates world generation loads one by one.
|
||||
const FOLDERS: [&str; 3] = ["data", "assets", "worldgen/structure"];
|
||||
const MAGIC: [u8; 8] = *b"HZDATA\0\0";
|
||||
const VERSION: u32 = 1;
|
||||
/// 2: `worldgen/structure` joined the folders (a version 1 file lacks it).
|
||||
const VERSION: u32 = 2;
|
||||
const HEADER: usize = 40;
|
||||
const ENTRY: usize = 24;
|
||||
|
||||
@@ -224,6 +226,21 @@ pub fn read(path: &Path) -> io::Result<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The files under `dir` (a folder of a prepared root) and its subfolders, as
|
||||
/// `/`-separated paths from `dir` in name order, from the root's data file
|
||||
/// without listing the disk: empty when it holds none there. `None` when no
|
||||
/// data file holds `dir` (walk the folder).
|
||||
pub fn files_under(dir: &Path) -> Option<Vec<String>> {
|
||||
let (files, rel) = find(dir)?;
|
||||
let names = files.list(&rel).unwrap_or_default();
|
||||
Some(
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| name[rel.len() + 1..].to_owned())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// At most the first `max` bytes of `path`, from where [`read`] takes them:
|
||||
/// a header, without reading the whole file.
|
||||
pub fn read_head(path: &Path, max: u64) -> io::Result<Vec<u8>> {
|
||||
@@ -392,6 +409,11 @@ mod tests {
|
||||
("data/minecraft/tags/item/logs.json", b"[1,2,3]"),
|
||||
("assets/minecraft/textures/block/stone.png", b"\x89PNG..."),
|
||||
("assets/minecraft/sounds/ambient/cave1.ogg", b"OggS"),
|
||||
(
|
||||
"worldgen/structure/minecraft/fossil/spine_1.nbt",
|
||||
b"\x1f\x8b",
|
||||
),
|
||||
("worldgen/biomes.json", b"{}"),
|
||||
("facts.json", b"{}"),
|
||||
] {
|
||||
let path = root.join(name);
|
||||
@@ -425,6 +447,14 @@ mod tests {
|
||||
.is_none()
|
||||
);
|
||||
assert!(files.read("facts.json").is_none());
|
||||
assert!(files.read("worldgen/biomes.json").is_none());
|
||||
assert_eq!(
|
||||
files
|
||||
.read("worldgen/structure/minecraft/fossil/spine_1.nbt")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
b"\x1f\x8b"
|
||||
);
|
||||
assert!(files.read("data/minecraft/recipe/missing.json").is_none());
|
||||
assert_eq!(
|
||||
files.list("data/minecraft/recipe").unwrap(),
|
||||
@@ -483,6 +513,31 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A folder's files come from the file, without the disk: one added since
|
||||
/// isn't listed (as `read` doesn't find it), and a folder it holds nothing
|
||||
/// in is empty. Outside its folders, the disk is walked.
|
||||
#[test]
|
||||
fn files_under_lists_a_folder_from_the_file() {
|
||||
let root = root("under");
|
||||
write(&root).unwrap();
|
||||
fs::write(root.join("assets/minecraft/textures/block/added.png"), b"x").unwrap();
|
||||
assert_eq!(
|
||||
files_under(&root.join("assets/minecraft/textures")).unwrap(),
|
||||
["block/stone.png"]
|
||||
);
|
||||
assert_eq!(
|
||||
files_under(&root.join("worldgen/structure")).unwrap(),
|
||||
["minecraft/fossil/spine_1.nbt"]
|
||||
);
|
||||
assert!(
|
||||
files_under(&root.join("assets/minecraft/textures/entity"))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(files_under(&root.join("worldgen")), None);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// `find` gives the file and the path from its root for a folder under a
|
||||
/// root that has one, and nothing elsewhere.
|
||||
#[test]
|
||||
@@ -491,7 +546,11 @@ mod tests {
|
||||
write(&root).unwrap();
|
||||
let (_, rel) = find(&root.join("data/minecraft/recipe")).unwrap();
|
||||
assert_eq!(rel, "data/minecraft/recipe");
|
||||
assert!(find(&root.join("worldgen/structure")).is_none());
|
||||
let (_, rel) = find(&root.join("worldgen/structure/minecraft")).unwrap();
|
||||
assert_eq!(rel, "worldgen/structure/minecraft");
|
||||
// The top-level tables beside the structures stay on the disk.
|
||||
assert!(find(&root.join("worldgen")).is_none());
|
||||
assert!(find(&root.join("worldgen/biomes.json")).is_none());
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ rust-version.workspace = true
|
||||
description = "OptiFine-format resource pack files: .properties, the name, range, biome and block matchers, paths and the optifine/ folders; CEM entity models and connected-texture rules, and the Fusion mod's connected-texture format"
|
||||
|
||||
[dependencies]
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
log = "0.4"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode-perl", "unicode-case"] }
|
||||
|
||||
|
||||
@@ -117,6 +117,18 @@ fn walk(
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
out: &mut Vec<String>,
|
||||
) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
if depth == 0
|
||||
&& let Some(files) = horizon_core::prepared_files::files_under(dir)
|
||||
{
|
||||
out.extend(
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|file| endings.iter().any(|end| file.ends_with(end)))
|
||||
.map(|file| format!("{rel}/{file}")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Links are followed (overlay folders are links into the packs and vanilla), so a link back
|
||||
// to a parent would loop: each real folder is read once, and never deeper than MAX_DEPTH.
|
||||
if depth > MAX_DEPTH || !fs::canonicalize(dir).is_ok_and(|real| seen.insert(real)) {
|
||||
|
||||
@@ -87,9 +87,13 @@ pub fn poll_game(shared: Arc<Shared>) {
|
||||
continue;
|
||||
};
|
||||
// A new game numbers its events from 1 again.
|
||||
let mut history = false;
|
||||
if control.as_ref().is_none_or(|(p, _)| *p != pid) {
|
||||
control = engine::Control::connect(&shared.dir).ok().map(|c| (pid, c));
|
||||
since = 0;
|
||||
// What the game recorded before this first fetch (its saves while it started) happened before
|
||||
// the management protocol's clients could hear of it.
|
||||
history = true;
|
||||
}
|
||||
let Some((_, game)) = control.as_mut() else {
|
||||
continue;
|
||||
@@ -105,6 +109,9 @@ pub fn poll_game(shared: Arc<Shared>) {
|
||||
if let Some(fields) = event.as_object_mut() {
|
||||
fields.remove("seq");
|
||||
}
|
||||
if !(history && matches!(kind.as_str(), "saving" | "saved")) {
|
||||
crate::management::game_event(&shared, &kind, &event);
|
||||
}
|
||||
shared.emit(&kind, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,21 @@ pub fn started(shared: &Shared) {
|
||||
notify(shared, "notification/server/started", None);
|
||||
}
|
||||
|
||||
/// The notification a typed event of the game is, if vanilla sends one for it: a player who joined or left
|
||||
/// (`playerJoined`, `playerLeft`, with the player as a `PlayerDto`), and a world save that began or ended
|
||||
/// (`serverSaveStarted`, `serverSaveCompleted`).
|
||||
pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
|
||||
let player = || Some(json!({ "id": event["uuid"], "name": event["name"] }));
|
||||
let (method, params) = match kind {
|
||||
"join" => ("notification/players/joined", player()),
|
||||
"leave" => ("notification/players/left", player()),
|
||||
"saving" => ("notification/server/saving", None),
|
||||
"saved" => ("notification/server/saved", None),
|
||||
_ => return,
|
||||
};
|
||||
notify(shared, method, params);
|
||||
}
|
||||
|
||||
/// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all.
|
||||
pub fn stopping(shared: &Shared) {
|
||||
if !shared.management.stopping_told.swap(true, Ordering::SeqCst) {
|
||||
@@ -765,12 +780,20 @@ mod tests {
|
||||
stream
|
||||
}
|
||||
|
||||
/// The text of the next frame the server sends (unmasked, short enough for a 7-bit length).
|
||||
/// The text of the next frame the server sends (unmasked, shorter than 65536 bytes).
|
||||
fn next_text(stream: &mut TcpStream) -> String {
|
||||
let mut head = [0u8; 2];
|
||||
stream.read_exact(&mut head).unwrap();
|
||||
assert_eq!(head[0], 0x81);
|
||||
let mut payload = vec![0u8; usize::from(head[1])];
|
||||
let length = match head[1] {
|
||||
126 => {
|
||||
let mut b = [0u8; 2];
|
||||
stream.read_exact(&mut b).unwrap();
|
||||
usize::from(u16::from_be_bytes(b))
|
||||
}
|
||||
n => usize::from(n),
|
||||
};
|
||||
let mut payload = vec![0u8; length];
|
||||
stream.read_exact(&mut payload).unwrap();
|
||||
String::from_utf8(payload).unwrap()
|
||||
}
|
||||
@@ -829,6 +852,47 @@ mod tests {
|
||||
assert_eq!(shared.management.sockets.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_games_events_become_the_notifications_vanilla_sends() {
|
||||
let shared = shared();
|
||||
let address = listen_on(shared.clone(), settings());
|
||||
let mut client = upgrade(address);
|
||||
registered(&shared, 1);
|
||||
let id = "0123abcd-0000-4000-8000-00000000000a";
|
||||
let joined = json!({ "type": "join", "name": "Kim0", "uuid": id });
|
||||
game_event(&shared, "join", &joined);
|
||||
game_event(
|
||||
&shared,
|
||||
"chat",
|
||||
&json!({ "name": "Kim0", "uuid": id, "message": "hi" }),
|
||||
);
|
||||
game_event(&shared, "saving", &json!({ "type": "saving" }));
|
||||
game_event(&shared, "saved", &json!({ "type": "saved" }));
|
||||
game_event(&shared, "leave", &json!({ "name": "Kim0", "uuid": id }));
|
||||
// The chat is not a notification; the rest come in order, the player as a `PlayerDto`.
|
||||
let player = format!(r#"[{{"id":"{id}","name":"Kim0"}}]"#);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
format!(
|
||||
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/joined","params":{player}}}"#
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saving"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/saved"}"#
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
format!(
|
||||
r#"{{"jsonrpc":"2.0","method":"minecraft:notification/players/left","params":{player}}}"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_notification_sent_is_one_discover_lists() {
|
||||
let listed = schema::discover();
|
||||
@@ -841,6 +905,10 @@ mod tests {
|
||||
for sent in [
|
||||
"notification/server/started",
|
||||
"notification/server/stopping",
|
||||
"notification/server/saving",
|
||||
"notification/server/saved",
|
||||
"notification/players/joined",
|
||||
"notification/players/left",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&format!("minecraft:{sent}").as_str()),
|
||||
|
||||
@@ -77,6 +77,28 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
|
||||
.ok_or_else(|| invalid_params(format!("Not a boolean: {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 +330,42 @@ 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())
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! name isn't stored again with every session. `extra` holds the later hardware fields
|
||||
//! (cores, clocks, GPU maker and kind, VRAM, drive, screen, power, form) as JSON.
|
||||
//! - `perf`: one row per play session, in columns. A frame or tick summary is an 80-byte blob
|
||||
//! ([`pack`]), not JSON.
|
||||
//! ([`pack`]), not JSON; `slow_frames` (the frames of 250 ms or more by the step they spent
|
||||
//! their time in) is JSON, empty for most sessions.
|
||||
//! - `reports`: crash and bug reports and benchmark uploads, their `data` zstd-compressed.
|
||||
//! - `perf_daily`: the anonymous totals per day, version, OS, GPU vendor and render distance
|
||||
//! that `/v1/aggregate` reads. Rebuilt from `perf` when a day changes (new sessions, a
|
||||
@@ -39,7 +40,7 @@ CREATE TABLE IF NOT EXISTS perf (
|
||||
render_distance INTEGER NOT NULL, simulation_distance INTEGER NOT NULL, max_fps INTEGER NOT NULL,
|
||||
vsync INTEGER NOT NULL, fullscreen INTEGER NOT NULL, graphics TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, frames BLOB NOT NULL, ticks BLOB,
|
||||
peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER
|
||||
peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER, slow_frames TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS perf_install ON perf (install);
|
||||
CREATE INDEX IF NOT EXISTS perf_day ON perf (day);
|
||||
@@ -160,13 +161,16 @@ impl Store {
|
||||
tx.execute(
|
||||
"INSERT INTO perf (install, day, version, system, render_distance, simulation_distance,
|
||||
max_fps, vsync, fullscreen, graphics, seconds, frames, ticks, peak_ram_mb,
|
||||
to_title_ms, to_world_ms) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
|
||||
to_title_ms, to_world_ms, slow_frames) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
params![
|
||||
install, day, item.version, system,
|
||||
p.settings.render_distance, p.settings.simulation_distance, p.settings.max_fps,
|
||||
p.settings.vsync, p.settings.fullscreen, p.settings.graphics,
|
||||
p.seconds, pack(&p.frames), p.ticks.as_ref().map(pack), p.peak_ram_mb,
|
||||
p.to_title_ms, p.to_world_ms,
|
||||
(!p.slow_frames.is_empty())
|
||||
.then(|| serde_json::to_string(&p.slow_frames).ok())
|
||||
.flatten(),
|
||||
],
|
||||
)?;
|
||||
}
|
||||
@@ -366,14 +370,67 @@ impl Store {
|
||||
.collect::<Result<_, _>>()?
|
||||
};
|
||||
let breakdowns = self.breakdowns(from, to)?;
|
||||
let slow_frames = self.slow_frames(from, to, k)?;
|
||||
Ok(serde_json::json!({
|
||||
"from_day": from, "to_day": to, "min_install_days": k,
|
||||
"bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0),
|
||||
"totals": { "sessions": sessions, "hours": (hours * 10.0).round() / 10.0 },
|
||||
"groups": out, "crashes_by_version": crashes, "breakdowns": breakdowns,
|
||||
"slow_frames": slow_frames,
|
||||
}))
|
||||
}
|
||||
|
||||
/// What froze in `from..=to`: per release and step (`span`, `""` when no step was timed),
|
||||
/// the frames of 250 ms or more spent mostly in it, the sessions and installs that had one,
|
||||
/// and the longest frame and step. Only steps seen by at least `k` installs. From the raw
|
||||
/// rows, so only within the retention period.
|
||||
fn slow_frames(&self, from: u32, to: u32, k: u64) -> rusqlite::Result<serde_json::Value> {
|
||||
#[derive(Default)]
|
||||
struct Step {
|
||||
frames: u64,
|
||||
sessions: u64,
|
||||
installs: HashSet<Vec<u8>>,
|
||||
frame_max_ms: f32,
|
||||
span_max_ms: f32,
|
||||
}
|
||||
let mut steps: HashMap<(String, String), Step> = HashMap::new();
|
||||
let mut q = self.db.prepare(
|
||||
"SELECT install, version, slow_frames FROM perf
|
||||
WHERE day BETWEEN ?1 AND ?2 AND slow_frames IS NOT NULL",
|
||||
)?;
|
||||
let mut rows = q.query([from, to])?;
|
||||
while let Some(r) = rows.next()? {
|
||||
let install: Vec<u8> = r.get(0)?;
|
||||
let version: String = r.get(1)?;
|
||||
let list: Vec<horizon_telemetry::payload::SlowFrames> =
|
||||
serde_json::from_str(&r.get::<_, String>(2)?).unwrap_or_default();
|
||||
for entry in list {
|
||||
let step = steps.entry((version.clone(), entry.span)).or_default();
|
||||
step.frames += u64::from(entry.frames);
|
||||
step.sessions += 1;
|
||||
step.installs.insert(install.clone());
|
||||
step.frame_max_ms = step.frame_max_ms.max(entry.frame_max_ms);
|
||||
step.span_max_ms = step.span_max_ms.max(entry.span_max_ms);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<((String, String), Step)> = steps
|
||||
.into_iter()
|
||||
.filter(|(_, s)| s.installs.len() as u64 >= k)
|
||||
.collect();
|
||||
out.sort_by(|a, b| b.1.frames.cmp(&a.1.frames).then(a.0.cmp(&b.0)));
|
||||
let round = |ms: f32| (f64::from(ms) * 10.0).round() / 10.0;
|
||||
Ok(out
|
||||
.into_iter()
|
||||
.map(|((version, span), s)| {
|
||||
serde_json::json!({
|
||||
"version": version, "span": span, "frames": s.frames, "sessions": s.sessions,
|
||||
"installs": s.installs.len(), "frame_max_ms": round(s.frame_max_ms),
|
||||
"span_max_ms": round(s.span_max_ms),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Play sessions in `from..=to` per hardware class (`classes`): for each RAM, VRAM, drive,
|
||||
/// OS and GPU kind class with at least `MIN_INSTALLS` different installs, its installs,
|
||||
/// sessions, hours and mean p50 and p99 frame times. From the raw rows, so only within the
|
||||
@@ -516,12 +573,17 @@ impl Extra {
|
||||
}
|
||||
}
|
||||
|
||||
/// Databases from before `systems.extra`: the table rebuilt with it (ids kept), once.
|
||||
/// Databases from before a column: `perf.slow_frames` added, and `systems` rebuilt with
|
||||
/// `extra` (ids kept), once each.
|
||||
fn migrate(db: &mut Connection) -> rusqlite::Result<()> {
|
||||
let has_extra = db
|
||||
.prepare("SELECT 1 FROM pragma_table_info('systems') WHERE name = 'extra'")?
|
||||
.exists([])?;
|
||||
if has_extra {
|
||||
let has = |db: &Connection, table: &str, column: &str| {
|
||||
db.prepare("SELECT 1 FROM pragma_table_info(?1) WHERE name = ?2")?
|
||||
.exists([table, column])
|
||||
};
|
||||
if !has(db, "perf", "slow_frames")? {
|
||||
db.execute_batch("ALTER TABLE perf ADD COLUMN slow_frames TEXT")?;
|
||||
}
|
||||
if has(db, "systems", "extra")? {
|
||||
return Ok(());
|
||||
}
|
||||
let tx = db.transaction()?;
|
||||
@@ -582,6 +644,42 @@ pub fn inflate(body: &[u8]) -> serde_json::Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A database from before `perf.slow_frames` gains the column and keeps its sessions.
|
||||
#[test]
|
||||
fn perf_from_before_slow_frames_gains_the_column() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"horizon-telemetry-slow-{}.sqlite",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
{
|
||||
let db = Connection::open(&path).unwrap();
|
||||
db.execute_batch(
|
||||
"CREATE TABLE perf (
|
||||
install BLOB NOT NULL, day INTEGER NOT NULL, version TEXT NOT NULL, system INTEGER NOT NULL,
|
||||
render_distance INTEGER NOT NULL, simulation_distance INTEGER NOT NULL, max_fps INTEGER NOT NULL,
|
||||
vsync INTEGER NOT NULL, fullscreen INTEGER NOT NULL, graphics TEXT NOT NULL,
|
||||
seconds INTEGER NOT NULL, frames BLOB NOT NULL, ticks BLOB,
|
||||
peak_ram_mb INTEGER NOT NULL, to_title_ms INTEGER, to_world_ms INTEGER
|
||||
);
|
||||
INSERT INTO perf VALUES (x'00', 1, 'v1', 1, 12, 12, 0, 0, 0, 'fancy', 60, x'00', NULL, 1, NULL, NULL);",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let path_str = path.to_str().unwrap();
|
||||
let store = Store::open(path_str).unwrap();
|
||||
let old: Option<String> = store
|
||||
.db
|
||||
.query_row("SELECT slow_frames FROM perf", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(old, None, "the old session is kept, with none");
|
||||
drop(store);
|
||||
Store::open(path_str).unwrap(); // a second open adds nothing
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let _ = std::fs::remove_file(format!("{path_str}{suffix}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A database from before `systems.extra` opens, keeps its machines' ids and takes new ones.
|
||||
#[test]
|
||||
fn systems_from_before_extra_are_kept() {
|
||||
|
||||
@@ -5,7 +5,9 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use horizon_telemetry::batch::{self, Batch};
|
||||
use horizon_telemetry::payload::{Crash, Payload, Perf, Settings, Source, System, Timings};
|
||||
use horizon_telemetry::payload::{
|
||||
Crash, Payload, Perf, Settings, SlowFrames, Source, System, Timings,
|
||||
};
|
||||
use horizon_telemetry::{Item, outbox};
|
||||
use horizon_telemetry_server::store::{self, Store};
|
||||
use horizon_telemetry_server::{App, Config, router};
|
||||
@@ -196,6 +198,55 @@ async fn totals_break_down_by_hardware_class() {
|
||||
}
|
||||
}
|
||||
|
||||
/// What froze: slow frames by release and step, summed over sessions; a step shows only once
|
||||
/// enough installs had it, and never with an install or a machine.
|
||||
#[tokio::test]
|
||||
async fn slow_frames_show_what_froze_with_enough_installs() {
|
||||
let s = server(Config {
|
||||
min_install_days: 2,
|
||||
..generous()
|
||||
});
|
||||
let slow = |span: &str, frames, frame_max_ms, span_max_ms| {
|
||||
let mut item = perf("NVIDIA GeForce RTX 4070", 12);
|
||||
if let Payload::Perf(p) = &mut item.payload {
|
||||
p.slow_frames = vec![SlowFrames {
|
||||
span: span.into(),
|
||||
frames,
|
||||
frame_max_ms,
|
||||
span_max_ms,
|
||||
}];
|
||||
}
|
||||
item
|
||||
};
|
||||
for (n, frame_max_ms) in [(0, 900.0), (1, 400.0)] {
|
||||
let item = slow("renderer/r_uploads", 3, frame_max_ms, 300.0);
|
||||
assert_eq!(
|
||||
s.post(&install(n), vec![item]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
let lone = slow("tick/tick_mobs", 1, 260.0, 200.0);
|
||||
assert_eq!(
|
||||
s.post(&install(2), vec![lone]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let quiet = perf("NVIDIA GeForce RTX 4070", 12);
|
||||
assert_eq!(
|
||||
s.post(&install(3), vec![quiet]).await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
let totals = s.aggregate().await;
|
||||
assert_eq!(
|
||||
totals["slow_frames"],
|
||||
serde_json::json!([{
|
||||
"version": "v2026.10.15", "span": "renderer/r_uploads", "frames": 6, "sessions": 2,
|
||||
"installs": 2, "frame_max_ms": 900.0, "span_max_ms": 300.0,
|
||||
}]),
|
||||
"one install is too few for tick_mobs: {totals}"
|
||||
);
|
||||
assert!(!totals.to_string().contains(&install(0)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_batches_are_refused() {
|
||||
let s = server(generous());
|
||||
|
||||
@@ -124,6 +124,19 @@ impl Item {
|
||||
Payload::Benchmark(report) if !report.is_object() => {
|
||||
Err("a benchmark report is a JSON object".into())
|
||||
}
|
||||
Payload::Perf(perf)
|
||||
if perf.slow_frames.len() > SlowFrames::MAX_SPANS
|
||||
|| perf
|
||||
.slow_frames
|
||||
.iter()
|
||||
.any(|s| s.span.len() > SlowFrames::MAX_NAME) =>
|
||||
{
|
||||
Err(format!(
|
||||
"at most {} slow-frame spans of {} bytes",
|
||||
SlowFrames::MAX_SPANS,
|
||||
SlowFrames::MAX_NAME
|
||||
))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -217,6 +230,69 @@ pub struct Perf {
|
||||
/// From choosing a world to playing in it, the first time this session.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub to_world_ms: Option<u32>,
|
||||
/// The frames of [`SLOW_FRAME_MS`] or more while playing, by what took longest in each:
|
||||
/// what froze. At most [`SlowFrames::MAX_SPANS`] entries.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub slow_frames: Vec<SlowFrames>,
|
||||
}
|
||||
|
||||
/// A frame this long or longer is a freeze worth explaining ([`Perf::slow_frames`]): the
|
||||
/// edge of [`Summary::buckets`]' last bucket.
|
||||
pub const SLOW_FRAME_MS: f32 = 250.0;
|
||||
|
||||
/// The slow frames whose longest step was one span: a named step of the render thread's frame
|
||||
/// (`spans.rs` in the client: `render.upload`, `tick`, `world.stream`…), the same names in
|
||||
/// every game of a release. `span` is empty when no step was timed in the frame (it waited
|
||||
/// outside them).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SlowFrames {
|
||||
pub span: String,
|
||||
pub frames: u32,
|
||||
/// The longest of these frames and the longest this span took in one of them.
|
||||
pub frame_max_ms: f32,
|
||||
pub span_max_ms: f32,
|
||||
}
|
||||
|
||||
impl SlowFrames {
|
||||
/// Distinct spans a session keeps; the later ones count under [`SlowFrames::OTHER`].
|
||||
pub const MAX_SPANS: usize = 16;
|
||||
pub const MAX_NAME: usize = 64;
|
||||
pub const OTHER: &'static str = "other";
|
||||
|
||||
/// Counts a slow frame of `frame_ms` whose longest span was `span` (`""`: none) for
|
||||
/// `span_ms`, in a session's list.
|
||||
pub fn record(list: &mut Vec<Self>, frame_ms: f32, span: &str, span_ms: f32) {
|
||||
let span = if span.len() > Self::MAX_NAME {
|
||||
Self::OTHER
|
||||
} else {
|
||||
span
|
||||
};
|
||||
let at = match list.iter().position(|s| s.span == span) {
|
||||
Some(at) => at,
|
||||
None if list.len() + 1 < Self::MAX_SPANS => {
|
||||
list.push(Self {
|
||||
span: span.to_owned(),
|
||||
..Self::default()
|
||||
});
|
||||
list.len() - 1
|
||||
}
|
||||
None => match list.iter().position(|s| s.span == Self::OTHER) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
list.push(Self {
|
||||
span: Self::OTHER.to_owned(),
|
||||
..Self::default()
|
||||
});
|
||||
list.len() - 1
|
||||
}
|
||||
},
|
||||
};
|
||||
let entry = &mut list[at];
|
||||
entry.frames = entry.frames.saturating_add(1);
|
||||
entry.frame_max_ms = entry.frame_max_ms.max(frame_ms);
|
||||
entry.span_max_ms = entry.span_max_ms.max(span_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// A crash, scrubbed: the error and its stack, the thread, and the details that say what the
|
||||
@@ -453,4 +529,50 @@ mod tests {
|
||||
assert!(size < 1024, "{size} bytes");
|
||||
assert!(perf.validate().is_ok());
|
||||
}
|
||||
|
||||
/// Slow frames count under their longest span; past the cap, new spans share one entry,
|
||||
/// so a session full of different freezes still fits its item.
|
||||
#[test]
|
||||
fn slow_frames_are_counted_by_span_within_a_cap() {
|
||||
let mut list = Vec::new();
|
||||
SlowFrames::record(&mut list, 300.0, "render.upload", 200.0);
|
||||
SlowFrames::record(&mut list, 900.0, "render.upload", 120.0);
|
||||
SlowFrames::record(&mut list, 260.0, "", 0.0);
|
||||
assert_eq!(
|
||||
list[0],
|
||||
SlowFrames {
|
||||
span: "render.upload".into(),
|
||||
frames: 2,
|
||||
frame_max_ms: 900.0,
|
||||
span_max_ms: 200.0,
|
||||
}
|
||||
);
|
||||
assert_eq!((list[1].span.as_str(), list[1].frames), ("", 1));
|
||||
for i in 0..40 {
|
||||
SlowFrames::record(&mut list, 400.0, &format!("step.{i}"), 300.0);
|
||||
}
|
||||
SlowFrames::record(&mut list, 400.0, &"x".repeat(200), 300.0);
|
||||
assert_eq!(list.len(), SlowFrames::MAX_SPANS);
|
||||
let other = list.iter().find(|s| s.span == SlowFrames::OTHER).unwrap();
|
||||
// 40 spans, 13 of which found room after the first two.
|
||||
assert_eq!(other.frames, 40 - 13 + 1);
|
||||
let perf = Item::new(
|
||||
Source::Game,
|
||||
"v0.4.2",
|
||||
Payload::Perf(Perf {
|
||||
slow_frames: list.clone(),
|
||||
..Perf::default()
|
||||
}),
|
||||
);
|
||||
assert!(perf.validate().is_ok());
|
||||
let too_many = Item::new(
|
||||
Source::Game,
|
||||
"v0.4.2",
|
||||
Payload::Perf(Perf {
|
||||
slow_frames: [list.clone(), list].concat(),
|
||||
..Perf::default()
|
||||
}),
|
||||
);
|
||||
assert!(too_many.validate().is_err());
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,13 @@ impl<'r> BlockCollisionRegistry<'r> {
|
||||
empty_shape: flags & 4 != 0,
|
||||
})
|
||||
}
|
||||
/// Whether `state` can have a collision shape: false for states whose shape is always
|
||||
/// empty, whatever the context. States outside the registry count.
|
||||
pub fn may_collide(&self, state: BlockStateId) -> bool {
|
||||
self.scan_flags
|
||||
.get(state.0 as usize)
|
||||
.is_none_or(|flags| flags & 4 == 0)
|
||||
}
|
||||
/// Whether a scan keeps `state` in the cells around its box: a large
|
||||
/// collision shape or a moving piston. States outside the registry count.
|
||||
pub fn reaches_out(&self, state: BlockStateId) -> bool {
|
||||
|
||||
@@ -368,18 +368,20 @@ impl StructureResources {
|
||||
}
|
||||
let (namespace, path) = id.split_once(':').unwrap();
|
||||
let file = self.dir.join(namespace).join(format!("{path}.nbt"));
|
||||
let loaded = std::fs::read(&file).ok().and_then(|bytes| {
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes.as_slice())
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
|
||||
Some(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.registry,
|
||||
&self.transforms,
|
||||
))
|
||||
});
|
||||
let loaded = horizon_core::prepared_files::read(&file)
|
||||
.ok()
|
||||
.and_then(|bytes| {
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes.as_slice())
|
||||
.read_to_end(&mut raw)
|
||||
.ok()?;
|
||||
let tag = nbt::read_named(&raw, Limits::templates()).ok()?.0.tag;
|
||||
Some(StructureTemplate::from_nbt(
|
||||
&tag,
|
||||
self.registry,
|
||||
&self.transforms,
|
||||
))
|
||||
});
|
||||
// StructureTemplateManager.getOrCreate: missing templates are empty.
|
||||
let mut loaded = loaded.unwrap_or_else(StructureTemplate::empty);
|
||||
loaded.location = id.as_str().into();
|
||||
|
||||
+2
-1
@@ -80,7 +80,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
|
||||
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
|
||||
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications other than
|
||||
`server/started` and `server/stopping`, and the status heartbeat. The methods answer an internal error saying
|
||||
`server/started`, `server/stopping`, `server/saving`, `server/saved`, `players/joined` and `players/left`,
|
||||
and the status heartbeat. The methods answer an internal error saying
|
||||
so; a kick's translatable message is sent as its key.
|
||||
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
|
||||
|
||||
|
||||
+24
-1
@@ -2220,7 +2220,7 @@ anything else a mod may change, a game struct or a list, is a `&mut` field. A ca
|
||||
the return value; a collection hook's list is a field (`event.lines`). One event goes through every mod, so each
|
||||
sees the changes before it. Because no mod can build or match the whole
|
||||
struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that
|
||||
a mod's own tests can't build an event yet.
|
||||
a mod's own tests drive a hook through its dispatch function ([Testing a trait mod](#testing-a-trait-mod)).
|
||||
|
||||
Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both
|
||||
the window's game and the server-thread game. A hook can also fire again inside
|
||||
@@ -2311,6 +2311,29 @@ hooks (trait methods, or injections by `hook`) and `Host` can set `"hooks"` in i
|
||||
A raw `target` like `tick_mobs` is not a hook point and may change in any commit: such a mod pins `horizon_revision` and
|
||||
leaves `hooks` out.
|
||||
|
||||
### Testing a trait mod
|
||||
|
||||
Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements
|
||||
only what its hook calls, and an unexpected call fails loudly. Drive the hook through
|
||||
`horizon_client::mod_api::dispatch_<hook>(&mut host, &mods, args…)`, which builds the event as the game does.
|
||||
If a `Host` method returns a game value a test can't build, a double can return `None` instead, as with
|
||||
`acting_player() -> Option<NativeServerPlayer>`. If the dispatch function itself takes an unconstructible value,
|
||||
such as `dispatch_perform_command`'s `&NativeServerPlayer`, test the logic beneath the hook instead. The level
|
||||
hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart.
|
||||
|
||||
```rust
|
||||
struct Stone;
|
||||
impl Host for Stone {
|
||||
fn block(&self, _pos: [i32; 3]) -> String {
|
||||
"minecraft:stone".to_string()
|
||||
}
|
||||
}
|
||||
let mods = [Box::new(MyMod) as Box<dyn Hooks>];
|
||||
let mut claimed = false;
|
||||
dispatch_use_item_on(&mut Stone, &mods, &stack, [0, 64, 0], [0, 1, 0], &mut claimed);
|
||||
assert!(claimed);
|
||||
```
|
||||
|
||||
### Adding a hook
|
||||
|
||||
A new hook needs its fragment in `tools/native-hook-fragments/` with its `mod_api` entry (for example
|
||||
|
||||
+4
-1
@@ -509,7 +509,10 @@ runtime or new dependency. Server-sent events can replace the polling later if a
|
||||
- notifications: the supervisor pushes them to every connected client as vanilla's `JsonRpcNotificationService`
|
||||
does (a JSON-RPC request without an `id`, its one parameter in an array), through a queue and a thread so a slow
|
||||
client never holds the game's output. Built: `server/started` (the game's `Done`) and `server/stopping`
|
||||
(when the supervisor or the game's own `stop` begins the shutdown, once per run);
|
||||
(when the supervisor or the game's own `stop` begins the shutdown, once per run), `server/saving` and
|
||||
`server/saved` (around every world save of the running game: `save-all`, the autosave, the pause when
|
||||
empty; a save the game makes while it stops is after the last one the supervisor hears of), and
|
||||
`players/joined` and `players/left` (from the game's typed events, within a quarter of a second);
|
||||
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
|
||||
|
||||
## 9. Benchmark plan (bots against vanilla and Paper)
|
||||
|
||||
+4
-2
@@ -29,7 +29,7 @@ Every item has `day` (days since 1970, UTC: no finer time), `source` (`launcher`
|
||||
|
||||
| Kind | Switch | `data` | Largest | A day |
|
||||
|---|---|---|---|---|
|
||||
| `perf` | performance | `system`, `settings` (render and simulation distance, max FPS, vsync, fullscreen, graphics mode), `seconds` played, `frames` and `ticks` summaries, `peak_ram_mb`, `to_title_ms`, `to_world_ms` | 4 KiB | 12 |
|
||||
| `perf` | performance | `system`, `settings` (render and simulation distance, max FPS, vsync, fullscreen, graphics mode), `seconds` played, `frames` and `ticks` summaries, `peak_ram_mb`, `to_title_ms`, `to_world_ms`, `slow_frames` | 4 KiB | 12 |
|
||||
| `crash` | crash reports | `system`, `error`, `stack` (one frame a line), `thread`, the graphics and settings details, all scrubbed. No log, no world, no server | 32 KiB | 5 |
|
||||
| `bug` | bug reports | `system`, the player's `text` (up to 4000 characters), and the `log` only if they add it after reading it | 80 KiB | 5 |
|
||||
| `benchmark` | benchmark uploads | the benchmark report as `docs/benchmark-report.md` defines it, as it is, with its own schema field | 128 KiB | 10 |
|
||||
@@ -55,6 +55,8 @@ No serial number, host name, user name, MAC address or path is read. Settings
|
||||
|
||||
A summary (`frames`, `ticks`) is the count, the 50th, 95th and 99th percentile and the maximum in milliseconds (to the tenth), and 14 bucket counts: under 4 ms, 4–6, 6–8, 8–10, 10–12, 12–14, 14–16.7, 16.7–20, 20–25, 25–33.3, 33.3–50, 50–100, 100–250, and 250 ms and more. Raw frame times never leave: `Timings` records them in tenths of a millisecond (20 KB however long the session) and only its summary is kept. A perf item is under 1 KiB.
|
||||
|
||||
`slow_frames` says what froze: the frames of 250 ms or more while playing, counted by the step of the frame they spent most of their time in. A step is a span of the render thread (`spans.rs`), named in the code and the same in every game of a release: the slowest outer span, then the span inside it that took at least half of it, as a path (`renderer/r_uploads`, `tick/tick_mobs`; empty when no step was timed). Each entry has the step, its frame count, the longest of those frames and the longest the step took. At most 16 entries; later steps count under `other`. With performance statistics on, the render thread times its steps (wall time only, two clock reads a span); with them off it times nothing. Most sessions have no slow frame and send no `slow_frames`.
|
||||
|
||||
Never sent: account names or UUIDs, world names, server or LAN addresses, chat, file paths with the user's name, IP addresses. `scrub::Scrubber` replaces the user folder in paths (`/Users/<user>/…`, `C:\Users\<user>\…`), the user name, e-mail and IP addresses and UUIDs. It also replaces the terms the caller passes (the world's name, the server's address, account names). The server does not store the sender's IP address.
|
||||
|
||||
## Storage and sending
|
||||
@@ -106,7 +108,7 @@ The launcher reads the count when it opens and every minute after, and shows "N
|
||||
|
||||
- `POST /v1/batch`
|
||||
- `DELETE /v1/installs/<install_id>`: deletes everything sent under that ID.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times. Its `slow_frames` list what froze, per release and step: the slow frames, the sessions and installs that had one, and the longest frame and step, for steps at least as many installs had as the groups need.
|
||||
- `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, exact memory or screen. Uploads are never published one by one. Each group's `breakdowns` count its installs per hardware class.
|
||||
- `GET /v1/export/benchmarks` and `GET /v1/export/hardware`: for the maintainers' performance work only, behind a bearer token the public cannot use (404 while the server has none). The raw benchmark reports and the play sessions per machine description, including CPU threads and physical cores, with no minimum group size. Nothing identifying: no install ID, and only what a player already agreed to send (this page's lists).
|
||||
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
|
||||
|
||||
+2
-2
@@ -1758,7 +1758,7 @@
|
||||
{
|
||||
"name": "menu_slot_rules",
|
||||
"version": 1,
|
||||
"target": "horizon_client::menu::native_menu_slot_rules",
|
||||
"target": "horizon_client_lib::menu::native_menu_slot_rules",
|
||||
"side": "server",
|
||||
"cancellable": false,
|
||||
"mod_api": {
|
||||
@@ -1993,7 +1993,7 @@
|
||||
{
|
||||
"name": "living_breathe",
|
||||
"version": 1,
|
||||
"target": "horizon_client::breathing::native_living_breathe",
|
||||
"target": "horizon_client_lib::breathing::native_living_breathe",
|
||||
"neoforge": "LivingBreatheEvent",
|
||||
"side": "server",
|
||||
"cancellable": false,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "item_expire",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_expire",
|
||||
"neoforge": "ItemExpireEvent",
|
||||
"side": "server",
|
||||
"cancellable": false,
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "a dropped item reaching its lifetime (`native_item_expire`).",
|
||||
"doc_summary": "`native_item_expire(entity, stack, extra_life)`, a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`): every mod may set or add to `extra_life`, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Extensions are capped at 32766 ticks. Bundles need \"hooks\": @API:item_expire@."
|
||||
}
|
||||
@@ -129,7 +129,7 @@ AbstractSchoolingFish.addFollowers inline crates/horizon-client/src/mobs/aquatic
|
||||
AbstractSchoolingFish.finalizeSpawn inline crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize the group shares a leader id and variant, and FollowFlockLeader (crates/horizon-client/src/mobs/aquatic.rs) paths a follower to the leader every 10 ticks
|
||||
Pufferfish.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt `getIntOr("PuffState", 0)` at most 2; mob_to_nbt writes it, and a reload test covers it
|
||||
Pufferfish.registerGoals port crates/horizon-client/src/mobs/aquatic.rs::register the puff goal at priority 1
|
||||
Pufferfish.touch missing minor: only the player is stung (inside ai_step); other mobs touching a puffed fish are not hurt or poisoned
|
||||
Pufferfish.touch inline crates/horizon-client/src/mobs/aquatic.rs::ai_step sting_touching_mobs: alive mobs outside #not_scary_for_pufferfish whose box meets the fish's inflated by 0.3, when teams allow damage, take a mobAttack hit for puff state + 1, then, once it lands, POISON for 60 * puff state and the sting sound (game/creatures.rs PufferfishSting)
|
||||
Salmon.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta size entry, small 0 to large 2
|
||||
Salmon.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant the type tag, medium by default
|
||||
Salmon.get inline crates/horizon-client/src/entity_nbt.rs::bucket_components the size component goes onto the bucket
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 130 and column 104.
|
@@ -1,5 +1,5 @@
|
||||
DedicatedServer.readCodeOfConducts port crates/horizon-client/src/lan/code_of_conduct.rs::read with enable-code-of-conduct the folder's files are read at startup (game/dedicated.rs::run_dedicated_inner) and handed to a joining player, whose client shows the prompt (game/code_of_conduct.rs)
|
||||
DedicatedServer.setEnforceWhitelist skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
|
||||
DedicatedServer.setEnforceWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::set_enforce_whitelist management stores enforce-whitelist and kicks unlisted players when enforcement is enabled
|
||||
DedicatedServer.setUsingWhitelist port crates/horizon-client/src/game/dedicated.rs::Game::console_whitelist console whitelist on|off; store_property writes white-list back
|
||||
DedicatedServer.saveAllChunks port crates/horizon-client/src/game.rs::Game::save_game gap: minor: no save notifications to the JSON-RPC management API
|
||||
DedicatedServer.sendLowDiskSpaceWarning port crates/horizon-client/src/lan/hub.rs::Hub::send_low_disk_space_warning chunk_io reports the low disk space and the operators are told (game/dedicated.rs); a client shows the toast (game/remote.rs)
|
||||
@@ -25,6 +25,8 @@ DedicatedServer.setEntityBroadcastRangePercentage skip:server JSON-RPC manageme
|
||||
DedicatedServer.createTextFilterForPlayer skip:service external chat filter service; Horizon has no text filter
|
||||
DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/management.rs::Game::management stores force-gamemode, updates the host and applies the default mode to online players
|
||||
MinecraftServerSettingsServiceImpl.setForceGameMode port crates/horizon-server/src/management_methods.rs::Game::force_game_mode forwards the live setting to the game and persists it in server.toml
|
||||
MinecraftServerSettingsServiceImpl.setUsingWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.whitelist in server.toml
|
||||
MinecraftServerSettingsServiceImpl.setEnforceWhitelist port crates/horizon-server/src/management_methods.rs::Game::allowlist_setting forwards the live setting to the game and persists server.enforce_whitelist in server.toml
|
||||
MinecraftServerSettingsServiceImpl.setAutoSave port crates/horizon-server/src/management_methods.rs::Game::autosave forwards the live save flag to the game; unlike a server property, it resets when the server restarts
|
||||
DedicatedServer.setGameMode port crates/horizon-client/src/game/lan.rs::Game::set_default_game_mode the /defaultgamemode command; gap: minor: the value is not written back to server.properties
|
||||
DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
|
||||
|
||||
|
@@ -130,4 +130,10 @@ ENTITIES = [
|
||||
('ghast', ''), ('piglin', ''), ('hoglin', ''), ('strider', 'Saddle:1b'), ('warden', ''),
|
||||
('trader_llama', ''), ('mooshroom', 'Type:"brown"'), ('panda', 'MainGene:"lazy"'),
|
||||
('copper_golem', ''), ('creaking', ''),
|
||||
# Riders (an entity saves inside its vehicle). The riders
|
||||
# have their own UUIDs, so a dropped or reordered one shows in the vehicle's Passengers diff.
|
||||
('pig', 'Passengers:[{id:"minecraft:zombie",UUID:[I;28673,0,0,1],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
|
||||
('oak_boat', 'Passengers:[{id:"minecraft:pig",UUID:[I;28673,0,0,2],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b},{id:"minecraft:chicken",UUID:[I;28673,0,0,3],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
|
||||
('minecart', 'Passengers:[{id:"minecraft:cow",UUID:[I;28673,0,0,4],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
|
||||
('horse', 'Tame:1b,Passengers:[{id:"minecraft:skeleton",UUID:[I;28673,0,0,5],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Passengers:[{id:"minecraft:bat",UUID:[I;28673,0,0,6],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]}]'),
|
||||
]
|
||||
@@ -17,7 +17,8 @@ sent the same things over TLS, and every answer is compared exactly:
|
||||
`server/system_message` with no one online, the four lists changed (`add`, `remove`, `set`,
|
||||
`clear`; with no profile service, players named resolve to offline players on both), the live server
|
||||
message, difficulty, default and forced game mode read and changed (including saved properties), autosave
|
||||
read and changed (including its reset after restart), and
|
||||
read and changed (including its reset after restart), allowlist use and enforcement read and changed
|
||||
(including saved properties and restart), and
|
||||
`server/stop`;
|
||||
- the TLS version each server agrees on.
|
||||
|
||||
@@ -158,6 +159,24 @@ MESSAGES = [
|
||||
'{"jsonrpc":"2.0","id":71,"method":"minecraft:serversettings/autosave"}',
|
||||
'{"jsonrpc":"2.0","id":72,"method":"minecraft:serversettings/autosave/set","params":["yes"]}',
|
||||
'{"jsonrpc":"2.0","id":73,"method":"minecraft:serversettings/autosave"}',
|
||||
'{"jsonrpc":"2.0","id":75,"method":"minecraft:serversettings/use_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":76,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
|
||||
'{"jsonrpc":"2.0","id":77,"method":"minecraft:serversettings/use_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":78,"method":"minecraft:serversettings/use_allowlist/set","params":[false]}',
|
||||
'{"jsonrpc":"2.0","id":79,"method":"minecraft:serversettings/use_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":80,"method":"minecraft:serversettings/use_allowlist/set","params":["yes"]}',
|
||||
'{"jsonrpc":"2.0","id":81,"method":"minecraft:serversettings/use_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":82,"method":"minecraft:serversettings/use_allowlist/set","params":[true]}',
|
||||
'{"jsonrpc":"2.0","id":83,"method":"minecraft:serversettings/use_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":84,"method":"minecraft:serversettings/enforce_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":85,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
|
||||
'{"jsonrpc":"2.0","id":86,"method":"minecraft:serversettings/enforce_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":87,"method":"minecraft:serversettings/enforce_allowlist/set","params":[false]}',
|
||||
'{"jsonrpc":"2.0","id":88,"method":"minecraft:serversettings/enforce_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":89,"method":"minecraft:serversettings/enforce_allowlist/set","params":["yes"]}',
|
||||
'{"jsonrpc":"2.0","id":90,"method":"minecraft:serversettings/enforce_allowlist"}',
|
||||
'{"jsonrpc":"2.0","id":91,"method":"minecraft:serversettings/enforce_allowlist/set","params":[true]}',
|
||||
'{"jsonrpc":"2.0","id":92,"method":"minecraft:serversettings/enforce_allowlist"}',
|
||||
]
|
||||
|
||||
|
||||
@@ -383,12 +402,22 @@ def main():
|
||||
sides[side]['saved_force_game_mode'] = next(
|
||||
(line.partition('=')[2] for line in properties.splitlines()
|
||||
if line.startswith('force-gamemode=')), None)
|
||||
sides[side]['saved_use_allowlist'] = next(
|
||||
(line.partition('=')[2] for line in properties.splitlines()
|
||||
if line.startswith('white-list=')), None)
|
||||
sides[side]['saved_enforce_allowlist'] = next(
|
||||
(line.partition('=')[2] for line in properties.splitlines()
|
||||
if line.startswith('enforce-whitelist=')), None)
|
||||
if side == 'horizon':
|
||||
world = (folder / 'server.toml').read_text().partition('[world]')[2].split('\n[', 1)[0]
|
||||
sides[side]['saved_config_difficulty'] = 'difficulty = "peaceful"' in world
|
||||
sides[side]['saved_config_game_mode'] = 'game_mode = "spectator"' in world
|
||||
sides[side]['saved_config_force_game_mode'] = 'force-gamemode = true' in (
|
||||
folder / 'server.toml').read_text()
|
||||
sides[side]['saved_config_use_allowlist'] = 'whitelist = true' in (
|
||||
folder / 'server.toml').read_text()
|
||||
sides[side]['saved_config_enforce_allowlist'] = 'enforce_whitelist = true' in (
|
||||
folder / 'server.toml').read_text()
|
||||
finally:
|
||||
server.stop()
|
||||
restarted = Server(command, folder, build / f'{side}-restart.log')
|
||||
@@ -408,6 +437,18 @@ def main():
|
||||
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
|
||||
break
|
||||
sides[side]['restarted_autosave'] = reply
|
||||
ws.send('{"jsonrpc":"2.0","id":93,"method":"minecraft:serversettings/use_allowlist"}')
|
||||
while True:
|
||||
reply = normal(ws.receive(timeout=60))
|
||||
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
|
||||
break
|
||||
sides[side]['restarted_use_allowlist'] = reply
|
||||
ws.send('{"jsonrpc":"2.0","id":94,"method":"minecraft:serversettings/enforce_allowlist"}')
|
||||
while True:
|
||||
reply = normal(ws.receive(timeout=60))
|
||||
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
|
||||
break
|
||||
sides[side]['restarted_enforce_allowlist'] = reply
|
||||
finally:
|
||||
ws.sock.close()
|
||||
finally:
|
||||
@@ -432,12 +473,26 @@ def main():
|
||||
failures.append(f"saved forced game mode: vanilla {vanilla['saved_force_game_mode']!r}, Horizon {horizon['saved_force_game_mode']!r}")
|
||||
if not horizon['saved_config_force_game_mode']:
|
||||
failures.append('Horizon did not save forced game mode in server.toml')
|
||||
if vanilla['saved_use_allowlist'] != horizon['saved_use_allowlist'] or vanilla['saved_use_allowlist'] != 'true':
|
||||
failures.append(f"saved allowlist use: vanilla {vanilla['saved_use_allowlist']!r}, Horizon {horizon['saved_use_allowlist']!r}")
|
||||
if vanilla['saved_enforce_allowlist'] != horizon['saved_enforce_allowlist'] or vanilla['saved_enforce_allowlist'] != 'true':
|
||||
failures.append(f"saved allowlist enforcement: vanilla {vanilla['saved_enforce_allowlist']!r}, Horizon {horizon['saved_enforce_allowlist']!r}")
|
||||
if not horizon['saved_config_use_allowlist']:
|
||||
failures.append('Horizon did not save allowlist use in server.toml')
|
||||
if not horizon['saved_config_enforce_allowlist']:
|
||||
failures.append('Horizon did not save allowlist enforcement in server.toml')
|
||||
if vanilla['restarted_force_game_mode'] != horizon['restarted_force_game_mode'] or \
|
||||
vanilla['restarted_force_game_mode'] != {'jsonrpc': '2.0', 'id': 64, 'result': True}:
|
||||
failures.append(f"restarted forced game mode: vanilla {vanilla['restarted_force_game_mode']!r}, Horizon {horizon['restarted_force_game_mode']!r}")
|
||||
if vanilla['restarted_autosave'] != horizon['restarted_autosave'] or \
|
||||
vanilla['restarted_autosave'] != {'jsonrpc': '2.0', 'id': 74, 'result': True}:
|
||||
failures.append(f"restarted autosave: vanilla {vanilla['restarted_autosave']!r}, Horizon {horizon['restarted_autosave']!r}")
|
||||
if vanilla['restarted_use_allowlist'] != horizon['restarted_use_allowlist'] or \
|
||||
vanilla['restarted_use_allowlist'] != {'jsonrpc': '2.0', 'id': 93, 'result': True}:
|
||||
failures.append(f"restarted allowlist use: vanilla {vanilla['restarted_use_allowlist']!r}, Horizon {horizon['restarted_use_allowlist']!r}")
|
||||
if vanilla['restarted_enforce_allowlist'] != horizon['restarted_enforce_allowlist'] or \
|
||||
vanilla['restarted_enforce_allowlist'] != {'jsonrpc': '2.0', 'id': 94, 'result': True}:
|
||||
failures.append(f"restarted allowlist enforcement: vanilla {vanilla['restarted_enforce_allowlist']!r}, Horizon {horizon['restarted_enforce_allowlist']!r}")
|
||||
(build / 'report.json').write_text(json.dumps(sides, indent=1))
|
||||
if failures:
|
||||
sys.exit(f'{NAME}: {len(failures)} differences (logs in {build}):\n' + '\n'.join(failures))
|
||||
|
||||
@@ -85,7 +85,7 @@ IGNORED = {
|
||||
r'^chunk\.block_entities\..*\.(ticks_since_song_started|Age|BrewTime|warning_level|ticks_in_hive|last_vibration_frequency|listener\..*|RecipesUsed\..*|'
|
||||
r'server_data\.state_updating_resumes_at)$':
|
||||
'block entity timers advance',
|
||||
r'^entity\.(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|'
|
||||
r'^entity\.(Passengers\[\]\.)*(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|'
|
||||
r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground|Time)$': 'entity simulation advances',
|
||||
r'^level\.dat\.Data\.(Time|LastPlayed)$': 'clock and wall time of the save',
|
||||
r'^level\.dat\.Data\.(ServerBrands.*|singleplayer_uuid)$': 'a client world names its brand and its player',
|
||||
|
||||
@@ -280,8 +280,15 @@ fn host_method(
|
||||
args.push(pat.ident.to_string());
|
||||
}
|
||||
let docs = doc_comments(&method.attrs, name, true)?;
|
||||
let unused = args
|
||||
.iter()
|
||||
.map(|arg| format!("let _ = {arg};"))
|
||||
.collect::<String>();
|
||||
Ok((
|
||||
format!("{docs}{};\n", sig.to_token_stream()),
|
||||
format!(
|
||||
"{docs}{} {{ {unused} unimplemented!(\"Host::{name}\") }}\n",
|
||||
sig.to_token_stream()
|
||||
),
|
||||
format!(
|
||||
"{} {{ {callee}{original}({}) }}\n",
|
||||
sig.to_token_stream(),
|
||||
@@ -964,7 +971,7 @@ fn generate(root: &Path, check: bool) -> Result<(), String> {
|
||||
imports.push_str("use std::sync::Arc;\n");
|
||||
}
|
||||
let host = format!(
|
||||
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\n{imports}/// Services supplied by the game to trait mods.\npub trait Host {{\n{host_traits}}}\n"
|
||||
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse super::*;\n{imports}/// Services supplied by the game to trait mods.\n/// Each method's default panics with its name, so a test double implements only what its test calls; the game implements every one.\npub trait Host {{\n{host_traits}}}\n"
|
||||
);
|
||||
let implementation = format!(
|
||||
"// @generated by cargo run -p xtask -- mod-api; do not edit.\nuse horizon_client::mod_api::*;\n{imports}impl Host for super::Game<'_> {{\n{host_impl}}}\n"
|
||||
|
||||
Reference in new issue
Block a user