Merge main into #7246

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 14:55:49 +01:00
commit bf7fbcb7eb
91 files changed
+3734 -260

No files matched your search

+3 -2
View File
@@ -96,8 +96,9 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
@@ -688,4 +688,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
+192 -80
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -150,9 +197,121 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -161,80 +320,39 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
emit_flat_cell(&mut self.vertices, x0, z0, uv);
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
dz as i32,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -243,15 +361,9 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
emit_faces(&mut self.vertices, mask, x0, z0, uv);
}
self.key = Some(key);
(true, uniform)
}
+43 -6
View File
@@ -115,6 +115,10 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
/// enough for a world map's 1024 blocks at a block a pixel.
pub const MOD_IMAGE_SPRITE: &str = "horizon/modimage";
pub const MOD_IMAGE_SIZE: u32 = 1024;
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
/// Vanilla registers a texture per skin. Here the slots are shared
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
@@ -681,13 +685,21 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
Self::build_over_with_image(root, defaults, false)
}
/// The atlas above, with a slot for a native mod's own image
/// (`horizon/modimage`) only when `mod_image` says a mod is loaded that
/// draws one: the slot costs 4 MiB of pixels, so a game without native
/// mods holds none of them.
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
/// minimap, a native mod's own image, downloaded skins, world and pack
/// icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
}
@@ -698,6 +710,15 @@ impl HudAtlas {
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
));
// A mod's own image (native `mod_image`), only when a mod is loaded.
if mod_image {
images.push((
MOD_IMAGE_SPRITE.into(),
MOD_IMAGE_SIZE,
MOD_IMAGE_SIZE,
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
));
}
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
@@ -740,7 +761,7 @@ impl HudAtlas {
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, root, &["font"], false)?;
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
@@ -886,7 +907,13 @@ impl HudAtlas {
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
fn pack(
root: &Path,
defaults: &Path,
folders: &[&str],
full: bool,
mod_image: bool,
) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let default_textures =
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
@@ -1117,7 +1144,7 @@ impl HudAtlas {
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images, crate::trims::slot_size(root));
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
let missing = (0..256)
@@ -2632,6 +2659,16 @@ impl HudRenderer {
mod tests {
use super::*;
/// The mod image slot is paid only by a game with a native mod loaded.
#[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
let root = Path::new("/nonexistent/horizon-test-assets");
let plain = HudAtlas::build(root).unwrap();
assert!(!plain.sprites.contains_key(MOD_IMAGE_SPRITE));
let with_mod = HudAtlas::build_over_with_image(root, root, true).unwrap();
assert!(with_mod.sprites.contains_key(MOD_IMAGE_SPRITE));
}
#[test]
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
@@ -1987,6 +1987,14 @@ impl Streaming {
if positions.is_empty() {
return;
}
let positions = if positions.len() > INLINE_RELIGHT_MAX {
self.relight_on_workers(world, positions)
} else {
positions
};
if positions.is_empty() {
return;
}
let mut changed = LightChanges::default();
crate::light::relight(
&mut world.chunks,
@@ -2005,6 +2013,25 @@ impl Streaming {
}
}
}
/// Hands a big batch of edits (a `/fill` arriving block by block) to the
/// workers: the columns they touch and their neighbours get full light
/// jobs, as `World::edit_runs` does, instead of relighting every changed
/// cell in the frame (clearing a 64x64x16 volume in the air relit the
/// sky below it: 43 ms of one frame). The sections wait for those jobs
/// before they are meshed again. Returns the edits left to light in the
/// frame: those in columns that can't be lit whole yet (the edge of the
/// loaded area).
fn relight_on_workers(
&self,
world: &mut World,
positions: Vec<(i32, i32, i32)>,
) -> Vec<(i32, i32, i32)> {
let (columns, inline) = whole_relight_columns(positions, |x, z| {
self.lit.contains(&(x, z)) && self.light_region_ready(world, x, z)
});
world.dirty_light.extend(columns);
inline
}
/// A light job computed from blocks older than some edits: redo those
/// edits on top of its result.
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
@@ -2693,6 +2720,36 @@ fn same_values(
x == y
}
/// The most changed blocks one frame relights itself (`Streaming::relight_edits`): a 16^3
/// section's worth. Past it, the columns are lit again whole on the workers.
const INLINE_RELIGHT_MAX: usize = 4096;
/// Splits edits at `positions` into the columns to light again whole and the
/// edits to relight in place. An edited column is lit whole with its
/// neighbours (light reaches 15 blocks, never past the next column) when all
/// nine are `ready` to be lit whole; otherwise its edits stay in place.
fn whole_relight_columns(
positions: Vec<(i32, i32, i32)>,
ready: impl Fn(i32, i32) -> bool,
) -> (FxHashSet<(i32, i32)>, Vec<(i32, i32, i32)>) {
let around =
|(x, z): (i32, i32)| (-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)));
let touched: FxHashSet<(i32, i32)> = positions
.iter()
.map(|&(x, _, z)| (x >> 4, z >> 4))
.collect();
let whole: FxHashSet<(i32, i32)> = touched
.into_iter()
.filter(|&column| around(column).all(|(x, z)| ready(x, z)))
.collect();
let columns = whole.iter().flat_map(|&column| around(column)).collect();
let inline = positions
.into_iter()
.filter(|&(x, _, z)| !whole.contains(&(x >> 4, z >> 4)))
.collect();
(columns, inline)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2840,6 +2897,31 @@ mod tests {
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
}
/// Edits in two neighbouring columns light those and the ten around
/// them again; a column next to one that can't be lit whole keeps its
/// edits in the frame.
#[test]
fn whole_relights_cover_the_edited_columns_and_their_neighbours() {
// Blocks in columns (0, 0) and (1, 0).
let positions = vec![(3, 70, 4), (15, 64, 15), (16, 80, 0), (31, 64, 9)];
let (columns, inline) = whole_relight_columns(positions.clone(), |_, _| true);
let mut columns: Vec<_> = columns.into_iter().collect();
columns.sort();
let expected: Vec<(i32, i32)> = (-1..=2)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
assert_eq!(columns, expected);
assert!(inline.is_empty());
// (2, 1) is next to (1, 0) only: (0, 0) is still lit whole.
let (columns, inline) = whole_relight_columns(positions, |x, z| (x, z) != (2, 1));
assert_eq!(inline, vec![(16, 80, 0), (31, 64, 9)]);
assert_eq!(columns.len(), 9);
assert!(columns.contains(&(-1, -1)) && columns.contains(&(1, 1)));
// Negative coordinates floor to their column.
let (west, _) = whole_relight_columns(vec![(-1, 64, -17)], |_, _| true);
assert!(west.contains(&(-2, -3)) && west.contains(&(0, -1)) && west.len() == 9);
}
#[test]
fn queued_jobs_follow_the_centre() {
let forced = HashSet::new();
+23
View File
@@ -1425,6 +1425,29 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
/// the second half of #7186.
#[test]
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
let w = Flat;
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
assert!(
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
"the crafting copy's bare name never matched the criterion"
);
assert!(
id_matches(
&criterion["recipes"],
"recipe",
&crate::crafting::qualified("folder_recipe"),
&w
),
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
+12
View File
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
augment(i, ingredients, stacks, &mut owner, &mut seen)
})
}
/// A resource id with its default namespace filled in — the whole id the
/// book, the network copy and the advancements' JSON know a recipe by,
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
/// on purpose (vanilla's own ids); this puts the namespace back.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let sources = Self::sources(root, overlay)?;
+23 -4
View File
@@ -2011,7 +2011,10 @@ impl UiResources {
horizon_client::gfx::panorama::Faces::load(root, defaults)
})
});
let atlas = startup::time("ui.atlas", || HudAtlas::build_over(root, defaults));
let mod_image = !horizon_client::mod_api::mods().is_empty();
let atlas = startup::time("ui.atlas", || {
HudAtlas::build_over_with_image(root, defaults, mod_image)
});
let panorama = panorama
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
@@ -4102,6 +4105,13 @@ impl Game<'_> {
if extra_life > 0 {
item.age = 6000 - extra_life.min(32766);
alive = true;
} else if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_item_despawn(
item.id,
&item.stack.item,
item.stack.count as i32,
[item.position.x, item.position.y, item.position.z],
);
}
}
if !alive || removed[i] {
@@ -8773,9 +8783,15 @@ impl Game<'_> {
/// (special ones never do).
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
for (recipe, ingredients) in crafts {
self.trigger_recipe_crafted(recipe, ingredients);
// The whole id: the advancements' `recipe_crafted` criteria name
// `minecraft:x`, and the crafting copy names the recipe bare.
let recipe = horizon_client::crafting::qualified(recipe);
self.trigger_recipe_crafted(&recipe, ingredients);
}
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
let recipes: Vec<String> = crafts
.iter()
.map(|(r, _)| horizon_client::crafting::qualified(r))
.collect();
let index = &world_stations!(self).recipe_index;
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
@@ -13355,9 +13371,12 @@ impl Game<'_> {
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let at = self.eye_position();
let old_level = self.experience.level;
let gave = self.experience.give(gained as i32);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = self.experience.give(gained as i32)
if let Some(level) = gave
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
@@ -614,6 +614,11 @@ impl Game<'_> {
let criterion = &a.criteria[ci].name;
if self.adv.progress.grant(&a.id, criterion, &date) {
self.advancement_progress(&a.id, criterion, true);
if let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_advancement_criterion_grant(player, &name, &a.id, criterion);
}
}
self.progress_changed(ai);
if !was && self.adv.progress.done(a) {
@@ -740,7 +745,9 @@ impl Game<'_> {
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
self.experience.give(a.rewards.experience);
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
+19 -4
View File
@@ -1067,7 +1067,7 @@ impl Game<'_> {
);
}
}
self.armor_stand_items(a, model, base, &posed, packed, out);
self.armor_stand_items(a, model, base, &posed, packed, skin.as_deref_mut(), out);
// HumanoidArmorLayer: head, chest, legs, feet. One pose map for every layer
// (`reset` keeps its capacity).
let mut layer_poses = Poses::default();
@@ -1380,7 +1380,9 @@ impl Game<'_> {
impl Game<'_> {
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
/// items in the third-person contexts, and a non-armour head item on the
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
/// head (`translateToHead`: down a quarter, turned, 0.625 scale). With
/// `skin`, items that can are posed on the GPU with the stand.
#[allow(clippy::too_many_arguments)]
fn armor_stand_items(
&self,
a: &ArmorStand,
@@ -1388,8 +1390,21 @@ impl Game<'_> {
base: glam::Mat4,
poses: &Poses,
light: u32,
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
out: &mut Vec<Vertex>,
) {
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
pose: glam::Mat4,
out: &mut Vec<Vertex>| {
let on_gpu = skin
.as_deref_mut()
.is_some_and(|skin| self.skin_item(state, pose, light, lights, skin));
if !on_gpu {
self.submit_item_world(state, pose, light, out);
}
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::mobs::render::part_matrix;
let owner = super::item_draw::owner_at(
@@ -1429,7 +1444,7 @@ impl Game<'_> {
} else {
self.resolve_item(stack, context, Some(&owner), seed)
};
self.submit_item_world(&state, pose, light, out);
submit(&state, pose, out);
}
// `CustomHeadLayer`: a worn skull draws the skull model.
if let Some(stack) = &a.equipment[5]
@@ -1490,7 +1505,7 @@ impl Game<'_> {
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
let state =
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
self.submit_item_world(&state, pose, light, out);
submit(&state, pose, out);
}
}
}
+18 -2
View File
@@ -1121,6 +1121,16 @@ impl Game<'_> {
);
self.world.modified.insert(key);
self.boats.push(boat);
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_vehicle_create(
self.boats.last().unwrap().entity_type().as_str(),
[
self.boats.last().unwrap().position.x,
self.boats.last().unwrap().position.y,
self.boats.last().unwrap().position.z,
],
);
}
if !self.creative {
self.consume_held();
}
@@ -1345,18 +1355,24 @@ impl Game<'_> {
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
let mut cancel = false;
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
if cancel {
return false;
}
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
b.hurt_time = 10;
b.damage += damage * 10.0;
let destroyed = creative || b.damage > 40.0;
if destroyed {
let at = [b.position.x, b.position.y, b.position.z];
self.native_vehicle_destroy("boat", at);
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
self.boat_removed(&b);
// `AbstractChestBoat.remove` spills the chest through
+42 -3
View File
@@ -159,6 +159,17 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
i32::try_from(id).ok()
}
impl Game<'_> {
/// `native_player_level_change` for a level that was `from` before an experience change.
pub(super) fn native_player_level_changed(&mut self, from: i32) {
let to = self.experience.level;
if from != to
&& !horizon_client::mod_api::mods().is_empty()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_level_change(player, &name, from, to);
}
}
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.client_mod_command(command) {
@@ -2175,7 +2186,8 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob_spawned(&short, pos);
self.spawn_mob_spawned(&short, pos)
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
@@ -2250,7 +2262,11 @@ impl Game<'_> {
let empty = horizon_world::nbt::Tag::Compound(Default::default());
cart.spawner = Some(Box::new(Spawner::load(&empty)));
}
let id = cart.id;
self.carts.push(cart);
if !self.native_joins_flush(false, id) {
return Err(err("commands.summon.failed.uuid", &[]));
}
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
}
// SummonCommand.createEntity: any other type from an empty compound.
@@ -2731,6 +2747,7 @@ impl Game<'_> {
Ok(count)
}
/// Player.giveExperiencePoints including negative amounts.
/// The caller reports the level change to the mods.
fn give_points(&mut self, amount: i32) {
let e = &mut self.experience;
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
@@ -2916,6 +2933,7 @@ impl Game<'_> {
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
let old_level = self.experience.level;
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
@@ -2934,8 +2952,30 @@ impl Game<'_> {
}
self.experience.progress = amount as f32 / needed as f32;
}
self.native_player_level_changed(old_level);
true
}
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
/// tells the management protocol's clients).
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
self.ui.rules.set(rule.name, value);
self.sync_rules();
if self.options.dedicated.is_some() {
let (value_type, json) = match value {
Value::Bool(b) => ("boolean", serde_json::json!(b)),
Value::Int(i) => ("integer", serde_json::json!(i)),
};
super::dedicated::events::record_server(
"gamerule",
serde_json::json!({
"key": format!("minecraft:{}", rule.name),
"valueType": value_type,
"value": json,
}),
);
}
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
return Ok(0);
@@ -2958,8 +2998,7 @@ impl Game<'_> {
if current == value {
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
}
self.ui.rules.set(rule.name, value);
self.sync_rules();
self.set_game_rule(rule, value);
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
Ok(match value {
Value::Bool(b) => i32::from(b),
@@ -276,7 +276,14 @@ impl Game<'_> {
}
}
pub(in crate::game) fn tick_worldborder(&mut self) {
let remaining = self.border.remaining_ticks();
self.border.tick();
if remaining > 0
&& self.border.remaining_ticks() == 0
&& (self.serving.is_some() || self.options.dedicated.is_some())
{
self.native_world_border_bounds_change_finish();
}
self.player_border();
self.border_damage();
}
+25 -14
View File
@@ -431,6 +431,9 @@ impl Game<'_> {
let id = mob.id;
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
if !self.mobs.iter().any(|m| m.id == id) {
return None;
}
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
if !persistent && !scripted {
@@ -2309,21 +2312,29 @@ impl Game<'_> {
thrown.thrower = Some(thrower);
}
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
item,
count,
damage: 0,
extra: None,
},
MobEvent::DropItem {
mob,
item,
count,
at,
// The ItemEntity constructor's motion, from the entity's
// own random (not the level's), so spawnAtLocation drops
// (a fox's berries, a panda's held food, a goat's horn)
// land apart.
dispenser::new_item_motion(),
10,
),
} => {
self.native_entity_drop_item(mob, &item, count as i32, at);
self.spawn_item(
ItemStack {
item,
count,
damage: 0,
extra: None,
},
at,
// The ItemEntity constructor's motion, from the entity's
// own random (not the level's), so spawnAtLocation drops
// (a fox's berries, a panda's held food, a goat's horn)
// land apart.
dispenser::new_item_motion(),
10,
);
}
MobEvent::DropGoatHorn {
mob,
screaming,
@@ -1831,6 +1831,16 @@ impl Game<'_> {
}
}
/// `DedicatedServer.setMaxPlayers`: the live login limit, and `max-players` in
/// server.properties.
fn set_max_players(&mut self, state: &mut ServerState, max_players: usize) {
state.max_players = max_players;
if let Some(lan) = &self.lan {
lan.hub.lan.shared.set_max_players(max_players);
}
self.store_property("max-players", max_players.to_string());
}
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
/// server.properties.
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
@@ -7,6 +7,7 @@ use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
gamerules::{self, Value as GameRuleValue},
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -29,6 +30,15 @@ struct Op {
bypass: Option<bool>,
}
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
let (kind, value) = match value {
GameRuleValue::Bool(b) => ("boolean", json!(b)),
GameRuleValue::Int(i) => ("integer", json!(i)),
};
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
}
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
fn shown(user: &NameAndId) -> String {
format!(
@@ -49,6 +59,48 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
// `GameRulesService.get`: every rule the server has, with its type and current value.
"gamerules" => {
let rules = &self.ui.rules;
let all: Vec<Value> = rules
.available_rules()
.map(|r| typed_rule(r, rules.get(r.name).unwrap_or(r.default)))
.collect();
return json!({ "value": all });
}
// `GameRulesService.update`: the rule is set only when its value changes, and logged.
"gamerules/update" => {
let update = &body["value"];
let Some(rule) = update["key"]
.as_str()
.and_then(gamerules::rule)
.filter(|r| self.ui.rules.get(r.name).is_some())
else {
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
};
let wanted = &update["value"];
let value = match (rule.default, wanted) {
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
(GameRuleValue::Int(_), n) => n
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.filter(|n| (rule.min..=rule.max).contains(n))
.map(GameRuleValue::Int),
_ => None,
};
let Some(value) = value else {
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
if current != value {
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
}
return json!({ "value": typed_rule(rule, value) });
}
"serversettings/autosave" => {
return json!({ "value": auto_save(state) });
}
@@ -120,6 +172,23 @@ impl Game<'_> {
self.set_force_game_mode(force);
return json!({ "value": state.force_game_mode });
}
"serversettings/max_players" => {
return json!({ "value": state.max_players });
}
"serversettings/max_players/set" => {
let Some(max_players) = body["value"]
.as_i64()
.and_then(|value| usize::try_from(value).ok())
else {
return json!({ "error": "max players must be a non-negative integer" });
};
rpc(format!(
"Update max players from {} to {}",
state.max_players, max_players
));
self.set_max_players(state, max_players);
return json!({ "value": state.max_players });
}
"serversettings/use_allowlist" => {
return json!({
"value": lists.lock().unwrap().use_whitelist
@@ -491,3 +560,22 @@ fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
reason,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_game_rule_is_written_as_vanillas_typed_codec_does() {
let pvp = gamerules::rule("pvp").unwrap();
assert_eq!(
typed_rule(pvp, GameRuleValue::Bool(false)),
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
);
let tick = gamerules::rule("minecraft:random_tick_speed").unwrap();
assert_eq!(
typed_rule(tick, GameRuleValue::Int(3)),
json!({ "key": "minecraft:random_tick_speed", "type": "integer", "value": 3 })
);
}
}
@@ -1216,6 +1216,13 @@ impl Game<'_> {
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.boats.push(boat);
if self.serving.is_some() || self.options.dedicated.is_some() {
let boat = self.boats.last().unwrap();
self.native_vehicle_create(
boat.entity_type().as_str(),
[boat.position.x, boat.position.y, boat.position.z],
);
}
(shrink(stack), CLICK)
}
/// `MinecartDispenseItemBehavior`.
@@ -1255,6 +1262,13 @@ impl Game<'_> {
);
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.carts.push(cart);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
cart.kind.id(),
[cart.position.x, cart.position.y, cart.position.z],
);
}
(shrink(stack), CLICK)
}
@@ -856,6 +856,9 @@ impl Game<'_> {
damage_type: &str,
resists: Option<([i32; 3], f32)>,
) -> Result<()> {
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_explosion_prime(center, radius);
}
if let Some(p) = &self.player {
let d = [
p.position.x - center[0],
+212 -74
View File
@@ -36,6 +36,7 @@ pub(super) struct Hook {
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
tick_count: i32,
life: i32,
on_ground: bool,
nibble: i32,
@@ -64,6 +65,67 @@ fn bounds(p: Vec3) -> Aabb {
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
)
}
fn segment_entry(start: Vec3, end: Vec3, bounds: Aabb) -> Option<f64> {
let min = bounds.min_position();
let max = bounds.max_position();
let start = [start.x, start.y, start.z];
let min = [min.x, min.y, min.z];
let max = [max.x, max.y, max.z];
if (0..3).all(|axis| start[axis] >= min[axis] && start[axis] < max[axis]) {
return None;
}
let delta = [end.x - start[0], end.y - start[1], end.z - start[2]];
let mut entry: f64 = 0.0;
let mut exit: f64 = 1.0;
for axis in 0..3 {
if delta[axis] == 0.0 {
if start[axis] < min[axis] || start[axis] > max[axis] {
return None;
}
continue;
}
let a = (min[axis] - start[axis]) / delta[axis];
let b = (max[axis] - start[axis]) / delta[axis];
entry = entry.max(a.min(b));
exit = exit.min(a.max(b));
if entry > exit {
return None;
}
}
(entry <= 1.0 && exit >= 0.0).then_some(entry)
}
/// `ProjectileUtil.computeMargin`: the fishing hook's target-box margin grows
/// to 0.3 blocks over its first eight ticks.
fn entity_hit_margin(tick_count: i32) -> f64 {
f64::from((tick_count - 2).clamp(0, 6)) / 20.0
}
fn first_entity_hit(
start: Vec3,
end: Vec3,
margin: f64,
targets: impl IntoIterator<Item = (u32, Aabb)>,
) -> Option<u32> {
let mut hit = None;
let mut hit_at = f64::INFINITY;
for (id, target_bounds) in targets {
if let Some(at) = segment_entry(
start,
end,
target_bounds.inflate(Vec3::new(margin, margin, margin)),
) && at < hit_at
{
hit = Some(id);
hit_at = at;
}
}
hit
}
fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
item.velocity = item.velocity.add_vector(delta);
// The change is made outside ItemEntity::tick, so its velocity comparison
// cannot request the immediate motion update for tracking clients.
item.needs_sync = true;
}
impl Hook {
pub(super) fn bounds(&self) -> Aabb {
bounds(self.position)
@@ -170,6 +232,7 @@ impl Game<'_> {
previous: start,
velocity: v,
state: State::Flying,
tick_count: 0,
life: 0,
on_ground: false,
nibble: 0,
@@ -221,7 +284,7 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = pulled(m.velocity);
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
pull_item(item, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1));
hooked_item = true;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
t.velocity = pulled(t.velocity);
@@ -566,6 +629,7 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
h.tick_count += 1;
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
@@ -600,58 +664,67 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
let (a, z) = (b.min_position(), b.max_position());
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
})
.ok()
.flatten()
};
let hit = self
.mobs
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
// ArmorStand.isPickable is false for a marker.
self.armor_stands
.iter()
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
.map(|a| a.id)
})
.or_else(|| {
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
// boat the owner rides (approximating `leftOwner`, as the
// players above skip their owner).
let own = self.own_vehicle();
self.boats
.iter()
.find(|b| Some(b.id) != own && touches(b.bounds()))
.map(|b| b.id)
})
.or_else(|| {
self.dropped
.iter()
.find(|i| touches(i.bounds()))
.map(|i| i.id)
})
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && touches(t.bounds()))
.map(|t| t.id)
});
let entity_end = block_hit.map_or(to, |hit| hit.location);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
let hit = first_entity_hit(
h.position,
entity_end,
entity_hit_margin(h.tick_count),
self.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (m.id, m.bounds()))
.chain(
self.creatures
.hook_targets
.iter()
.filter(|t| t.conn != owner)
.map(|t| (t.id, t.bounds)),
)
.chain(
self.armor_stands
.iter()
.filter(|a| a.health > 0.0 && !a.marker)
.map(|a| (a.id, a.bounds())),
)
.chain(
self.boats
.iter()
.filter(|b| Some(b.id) != own_vehicle)
.map(|b| (b.id, b.bounds())),
)
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
.chain(
self.tnts
.iter()
.filter(|t| t.id != 0)
.map(|t| (t.id, t.bounds())),
),
);
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
self.projectile_event(
@@ -664,26 +737,7 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: the block the path hits before the move, then
// `onHitBlock`'s rebound.
let to = h.position.add_vector(h.velocity);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
if let Some(hit) = block_hit {
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
@@ -1124,6 +1178,7 @@ impl Hook {
previous: at,
velocity: Vec3::ZERO,
state: State::Flying,
tick_count: 0,
life: 0,
on_ground: false,
nibble: 0,
@@ -1419,8 +1474,9 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::rebound;
use horizon_core::geometry::Vec3;
use super::{entity_hit_margin, first_entity_hit, pull_item, rebound};
use horizon_client::{entities::ItemEntity, items::ItemStack};
use horizon_core::geometry::{Aabb, Vec3};
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
#[test]
@@ -1429,4 +1485,86 @@ mod tests {
let hit = Vec3::new(0.0, 0.0, -0.5);
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
}
#[test]
fn reeling_an_item_requests_an_immediate_motion_update() {
let mut item = ItemEntity {
no_gravity: false,
health: 5,
fire_ticks: 0,
id: 1,
stack: ItemStack::new("minecraft:stone", 1),
thrower: None,
target: None,
position: Vec3::ZERO,
previous: Vec3::ZERO,
velocity: Vec3::new(0.2, 0.0, -0.1),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
};
pull_item(&mut item, Vec3::new(0.5, 0.1, -0.2));
assert!((item.velocity.x - 0.7).abs() < 1.0e-12);
assert!((item.velocity.y - 0.1).abs() < 1.0e-12);
assert!((item.velocity.z + 0.3).abs() < 1.0e-12);
assert!(item.needs_sync);
}
#[test]
fn a_flying_hook_hits_a_small_target_between_ticks() {
let target = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.8, 0.0, 0.0), target).is_some());
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.4, 0.0, 0.0), target).is_none());
}
#[test]
fn a_flying_hook_uses_vanillas_growing_entity_hit_margin() {
let start = Vec3::ZERO;
let end = Vec3::new(1.0, 0.0, 0.0);
let target = Aabb::new(Vec3::new(0.5, 0.31, -0.1), Vec3::new(0.7, 0.5, 0.1));
assert_eq!(entity_hit_margin(2), 0.0);
assert_eq!(entity_hit_margin(8), 0.3);
assert_eq!(
first_entity_hit(start, end, entity_hit_margin(8), [(1, target)]),
None
);
let within_margin = Aabb::new(Vec3::new(0.5, 0.29, -0.1), Vec3::new(0.7, 0.5, 0.1));
assert_eq!(
first_entity_hit(start, end, entity_hit_margin(8), [(1, within_margin)]),
Some(1)
);
}
#[test]
fn a_flying_hook_does_not_hit_a_target_it_starts_inside() {
let target = Aabb::new(Vec3::new(-0.1, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
assert_eq!(
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, [(1, target)]),
None
);
}
#[test]
fn a_flying_hook_uses_the_first_target_along_its_path() {
let start = Vec3::ZERO;
let end = Vec3::new(1.0, 0.0, 0.0);
let near = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
let far = Aabb::new(Vec3::new(0.8, -0.1, -0.1), Vec3::new(0.95, 0.15, 0.1));
assert_eq!(
first_entity_hit(start, end, 0.0, [(2, far), (1, near)]),
Some(1)
);
}
}
+12 -7
View File
@@ -623,6 +623,9 @@ impl Game<'_> {
items.push((stack, f.id, m.as_mat4(), item_light));
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
for (stack, id, m, light) in items {
let entity_start = out.len();
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
@@ -632,13 +635,15 @@ impl Game<'_> {
Some(&owner),
id as i32,
);
self.submit_item_world(
&state,
m,
horizon_client::block_entity_render::vertex_light(light),
out,
);
self.cull_entity_vertices(out, entity_start);
let light = horizon_client::block_entity_render::vertex_light(light);
// Posed on the GPU when every layer can be.
let skinned = skinning
.then(|| self.submit_item_skinned(&state, &[m], light, lights))
.flatten();
if skinned.is_none() {
self.submit_item_world(&state, m, light, out);
}
self.cull_skinned(out, entity_start, skinned);
}
for (id, m, light) in maps {
let entity_start = out.len();
+20 -7
View File
@@ -478,20 +478,33 @@ impl Game<'_> {
frame: &mut frame,
bounds: None,
};
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
for &pose in poses {
let renderer = &self.pack.item_renderer;
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
if !self.skin_item(state, pose, light, lights, &mut skin) {
// Decided before any draw, the same for every pose.
return None;
}
}
Some((mark, skin.bounds))
}
/// One pose of [`Self::submit_item_skinned`] into an entity's open
/// `skin` (its bounds grow by the item's): false, with nothing drawn,
/// when the item cannot take the GPU path.
pub(super) fn skin_item(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
lights: (glam::Vec3, glam::Vec3),
skin: &mut horizon_client::mobs::skin::Skin,
) -> bool {
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
let renderer = &self.pack.item_renderer;
state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, skin)
}
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
/// nothing drawn, when the state cannot take it.
+62
View File
@@ -1939,6 +1939,47 @@ impl Game<'_> {
HostEvent::Flight { conn, flying } => {
self.lan_flight_toggled(&mut host, conn, flying);
}
HostEvent::Sneak { conn, sneaking } => {
if horizon_client::mod_api::mods().is_empty() {
continue;
}
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.as_guest(&mut host, conn, |game| {
game.native_player_toggle_sneak(uuid, &name, sneaking);
});
}
}
HostEvent::Kicked {
conn,
player,
name,
reason,
} => {
if horizon_client::mod_api::mods().is_empty() {
continue;
}
// The same poll can have removed the guest (their `Left`): the mods hear the
// kick anyway, without the guest's state swapped in.
if host.hub.guest(conn).is_some() {
self.as_guest(&mut host, conn, |game| {
game.native_player_kicked(player, &name, &reason);
});
} else {
self.native_player_kicked(player, &name, &reason);
}
}
HostEvent::Input { conn, flags } => {
if horizon_client::mod_api::mods().is_empty() {
continue;
}
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.as_guest(&mut host, conn, |game| {
game.native_player_input(uuid, &name, flags);
});
}
}
HostEvent::MainHandChanged(conn, left) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
@@ -1951,6 +1992,25 @@ impl Game<'_> {
self.native_player_locale_change(uuid, &name, &locale);
}
}
HostEvent::PlayerLoaded(conn) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_client_loaded_world(uuid, &name);
}
}
HostEvent::RecipeBookSettings {
conn,
book,
open,
filtering,
} => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_settings_change(
uuid, &name, book, open, filtering,
);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
@@ -2002,6 +2062,7 @@ impl Game<'_> {
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
let pass = self.level_pass;
let mut events = if pass.first {
host.hub.poll(&mut ctx)
@@ -2184,6 +2245,7 @@ impl Game<'_> {
// (`tick_server`): the network is read once, before the first.
// Player packets may already have been drained before
// scheduled block ticks (`tick_lan_packets`).
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
let pass = self.level_pass;
let mut events = if pass.first && !pass.packets_drained {
host.hub.poll(&mut ctx)
@@ -769,6 +769,34 @@ impl Game<'_> {
p.guest = Some(conn);
}
}
let guest = &host.hub.guests[i];
let listening = !horizon_client::mod_api::mods().is_empty();
for projectile in projectiles..self.creatures.projectiles.len() {
if !listening {
break;
}
let item = match &self.creatures.projectiles[projectile].kind {
horizon_client::projectiles::ProjectileKind::Snowball => {
Some("minecraft:snowball".to_owned())
}
horizon_client::projectiles::ProjectileKind::Egg => {
Some("minecraft:egg".to_owned())
}
horizon_client::projectiles::ProjectileKind::EnderPearl => {
Some("minecraft:ender_pearl".to_owned())
}
horizon_client::projectiles::ProjectileKind::Trident => {
Some("minecraft:trident".to_owned())
}
horizon_client::projectiles::ProjectileKind::Potion(stack, _) => {
Some(stack.item.to_string())
}
_ => None,
};
if let Some(item) = item {
self.native_player_launch_projectile(guest.uuid, &guest.name, &item);
}
}
// The guest's swings are theirs: the host's own count stays the host's.
play.swings += self.swings_started.wrapping_sub(swings);
self.swings_started = swings;
@@ -1836,6 +1864,8 @@ impl Game<'_> {
}),
Some(conn),
);
let guest = &host.hub.guests[gi];
self.native_block_damage_abort(guest.uuid, &guest.name, a.pos);
}
4 | 5 => {
if !spectator {
@@ -1845,6 +1875,13 @@ impl Game<'_> {
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
// hand of the item in use acting.
6 => {
let listening = !horizon_client::mod_api::mods().is_empty();
let (uuid, name) = if listening {
let guest = &host.hub.guests[gi];
(guest.uuid, guest.name.clone())
} else {
(0, String::new())
};
self.as_guest(host, conn, |g| {
let Some(hand) = g.used_hand() else {
return;
@@ -1853,7 +1890,18 @@ impl Game<'_> {
if let Some((slot, ticks)) = g.using.take()
&& let Some(stack) = g.stack_in(slot).cloned()
{
if listening
&& &*stack.item == "minecraft:bow"
&& let Some(arrow) = g
.projectile_slot("minecraft:bow")
.and_then(|slot| g.stack_in(slot).map(|s| s.item.clone()))
{
g.native_player_ready_arrow(uuid, &name, &arrow);
}
g.release_used_item(&stack, ticks, false);
if listening {
g.native_player_stop_using_item(uuid, &name);
}
}
});
});
@@ -1957,6 +2005,7 @@ impl Game<'_> {
g.reset_attack_strength();
});
self.native_player_attack_cooldown_reset(player, &name);
self.native_player_arm_swing(player, &name, "mainhand");
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
@@ -2291,6 +2340,8 @@ impl Game<'_> {
if !spectator && let Some(slot) = slot {
host.hub
.select_bundle_item(conn, slot, p.index, &mut self.world);
let guest = &host.hub.guests[gi];
self.native_player_bundle_item_select(guest.uuid, &guest.name, p.slot, p.index);
}
}
S::ContainerClick(p) => {
@@ -4023,6 +4074,7 @@ impl Game<'_> {
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let riding = g.vehicle.is_some();
let mut soul = None;
let mut jumps = 0;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
@@ -4083,6 +4135,7 @@ impl Game<'_> {
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
// the server's copy jumps with the player, so the velocity
// it sends them keeps the jump.
jumps += 1;
let _ = w.jump_from_ground();
}
let landed = (|| -> horizon_client::Result<()> {
@@ -4197,6 +4250,16 @@ impl Game<'_> {
let Some((events, marked)) = events else {
return;
};
if jumps > 0
&& !horizon_client::mod_api::mods().is_empty()
&& let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
{
self.as_guest(host, conn, |game| {
for _ in 0..jumps {
game.native_player_jump(uuid, &name);
}
});
}
if let Some((at, velocity)) = soul {
super::lan_particles::send_soul_particle(host, at, velocity);
}
@@ -126,7 +126,13 @@ impl Game<'_> {
if let Some(maps) = maps {
self.register_made_maps(maps);
}
let dropped = std::mem::take(&mut menu.dropped);
// A taken anvil result waits for AnvilCraftEvent.Pre: what the click
// dropped waits with it (lan_station_op throws it or refuses the take).
let dropped = if menu.anvil_craft.is_some() {
Vec::new()
} else {
std::mem::take(&mut menu.dropped)
};
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
let crafted = std::mem::take(&mut menu.crafted);
+54 -11
View File
@@ -13,7 +13,7 @@ use super::{
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
menu::{Kind, Menu},
menu::{AnvilCraft, Kind, Menu},
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -80,6 +80,8 @@ impl Game<'_> {
return true;
};
let mut menu = Box::new(Menu::new(kind));
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& kind == Kind::Enchanting
{
@@ -136,14 +138,42 @@ impl Game<'_> {
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
(slots, gs.menu.data[0])
});
self.guest_menu_op(host, gi, &mut gs.menu, op);
let recomputed = before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
let mut undo = None;
let hook = gs.menu.anvil_hook;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
undo = hook.then(|| menu.anvil_undo());
op(menu, env);
});
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
let mut anvil_craft = gs.menu.anvil_craft.take();
let mut cancelled = false;
if let Some(craft) = &anvil_craft {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut cancel = false;
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
if cancel && let Some(undo) = undo {
// The take is refused: the result, inputs, cost, inventory
// and cursor are as before the click (the resync below
// tells the client).
anvil_craft = None;
cancelled = true;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
} else {
gs.menu.anvil_take(&self.menu_env());
let dropped = std::mem::take(&mut gs.menu.dropped);
self.drop_guest_items(host, gi, dropped);
}
}
// A cancelled take runs no createResult: the inputs are untouched.
let recomputed = !cancelled
&& before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
});
if recomputed && gs.kind == StationKind::Anvil {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
@@ -172,7 +202,7 @@ impl Game<'_> {
if let Some(amount) = gs.menu.ground.take() {
self.grindstone_taken(gs.pos, amount);
}
self.lan_station_combined(host, conn, &mut gs);
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
if let Some(button) = gs.menu.enchant_button.take() {
self.lan_enchant(host, gi, &mut gs, button);
}
@@ -305,8 +335,15 @@ impl Game<'_> {
/// The rest of a taken anvil or smithing result's `onTake`: the guest
/// pays the levels, the anvil wears (level event 1030 or 1029), the
/// smithing table sounds (1044).
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
/// smithing table sounds (1044). An anvil's `craft` is then reported to
/// the mods (`AnvilCraftEvent.Post`).
fn lan_station_combined(
&mut self,
host: &mut LanHost,
conn: u32,
gs: &mut GuestStation,
craft: Option<&AnvilCraft>,
) {
let change = std::mem::take(&mut gs.menu.level_change);
if change != 0 {
self.as_guest(host, conn, |g| g.give_levels(change));
@@ -320,6 +357,12 @@ impl Game<'_> {
let creative = gs.menu.creative;
// A broken anvil closes the menu on the next stillValid check.
self.anvil_used(gs.pos, creative);
if let Some(craft) = craft
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
{
let (uuid, name) = (guest.uuid, guest.name.clone());
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
}
}
}
+1
View File
@@ -146,6 +146,7 @@ impl Game<'_> {
}) else {
return;
};
self.native_entity_unleash(id, &kind);
if used
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
@@ -740,6 +740,7 @@ impl Game<'_> {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
@@ -866,6 +867,11 @@ impl Game<'_> {
let front = offset(pos, facing);
let face = facing.opposite();
if let Some(target) = c.view(front, false) {
self.native_hopper_inventory_search(
[pos.0, pos.1, pos.2],
Some([front.0, front.1, front.2]),
None,
);
return self.eject_into(pos, hopper, &target, face);
}
let Some(id) = self.entity_container_at([
@@ -875,15 +881,62 @@ impl Game<'_> {
]) else {
return false;
};
self.native_hopper_inventory_search([pos.0, pos.1, pos.2], None, Some(id));
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
.unwrap_or(false)
}
/// Where a container is in the world for the mods: its block, or the block a chest
/// boat or minecart is in (its view sits under a placeholder key).
fn container_world_pos(&self, view: &View) -> [i32; 3] {
let (x, y, z) = view.parts[0].0;
if x == i32::MAX - 15 && y == super::minecarts::CART_KEY_Y {
let id = z as u32;
let at = self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| c.position)
.or_else(|| self.boats.iter().find(|b| b.id == id).map(|b| b.position));
if let Some(p) = at {
return [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
}
}
[x, y, z]
}
/// `InventoryMoveItemEvent` for the item in `slot` of `holder` going from `source` to
/// `destination`: whether a mod canceled the move. Nothing is looked up with no mod loaded.
fn hopper_move_canceled(
&self,
holder: &View,
slot: usize,
source: &View,
destination: &View,
) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return false;
}
let containers = self.containers();
let Some(stack) = containers.get(holder, slot) else {
return false;
};
let mut cancel = false;
self.native_inventory_move_item(
self.container_world_pos(source),
self.container_world_pos(destination),
&stack.item,
&mut cancel,
);
cancel
}
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
if self.containers().is_full(target, face) {
return false;
}
for slot in 0..hopper.len() {
if self.hopper_move_canceled(hopper, slot, hopper, target) {
continue;
}
let Some(one) = self.take_one(hopper, slot) else {
continue;
};
@@ -946,9 +999,27 @@ impl Game<'_> {
let c = self.containers();
let Some(source) = c.view(above, false) else {
let id = self.entity_container_at(at)?;
self.native_hopper_inventory_search(
[
hopper.parts[0].0.0,
hopper.parts[0].0.1,
hopper.parts[0].0.2,
],
None,
Some(id),
);
return self
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
};
self.native_hopper_inventory_search(
[
hopper.parts[0].0.0,
hopper.parts[0].0.1,
hopper.parts[0].0.2,
],
Some([above.0, above.1, above.2]),
None,
);
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
@@ -959,6 +1030,18 @@ impl Game<'_> {
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
[above.0, above.1, above.2],
self.container_world_pos(hopper),
&bone_meal.item,
&mut cancel,
);
if cancel {
return Some(false);
}
}
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
@@ -984,6 +1067,9 @@ impl Game<'_> {
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
}
let Some(one) = self.take_one(source, slot) else {
continue;
};
+22 -1
View File
@@ -1875,6 +1875,13 @@ impl Game<'_> {
.modified
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
cart.kind.id(),
[cart.position.x, cart.position.y, cart.position.z],
);
}
if !self.creative {
self.consume_held();
}
@@ -2134,7 +2141,13 @@ impl Game<'_> {
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
/// once for creative players); TNT minecarts prime instead when moving
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
pub(super) fn hurt_cart(&mut self, i: usize, mut damage: f32, creative: bool, fire: bool) {
let kind = self.carts[i].kind.id();
let mut cancel = false;
self.native_vehicle_damage(kind, &mut damage, &mut cancel);
if cancel {
return;
}
let destroyed = {
let c = &mut self.carts[i];
c.hurt_dir = -c.hurt_dir;
@@ -2154,6 +2167,14 @@ impl Game<'_> {
if !tnt_prime {
self.release_vehicle_container(id);
}
if !tnt_prime {
let at = [
self.carts[i].position.x,
self.carts[i].position.y,
self.carts[i].position.z,
];
self.native_vehicle_destroy(self.carts[i].kind.id(), at);
}
let mut c = self.carts.remove(i);
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
@@ -483,6 +483,9 @@ impl Host for super::Game<'_> {
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
self.native_mod_image(width, height, rgba)
}
fn instance_dir(&self) -> PathBuf {
self.native_instance_dir()
}
+31 -4
View File
@@ -273,10 +273,20 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
/// The native mods the launcher built this game with, `id version, id version`, for the crash
/// report (`built`: its [`BUILD_INFO`]). None for a release or Nightly, which have no mods.
fn native_mods_detail(built: &str) -> Option<String> {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
let list = info["mods"]
.as_array()?
.iter()
.map(|m| {
let text = |key: &str| m[key].as_str().unwrap_or("?").to_owned();
format!("{} {}", text("id"), text("version"))
})
.collect::<Vec<_>>()
.join(", ");
(!list.is_empty()).then_some(list)
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
@@ -366,8 +376,13 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
let built_mods = native_mods_detail(&built);
let built_with_mods = built_mods.is_some();
if let Some(list) = built_mods {
horizon_client::crash::set_detail("Native Mods", list);
}
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
built_with_mods
|| self
.settings
.mods
@@ -428,6 +443,18 @@ mod tests {
zip.finish().unwrap();
}
#[test]
fn the_crash_report_lists_the_builds_native_mods() {
let built = r#"{"mods":[{"id":"machine_sample","version":"0.1.0","sha256":null},{"id":"pipes","version":"2.0"}]}"#;
assert_eq!(
native_mods_detail(built).as_deref(),
Some("machine_sample 0.1.0, pipes 2.0")
);
// A release has no file; a build without mods has an empty list.
assert_eq!(native_mods_detail(""), None);
assert_eq!(native_mods_detail(r#"{"mods":[]}"#), None);
}
#[test]
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
@@ -3162,6 +3162,11 @@ impl Game<'_> {
) {
mod_api::dispatch_player_toggle_flight(self, mod_api::mods(), player, name, flying, cancel);
}
/// A guest's client changed sneaking in its player input packet, after the hub applied the new state.
/// The guest's state is swapped in. Server-side. @API:player_toggle_sneak@.
pub(super) fn native_player_toggle_sneak(&mut self, player: u128, name: &str, sneaking: bool) {
mod_api::dispatch_player_toggle_sneak(self, mod_api::mods(), player, name, sneaking);
}
/// A player finished editing a sign: `ServerGamePacketListenerImpl.updateSignText`
/// has taken their update packet and every check has passed (they were
/// editing that sign, within reach, the sign is not waxed, the chat filter
@@ -4543,6 +4548,27 @@ impl Game<'_> {
) {
mod_api::dispatch_prepare_anvil(self, mod_api::mods(), player, name, pos, cost, result);
}
/// A player took an anvil's result at `pos` (`AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` holds the inputs and the result as they were before. Set `cancel` to refuse the take: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. @API:native_anvil_craft_pre@.
pub(super) fn native_anvil_craft_pre(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
cancel: &mut bool,
) {
mod_api::dispatch_anvil_craft_pre(self, mod_api::mods(), player, name, pos, craft, cancel);
}
/// A player took an anvil's result at `pos` and the levels, inputs and anvil wear are done (`AnvilCraftEvent.Post`); a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not called when `native_anvil_craft_pre` cancelled. @API:native_anvil_craft_post@.
pub(super) fn native_anvil_craft_post(
&mut self,
player: u128,
name: &str,
pos: [i32; 3],
craft: &horizon_client::menu::AnvilCraft,
) {
mod_api::dispatch_anvil_craft_post(self, mod_api::mods(), player, name, pos, craft);
}
/// A grindstone result was prepared for a server player at `pos`. Change it before it is sent. @API:prepare_grindstone@.
pub(super) fn native_prepare_grindstone(
&mut self,
@@ -4864,6 +4890,223 @@ impl Game<'_> {
mod_api::dispatch_entity_enter_block(self, mod_api::mods(), entity, kind, pos, block);
}
/// A server player finished loading the world around them after joining,
/// respawning or changing dimension. The guest's state is not swapped in,
/// so helpers that look them up through `self.lan` may find nothing while
/// a guest packet is handled. Server-side. @API:player_client_loaded_world@.
pub(super) fn native_player_client_loaded_world(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_client_loaded_world(self, mod_api::mods(), player, name);
}
/// A server player released the use-item key before the item finished
/// being used. The guest's state is not swapped in, so helpers that look
/// them up through `self.lan` may find nothing during packet handling.
/// @API:player_stop_using_item@.
pub(super) fn native_player_stop_using_item(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_stop_using_item(self, mod_api::mods(), player, name);
}
/// A server player's recipe book open or filter setting changed. `book`
/// is the book type's protocol id. The guest's state is not swapped in.
/// @API:player_recipe_book_settings_change@.
pub(super) fn native_player_recipe_book_settings_change(
&mut self,
player: u128,
name: &str,
book: i32,
open: bool,
filtering: bool,
) {
mod_api::dispatch_player_recipe_book_settings_change(
self,
mod_api::mods(),
player,
name,
book,
open,
filtering,
);
}
/// A server player earned one criterion of an advancement, after the
/// progress was recorded. @API:player_advancement_criterion_grant@.
pub(super) fn native_player_advancement_criterion_grant(
&mut self,
player: u128,
name: &str,
advancement: &str,
criterion: &str,
) {
mod_api::dispatch_player_advancement_criterion_grant(
self,
mod_api::mods(),
player,
name,
advancement,
criterion,
);
}
/// A server player released a drawn bow with `arrow` selected. The guest's
/// state is not swapped in during packet handling. @API:player_ready_arrow@.
pub(super) fn native_player_ready_arrow(&mut self, player: u128, name: &str, arrow: &str) {
mod_api::dispatch_player_ready_arrow(self, mod_api::mods(), player, name, arrow);
}
/// A server player selected a bundle item in `slot` at `index`. The guest's
/// state is not swapped in during packet handling. @API:player_bundle_item_select@.
pub(super) fn native_player_bundle_item_select(
&mut self,
player: u128,
name: &str,
slot: i32,
index: i32,
) {
mod_api::dispatch_player_bundle_item_select(
self,
mod_api::mods(),
player,
name,
slot,
index,
);
}
/// A server player stopped digging `pos` before the block broke, after
/// the server cleared the dig and sent the block back to the client.
/// The player's state is not swapped in. @API:block_damage_abort@.
pub(super) fn native_block_damage_abort(&mut self, player: u128, name: &str, pos: [i32; 3]) {
mod_api::dispatch_block_damage_abort(self, mod_api::mods(), player, name, pos);
}
/// A hopper at `pos` began an item transfer attempt. This runs after its
/// cooldown and enabled checks, before pushing or pulling any items.
/// Server-side, including the single-player server. @API:prepare_hopper_transfer@.
pub(super) fn native_prepare_hopper_transfer(&self, pos: [i32; 3]) {
mod_api::dispatch_prepare_hopper_transfer(self, mod_api::mods(), pos);
}
/// A hopper is about to move one item between containers, a composter's
/// bone meal included. Set `cancel` to leave it in its source slot. A chest
/// boat or minecart is reported at the block it is in. @API:inventory_move_item@.
pub(super) fn native_inventory_move_item(
&self,
source: [i32; 3],
destination: [i32; 3],
item: &str,
cancel: &mut bool,
) {
mod_api::dispatch_inventory_move_item(
self,
mod_api::mods(),
source,
destination,
item,
cancel,
);
}
/// A hopper selected a container while searching for items to push or
/// pull. Exactly one of `container` and `entity` identifies the target.
/// @API:hopper_inventory_search@.
pub(super) fn native_hopper_inventory_search(
&self,
hopper: [i32; 3],
container: Option<[i32; 3]>,
entity: Option<u32>,
) {
mod_api::dispatch_hopper_inventory_search(self, mod_api::mods(), hopper, container, entity);
}
/// A vault at `pos` is about to eject a loot item. @API:vault_loot@.
pub(super) fn native_vault_loot(&self, pos: [i32; 3], item: &str) {
mod_api::dispatch_vault_loot(self, mod_api::mods(), pos, item);
}
/// A timed world-border size change finished on the server, after its
/// final size was applied. Server-side, including the single-player
/// server. @API:world_border_bounds_change_finish@.
pub(super) fn native_world_border_bounds_change_finish(&mut self) {
mod_api::dispatch_world_border_bounds_change_finish(self, mod_api::mods());
}
/// A server player launched a projectile item, after it was created.
/// `item` is the projectile's held item. @API:player_launch_projectile@.
pub(super) fn native_player_launch_projectile(&mut self, player: u128, name: &str, item: &str) {
mod_api::dispatch_player_launch_projectile(self, mod_api::mods(), player, name, item);
}
/// A server explosion is about to begin at `center`, with `radius` (Bukkit's
/// `ExplosionPrimeEvent`). This is listen-only: the explosion proceeds as
/// usual. Server-side, including the single-player server. @API:explosion_prime@.
pub(super) fn native_explosion_prime(&mut self, center: [f64; 3], radius: f32) {
mod_api::dispatch_explosion_prime(self, mod_api::mods(), center, radius);
}
/// A dropped item entity reached its 6000-tick lifetime and is about to be
/// removed (Bukkit's `ItemDespawnEvent`). @API:item_despawn@.
pub(super) fn native_item_despawn(
&mut self,
entity: u32,
item: &str,
count: i32,
pos: [f64; 3],
) {
mod_api::dispatch_item_despawn(self, mod_api::mods(), entity, item, count, pos);
}
/// A mob is about to drop `count` of `item` at `pos` (Bukkit's
/// `EntityDropItemEvent`). @API:entity_drop_item@.
pub(super) fn native_entity_drop_item(
&mut self,
entity: u32,
item: &str,
count: i32,
pos: [f64; 3],
) {
mod_api::dispatch_entity_drop_item(self, mod_api::mods(), entity, item, count, pos);
}
/// A leashed mob's leash was removed, before any lead drop is handled
/// (Bukkit's `EntityUnleashEvent`). @API:entity_unleash@.
pub(super) fn native_entity_unleash(&mut self, entity: u32, kind: &str) {
mod_api::dispatch_entity_unleash(self, mod_api::mods(), entity, kind);
}
/// A vehicle is about to take damage from a player's attack. `kind` is
/// `boat` or the minecart type; mods may change `damage` or set `cancel`
/// to leave its damage and wobble unchanged. The attack itself still
/// completes. Server-side. @API:native_vehicle_damage@.
pub(super) fn native_vehicle_damage(
&mut self,
kind: &str,
damage: &mut f32,
cancel: &mut bool,
) {
mod_api::dispatch_vehicle_damage(self, mod_api::mods(), kind, damage, cancel);
}
/// A boat or minecart was created at `pos`, after it joined the level.
/// `kind` is the vehicle type. Server-side. @API:native_vehicle_create@.
pub(super) fn native_vehicle_create(&mut self, kind: &str, pos: [f64; 3]) {
mod_api::dispatch_vehicle_create(self, mod_api::mods(), kind, pos);
}
/// A boat or minecart is about to be destroyed at `pos`. TNT minecarts
/// that prime instead of breaking are not heard. Server-side.
/// @API:native_vehicle_destroy@.
pub(super) fn native_vehicle_destroy(&mut self, kind: &str, pos: [f64; 3]) {
mod_api::dispatch_vehicle_destroy(self, mod_api::mods(), kind, pos);
}
/// A guest sent the server's current `PlayerInput` flags, after they were applied to the hub. The guest's state is swapped in. Server-side. @API:player_input@.
pub(super) fn native_player_input(&mut self, player: u128, name: &str, flags: i32) {
mod_api::dispatch_player_input(self, mod_api::mods(), player, name, flags);
}
/// A guest's arm swing was handled by the server. `hand` is `mainhand`. Server-side. @API:player_arm_swing@.
pub(super) fn native_player_arm_swing(&mut self, player: u128, name: &str, hand: &str) {
mod_api::dispatch_player_arm_swing(self, mod_api::mods(), player, name, hand);
}
/// A guest's accepted movement made the server jump them from the ground. Server-side. @API:player_jump@.
pub(super) fn native_player_jump(&mut self, player: u128, name: &str) {
mod_api::dispatch_player_jump(self, mod_api::mods(), player, name);
}
/// A guest was disconnected by a server kick, after the hub recorded the rendered `reason`. The guest's state is
/// swapped in unless they were already removed in the same poll. @API:player_kicked@.
pub(super) fn native_player_kicked(&mut self, player: u128, name: &str, reason: &str) {
mod_api::dispatch_player_kicked(self, mod_api::mods(), player, name, reason);
}
/// A server player's experience level changed, after the new level was applied. @API:player_level_change@.
pub(super) fn native_player_level_change(
&mut self,
player: u128,
name: &str,
from: i32,
to: i32,
) {
mod_api::dispatch_player_level_change(self, mod_api::mods(), player, name, from, to);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
@@ -7103,6 +7346,36 @@ impl Game<'_> {
let [a, r, g, b] = argb.to_be_bytes();
batch.fill(&self.gui.atlas, x, y, w, h, rgba(r, g, b, a));
}
/// A mod's own RGBA image, uploaded into the HUD atlas slot
/// [`MOD_IMAGE_SPRITE`](crate::hud::MOD_IMAGE_SPRITE) and drawn with
/// `native_sprite`: its own source rectangle crops and stretches it. `rgba`
/// is `width * height` pixels of four bytes, each side at most
/// [`MOD_IMAGE_SIZE`](crate::hud::MOD_IMAGE_SIZE); false when it is not that
/// size. The upload costs a texture patch of the image, so send one when the
/// image changed, not every frame. API @API:mod_image@.
pub(super) fn native_mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
let Some(rect) = self
.gui
.atlas
.sprites
.get(crate::hud::MOD_IMAGE_SPRITE)
.copied()
else {
return false;
};
if !mod_image_fits(width, height, rgba.len(), rect.w, rect.h) {
return false;
}
let (w, h) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
self.map_patches.push(horizon_client::font::Patch {
x: (rect.u0 * w).round() as u32,
y: (rect.v0 * h).round() as u32,
width,
height,
rgba: rgba.to_vec(),
});
true
}
/// Part `src` (`[x, y, w, h]` in the sprite's own pixels) of sprite
/// `name` (vanilla's `gui/...`, or a content mod's GUI texture
/// `mymod:gui/...`), drawn stretched into `dst` (`[x, y, w, h]` in GUI
@@ -8487,6 +8760,16 @@ impl Game<'_> {
}
}
/// Whether `rgba` is a whole `width` x `height` image that fits a
/// `slot_width` x `slot_height` atlas slot (a native mod's `mod_image`).
fn mod_image_fits(width: u32, height: u32, rgba: usize, slot_width: u32, slot_height: u32) -> bool {
width > 0
&& height > 0
&& width <= slot_width
&& height <= slot_height
&& rgba == width as usize * height as usize * 4
}
/// Whether F3's debug screen, open when `debug`, covers `corner`
/// (`DebugScreenOverlay.extractRenderState`): its left and right columns
/// the top corners, and its charts the bottom corners `charts` (bottom left,
@@ -9333,4 +9616,16 @@ mod tests {
assert!(!debug_takes(Corner::BottomRight, true, [true, false]));
assert!(debug_takes(Corner::BottomRight, true, [false, true]));
}
/// An image is taken whole, and only when it fits its slot.
#[test]
fn a_mod_image_is_taken_whole_and_only_when_it_fits() {
use super::mod_image_fits;
assert!(mod_image_fits(4, 2, 32, 1024, 1024));
assert!(mod_image_fits(1024, 1024, 1024 * 1024 * 4, 1024, 1024));
assert!(!mod_image_fits(0, 2, 0, 1024, 1024));
assert!(!mod_image_fits(4, 2, 31, 1024, 1024));
assert!(!mod_image_fits(4, 2, 33, 1024, 1024));
assert!(!mod_image_fits(2048, 1, 2048 * 4, 1024, 1024));
}
}
+5 -2
View File
@@ -461,11 +461,14 @@ pub(super) fn stage_pack(
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
// each other: about a second each, side by side.
let mod_image = !horizon_client::mod_api::mods().is_empty();
// The sound events, the GUI sprites' metadata and the language are read here too, not in
// the frame that applies the reload (about 20 ms of it).
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
let atlas =
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
let atlas = s.spawn(|| {
HudAtlas::build_over_with_image(&root, vanilla_root, mod_image)
.map_err(|e| e.to_string())
});
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
let small = s.spawn(|| {
(
@@ -979,6 +979,7 @@ impl Game<'_> {
}
/// `Player.giveExperienceLevels`.
pub(super) fn give_levels(&mut self, levels: i32) {
let old_level = self.experience.level;
let xp = &mut self.experience;
xp.level = xp.level.saturating_add(levels);
if xp.level < 0 {
@@ -986,6 +987,7 @@ impl Game<'_> {
xp.progress = 0.0;
xp.total = 0;
}
self.native_player_level_changed(old_level);
}
/// Whether the result slot may be taken (`mayPickup`).
fn may_take_result(&self) -> bool {
+1
View File
@@ -190,6 +190,7 @@ impl Game<'_> {
}
// Vec3.atBottomCenterOf(pos).relative(UP, 1.2), lowered by spawnItem.
VaultEvent::Eject(stack, v) => {
self.native_vault_loot(pos, &stack.item);
self.spawn_item(stack, [center[0], center[1] + 0.7 - 0.125, center[2]], v, 0)
}
}
+47
View File
@@ -1102,4 +1102,51 @@ mod tests {
other(&item, "minecraft:custom_model_data")
);
}
#[test]
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
use horizon_protocol::buf::{Reader, Writer};
use horizon_protocol::item::Slot;
use horizon_protocol::registry::Ids;
let mut components = vec![String::new(); component::RARITY as usize + 1];
components[component::RARITY as usize] = "minecraft:rarity".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
item.enchant("minecraft:aqua_affinity", 1);
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|_, _| None,
&Some(item.clone()),
);
let mut w = Writer::new();
Slot::write(&wire, &mut w);
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
let back = from_network(&received, &builtin, &dynamic);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:rarity")),
Some(&Tag::String("epic".into()))
);
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
}
}
+39 -5
View File
@@ -1053,7 +1053,7 @@ pub fn lines(
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
/// the component's own styles (including explicit `false`) winning on children.
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
let lines: Vec<crate::text::Text> = match raw {
Tag::List(entries) => entries
.iter()
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
.collect(),
Tag::Compound(_) | Tag::String(_) => {
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
}
_ => Vec::new(),
};
lines
.into_iter()
.map(|mut text| {
for span in &mut text.spans {
span.style.inherit(&base);
}
@@ -1360,6 +1369,31 @@ mod tests {
assert_eq!(rarity(&sword, "common"), "rare");
}
#[test]
fn lore_texts_handles_compound_lists() {
// Direct command components from issue #6405
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
let comp = horizon_items::item_data::command_components(cmd).unwrap();
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
let stack = ItemStack {
item: "minecraft:stick".into(),
count: 1,
damage,
extra,
};
assert_eq!(rarity(&stack, "common"), "epic");
let name = styled_name(&stack, || "fallback".into());
assert_eq!(name.plain(), "Simple Custom Name");
assert_eq!(name.spans[0].style.italic, Some(false));
let lore = lore_texts(&stack);
assert_eq!(lore.len(), 1);
assert_eq!(lore[0].plain(), "Simple Custom Lore");
assert_eq!(lore[0].spans[0].style.italic, Some(false));
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn name_line_translates_items_of_any_namespace() {
let translate = |key: &str| match key {
@@ -716,6 +716,78 @@ mod tests {
/// A recipe whose ingredient kind no loader knows stays out of the book:
/// the server does not load it, and an empty ingredient slot in its
/// place would mislead the client.
/// The shared probe: a recipe the player crafts is learned by the
/// name the crafting copy hands out, and rejoining sends what the
/// book knows.
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
"key":{"X":"minecraft:raw_copper"},
"result":{"id":"minecraft:copper_ingot"}}"#;
let root = if pack {
resources(name, &[])
} else {
resources(name, &[(name, shaped)])
};
let packs = pack.then(|| {
vec![(
format!("recipe/minecraft:{name}"),
vec![shaped.as_bytes().to_vec()],
)]
});
let overlay = match &packs {
Some(p) => horizon_world::loot::PackOverlay { files: p },
None => horizon_world::loot::PackOverlay::NONE,
};
let sync = SyncData::load(&root, &overlay).unwrap();
// What the craft hands the book: find_with over the crafting
// copy, the way award_crafted calls it.
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
let grid = vec![Some(horizon_items::items::ItemStack {
item: "minecraft:raw_copper".into(),
count: 1,
damage: 0,
extra: None,
})];
let (found, _) = recipes
.find_with(&grid, 1, &|_| None)
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
assert_eq!(
found, name,
"{name}: the crafting copy names it bare, folder and pack alike"
);
// The book learns what the craft awards; rejoining sends it.
let mut book = crate::recipe_book::Settings::default();
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
assert_eq!(
unlocked,
[format!("minecraft:{name}")],
"{name}: the book knows it under its whole id"
);
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
let mut r = Reader::new(&bytes);
let add =
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
horizon_protocol::packets::recipes::RecipeBookAdd,
>>::read(&mut r)
.unwrap();
assert!(
!add.entries.is_empty(),
"{name}: the crafted recipe reaches the book after rejoining"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("folder_recipe", false);
}
#[test]
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("pack_recipe", true);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+1 -8
View File
@@ -82,14 +82,7 @@ pub struct SyncData {
pub advancements: super::advancements::ServerAdvancements,
}
/// A resource id with its default namespace filled in.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
use crate::crafting::qualified;
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
+66 -6
View File
@@ -1106,13 +1106,55 @@ fn network_stack(
}
// Lore lines as loaded or as a server mod wrote them (a list of text
// components), so a server menu's items can say more than their name.
if let Some(horizon_world::nbt::Tag::List(lines)) =
s.data().and_then(|d| d.other("minecraft:lore"))
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
let lines = match lore_tag {
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
// A string is one literal line, as ComponentSerialization decodes a string tag.
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
Some(vec![lore_tag.clone()])
}
_ => None,
};
if let Some(lines) = lines {
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:lore").is_some())
{
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines.clone()),
));
stack.removed.push(horizon_protocol::item::component::LORE);
}
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
let index = match r {
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
"common" => Some(0),
"uncommon" => Some(1),
"rare" => Some(2),
"epic" => Some(3),
_ => None,
},
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
_ => None,
};
if let Some(index) = index {
stack.added.push((
horizon_protocol::item::component::RARITY,
horizon_protocol::item::Component::Int(index),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
{
stack
.removed
.push(horizon_protocol::item::component::RARITY);
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
@@ -1241,6 +1283,24 @@ mod tests {
);
}
#[test]
fn rarity_reaches_the_client() {
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
let mut writer = Writer::new();
horizon_protocol::item::Slot::write(&sent, &mut writer);
let mut reader = Reader::new(&writer.data);
let received = horizon_protocol::item::Slot::read(&mut reader)
.unwrap()
.unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
}
#[test]
fn styled_custom_name_survives_inventory_sync() {
let name = Tag::compound([
+68 -3
View File
@@ -135,6 +135,32 @@ pub enum HostEvent {
MainHandChanged(u32, bool),
/// A guest changed their client language option.
LocaleChanged(u32, String),
/// A guest's client finished loading the level around them.
PlayerLoaded(u32),
/// A guest changed recipe book settings.
RecipeBookSettings {
conn: u32,
book: i32,
open: bool,
filtering: bool,
},
/// A guest changed sneaking in a player input packet, already applied.
Sneak {
conn: u32,
sneaking: bool,
},
/// A loaded guest sent their current player input flags.
Input {
conn: u32,
flags: i32,
},
/// A guest was kicked, with the reason rendered by the hub.
Kicked {
conn: u32,
player: u128,
name: String,
reason: String,
},
/// A guest's client started (`flying`) or stopped flying (their abilities packet), applied to
/// the guest already: the game tells the mods, which may refuse it.
Flight {
@@ -205,12 +231,17 @@ fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
pub struct Hub {
/// Clicks on and closings of native mods' menus, for the game.
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
/// Kicks waiting for the game thread to notify native mods.
pending_kicks: Vec<HostEvent>,
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
/// tell the mods.
pub native_advancement_grants: Vec<TickGrants>,
/// Whether any native mod is loaded: `Input` and `Sneak` events only exist for them, so
/// the hub queues none otherwise. The game sets it before each poll.
pub mods_loaded: bool,
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
/// only players of at least that permission level see them, in the
/// player list, the world and their joining and leaving.
@@ -569,8 +600,10 @@ impl Hub {
entity_gone: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
native_menu_events: Vec::new(),
pending_kicks: Vec::new(),
native_tracking: Vec::new(),
native_advancement_grants: Vec::new(),
mods_loaded: false,
hidden: Default::default(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
@@ -834,8 +867,15 @@ impl Hub {
// The log's "lost connection" line gives the reason, as vanilla's.
let text = self.english(&reason);
if let Some(g) = self.guest_mut(conn) {
g.left_reason = text;
g.left_reason = text.clone();
let (player, name) = (g.uuid, g.name.clone());
g.sender.disconnect(reason);
self.pending_kicks.push(HostEvent::Kicked {
conn,
player,
name,
reason: text,
});
}
}
@@ -1534,7 +1574,7 @@ impl Hub {
pub fn poll(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
self.ticks += 1;
self.anticheat_tick(ctx.tick_rate);
let mut events = Vec::new();
let mut events = std::mem::take(&mut self.pending_kicks);
for event in self.lan.poll() {
match event {
NetEvent::Joined(j) => {
@@ -3034,6 +3074,7 @@ impl Hub {
g.loaded = true;
g.loading = false;
g.load_timeout = 0;
events.push(HostEvent::PlayerLoaded(conn));
}
// PlayerChunkSender.onChunkBatchReceivedByClient.
S::ChunkBatchReceived(p) => {
@@ -3079,8 +3120,18 @@ impl Hub {
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
{
let settings = &mut self.guests[i].recipe_book;
let changed = settings.open[book.index()] != p.open
|| settings.filtering[book.index()] != p.filtering;
settings.open[book.index()] = p.open;
settings.filtering[book.index()] = p.filtering;
if changed {
events.push(HostEvent::RecipeBookSettings {
conn,
book: p.book_type,
open: p.open,
filtering: p.filtering,
});
}
}
}
S::ClientInformation(info) => {
@@ -3117,7 +3168,21 @@ impl Hub {
let g = &mut self.guests[i];
g.input = p.flags;
if g.client_loaded() {
g.sneaking = p.flags & 32 != 0;
// The events only tell the mods: nothing is queued with none loaded.
let listening = self.mods_loaded;
if listening {
events.push(HostEvent::Input {
conn,
flags: i32::from(p.flags),
});
}
let sneaking = p.flags & 32 != 0;
if g.sneaking != sneaking {
g.sneaking = sneaking;
if listening {
events.push(HostEvent::Sneak { conn, sneaking });
}
}
}
// handlePlayerInput only records keys and updates crouching.
// Sprinting persists after the sprint key is released (or
+4
View File
@@ -412,6 +412,10 @@ impl Shared {
pub fn max_players(&self) -> usize {
self.max_players.load(Ordering::Relaxed)
}
/// `PlayerList.setMaxPlayers`: change the limit used by new logins and status responses.
pub fn set_max_players(&self, max_players: usize) {
self.max_players.store(max_players, Ordering::Relaxed);
}
pub fn release(&self, uuid: u128) {
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
}
+129 -1
View File
@@ -324,6 +324,14 @@ pub struct Menu {
/// a level) runs the rest of its `onTake` (the anvil's wear and level
/// event 1030 or 1029, the smithing table's 1044).
pub combined: bool,
/// The caller fires `AnvilCraftEvent.Pre` for a taken anvil result: its
/// `onTake` waits in `anvil_craft` for the caller to run `anvil_take`, or
/// to refuse the take with `anvil_cancel` (from an `anvil_undo` made
/// before the click).
pub anvil_hook: bool,
/// The anvil result taken while `anvil_hook` is set, as it was before
/// crafting.
pub anvil_craft: Option<AnvilCraft>,
/// The enchanting table's item changed to an enchantable one: the caller
/// (with a level) fills the costs and clues (`slotsChanged`'s
/// `access.execute`).
@@ -349,6 +357,26 @@ pub struct Anvil {
pub only_renaming: bool,
}
/// An anvil craft as `AnvilCraftEvent` reports it: the inputs and the result
/// taken, before the crafting changed anything.
#[derive(Clone, Debug, PartialEq)]
pub struct AnvilCraft {
pub left: Option<ItemStack>,
pub right: Option<ItemStack>,
pub output: ItemStack,
}
/// An anvil menu before a click, for `Menu::anvil_cancel`: the anvil's three
/// slots and cost, the player's slots and the cursor.
#[derive(Clone, Debug, PartialEq)]
pub struct AnvilUndo {
inputs: Vec<Option<ItemStack>>,
result: Option<ItemStack>,
cost: i32,
player: Vec<Option<ItemStack>>,
carried: Option<ItemStack>,
}
/// `StonecutterMenu`'s state besides its slots and `selectedRecipeIndex`.
#[derive(Clone, Default, Debug, PartialEq)]
pub struct Stonecutter {
@@ -742,6 +770,8 @@ impl Menu {
level_change: 0,
anvil: Anvil::default(),
combined: false,
anvil_hook: false,
anvil_craft: None,
enchant_changed: false,
enchant_button: None,
may_build: true,
@@ -1594,8 +1624,33 @@ impl Menu {
out
}
/// The anvil menu as it is before a click that may take its result.
pub fn anvil_undo(&self) -> AnvilUndo {
AnvilUndo {
inputs: self.containers[MAIN].clone(),
result: self.containers[RESULT][0].clone(),
cost: self.data[0],
player: self.containers[PLAYER].clone(),
carried: self.carried.clone(),
}
}
/// Refuses a take that `anvil_hook` deferred (a cancelled
/// `AnvilCraftEvent.Pre`): the result goes back in its slot and the
/// inputs, cost, inventory and cursor are as `undo` held them, so nothing
/// the click did (cursor, shift-click, swap, drop) is left.
pub fn anvil_cancel(&mut self, undo: AnvilUndo) {
self.containers[MAIN] = undo.inputs;
self.containers[RESULT][0] = undo.result;
self.data[0] = undo.cost;
self.containers[PLAYER] = undo.player;
self.carried = undo.carried;
self.anvil_craft = None;
self.dropped.clear();
}
/// `AnvilMenu.onTake`: the levels, the material used up, then the input.
fn anvil_take(&mut self, env: &Env) {
pub fn anvil_take(&mut self, env: &Env) {
let cost = self.data[0];
if !self.creative {
// Player.giveExperienceLevels(-cost).
@@ -1680,6 +1735,14 @@ impl Menu {
return;
}
if self.slots[s].role == Role::CombineResult && self.kind == Kind::Anvil {
if self.anvil_hook {
self.anvil_craft = Some(AnvilCraft {
left: self.containers[MAIN][0].clone(),
right: self.containers[MAIN][1].clone(),
output: taken.clone(),
});
return;
}
self.anvil_take(env);
return;
}
@@ -3492,6 +3555,71 @@ pub(crate) mod tests {
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
}
/// A cancelled `AnvilCraftEvent.Pre` refuses the take: the result sits in
/// its slot, both inputs and the cost are as they were, and neither the
/// cursor nor the inventory holds the item (no dupe).
#[test]
fn a_cancelled_anvil_craft_leaves_the_menu_as_it_was() {
let root =
std::env::temp_dir().join(format!("horizon-anvil-cancel-{}", std::process::id()));
std::fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
let items = Items::for_tests(Default::default());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
let _ = std::fs::remove_dir_all(&root);
let (banners, station_recipes) = (Banners::default(), Default::default());
let (enchantments, names) = (Default::default(), Default::default());
let env = &Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
// Both a pick-up onto the cursor and a shift-click into the inventory.
for input in [Input::Pickup, Input::QuickMove] {
let mut menu = Menu::new(Kind::Anvil);
menu.creative = false;
menu.levels = 10;
menu.anvil_hook = true;
menu.set_slot(env, 0, stack("iron_sword", 1));
menu.set_slot(env, 1, stack("iron_ingot", 2));
menu.set_slot(env, 2, stack("iron_sword", 1));
menu.data[0] = 3;
let before = (
menu.slot(0).cloned(),
menu.slot(1).cloned(),
menu.slot(2).cloned(),
);
let undo = menu.anvil_undo();
menu.click(env, 2, 0, input).unwrap();
// The take waits for the mods, and the click moved the result.
let craft = menu.anvil_craft.take().expect("the take is deferred");
assert_eq!(craft.output, before.2.clone().unwrap());
assert_eq!(menu.slot(2), None);
assert_eq!(menu.data[0], 3);
menu.anvil_craft = Some(craft);
menu.anvil_cancel(undo);
assert_eq!(
(
menu.slot(0).cloned(),
menu.slot(1).cloned(),
menu.slot(2).cloned()
),
before
);
assert_eq!(menu.data[0], 3);
assert_eq!(menu.level_change, 0);
assert_eq!(menu.carried, None);
assert!((0..36).all(|i| menu.player_item(i).is_none()));
assert!(menu.anvil_craft.is_none() && menu.dropped.is_empty());
}
}
/// A mod menu type: three slots, then a result (slot 3).
fn mod_menu() -> Kind {
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
+1
View File
@@ -1630,6 +1630,7 @@ pub enum MobEvent {
params: conversion::Params,
},
DropItem {
mob: u32,
item: Arc<str>,
count: u32,
at: [f64; 3],
+47
View File
@@ -271,6 +271,8 @@ pub enum ArmPose {
ThrowTrident,
/// Playing a goat horn.
TootHorn,
/// Looking through a spyglass (`HumanoidModel.ArmPose.SPYGLASS`).
Spyglass,
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
Spear,
/// A brush being used (`HumanoidModel.ArmPose.BRUSH`).
@@ -400,6 +402,8 @@ pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: boo
let x = lerp(alpha, p.get(pull)[3], -PI / 2.0);
p.xrot(pull, x);
}
/// `HumanoidModel.SPYGLASS_ARM_ROT_X`.
const SPYGLASS_ARM_ROT_X: f32 = 1.919_862_2;
/// `HumanoidModel.poseRightArm` / `poseLeftArm`.
fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
let arm = if right { "right_arm" } else { "left_arm" };
@@ -436,6 +440,11 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
p.xrot(arm, x * 0.5 - PI);
p.yrot(arm, 0.0);
}
ArmPose::Spyglass => {
let crouch = if h.crouching { 0.261_799_4 } else { 0.0 };
p.xrot(arm, (hx - SPYGLASS_ARM_ROT_X - crouch).clamp(-2.4, 3.3));
p.yrot(arm, hy + if right { -0.261_799_4 } else { 0.261_799_4 });
}
ArmPose::TootHorn => {
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
@@ -818,6 +827,7 @@ pub fn setup(
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
Some("minecraft:bow") if using_main => ArmPose::Bow,
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
Some("minecraft:spyglass") if using_main => ArmPose::Spyglass,
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
ArmPose::CrossbowCharge
@@ -838,6 +848,8 @@ pub fn setup(
ArmPose::Spear
} else if using_off && m.extra.blocking {
ArmPose::Block
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:spyglass") {
ArmPose::Spyglass
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:goat_horn") {
ArmPose::TootHorn
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") {
@@ -1067,6 +1079,41 @@ mod flight_pose_tests {
}
}
}
/// HumanoidModel.poseRightArm / poseLeftArm's SPYGLASS case: pitch, yaw,
/// crouching, and the arm's pitch.
#[test]
fn a_spyglass_raises_the_arm_with_the_head_in_both_hands() {
for right in [false, true] {
for (pitch, yaw, crouching, expected_pitch) in [
(0.0, 0.0, false, -1.919_862_2),
(0.5, 0.3, false, -1.419_862_2),
(0.0, 0.0, true, -2.181_661_6),
// Looking up: limited to -2.4.
(-1.5, 0.0, false, -2.4),
] {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
let arm = if right { "right_arm" } else { "left_arm" };
p.xrot("head", pitch);
p.yrot("head", yaw);
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
h.crouching = crouching;
pose_arm(&mut p, ArmPose::Spyglass, right, &h);
let pose = *p.get(arm);
assert!((pose[3] - expected_pitch).abs() < 1e-6, "{pose:?}");
let side = if right { -0.261_799_4 } else { 0.261_799_4 };
assert!((pose[4] - (yaw + side)).abs() < 1e-6, "{pose:?}");
assert_eq!(p.get(if right { "left_arm" } else { "right_arm" })[3], 0.0);
}
}
}
/// EndermanModel.setupAnim (#4565): humanoid_pub forwards isPassenger so the
/// riding leg y/z survive; only xRot is halved and clamped to ±0.4 after.
#[test]
+2
View File
@@ -958,6 +958,7 @@ impl EatBerries {
}
if count > 0 {
cx.events.push(MobEvent::DropItem {
mob: m.id,
item: "minecraft:sweet_berries".into(),
count: count as u32,
at,
@@ -975,6 +976,7 @@ impl EatBerries {
} else {
// CaveVines.use: one glow berry, the vine goes back to bare.
cx.events.push(MobEvent::DropItem {
mob: m.id,
item: "minecraft:glow_berries".into(),
count: 1,
at,
+1
View File
@@ -866,6 +866,7 @@ impl Goal for PandaSit {
if let Some(item) = m.extra.main_hand.clone() {
let count = held_count(m);
cx.events.push(MobEvent::DropItem {
mob: m.id,
item,
count,
at: [m.position.x, m.position.y, m.position.z],
+1
View File
@@ -1711,6 +1711,7 @@ pub fn ai_step(
|| m.extra.piglin.seen_admire > 0;
if admiring && let Some(item) = m.extra.off_hand.take() {
events.push(MobEvent::DropItem {
mob: m.id,
item,
count: 1,
at: [m.position.x, m.position.y, m.position.z],
File diff suppressed because it is too large. Load diff
+13
View File
@@ -1180,6 +1180,19 @@ pub trait Host {
let _ = size;
unimplemented!("Host::item_stack")
}
/// A mod's own RGBA image, uploaded into the HUD atlas slot
/// [`MOD_IMAGE_SPRITE`](crate::hud::MOD_IMAGE_SPRITE) and drawn with
/// `native_sprite`: its own source rectangle crops and stretches it. `rgba`
/// is `width * height` pixels of four bytes, each side at most
/// [`MOD_IMAGE_SIZE`](crate::hud::MOD_IMAGE_SIZE); false when it is not that
/// size. The upload costs a texture patch of the image, so send one when the
/// image changed, not every frame. API @API:mod_image@.
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
let _ = width;
let _ = height;
let _ = rgba;
unimplemented!("Host::mod_image")
}
/// The instance (game) folder, which its players share: `saves/`,
/// `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are in it, and the
/// title's screens create worlds and read packs there. A world's own
+29 -6
View File
@@ -835,11 +835,15 @@ impl Settings {
let mut displays = 0;
let mut added = Vec::new();
for id in ids {
if !self.recipes.contains(id) && !index.special(id) {
// The whole id: the crafting copy names a `minecraft:` recipe
// bare, and every reader of the book (the network copy, the
// advancements' JSON, the saved file) knows it qualified.
let id = crate::crafting::qualified(id);
if !self.recipes.contains(&id) && !index.special(&id) {
self.recipes.insert(id.clone());
self.to_be_displayed.insert(id.clone());
displays += index.displays(id);
added.push(id.clone());
displays += index.displays(&id);
added.push(id);
}
}
(displays, added)
@@ -879,9 +883,10 @@ impl Settings {
pub fn remove_recipes(&mut self, index: &RecipeIndex, ids: &[String]) -> Vec<i32> {
let mut displays = Vec::new();
for id in ids {
if self.recipes.remove(id) {
self.to_be_displayed.remove(id);
displays.extend(index.display_ids(id));
let id = crate::crafting::qualified(id);
if self.recipes.remove(&id) {
self.to_be_displayed.remove(&id);
displays.extend(index.display_ids(&id));
}
}
displays
@@ -959,6 +964,24 @@ impl Settings {
#[cfg(test)]
mod tests {
/// A book saved before #7186's fix holds bare names: reading fills the
/// namespace in, so those recipes show in the book too.
#[test]
fn a_book_saved_with_bare_names_reads_qualified() {
use horizon_world::nbt::{Compound, Tag};
let list = || Tag::List(vec![Tag::String("folder_recipe".into())]);
let mut compound = Compound::new();
compound.insert("recipes".into(), list());
compound.insert("toBeDisplayed".into(), list());
let tag = Tag::Compound(compound);
let read = Settings::read_valid(Some(&tag), &|_| true);
assert_eq!(
read.recipes,
["minecraft:folder_recipe".to_owned()].into_iter().collect()
);
assert!(read.to_be_displayed.contains("minecraft:folder_recipe"));
}
use super::*;
fn ing(items: &[&str]) -> Ingredient {
+8 -1
View File
@@ -797,7 +797,14 @@ pub fn apply_operation(op: &str, a: i32, b: i32) -> CmdResult<(i32, i32)> {
if b == 0 {
return Err(div0());
}
(a.wrapping_rem(b).wrapping_add(b).wrapping_rem(b), b)
let remainder = a.wrapping_rem(b);
// Floor remainder has the divisor's sign; only correct opposite signs.
let remainder = if remainder != 0 && ((remainder < 0) != (b < 0)) {
remainder + b
} else {
remainder
};
(remainder, b)
}
"<" => (a.min(b), b),
">" => (a.max(b), b),
@@ -210,6 +210,47 @@ fn division_wraps_minimum_integer_by_negative_one() {
);
}
#[test]
fn remainder_handles_integer_boundaries() {
for (dividend, divisor, expected) in [
(i32::MAX - 1, i32::MAX, i32::MAX - 1),
(7, -3, -2),
(-7, 3, 2),
(i32::MIN, -1, 0),
(-1, i32::MIN, -1),
(1, i32::MIN, i32::MIN + 1),
(0, i32::MAX, 0),
] {
let mut m = mock();
run(&mut m, "scoreboard objectives add boundary dummy").unwrap();
run(
&mut m,
&format!("scoreboard players set left boundary {dividend}"),
)
.unwrap();
run(
&mut m,
&format!("scoreboard players set right boundary {divisor}"),
)
.unwrap();
run(
&mut m,
"scoreboard players operation left boundary %= right boundary",
)
.unwrap();
assert_eq!(
run(&mut m, "scoreboard players get left boundary").unwrap(),
expected,
"{dividend} %= {divisor}"
);
assert_eq!(
run(&mut m, "scoreboard players get right boundary").unwrap(),
divisor
);
}
assert!(apply_operation("%=", 1, 0).is_err());
}
#[test]
fn triggers() {
let mut m = mock();
@@ -41,6 +41,8 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"gamerules" => game.gamerules(client, None),
"gamerules/update" => game.gamerules(client, Some(argument)),
"serversettings/motd" => game.motd(client, None),
"serversettings/motd/set" => game.motd(
client,
@@ -77,6 +79,16 @@ pub fn call(shared: &Shared, client: u32, short: &str, argument: &Value) -> Resu
.ok_or_else(|| invalid_params(format!("Not a boolean: {argument}")))?,
),
),
"serversettings/max_players" => game.max_players(client, None),
"serversettings/max_players/set" => game.max_players(
client,
Some(
argument
.as_i64()
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| invalid_params(format!("Not a number: {argument}")))?,
),
),
"serversettings/use_allowlist" => game.allowlist_setting(client, "use_allowlist", None),
"serversettings/use_allowlist/set" => game.allowlist_setting(
client,
@@ -366,6 +378,23 @@ impl Game<'_> {
Ok(reply["value"].clone())
}
/// `GameRulesService.get` and `update`: the game's rules as typed rules, or the one `update` set.
fn gamerules(&self, client: u32, update: Option<&Value>) -> Result<Value, Failure> {
let mut request = json!({ "method": "gamerules" });
if let Some(update) = update {
request["method"] = json!("gamerules/update");
request["value"] = update.clone();
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(invalid) = reply["invalid"].as_str() {
return Err(invalid_params(invalid));
}
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.isAutoSave`: read the game's live save flag.
/// `MinecraftServerSettingsServiceImpl.setAutoSave`: update that flag and return its new value.
fn autosave(&self, client: u32, value: Option<bool>) -> Result<Value, Failure> {
@@ -404,6 +433,29 @@ impl Game<'_> {
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.getMaxPlayers` and `setMaxPlayers`.
fn max_players(&self, client: u32, value: Option<usize>) -> Result<Value, Failure> {
let mut request = json!({ "method": "serversettings/max_players" });
if let Some(value) = value {
request["method"] = json!("serversettings/max_players/set");
request["value"] = json!(value);
}
let reply = self.ask(&format!("management {client} {request}"))?;
if let Some(error) = reply["error"].as_str() {
return Err((INTERNAL_ERROR, Some(error.to_owned())));
}
if let Some(value) = value {
crate::config::update(
&self.shared.dir,
"server",
"max_players",
toml::Value::Integer(value as i64),
)
.map_err(|e| (INTERNAL_ERROR, Some(e)))?;
}
Ok(reply["value"].clone())
}
/// `MinecraftServerSettingsServiceImpl.getGameMode` and `setGameMode`.
fn game_mode(&self, client: u32, value: Option<&str>) -> Result<Value, Failure> {
let mut request = json!({ "method": "serversettings/game_mode" });
@@ -683,6 +735,11 @@ pub fn list_notification(kind: &str, event: &Value) -> Option<(&'static str, Val
"notification/bans/added",
ban(("player", player()), &reason, source, expires),
),
// `MinecraftServer.onGameRuleChanged`: the rule as a typed rule (the event's own `type` is its kind).
"gamerule" => (
"notification/gamerules/updated",
json!({ "key": event["key"], "type": event["valueType"], "value": event["value"] }),
),
"bans_removed" => ("notification/bans/removed", player()),
"ip_bans_added" => (
"notification/ip_bans/added",
@@ -784,6 +841,17 @@ mod tests {
note("ip_bans_removed", json!({ "ip": "10.0.0.1" })),
("notification/ip_bans/removed", json!("10.0.0.1"))
);
// A game rule changed: the rule as a typed rule, whose `type` the event keeps as `valueType`.
assert_eq!(
note(
"gamerule",
json!({ "key": "minecraft:pvp", "valueType": "boolean", "value": false })
),
(
"notification/gamerules/updated",
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
)
);
assert!(list_notification("kick", &event(json!({}))).is_none());
}
+2 -2
View File
@@ -79,8 +79,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
- **Backups**: restore a single dimension.
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
- **Vanilla's management protocol**: `serversettings/*`, `gamerules`, the notifications of the game rules, the
server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done), the notifications of
the server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
an internal error saying so; a kick's translatable message is sent as its key.
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
+119 -2
View File
@@ -956,6 +956,8 @@ API 153 adds a player starting or stopping flight: `native_player_toggle_flight(
API @API:physical_window_size@ adds `native_physical_window_size()`, the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. Bundles that use it need `"hooks": @API:physical_window_size@`.
API @API:mod_image@ adds `Game::native_mod_image(width, height, rgba)`: a mod's own RGBA image, uploaded into the HUD atlas slot `horizon/modimage` (1024 by 1024) and drawn with `native_sprite`, whose source rectangle crops and stretches it, so a map or a schematic view needs no texture of its own. The slot is reserved only while a native mod is loaded, so a game without mods pays none of its 4 MiB. False for an image that is not `width * height` pixels of four bytes, is larger than the slot, or when there is no slot. Each upload is a texture patch of the image, so send one when the image changed, not every frame.
## What the launcher does
**Developing a mod?** Run `horizon-launcher settings native-profile dev` once, or pick "Dev build" in the Mods page's
@@ -1045,7 +1047,8 @@ trusting native code is the player's decision, never an agent's.
7. **Install**: a new version, `native-<commit>-<key>`, a copy of the starting version's folder (resources
included, cloned on APFS, Btrfs and ReFS) with the built game in place of its own, and a `native-mods.json`
saying which release or Nightly it started from (`base`) and which commit, mods, horizon-mixins and Rust made
it. The instance the mods are installed in plays it, the default **Horizon** instance included: the build
it. The game lists its `mods` with their versions in a crash report's details (`Native Mods: id version, …`;
they stay in the local report, not in crash telemetry). The instance the mods are installed in plays it, the default **Horizon** instance included: the build
pins its version, as picking a version does. Updates of the launcher's version leave a pinned instance where
it is, and their clean-up never removes a native build. An instance built before this (the **Native mods**
instance the default home's mods used to go to) keeps its worlds and its build.
@@ -1384,6 +1387,9 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
to travel through a Nether portal, End portal or End gateway; `cause` is `nether_portal`, `end_portal` or
`end_gateway`. Set `cancel` to leave the player where they are. The player's state is not swapped in, so helpers that
look them up through `self.lan` may find nothing while a guest tick is handled.
- **`native_block_damage_abort(player, name, pos)`**: a server player stopped digging `pos` before it broke. It is
heard after the server clears the dig and sends the block state back to the client. The player's state is not
swapped in, so helpers that look them up through `self.lan` may find nothing while a guest packet is handled.
- **`native_player_pick_item(player, name, from_entity, pos, entity_id, cancel)`**: a server player's middle-click
pick resolved a block or entity's pick stack and is about to put it in the hotbar. For a block, `pos` is its
position and `entity_id` is zero; for an entity, `from_entity` is true, `pos` is `[0, 0, 0]` and `entity_id`
@@ -1396,6 +1402,16 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
what the sign gets (the chat filter does not look at it again), or set `cancel` and it keeps its old text. The
player's state is swapped in, so `native_tell` reaches them. Not told: the host's own editing on a LAN or
single-player world, and changes that are no text edit (dye, glow ink, wax, `/data`, `/setblock`).
- **`native_player_client_loaded_world(player, name)`**: the server heard that a guest's client finished loading
the level around them after joining, respawning or changing dimension. The player's state is not swapped in, so
helpers that look them up through `self.lan` may find nothing while the guest packet is handled.
- **`native_player_stop_using_item(player, name)`**: a server player released the use-item key before the item
finished being used. The player's state is not swapped in, so helpers that look them up through `self.lan` may
find nothing during packet handling.
- **`native_player_recipe_book_settings_change(player, name, book, open, filtering)`**: a server player's recipe
book open or filter setting changed; `book` is the book type's protocol id. The player's state is not swapped in.
- **`native_player_advancement_criterion_grant(player, name, advancement, criterion)`**: a server player earned
one advancement criterion, after its progress was recorded.
- **`native_player_command(player, name, command, canceled, replies)`**: any command a player typed (without the
slash), vanilla's too and commands the player may not run, heard before anything looks at it, and each line of a
quiet command batch. Set `canceled` and nothing runs; push lines to `replies` and the player gets them as system chat
@@ -1587,6 +1603,8 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_enchant_item(player, name, pos, item, levels)`**: a player applied an enchantment at `pos`; `item` is the finished item and `levels` the levels charged. Guest state is not swapped in.
- **`native_prepare_item_enchant(player, name, pos, costs)`**: enchanting offers were calculated at `pos`; change the three offer costs before they are sent to the player's menu. Guest state is not swapped in.
- **`native_prepare_anvil(player, name, pos, result, cost)`**: an anvil result was prepared for a player at `pos`; change the result or level cost before it is sent to their menu. Guest state is not swapped in.
- **`native_anvil_craft_pre(player, name, pos, craft, cancel)`**: a player took an anvil's result at `pos` (NeoForge's `AnvilCraftEvent.Pre`), before the levels are charged, the inputs used up and the anvil worn. `craft` has the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Fires on the server only; the window's predicted take does not call it.
- **`native_anvil_craft_post(player, name, pos, craft)`**: the levels, inputs and anvil wear of that take are done (NeoForge's `AnvilCraftEvent.Post`); a broken anvil is already air. `craft` is the same as in `native_anvil_craft_pre`. Not called when it cancelled.
- **`native_prepare_grindstone(player, name, pos, result)`**: a grindstone result was prepared for a player at `pos`; change it before it is sent to their menu. Guest state is not swapped in.
- **`native_prepare_smithing(player, name, pos, result)`**: a smithing result was prepared for a player at `pos`; change it before it is sent to their menu. Guest state is not swapped in.
- **`native_prepare_item_craft(player, name, pos, result)`**: a crafting-grid result was prepared; `pos` is the crafting table, or None for the player's inventory grid. Change it before it is sent to the menu. Guest state is not swapped in.
@@ -1621,6 +1639,36 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
- **`native_tab_complete(player, name, command, range, suggestions)`**: a guest's server completion request was evaluated; mods may add or filter suggestions. `range` is the replaced byte range. Guest state is not swapped in, and helpers that look through `self.lan` may find nothing while the guest packet is handled.
- **`native_broadcast_message(line, cancel)`**: a system broadcast line is about to be logged and sent to every player. A mod may change `line` or set `cancel` to drop the broadcast.
- **`native_whitelist_state_update(enabled)`**: the dedicated server's player whitelist was enabled or disabled.
- **`native_player_bundle_item_select(player, name, slot, index)`**: a server player selected an item in a bundle;
`slot` is the packet's menu slot and `index` is the chosen item index.
- **`native_player_ready_arrow(player, name, arrow)`**: a server player released a drawn bow, with `arrow` naming
the selected projectile item. The guest's state is not swapped in while the packet is handled.
- **`native_prepare_hopper_transfer(pos)`**: a hopper at `pos` begins a transfer attempt, after its cooldown and enabled checks and before pushing or pulling items.
- **`native_inventory_move_item(source, destination, item, cancel)`**: a hopper is about to move one item between containers (a composter's bone meal too). Set `cancel` to leave it in the source slot. A chest boat or minecart is reported at the block it is in.
- **`native_hopper_inventory_search(hopper, container, entity)`**: a hopper selects a block container or container entity while searching for items.
- **`native_vault_loot(pos, item)`**: a vault is about to eject a loot item, before the item entity spawns.
- **`native_world_border_bounds_change_finish()`**: a timed world border size change finished on the server, after the border reached its final size.
- **`native_player_launch_projectile(player, name, item)`**: a server player launched a snowball, egg, ender pearl,
potion or trident. It fires after the projectile is created and only for guest players in a LAN world.
- **`native_entity_unleash(entity, kind)`**: a mob's leash was removed, before any lead drop is handled.
- **`native_entity_drop_item(entity, item, count, pos)`**: a mob is about to drop `count` of `item` at `pos`.
- **`native_item_despawn(entity, item, count, pos)`**: a dropped item entity reached its 6000-tick lifetime and is
about to be removed.
- **`native_explosion_prime(center, radius)`**: a server explosion is about to begin at `center` with `radius`.
It is listen-only, so the explosion proceeds as usual; it also fires on the single-player server.
- **`native_vehicle_damage(kind, damage, cancel)`**: a player is about to damage a boat or minecart. Change `damage`
or set `cancel` to leave its damage and wobble unchanged; the attack still completes. `kind` is `boat` or the
minecart type. Server-side.
- **`native_vehicle_destroy(kind, pos)`**: a boat or minecart is about to be destroyed at `pos`. TNT minecarts that
prime instead of breaking are not heard. Server-side.
- **`native_vehicle_create(kind, pos)`**: a boat or minecart joined the level at `pos`, after being placed by a
player or dispenser. Server-side.
- **`native_player_toggle_sneak(player, name, sneaking)`**: a guest's player input changed sneaking, after the hub applied the state. The guest's state is swapped in; only actual changes are reported.
- **`native_player_input(player, name, flags)`**: a loaded guest sent a `PlayerInput` packet, after its flags were applied to the hub. `flags` is the packet's bit field, and the guest's state is swapped in.
- **`native_player_arm_swing(player, name, hand)`**: a guest's arm swing was handled and their attack strength reset. The packet currently swings `mainhand`; the guest's state is swapped in.
- **`native_player_jump(player, name)`**: accepted guest movement made the server player jump from the ground, after movement simulation finishes. The guest's state is swapped in.
- **`native_player_kicked(player, name, reason)`**: a guest was kicked after the hub recorded the rendered disconnect reason and sent the disconnect. The guest's state is swapped in, unless the guest was already removed in the same poll (a kick and the disconnect arriving together): the hook still fires, without it.
- **`native_player_level_change(player, name, from, to)`**: a server player's experience change altered their level, after the new level was applied. The guest's state is swapped in when the player is a guest.
- **`native_perform_command(player, command, ran)`**: a player typed a registered command (without its slash) and
their level allows it, whether the world allows commands or not. The mod parses the line and sets `ran`. The
player's state is swapped in for the call, as for their other actions, so `native_player` reads them; a
@@ -2244,6 +2292,73 @@ renderer list is installed once alongside the mod list. `render_needs` collects
run on the render thread while the frame is built, before any deferred GPU submission.
The renderer module re-exports the game's wgpu types.
### The typed API from mixin code
A helper is compiled inside its target, as a [Sponge Mixin](https://github.com/SpongePowered/Mixin/wiki) handler is merged
into its target class, so it can use the typed API. On a `Game` method, bind the game and coerce it to `dyn Host`:
```toml
[[injection]]
id = "count"
target = "horizon_client::game::Game<'_>::tick_mobs"
kind = "HEAD"
helper = "count.rs"
bindings = [{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" }]
```
```rust
fn count(game: &mut crate::game::Game<'_>) {
let host: &mut dyn horizon_client::mod_api::Host = game;
let holder = horizon_client::mod_api::Holder::Level;
if let Ok(old) = crate::game::example::COUNTER.get(&mut *host, holder) {
let _ = crate::game::example::COUNTER.set(host, holder, old + 1);
}
}
```
`COUNTER` is the `AttachmentType<i32>` of [Server attachments](#server-attachments), kept in a `[[module]]` of the
bundle (horizon-mixins' README): one with `file = "crates/horizon-client/src/game.rs"` and `name = "example"` is
`crate::game::example`. `Game` is not an `AttachmentHost` itself, so the `dyn Host` coercion is the step that matters;
any `Host` helper works on `host` too.
On a `GameLevel` method that takes `&mut self` (`native_random_tick`, `native_tick_block`, `native_perform_bonemeal`,
`native_is_bonemeal_success`), bind `{ name = "level", ty = "&mut dyn crate::mod_api::LevelRandomHost", value = "&mut *self" }`
for attachments and `random()`. Scheduling and `set_block_property` stay on the `LevelTicksHost` and `LevelWriteHost` the
hook points pass; `is_bonemeal_success` gets only a `LevelRandomHost`.
A trait injection gives the mod's `[api]` crate the same `Host` at that method. The `[api]` crate depends on
horizon-client's library, not on the game executable that helpers and modules are compiled into, so it declares its
own `AttachmentType<i32>` with the same `namespace` and `name`: the pair is the key, so both statics read one value.
```toml
[[injection]]
id = "count"
target = "horizon_client::game::Game<'_>::tick_mobs"
kind = "HEAD"
trait_method = "count"
bindings = [{ name = "host", ty = "&mut dyn crate::mod_api::Host", value = "&mut *self" }]
```
```rust
fn inject_example_count(&self, event: &mut InjectExampleCountEvent<'_>) {
let _ = COUNTER.get(&mut *event.host, Holder::Level);
}
```
The `[api]` crate reads `event.host` at any `Game` method horizon-mixins can inject into at HEAD (its README lists the
limits). `hook` and `hook_version` work in place of `target`.
The helper runs in every `Game` that calls its target, and attachments answer `MissingHolder` outside the server's game,
as they do in hooks.
A catalog hook's trait method, event and return are part of its `hook_version` contract ([Named hook
points](#named-hook-points), [Mods on the hook points](#mods-on-the-hook-points)). Later Horizons only add event fields
(`#[non_exhaustive]`), trait methods with default bodies, and `Host` methods. So a mod whose `[api]` package uses only catalog
hooks (trait methods, or injections by `hook`) and `Host` can set `"hooks"` in its `.hnmod`'s `horizon-native.json` to the
`NATIVE_HOOKS_API` it was made on.
A raw `target` like `tick_mobs` is not a hook point and may change in any commit: such a mod pins `horizon_revision` and
leaves `hooks` out.
### Testing a trait mod
Every `Host` method defaults to a panic naming it (`not implemented: Host::tell`), so a test double implements
@@ -2310,13 +2425,15 @@ static COUNTER: AttachmentType<i32> = AttachmentType {
}),
copy_on_death: false,
};
// Inside a server hook with `host: &mut dyn Host`:
// Inside a server hook, or mixin code, with `host: &mut dyn Host`:
if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ }
let old = COUNTER.get(host, Holder::Chunk([0, 0]))?;
COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?;
Ok::<(), AttachmentError>(())
```
For mixin code, see [The typed API from mixin code](#the-typed-api-from-mixin-code).
Use `Holder::Level`, `Holder::Chunk([x, z])`, `Holder::BlockEntity([x, y, z])`,
`Holder::Entity(id)`, or `Holder::Player(uuid)`. Entity IDs cover mobs, item
entities, boats, minecarts, armor stands, cushions, item frames, paintings and the
+5 -1
View File
@@ -517,7 +517,11 @@ runtime or new dependency. Server-sent events can replace the polling later if a
watcher what `add`, `remove` and `clear` do, as vanilla's `StoredUserList` tells its `NotificationService`,
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
- planned: `serversettings/*` and `gamerules`, the other notifications and the heartbeat.
- game rules: `gamerules` lists every rule the game has as a typed rule (`{key, type, value}`), `gamerules/update`
sets one (checked against its type and bounds, logged and set only when the value changes) and
`notification/gamerules/updated` follows every change, by that method or by `/gamerule`
(`Game::set_game_rule`);
- planned: the rest of `serversettings/*`, the activity and world-upgrade notifications and the heartbeat.
## 9. Benchmark plan (bots against vanilla and Paper)
+13
View File
@@ -1191,8 +1191,21 @@ def compute():
]
def scoreboard_remainder_boundaries():
operations = setup('/scoreboard objectives add boundary dummy')
for dividend, divisor in [(2147483646, 2147483647), (7, -3), (-2147483648, -1)]:
operations += setup(f'/scoreboard players set left boundary {dividend}',
f'/scoreboard players set right boundary {divisor}')
operations += [cmd('/scoreboard players operation left boundary %= right boundary'),
cmd('/scoreboard players get left boundary'),
cmd('/scoreboard players get right boundary')]
operations += setup('/scoreboard objectives remove boundary')
return operations
def social():
return [
*scoreboard_remainder_boundaries(),
*setup('/scoreboard objectives add trig trigger', '/scoreboard objectives add plain dummy'),
cmd('/trigger trig'),
cmd('/scoreboard players enable @s trig'),
+106
View File
@@ -283,6 +283,8 @@ address. The script then checks, from both sides, what a guest's game used to ge
- fishing: the host and the guest each cast a fishing rod: the hook stays out on the server and
both sides draw it and it reels back in from the ground, and in open water a fish
bites and reeling in catches it (#1348).
- fishing-item: reeling in an item hooked by a fishing rod moves it smoothly on both clients
instead of leaving it still until a later position update.
- maps: the host and the guest each make a map and hold it: their client draws the terrain
around them and their marker, from the server's map data (#1382).
- body: the host and the guest each walk while drawing a bow (slowed), stand on powder snow
@@ -7492,6 +7494,110 @@ CHECKS += ['fishing-tnt']
CHECK_FNS['fishing-tnt'] = check_fishing_tnt
def check_fishing_item(host, guest, out):
"""Reeling an item sends its velocity to both clients immediately; they move it smoothly."""
spots = apart(host, guest)
x, y, z = spots['host']
item_x, item_z = x + 4.5, z + 0.5
item_id = None
def item_position(game):
items = game.raw('entities', 16, 'item')['entities']
found = [item for item in items if item['id'] == item_id]
assert len(found) == 1, f'{game.name} does not see hooked item {item_id}: {items}'
return found[0]['pos']
try:
host('command', command='kill @e[type=minecraft:item]')
host('command', command='gamemode survival')
host('command', command='clear @s')
host('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
host.raw('look-at', f'{item_x:.2f}', f'{y + 1.5:.2f}', f'{item_z:.2f}')
host.raw('use-item')
for _ in range(10):
status = host('status')
bobbers = [
projectile for projectile in status.get('projectiles', [])
if projectile.get('kind') == 'minecraft:fishing_bobber'
]
if bobbers:
break
host.wait(1)
else:
raise AssertionError(f'fishing bobber did not spawn: {status}')
# Spawn on the moving bobber so the test covers hooking/reeling and
# client motion sync, not the rod's ballistic aim or item fall timing.
hook_x, hook_y, hook_z = bobbers[0]['position']
host('command', command=f'summon minecraft:item {hook_x:.4f} {hook_y:.4f} {hook_z:.4f} '
'{Item:{id:"minecraft:stone",count:1},PickupDelay:32767}')
host.wait(1)
guest.wait(1)
items = host.raw('entities', 16, 'item')['entities']
target = next(item for item in items
if item['item']['item'] == 'minecraft:stone'
and math.dist(item['pos'], [hook_x, hook_y, hook_z]) < 1)
item_id = target['id']
for _ in range(10):
host_items = host.raw('entities', 16, 'item')['entities']
guest_items = guest.raw('entities', 16, 'item')['entities']
if (any(item['id'] == item_id for item in host_items)
and any(item['id'] == item_id for item in guest_items)):
break
host.wait(1)
guest.wait(1)
start = item_position(host)
guest_start = item_position(guest)
assert math.dist(guest_start, start) < 0.5, \
f'clients disagree before reeling: {start}, {guest_start}'
hook_path = []
for _ in range(80):
status = host('status')
fishing = status.get('fishing')
if _ % 10 == 0:
hook_path.append([
projectile for projectile in status.get('projectiles', [])
if projectile.get('kind') == 'minecraft:fishing_bobber'
])
if fishing and fishing['state'] == f'Hooked({item_id})':
break
host.wait(1)
else:
raise AssertionError(
f'fishing hook did not catch item {item_id}: {fishing}; '
f'item={item_position(host)} player={host("state")["player"]} '
f'aim=({item_x}, {item_y}, {item_z}) path={hook_path}'
)
host.raw('use-item')
samples = {'host': [item_position(host)], 'guest': [item_position(guest)]}
for _ in range(5):
host.wait(1)
samples['host'].append(item_position(host))
samples['guest'].append(item_position(guest))
host.shot(out, 'fishing-item-pull')
guest.shot(out, 'fishing-item-pull')
progress = {}
for name, positions in samples.items():
path = [start if name == 'host' else guest_start, *positions]
steps = [path[i][0] - path[i + 1][0] for i in range(len(path) - 1)]
moved = path[0][0] - path[-1][0]
progress[name] = {'x': [p[0] for p in path], 'moved_toward_player': moved}
assert moved >= 0.5, f'{name} client did not receive item motion promptly: {progress[name]}'
assert all(-0.02 <= step < 1.0 for step in steps), \
f'{name} client saw a teleport or reversed movement: {progress[name]}'
return progress
finally:
host('command', command='kill @e[type=minecraft:item]')
host('command', command='clear @s')
face_each_other(host, guest)
CHECKS += ['fishing-item']
CHECK_FNS['fishing-item'] = check_fishing_item
def check_maps(host, guest, out):
"""MapItem.update for each player, on the server: a map the player makes and holds reveals the
terrain around them and shows their marker, which reach their client in
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "block_damage_abort",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_damage_abort",
"side": "server",
"neoforge": "none (NeoForge has no such event; Bukkit's BlockDamageAbortEvent)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player stopped digging before the block broke (`native_block_damage_abort`).",
"doc_summary": "A server player stopped digging `pos` before the block broke: `native_block_damage_abort(player, name, pos)` is heard after the server clears the dig and sends the block state back to the client. The player's state is not swapped in. Bundles need \"hooks\": @API:block_damage_abort@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "entity_drop_item",
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_drop_item",
"neoforge": "none (NeoForge has no such event; Bukkit's EntityDropItemEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A mob is about to drop an item (`native_entity_drop_item`).",
"doc_summary": "A mob is about to drop `count` of `item` at `pos`: `native_entity_drop_item(entity, item, count, pos)`. Bundles need \"hooks\": @API:entity_drop_item@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "entity_unleash",
"target": "horizon_client::game::native_hooks::Game<'_>::native_entity_unleash",
"neoforge": "none (NeoForge has no such event; Bukkit's EntityUnleashEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A mob's leash was removed (`native_entity_unleash`).",
"doc_summary": "A mob's leash was removed, before any lead drop is handled: `native_entity_unleash(entity, kind)`. Bundles need \"hooks\": @API:entity_unleash@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "explosion_prime",
"target": "horizon_client::game::native_hooks::Game<'_>::native_explosion_prime",
"neoforge": "none (NeoForge has no such event; Bukkit's ExplosionPrimeEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server explosion is about to begin (`native_explosion_prime`).",
"doc_summary": "A server explosion is about to begin at `center` with `radius` (`native_explosion_prime`); the hook is listen-only and the explosion proceeds as usual. Bundles need \"hooks\": @API:explosion_prime@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "hopper_inventory_search",
"target": "horizon_client::game::native_hooks::Game<'_>::native_hopper_inventory_search",
"side": "server",
"neoforge": "none (NeoForge has no such event; Bukkit's InventoryMoveItemEvent)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A hopper selects a container while searching for items (`native_hopper_inventory_search`).",
"doc_summary": "A hopper selects a container while searching for items: `native_hopper_inventory_search(hopper, container, entity)` identifies the hopper and the selected block container or container entity. Bundles need \"hooks\": @API:hopper_inventory_search@."
}
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "inventory_move_item",
"target": "horizon_client::game::native_hooks::Game<'_>::native_inventory_move_item",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event; Bukkit's InventoryMoveItemEvent)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A hopper moves an item between containers (`native_inventory_move_item`).",
"doc_summary": "A hopper is about to move one item between containers: `native_inventory_move_item(source, destination, item, cancel)` runs before the source slot is changed, a composter's bone meal included; a chest boat or minecart is reported at the block it is in. Set `cancel` to keep the item in its source slot. Bundles need \"hooks\": @API:inventory_move_item@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "item_despawn",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_despawn",
"neoforge": "none (NeoForge has no such event; Bukkit's ItemDespawnEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A dropped item entity reached its lifetime and is about to despawn (`native_item_despawn`).",
"doc_summary": "A dropped item entity reached its 6000-tick lifetime and is about to be removed: `native_item_despawn(entity, item, count, pos)`. Bundles need \"hooks\": @API:item_despawn@."
}
@@ -0,0 +1,11 @@
{
"hooks": [],
"helpers": [
{
"name": "mod_image",
"target": "horizon_client::game::native_hooks::Game<'_>::native_mod_image"
}
],
"rust_summary": "a mod's own picture in the HUD atlas (`native_mod_image`).",
"doc_summary": "a mod's own picture: `native_mod_image(width, height, rgba)` uploads an RGBA image (each side at most 1024) into the HUD atlas slot `horizon/modimage`, drawn with `native_sprite` (its source rectangle crops and stretches it). The slot exists only while a native mod is loaded; false for an image of the wrong size or when there is no slot. Bundles need \"hooks\": @API:mod_image@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "anvil_craft_post",
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_craft_post",
"side": "server",
"neoforge": "AnvilCraftEvent.Post",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A player finished taking an anvil's result (`native_anvil_craft_post`).",
"doc_summary": "A player took an anvil's result: `native_anvil_craft_post(player, name, pos, craft)` (NeoForge's `AnvilCraftEvent.Post`) is heard after the levels, the inputs and the anvil wear are done, so a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not heard when `native_anvil_craft_pre` cancelled. Bundles need \"hooks\": @API:native_anvil_craft_post@."
}
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "anvil_craft_pre",
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_craft_pre",
"side": "server",
"cancellable": true,
"neoforge": "AnvilCraftEvent.Pre",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A player took an anvil's result (`native_anvil_craft_pre`).",
"doc_summary": "A player took an anvil's result: `native_anvil_craft_pre(player, name, pos, craft, cancel)` (NeoForge's `AnvilCraftEvent.Pre`) runs before the levels are charged, the inputs used up and the anvil worn. `craft` holds the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip the matching `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Bundles need \"hooks\": @API:native_anvil_craft_pre@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "vehicle_create",
"target": "horizon_client::game::native_hooks::Game<'_>::native_vehicle_create",
"neoforge": "none (NeoForge has no such event; Bukkit's VehicleCreateEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a boat or minecart created (`native_vehicle_create`).",
"doc_summary": "A boat or minecart joined the level at `pos`, after a player or dispenser placed it: `native_vehicle_create(kind, pos)` is listen-only. Bundles need \"hooks\": @API:native_vehicle_create@."
}
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "vehicle_damage",
"target": "horizon_client::game::native_hooks::Game<'_>::native_vehicle_damage",
"neoforge": "none (NeoForge has no such event; Bukkit's VehicleDamageEvent)",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a vehicle taking damage (`native_vehicle_damage`).",
"doc_summary": "A player is about to damage a boat or minecart: `native_vehicle_damage(kind, damage, cancel)` may change the damage or cancel it before the vehicle's damage and wobble change; the attack still completes. Bundles need \"hooks\": @API:native_vehicle_damage@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "vehicle_destroy",
"target": "horizon_client::game::native_hooks::Game<'_>::native_vehicle_destroy",
"neoforge": "none (NeoForge has no such event; Bukkit's VehicleDestroyEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "a boat or minecart about to be destroyed (`native_vehicle_destroy`).",
"doc_summary": "A boat or minecart is about to be destroyed at `pos`: `native_vehicle_destroy(kind, pos)` is heard just before it is removed; TNT minecarts that prime instead are not heard. Bundles need \"hooks\": @API:native_vehicle_destroy@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_advancement_criterion_grant",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_advancement_criterion_grant",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerAdvancementCriterionGrant)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player earned an advancement criterion (`native_player_advancement_criterion_grant`).",
"doc_summary": "A server player earned one criterion of an advancement, after its progress was recorded: `native_player_advancement_criterion_grant(player, name, advancement, criterion)`. Bundles need \"hooks\": @API:player_advancement_criterion_grant@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_arm_swing",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_arm_swing",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerAnimationEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A guest's server handled arm swing (`native_player_arm_swing`).",
"doc_summary": "A guest's arm swing: `native_player_arm_swing(player, name, hand)` is called after the server handles the guest's `Punch` packet and resets attack strength. The current packet swings `mainhand`; the guest's state is swapped in. Bundles need \"hooks\": @API:player_arm_swing@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_bundle_item_select",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_bundle_item_select",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerBundleItemSelect)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player selected an item in a bundle (`native_player_bundle_item_select`).",
"doc_summary": "A server player selected the item at `index` in a bundle in menu `slot`: `native_player_bundle_item_select(player, name, slot, index)`. Bundles need \"hooks\": @API:player_bundle_item_select@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_client_loaded_world",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_client_loaded_world",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerClientLoadedWorld)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player's client finished loading the world (`native_player_client_loaded_world`).",
"doc_summary": "A server player's client finished loading the level around them after joining, respawning or changing dimension: `native_player_client_loaded_world(player, name)`. Bundles need \"hooks\": @API:player_client_loaded_world@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_input",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_input",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerInputEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A guest sent player input flags (`native_player_input`).",
"doc_summary": "A guest's player input flags: `native_player_input(player, name, flags)` is called after a loaded guest's `PlayerInput` packet is applied to the hub. `flags` is the packet's bit field; the guest's state is swapped in. Bundles need \"hooks\": @API:player_input@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_jump",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_jump",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerJumpEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A guest jumped from the ground (`native_player_jump`).",
"doc_summary": "A guest jumped: `native_player_jump(player, name)` is called after accepted movement made the server player jump from the ground, once the guest's movement simulation finished. The guest's state is swapped in. Bundles need \"hooks\": @API:player_jump@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_kicked",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_kicked",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerKickEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A guest was disconnected by a server kick (`native_player_kicked`).",
"doc_summary": "A guest was kicked from the server: `native_player_kicked(player, name, reason)` is called after the hub records the rendered reason and sends the disconnect. The guest's state is swapped in. Bundles need \"hooks\": @API:player_kicked@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_launch_projectile",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_launch_projectile",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerLaunchProjectile)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player launched a projectile item (`native_player_launch_projectile`).",
"doc_summary": "A LAN guest launched a snowball, egg, ender pearl, potion or trident: `native_player_launch_projectile(player, name, item)` runs after the projectile is created. Bundles need \"hooks\": @API:player_launch_projectile@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_level_change",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_level_change",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerLevelChangeEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player's experience level changed (`native_player_level_change`).",
"doc_summary": "A server player's experience level changed: `native_player_level_change(player, name, from, to)` is called after experience points or levels change their level, including orb points. The player's state is swapped in when they are a guest. Bundles need \"hooks\": @API:player_level_change@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_ready_arrow",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_ready_arrow",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerReadyArrow)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "The server player's selected bow projectile (`native_player_ready_arrow`).",
"doc_summary": "A server player released a drawn bow with `native_player_ready_arrow(player, name, arrow)`: the name of the selected projectile item. Bundles need \"hooks\": @API:player_ready_arrow@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_recipe_book_settings_change",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_recipe_book_settings_change",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerRecipeBookSettingsChange)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player's recipe book settings changed (`native_player_recipe_book_settings_change`).",
"doc_summary": "A server player's recipe book open or filter setting changed: `native_player_recipe_book_settings_change(player, name, book, open, filtering)`, where `book` is its protocol id. Bundles need \"hooks\": @API:player_recipe_book_settings_change@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_stop_using_item",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_stop_using_item",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerStopUsingItem)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player released the use-item key (`native_player_stop_using_item`).",
"doc_summary": "A server player released the use-item key before the item finished being used: `native_player_stop_using_item(player, name)`. Bundles need \"hooks\": @API:player_stop_using_item@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "player_toggle_sneak",
"target": "horizon_client::game::native_hooks::Game<'_>::native_player_toggle_sneak",
"neoforge": "none (NeoForge has no such event; Bukkit's PlayerToggleSneakEvent)",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A guest player changed sneaking (`native_player_toggle_sneak`).",
"doc_summary": "A guest's player input changed sneaking: `native_player_toggle_sneak(player, name, sneaking)` is called after the hub applies the new state, only when it changes. The guest's state is swapped in. Bundles need \"hooks\": @API:player_toggle_sneak@."
}
@@ -0,0 +1,14 @@
{
"hooks": [
{
"name": "prepare_hopper_transfer",
"target": "horizon_client::game::native_hooks::Game<'_>::native_prepare_hopper_transfer",
"side": "server",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A hopper begins an item transfer attempt (`native_prepare_hopper_transfer`).",
"doc_summary": "A hopper begins a transfer attempt: `native_prepare_hopper_transfer(pos)` runs after its cooldown and enabled checks, before it pushes or pulls items. Bundles need \"hooks\": @API:prepare_hopper_transfer@."
}
@@ -0,0 +1,15 @@
{
"hooks": [
{
"name": "vault_loot",
"target": "horizon_client::game::native_hooks::Game<'_>::native_vault_loot",
"side": "server",
"neoforge": "none (NeoForge has no such event; Bukkit's BlockDispenseLoot)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A vault ejects a loot item (`native_vault_loot`).",
"doc_summary": "A vault is about to eject a loot item: `native_vault_loot(pos, item)` supplies the vault position and item identifier before it spawns. Bundles need \"hooks\": @API:vault_loot@."
}
@@ -0,0 +1,16 @@
{
"hooks": [
{
"name": "world_border_bounds_change_finish",
"target": "horizon_client::game::native_hooks::Game<'_>::native_world_border_bounds_change_finish",
"side": "server",
"cancellable": false,
"neoforge": "none (NeoForge has no such event; Bukkit's WorldBorderBoundsChangeFinish)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A timed world border size change finished (`native_world_border_bounds_change_finish`).",
"doc_summary": "A timed world border size change finished: `native_world_border_bounds_change_finish()` fires after the border reaches its final size. Bundles need \"hooks\": @API:world_border_bounds_change_finish@."
}
+72 -1
View File
@@ -21,6 +21,12 @@ p99/p99.9, the worst frame, frames over 33 ms) in the situations where players s
screens the creative inventory and its 12 tabs, then the survival inventory and its recipe book,
opened for the first time and once more
reload F3+T, 15 s recorded
tnt 50 TNT set off on a stone block, 10 s recorded (world changes: block updates, light and
remeshing in one frame; each built in the air in front of the camera)
fill /fill of a 64x64x16 stone volume (two fills, the default block limit), then cleared
fluids water, then lava, poured down a 16-block slope, 15 s each
fire fire lit at 5 places on a 25x25 grove's canopy of leaves over logs, 25 s
pistons an observer clock driving 20 pistons, 15 s
One game per render distance, from a fresh world (seed 1234, creative), driven over horizon-ctl.
The game runs with HORIZON_SPIKES so each slow frame logs its timed spans; the report keeps the
@@ -49,7 +55,8 @@ except ImportError:
WORK = lab.ROOT / ".local/pacing-bench"
SCENARIOS = ["fly16", "ocean", "fly64", "elytra", "teleport", "nether", "portal", "inventory", "mobs", "items",
"block_entities", "screens", "reload", "join"]
"block_entities", "screens", "reload", "tnt", "fill", "fluids", "fire", "pistons", "join"]
WORLD_CHANGES = ["tnt", "fill", "fluids", "fire", "pistons"]
# First sight: what each kind of thing costs the first frame it is on screen.
MOBS = ["zombie", "skeleton", "creeper", "spider", "enderman", "cow", "pig", "sheep", "chicken", "horse", "donkey",
@@ -218,6 +225,9 @@ def scenario(name, control, distance):
control("key t tap")
control("key f3 up")
add(record(control, reload, 15), "F3+T")
elif name in WORLD_CHANGES:
for step, result in world_change(name, control, distance, lambda action, seconds: record(control, action, seconds)):
add(result, step)
elif name == "inventory":
def spam():
for _ in range(20):
@@ -234,6 +244,67 @@ def scenario(name, control, distance):
return frames, cpu
def world_change(name, control, distance, record):
"""Builds `name`'s setup in the air (y 200, away from terrain) in front of the camera, then
records it running: a list of (step, (frame times, cpu times))."""
cx, y, cz = 9000 + 300 * WORLD_CHANGES.index(name), 200, 9000
control(f"command execute in minecraft:overworld run tp @s {cx} {y + 22} {cz - 34} 0 30")
control(f"fly {cx} {y + 22} {cz - 34} {cx} {y + 22} {cz - 34} 0.1 30")
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
# Typed in chat, as a player does: `command` blocks the frame on the server tick.
def chat(command):
control(f"chat /{command}")
time.sleep(0.06)
steps = []
if name == "tnt":
chat(f"fill {cx - 10} {y - 10} {cz - 10} {cx + 10} {y - 1} {cz + 10} minecraft:stone")
chat(f"fill {cx - 2} {y} {cz - 2} {cx + 2} {y + 1} {cz + 2} minecraft:tnt")
time.sleep(2)
steps.append(("50 TNT", record(lambda: chat(f"setblock {cx + 3} {y} {cz} minecraft:redstone_block"), 10)))
elif name == "fill":
fills = [(y, y + 7), (y + 8, y + 15)]
steps.append(("fill 64x64x16 stone", record(lambda: [chat(
f"fill {cx - 32} {lo} {cz - 32} {cx + 31} {hi} {cz + 31} minecraft:stone") for lo, hi in fills], 5)))
steps.append(("clear it", record(lambda: [chat(
f"fill {cx - 32} {lo} {cz - 32} {cx + 31} {hi} {cz + 31} minecraft:air") for lo, hi in fills], 5)))
elif name == "fluids":
# Two 9-wide slopes falling 16 blocks over 16, side by side: water on one, lava on the other.
for i in range(16):
chat(f"fill {cx - 14} {y - 20} {cz + i} {cx + 14} {y - i} {cz + i} minecraft:stone")
chat(f"fill {cx - 14} {y - 20} {cz + 16} {cx + 14} {y - 15} {cz + 30} minecraft:stone")
time.sleep(2)
steps.append(("water", record(lambda: chat(f"fill {cx - 13} {y + 1} {cz} {cx - 5} {y + 1} {cz} minecraft:water"), 15)))
steps.append(("lava", record(lambda: chat(f"fill {cx + 5} {y + 1} {cz} {cx + 13} {y + 1} {cz} minecraft:lava"), 15)))
elif name == "fire":
# A 25x25 grove: grass, a log every 4 blocks, persistent leaves around them.
chat(f"fill {cx - 12} {y - 1} {cz - 12} {cx + 12} {y - 1} {cz + 12} minecraft:grass_block")
chat(f"fill {cx - 12} {y + 3} {cz - 12} {cx + 12} {y + 6} {cz + 12} minecraft:oak_leaves[persistent=true]")
for x in range(cx - 12, cx + 13, 4):
for z in range(cz - 12, cz + 13, 4):
chat(f"fill {x} {y} {z} {x} {y + 5} {z} minecraft:oak_log")
time.sleep(2)
spots = [(cx - 8, cz - 8), (cx + 8, cz - 8), (cx, cz), (cx - 8, cz + 8), (cx + 8, cz + 8)]
steps.append(("fire spreading", record(lambda: [chat(f"setblock {x} {y + 7} {z} minecraft:fire")
for x, z in spots], 25)))
elif name == "pistons":
# Two observers facing each other pulse every other tick into a dust line each way; each
# line powers the stone it lies on, and 10 pistons beside that stone push a block.
chat(f"fill {cx - 12} {y - 1} {cz - 1} {cx + 12} {y - 1} {cz + 3} minecraft:stone")
for xs in (range(cx + 1, cx + 11), range(cx - 11, cx - 1)):
for x in xs:
chat(f"setblock {x} {y} {cz} minecraft:stone")
chat(f"setblock {x} {y + 1} {cz} minecraft:redstone_wire")
chat(f"setblock {x} {y} {cz + 1} minecraft:piston[facing=south]")
chat(f"setblock {x} {y} {cz + 2} minecraft:oak_planks")
chat(f"setblock {cx} {y} {cz} minecraft:stone")
time.sleep(2)
def start():
chat(f"setblock {cx} {y + 1} {cz} minecraft:observer[facing=west]")
chat(f"setblock {cx - 1} {y + 1} {cz} minecraft:observer[facing=east]")
steps.append(("20-piston clock", record(start, 15)))
return steps
def launch(args, distance, tag):
"""A fresh game on its title screen: (process, control, log path, log file, game dir, socket)."""
import shutil
+2 -1
View File
@@ -10,7 +10,7 @@ DedicatedServer.setSimulationDistance skip:server JSON-RPC management API only;
DedicatedServer.fillServerSystemReport skip:debug adds rows to the system report of a server crash report; Horizon writes no server crash report
DedicatedServer.dumpServerProperties skip:debug writes server.properties.txt into the debug report only
DedicatedServer.onServerExit inline crates/horizon-client/src/game/dedicated.rs::Game::stop_dedicated gap: minor: closes no query, GUI or JSON-RPC (RCON is the supervisor's, which outlives the game); the LAN pinger and listener are closed by lan_close
DedicatedServer.setMaxPlayers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
DedicatedServer.setMaxPlayers port crates/horizon-client/src/game/dedicated.rs::Game::set_max_players live login limit and server.properties max-players via the management protocol
DedicatedServer.setSpawnProtectionRadius skip:server JSON-RPC management API only; spawn-protection is read at start and used by Game::under_spawn_protection
DedicatedServer.setRepliesToStatus skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
DedicatedServer.setHidesOnlinePlayers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
@@ -27,6 +27,7 @@ DedicatedServer.setForceGameMode port crates/horizon-client/src/game/dedicated/m
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.setMaxPlayers port crates/horizon-server/src/management_methods.rs::Game::max_players forwards the live limit to the game and persists server.max_players
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
1 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)
10 DedicatedServer.fillServerSystemReport skip:debug adds rows to the system report of a server crash report; Horizon writes no server crash report
11 DedicatedServer.dumpServerProperties skip:debug writes server.properties.txt into the debug report only
12 DedicatedServer.onServerExit inline crates/horizon-client/src/game/dedicated.rs::Game::stop_dedicated gap: minor: closes no query, GUI or JSON-RPC (RCON is the supervisor's, which outlives the game); the LAN pinger and listener are closed by lan_close
13 DedicatedServer.setMaxPlayers skip:server port crates/horizon-client/src/game/dedicated.rs::Game::set_max_players JSON-RPC management API only (ServerSettingsService); Horizon has no such API live login limit and server.properties max-players via the management protocol
14 DedicatedServer.setSpawnProtectionRadius skip:server JSON-RPC management API only; spawn-protection is read at start and used by Game::under_spawn_protection
15 DedicatedServer.setRepliesToStatus skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
16 DedicatedServer.setHidesOnlinePlayers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
27 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
28 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
29 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
30 MinecraftServerSettingsServiceImpl.setMaxPlayers port crates/horizon-server/src/management_methods.rs::Game::max_players forwards the live limit to the game and persists server.max_players
31 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
32 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
33 DedicatedServer.setAcceptsTransfers skip:server JSON-RPC management API only (ServerSettingsService); Horizon has no such API
+29 -1
View File
@@ -18,7 +18,8 @@ sent the same things over TLS, and every answer is compared exactly:
`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), allowlist use and enforcement read and changed
(including saved properties and restart), and
(including saved properties and restart), max players read and changed (including saved
properties and restart), and
`server/stop`;
- the TLS version each server agrees on.
@@ -177,6 +178,15 @@ MESSAGES = [
'{"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"}',
'{"jsonrpc":"2.0","id":100,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":101,"method":"minecraft:serversettings/max_players/set","params":[10]}',
'{"jsonrpc":"2.0","id":102,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":103,"method":"minecraft:serversettings/max_players/set","params":[30]}',
'{"jsonrpc":"2.0","id":104,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":105,"method":"minecraft:serversettings/max_players/set","params":["yes"]}',
'{"jsonrpc":"2.0","id":106,"method":"minecraft:serversettings/max_players"}',
'{"jsonrpc":"2.0","id":107,"method":"minecraft:serversettings/max_players/set","params":[30]}',
'{"jsonrpc":"2.0","id":108,"method":"minecraft:serversettings/max_players"}',
]
@@ -408,6 +418,9 @@ def main():
sides[side]['saved_enforce_allowlist'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('enforce-whitelist=')), None)
sides[side]['saved_max_players'] = next(
(line.partition('=')[2] for line in properties.splitlines()
if line.startswith('max-players=')), 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
@@ -418,6 +431,8 @@ def main():
folder / 'server.toml').read_text()
sides[side]['saved_config_enforce_allowlist'] = 'enforce_whitelist = true' in (
folder / 'server.toml').read_text()
sides[side]['saved_config_max_players'] = 'max_players = 30' in (
folder / 'server.toml').read_text()
finally:
server.stop()
restarted = Server(command, folder, build / f'{side}-restart.log')
@@ -449,6 +464,12 @@ def main():
if not isinstance(reply, dict) or 'method' not in reply or 'id' in reply:
break
sides[side]['restarted_enforce_allowlist'] = reply
ws.send('{"jsonrpc":"2.0","id":109,"method":"minecraft:serversettings/max_players"}')
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_max_players'] = reply
finally:
ws.sock.close()
finally:
@@ -493,6 +514,13 @@ def main():
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}")
if vanilla['saved_max_players'] != horizon['saved_max_players'] or vanilla['saved_max_players'] != '30':
failures.append(f"saved max players: vanilla {vanilla['saved_max_players']!r}, Horizon {horizon['saved_max_players']!r}")
if not horizon['saved_config_max_players']:
failures.append('Horizon did not save max players in server.toml')
if vanilla['restarted_max_players'] != horizon['restarted_max_players'] or \
vanilla['restarted_max_players'] != {'jsonrpc': '2.0', 'id': 109, 'result': 30}:
failures.append(f"restarted max players: vanilla {vanilla['restarted_max_players']!r}, Horizon {horizon['restarted_max_players']!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))
+3
View File
@@ -1130,6 +1130,9 @@ mod tests {
pub mod camera {
pub struct CameraEntity;
}
pub mod menu {
pub struct AnvilCraft;
}
pub mod fog {
#[derive(Clone, Copy)]
pub struct FogType;