From 4df6818e3a0a2375fae705cc8f4b58bd70aa52b0 Mon Sep 17 00:00:00 2001 From: delete Date: Wed, 4 Nov 2026 04:12:25 +0300 Subject: [PATCH 01/70] Add native world text and atlas quad canvas Submit camera-relative world overlays independently of block entities, using normal depth-tested atlas and font rendering. Preserve RGBA quads and draw eight-offset outlined text with the existing polygon-offset front pass. Co-Authored-By: Codex --- crates/horizon-client-render/src/gfx.rs | 46 +++- .../src/gfx/entity_batch.rs | 71 ++++- .../src/gfx/world_text.rs | 208 ++++++++++++++ crates/horizon-client-render/src/hud.rs | 98 ++++++- crates/horizon-client/src/game.rs | 3 + .../src/game/native_block_entity_draw.rs | 5 +- .../horizon-client/src/game/native_hooks.rs | 9 + .../src/game/native_world_draw.rs | 255 ++++++++++++++++++ crates/horizon-client/src/mod_api.rs | 2 +- tools/native-hook-fragments/world_canvas.json | 18 ++ 10 files changed, 696 insertions(+), 19 deletions(-) create mode 100644 crates/horizon-client-render/src/gfx/world_text.rs create mode 100644 crates/horizon-client/src/game/native_world_draw.rs create mode 100644 tools/native-hook-fragments/world_canvas.json diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index bc86b849d9..aae10dee9a 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -50,11 +50,13 @@ mod inter_stage_tests; #[cfg(test)] pub(crate) mod test_gpu; mod weather; +mod world_text; use entity_batch::EntityBatch; pub use entity_batch::{ DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, - TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit, + TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, + WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit, }; pub use view::{View, ViewSet}; @@ -986,6 +988,7 @@ pub struct Renderer { lightning: lightning::Renderer, see_through_pipeline: wgpu::RenderPipeline, text_polygon_offset_pipeline: wgpu::RenderPipeline, + world_text_pipeline: wgpu::RenderPipeline, celestial_pipeline: wgpu::RenderPipeline, celestial_buffer: wgpu::Buffer, end_sky: end_sky::EndSky, @@ -3433,6 +3436,12 @@ impl Renderer { alpha_blend, false, ); + let world_text_pipeline = world_text::pipeline( + &device, + Some(&entity_layout), + &entity_shader, + config.format.remove_srgb_suffix(), + ); // RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with // terrain wins the tie in vanilla (the boat's floor over the trapdoor on the // ground it is stopped by, #5950). Terrain and entities are separate draw @@ -4260,6 +4269,7 @@ impl Renderer { lightning: lightning::Renderer::new(), see_through_pipeline, text_polygon_offset_pipeline, + world_text_pipeline, celestial_pipeline, celestial_buffer, end_sky: end_sky::EndSky::default(), @@ -7714,6 +7724,8 @@ impl Renderer { translucent_particle_quads, translucent_item_quads, oit_entity_quads, + world_text_quads, + world_text_offset_quads, ] = self.entity_batch.update( entities, frame.additive, @@ -7739,11 +7751,14 @@ impl Renderer { + shadow_quads + translucent_particle_quads + translucent_item_quads - + oit_entity_quads; + + oit_entity_quads + + world_text_quads + + world_text_offset_quads; laps.lap("r_draw_lists"); // Where the tail of materials the classic and the improved passes // both draw ends: the shadows, particles and items count back from it. - let base_quads = world_quads - oit_entity_quads; + let base_quads = + world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads; let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds); let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) { let (rebuilt, uniform) = @@ -9473,6 +9488,29 @@ impl Renderer { } pass.set_bind_group(0, &self.group, &[]); } + // Native world text is an ordinary depth-tested overlay, after + // particles (also after the OIT composite) and before the hand. + // Its own geometry is independent of block-entity lifetime. + if world_text_quads + world_text_offset_quads > 0 { + pass.set_bind_group(0, &self.entity_group, &[]); + pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + let first = base_quads + oit_entity_quads; + for (pipeline, count, offset) in [ + (&self.world_text_pipeline, world_text_quads, first), + ( + &self.text_polygon_offset_pipeline, + world_text_offset_quads, + first + world_text_quads, + ), + ] { + if count > 0 { + pass.set_pipeline(pipeline); + pass.draw_indexed(0..count * 6, (offset * 4) as i32, 0..1); + } + } + pass.set_bind_group(0, &self.group, &[]); + } // LevelRenderer.executeClassicTransparency: translucent targets' // outlines follow terrain, so its blending cannot wash them out. if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline { @@ -11056,7 +11094,7 @@ mod tests { let mut batch = super::EntityBatch::default(); let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false); let vertices = &batch.combined; - assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]); + assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]); assert_eq!( vertices .as_chunks::<4>() diff --git a/crates/horizon-client-render/src/gfx/entity_batch.rs b/crates/horizon-client-render/src/gfx/entity_batch.rs index c490a81d72..d1a9f8bca0 100644 --- a/crates/horizon-client-render/src/gfx/entity_batch.rs +++ b/crates/horizon-client-render/src/gfx/entity_batch.rs @@ -14,7 +14,8 @@ use super::Vertex; /// material at the end: the render types vanilla gives an `OitPipelineSet` /// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is /// ignored and they stay in the first one, in their order. -const MATERIALS: usize = 10; +/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET. +const MATERIALS: usize = 12; /// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier /// (bits 16 to 23): the quad's render type has an `OitPipelineSet` /// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`, @@ -51,6 +52,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33; /// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays): /// drawn like an ordinary entity, pulled toward the camera. pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35; +/// A native world canvas, drawn after particles with normal text depth/culling. +pub const WORLD_TEXT_OVERLAY: u32 = 39; +/// The front of its eight-offset outlined text, using POLYGON_OFFSET. +pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40; /// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16 /// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones. @@ -59,6 +64,8 @@ fn material(layer: u32, oit: bool) -> usize { return 9; } match layer & 0xffff { + WORLD_TEXT_OVERLAY => 10, + WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11, TEXT_POLYGON_OFFSET_OVERLAY => 1, 11 => 2, 12 => 3, @@ -118,7 +125,7 @@ impl EntityBatch { self.combined.clear(); self.combined .reserve(entity_vertices + additive.len() + hand.len() + see_through.len()); - for runs in &self.runs { + for runs in &self.runs[..10] { for &(first, count) in runs { self.combined .extend_from_slice(&entities[first * 4..(first + count) * 4]); @@ -141,6 +148,27 @@ impl EntityBatch { } } } + // RenderType.text sorts alpha-blended quads from far to near. Keep + // both world-canvas layers after existing materials (including eyes + // moved out of additive); front text uses polygon offset. + for runs in &self.runs[10..] { + let start = self.combined.len(); + for &(first, count) in runs { + self.combined + .extend_from_slice(&entities[first * 4..(first + count) * 4]); + } + self.combined[start..] + .as_chunks_mut::<4>() + .0 + .sort_by(|a, b| { + let distance = |q: &[Vertex; 4]| { + (0..3) + .map(|i| ((q[0].pos[i] + q[2].pos[i]) * 0.5).powi(2)) + .sum::() + }; + distance(b).total_cmp(&distance(a)) + }); + } if self.moved_additive == 0 { self.combined.extend_from_slice(additive); } else { @@ -160,6 +188,41 @@ impl EntityBatch { mod tests { use super::*; use std::{hint::black_box, time::Instant}; + #[test] + fn world_canvas_sorts_after_oit_eyes_before_frame_tails() { + let quad = |layer, x| { + [Vertex { + layer, + pos: [x, 0.0, 0.0], + ..geometry(1, false)[0] + }; 4] + }; + let entities: Vec<_> = [ + (0, 1.0), + (35, 2.0), + (OIT_ENTITY, 3.0), + (WORLD_TEXT_OVERLAY, 4.0), + (WORLD_TEXT_OVERLAY, 5.0), + (WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0), + ] + .into_iter() + .flat_map(|(layer, x)| quad(layer, x)) + .collect(); + let additive: Vec<_> = [(OIT_ENTITY | 2, 7.0), (2, 8.0)] + .into_iter() + .flat_map(|(layer, x)| quad(layer, x)) + .collect(); + let mut batch = EntityBatch::default(); + assert_eq!( + batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true), + [1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1] + ); + let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); + assert_eq!( + positions, + [1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0] + ); + } // Retain the original implementation as the equivalence/benchmark oracle. fn baseline( @@ -257,12 +320,12 @@ mod tests { let mut batch = EntityBatch::default(); // Off: the tag changes nothing, the quads keep their place. let counts = batch.update(&entities, &[], &[], &[], false); - assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]); + assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); let xs: Vec = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]); // On: the tagged quads come last, in order. let counts = batch.update(&entities, &[], &[], &[], true); - assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]); + assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]); let xs: Vec = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect(); assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]); } diff --git a/crates/horizon-client-render/src/gfx/world_text.rs b/crates/horizon-client-render/src/gfx/world_text.rs new file mode 100644 index 0000000000..92be4dddbf --- /dev/null +++ b/crates/horizon-client-render/src/gfx/world_text.rs @@ -0,0 +1,208 @@ +//! NORMAL world text: alpha blended, back-face culled, ordinary depth writes. +pub(super) fn pipeline( + device: &wgpu::Device, + layout: Option<&wgpu::PipelineLayout>, + shader: &wgpu::ShaderModule, + format: wgpu::TextureFormat, +) -> wgpu::RenderPipeline { + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Normal world text"), + layout, + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_entity"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &attributes, + })], + }, + primitive: wgpu::PrimitiveState { + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_entity"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use wgpu::util::DeviceExt; + #[test] + #[ignore = "requires a Vulkan/Metal/DX12 adapter"] + fn world_text_blends_culls_and_obeys_world_depth() { + let instance = crate::gfx::test_gpu::instance(); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + // Use the production pipeline/vertex layout with a pass-through shader + // to isolate its actual depth, winding and blend behavior from lighting. + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl( + r#" +struct Out { @builtin(position) pos:vec4, @location(0) color:vec4 } +@vertex fn vs_entity(@location(0) pos:vec3, @location(3) color:vec4)->Out { + return Out(vec4(pos,1.0),color); +} +@fragment fn fs_entity(v:Out)->@location(0) vec4 { return v.color; } +"# + .into(), + ), + }); + let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm); + let texture = |format, usage| { + device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 4, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let color = texture( + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + ); + let depth = texture( + wgpu::TextureFormat::Depth32Float, + wgpu::TextureUsages::RENDER_ATTACHMENT, + ); + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + let mut quad = |pixel: u32, z: f32, color: [u8; 4], back: bool| { + let x = -1.0 + pixel as f32 * 0.5; + let first = vertices.len() as u32; + for pos in [ + [x, -1.0, z], + [x + 0.5, -1.0, z], + [x + 0.5, 1.0, z], + [x, 1.0, z], + ] { + vertices.push(super::super::Vertex { + pos, + uv: [0.0; 2], + color: u32::from_le_bytes(color), + layer: 0, + light: 0, + normal: [0; 4], + glint_uv: [0.0; 2], + }); + } + for i in if back { + [0, 2, 1, 0, 3, 2] + } else { + [0, 1, 2, 0, 2, 3] + } { + indices.push(first + i); + } + }; + quad(0, 0.6, [255, 0, 0, 255], false); + // A hair behind: the entity model pipeline's 128-ulp depth bias + // would incorrectly put the green quad in front of the red surface. + quad(0, 0.599_999, [0, 255, 0, 255], false); + quad(1, 0.8, [0, 255, 0, 255], false); + quad(1, 0.7, [255, 0, 0, 255], false); + quad(2, 0.5, [255, 0, 0, 128], false); + quad(3, 0.5, [255, 255, 0, 255], true); + let buffer = |data: &[u8], usage| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents: data, + usage, + }) + }; + let vertex = buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX); + let index = buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 256, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let color = color.create_view(&Default::default()); + let depth = depth.create_view(&Default::default()); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &color, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLUE), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_vertex_buffer(0, vertex.slice(..)); + pass.set_index_buffer(index.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..indices.len() as u32, 0, 0..1); + } + encoder.copy_texture_to_buffer( + color.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + color.size(), + ); + queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + readback.map_async(wgpu::MapMode::Read, .., move |result| { + tx.send(result).unwrap() + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + let pixels = readback.get_mapped_range(..).unwrap(); + assert_eq!( + &pixels[..16], + &[ + 255, 0, 0, 255, 0, 255, 0, 255, 128, 0, 127, 255, 0, 0, 255, 255 + ] + ); + } +} diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index dcb7060f72..1af85923f4 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -1892,7 +1892,7 @@ impl HudBatch { color: u32, shadow: bool, ) -> f32 { - self.styled_text(atlas, text, x, y, color, shadow, false) + self.styled_text(atlas, text, x, y, color, shadow, false, None) } /// `text_styled` for text drawn in the world (`TextRenderable.render` /// with `flat` false): each quad's `depth` is its z in text units, the @@ -1908,7 +1908,42 @@ impl HudBatch { color: u32, shadow: bool, ) -> f32 { - self.styled_text(atlas, text, x, y, color, shadow, true) + self.styled_text(atlas, text, x, y, color, shadow, true, None) + } + /// `Font.drawInBatch8xOutline`: eight glyph offsets in the outline color, + /// then the original text. Returns (advance, first front-text vertex), so + /// world rendering can use POLYGON_OFFSET for the front and NORMAL for + /// its outline. Offsets use each glyph's own shadow offset (unihex: 0.5). + pub fn text_styled_outline_world( + &mut self, + atlas: &HudAtlas, + text: &crate::text::Text, + x: f32, + y: f32, + color: u32, + outline: u32, + ) -> (f32, usize) { + for dx in -1..=1 { + for dy in -1..=1 { + if dx != 0 || dy != 0 { + self.styled_text( + atlas, + text, + x, + y, + color, + false, + true, + Some((outline, [dx as f32, dy as f32])), + ); + } + } + } + let front = self.vertices.len(); + ( + self.styled_text(atlas, text, x, y, color, false, true, None), + front, + ) } #[allow(clippy::too_many_arguments)] fn styled_text( @@ -1920,6 +1955,7 @@ impl HudBatch { color: u32, shadow: bool, world: bool, + outline: Option<(u32, [f32; 2])>, ) -> f32 { // StringDecomposer.iterateFormatted inside Font.drawInternal: // `ยง` codes inside a span restyle what follows and are not drawn. @@ -1987,10 +2023,11 @@ impl HudBatch { } self.patches.extend(font.take_patches()); let white = font.white(); - let has_shadow = shadow - || glyphs - .iter() - .any(|glyph| glyph.3.is_some_and(|color| color != 0)); + let has_shadow = outline.is_none() + && (shadow + || glyphs + .iter() + .any(|glyph| glyph.3.is_some_and(|color| color != 0))); let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] }; // ARGB.scaleRGB(color, 0.25): the default shadow colour. let shade = |c: u32| { @@ -2025,8 +2062,9 @@ impl HudBatch { } else { shade(*c) }; - let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0) - || custom_shadow.is_none() && shadow) + let has_shadow = outline.is_none() + && (custom_shadow.is_some_and(|argb| argb != 0) + || custom_shadow.is_none() && shadow) && !(world && shadow_color == 0); if is_shadow && !has_shadow { continue; @@ -2036,7 +2074,11 @@ impl HudBatch { } else { (0.0, *c) }; - let (px, py) = (*gx + dx, y + dx); + let (px, py, col) = if let Some((color, [dx, dy])) = outline { + (*gx + dx * g.offset, y + dy * g.offset, color) + } else { + (*gx + dx, y + dx, col) + }; if let Some(object) = object { // `PlainTextRenderable.render`: sprite glyphs use the same // shadow colour and offset as text, with the front at SHADOW_DEPTH. @@ -3737,6 +3779,44 @@ texture=wide", assert_eq!(fonts.font.width("abba"), width); } #[test] + fn outlined_world_text_keeps_font_metrics_and_overrides_outline_color() { + let root = two_glyph_font("world-outline"); + let atlas = HudAtlas::build(&root).unwrap(); + let _ = fs::remove_dir_all(&root); + let mut batch = HudBatch::new([1, 1]); + let text = crate::text::Text::colored("ab", 0xff0000); + let outline = rgba(1, 2, 3, 128); + let (advance, front) = + batch.text_styled_outline_world(&atlas, &text, 10.0, 20.0, u32::MAX, outline); + assert_eq!((advance, front, batch.vertices.len()), (10.0, 96, 108)); + assert!(batch.vertices[..front].iter().all(|v| v.color == outline)); + assert!( + batch.vertices[front..] + .iter() + .all(|v| v.color == rgba(255, 0, 0, 255)) + ); + // Eight independently offset silhouettes, retaining both glyphs and + // the bitmap font's advance (a=4, b=6); front text has no shadow. + for (i, [dx, dy]) in [ + [-1.0, -1.0], + [-1.0, 0.0], + [-1.0, 1.0], + [0.0, -1.0], + [0.0, 1.0], + [1.0, -1.0], + [1.0, 0.0], + [1.0, 1.0], + ] + .into_iter() + .enumerate() + { + let at = batch.vertices[i * 12].pos; + let main = batch.vertices[front].pos; + assert_eq!(at, [main[0] + dx, main[1] + dy]); + assert_eq!(batch.vertices[i * 12 + 6].pos[0] - at[0], 4.0); + } + } + #[test] fn obfuscated_glyphs_keep_the_width() { let root = two_glyph_font("obfuscated"); let fonts = HudAtlas::fonts(&root).unwrap(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 20452aa2e2..4d74e9c6ae 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -237,6 +237,7 @@ mod native_shaders; mod native_ticks; mod native_visuals; mod native_world_columns; +pub mod native_world_draw; mod native_world_edit; mod native_world_read; mod natural_spawning; @@ -422,6 +423,7 @@ pub use native_item_energy::ItemEnergyBuffer; pub use native_mob_draw::{NativeMobRender, NativePlayerRender}; pub use native_server::NativeExperienceOrb; pub use native_world_columns::NativeColumnBlocks; +pub use native_world_draw::NativeWorldDraw; pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap}; use std::{ cell::RefCell, @@ -11415,6 +11417,7 @@ impl Game<'_> { laps.lap("eg_cracks"); self.finish_async_entity_culling(); laps.lap("eg_culling_finish"); + self.native_world_geometry(camera, alpha as f32, &mut out); ( out, static_block_entities, diff --git a/crates/horizon-client/src/game/native_block_entity_draw.rs b/crates/horizon-client/src/game/native_block_entity_draw.rs index 582b140b97..89e8c17b2f 100644 --- a/crates/horizon-client/src/game/native_block_entity_draw.rs +++ b/crates/horizon-client/src/game/native_block_entity_draw.rs @@ -138,9 +138,12 @@ impl BlockEntityDraw { *pose *= m; } } - fn pose(&self) -> Mat4 { + pub(super) fn pose(&self) -> Mat4 { self.poses.last().copied().unwrap_or(Mat4::IDENTITY) } + pub(super) fn vertices_mut(&mut self) -> &mut Vec { + &mut self.vertices + } /// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as /// vanilla's block entity models are (`Lighting`'s two lights). fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) { diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index f655865a36..fe1b338949 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -880,6 +880,7 @@ use super::{ native_configs::{NativeConfig, NativeConfigFile}, native_mob_draw::{NativeMobRender, NativePlayerRender}, native_panel_recipes::RecipePage, + native_world_draw::NativeWorldDraw, }; use horizon_assets::item_model::StackView; use horizon_client::mod_api::{ @@ -3018,6 +3019,14 @@ impl Game<'_> { bounds, ); } + /// Submit depth-tested world text and textured quads, after particles and + /// before the hand/HUD. The pose starts camera-relative; use + /// `draw.translate_world` for a world position. Called independently of + /// block entities and chunks, so transient overlays may outlive their + /// source block. API @API:world_canvas@. + pub(super) fn native_world_submit(&self, draw: &mut NativeWorldDraw) { + mod_api::dispatch_world_submit(self, mod_api::mods(), draw); + } /// Draw the mob `mob` (its id, kind, custom name and the frame's body /// and head turns and walk cycle, `LivingEntityRenderState`) on this /// client with a model of your own, once a frame while it is in view diff --git a/crates/horizon-client/src/game/native_world_draw.rs b/crates/horizon-client/src/game/native_world_draw.rs new file mode 100644 index 0000000000..41c409517a --- /dev/null +++ b/crates/horizon-client/src/game/native_world_draw.rs @@ -0,0 +1,255 @@ +//! A native mod's world-space text and atlas quads, through the normal renderer. +use super::{Game, native_block_entity_draw::BlockEntityDraw}; +use glam::{DVec3, Mat4, Vec3}; +use horizon_client::{ + font::Patch, + gfx::{WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY}, + hud::{HudBatch, Rect, rgba}, + mesher::Vertex, + text::Text, +}; +use std::ops::{Deref, DerefMut}; + +/// One world frame, independently of block entities or source blocks. Its pose +/// starts camera-relative and shares `BlockEntityDraw`'s push/pop, translate, +/// rotate and scale operations, partial tick, game time and packed light. +/// `keep` does not cache a world canvas. API @API:world_canvas@. +pub struct NativeWorldDraw { + pub camera: [f64; 3], + draw: BlockEntityDraw, + patches: Vec, + profiles: Vec, +} +impl Deref for NativeWorldDraw { + type Target = BlockEntityDraw; + fn deref(&self) -> &Self::Target { + &self.draw + } +} +impl DerefMut for NativeWorldDraw { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.draw + } +} +impl NativeWorldDraw { + fn new(camera: [f64; 3], partial: f32, time: i64) -> Self { + Self { + camera, + draw: BlockEntityDraw::posed( + partial, + time, + 240 | 240 << 8, + [0; 3], + Mat4::IDENTITY, + (Vec3::Y, Vec3::Y), + Vec::new(), + ), + patches: Vec::new(), + profiles: Vec::new(), + } + } + /// Translate to a world position, subtracting the camera in f64 first. + /// Returns false without changing the pose for a non-finite offset. + pub fn translate_world(&mut self, position: [f64; 3]) -> bool { + let offset = (DVec3::from_array(position) - DVec3::from_array(self.camera)).as_vec3(); + if !offset.is_finite() { + return false; + } + self.draw.translate(offset.to_array()); + true + } + fn quad(&mut self, sprite: Rect, corners: [[f32; 3]; 4], uv: [f32; 4], color: [u8; 4]) -> bool { + let pose = self.draw.pose(); + if !pose.is_finite() + || !corners.iter().flatten().all(|v| v.is_finite()) + || !uv.iter().all(|v| v.is_finite()) + { + return false; + } + let [u0, v0, u1, v1] = uv.map(|v| v / 16.0); + let points = corners.map(|point| pose.transform_point3(Vec3::from_array(point))); + if points.iter().any(|point| !point.is_finite()) { + return false; + } + let light = self.draw.light; + for (point, [u, v]) in points + .into_iter() + .zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]]) + { + self.draw.vertices_mut().push(Vertex { + pos: point.to_array(), + uv: [ + sprite.u0 + (sprite.u1 - sprite.u0) * u, + sprite.v0 + (sprite.v1 - sprite.v0) * v, + ], + layer: WORLD_TEXT_OVERLAY, + color: u32::from_le_bytes(color), + light, + normal: [0; 4], + glint_uv: [0.0; 2], + }); + } + true + } + fn text(&mut self, batch: HudBatch, front: Option) { + let pose = self.draw.pose(); + let light = self.draw.light; + if pose.is_finite() { + for (index, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() { + for i in horizon_client::text_display::CORNERS { + let vertex = quad[i]; + self.draw.vertices_mut().push(Vertex { + pos: pose + .transform_point3(Vec3::new(vertex.pos[0], vertex.pos[1], vertex.depth)) + .to_array(), + uv: vertex.uv, + color: vertex.color, + light, + layer: if front.is_some_and(|start| index * 6 >= start) { + WORLD_TEXT_POLYGON_OFFSET_OVERLAY + } else { + WORLD_TEXT_OVERLAY + }, + normal: [0; 4], + glint_uv: [0.0; 2], + }); + } + } + } + self.patches.extend(batch.patches); + self.profiles.extend(batch.inline_profiles); + } +} +impl Game<'_> { + pub(super) fn native_world_geometry( + &mut self, + camera: DVec3, + partial: f32, + out: &mut Vec, + ) { + if !horizon_client::gfx::native_render::world_drawn_hooked() + && crate::mod_api::mods().is_empty() + { + return; + } + let mut draw = NativeWorldDraw::new(camera.to_array(), partial, self.world.game_time); + // NONE keeps these quads out of shader-pack entity programs. + self.mark_stage(out.len(), "NONE"); + self.native_world_submit(&mut draw); + self.resolve_inline_profiles(&draw.profiles); + self.map_patches.extend(draw.patches); + out.extend(draw.draw.into_vertices()); + } + /// Normal, depth-tested atlas quad under the current pose. UVs use 16 + /// texels per sprite side; color is [red, green, blue, alpha]. No model + /// diffuse lighting is applied; `draw.light` still selects lightmap light. + /// False for an unknown sprite or non-finite geometry. API @API:world_canvas@. + pub(super) fn native_world_quad( + &self, + draw: &mut NativeWorldDraw, + sprite: &str, + corners: [[f32; 3]; 4], + uv: [f32; 4], + color: [u8; 4], + ) -> bool { + self.gui + .atlas + .sprites + .get(sprite) + .copied() + .is_some_and(|sprite| draw.quad(sprite, corners, uv, color)) + } + /// Actual styled world font at (0,0), with ARGB default color and optional + /// eight-offset ARGB outline. Returns the text advance in font pixels. + /// The front of outlined text uses POLYGON_OFFSET. API @API:world_canvas@. + pub(super) fn native_world_text( + &self, + draw: &mut NativeWorldDraw, + text: &Text, + color: u32, + outline: Option, + ) -> f32 { + let packed = |c: u32| rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, (c >> 24) as u8); + let mut batch = HudBatch::new([1, 1]); + let (advance, front) = if let Some(outline) = outline { + let (advance, front) = batch.text_styled_outline_world( + &self.gui.atlas, + text, + 0.0, + 0.0, + packed(color), + packed(outline), + ); + (advance, Some(front)) + } else { + ( + batch.text_styled_world(&self.gui.atlas, text, 0.0, 0.0, packed(color), false), + None, + ) + }; + draw.text(batch, front); + advance + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn world_quad_preserves_sub_block_precision_rgba_uv_and_pose() { + let mut frame = NativeWorldDraw::new([30_000_000.125, 64.0, -30_000_000.5], 0.5, 123); + assert!(frame.translate_world([30_000_000.625, 65.0, -30_000_000.25])); + frame.scale([2.0, 3.0, 4.0]); + frame.light = 0x12_34; + assert!(frame.quad( + Rect { + w: 16, + h: 16, + u0: 0.25, + v0: 0.5, + u1: 0.75, + v1: 1.0 + }, + [ + [0.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [1.0, 1.0, 0.0], + [1.0, 0.0, 0.0] + ], + [4.0, 0.0, 12.0, 8.0], + [12, 34, 56, 78] + )); + let vertices = frame.draw.into_vertices(); + assert_eq!( + vertices.iter().map(|v| v.pos).collect::>(), + [ + [0.5, 1.0, 0.25], + [0.5, 4.0, 0.25], + [2.5, 4.0, 0.25], + [2.5, 1.0, 0.25] + ] + ); + assert_eq!( + vertices.iter().map(|v| v.uv).collect::>(), + [[0.375, 0.5], [0.375, 0.75], [0.625, 0.75], [0.625, 0.5]] + ); + assert!( + vertices + .iter() + .all(|v| v.color.to_le_bytes() == [12, 34, 56, 78] + && v.light == 0x12_34 + && v.normal == [0; 4]) + ); + } + #[test] + fn invalid_world_offsets_leave_the_pose_usable() { + let mut frame = NativeWorldDraw::new([0.0; 3], 0.0, 0); + assert!(!frame.translate_world([f64::NAN, 0.0, 0.0])); + assert!(!frame.translate_world([f64::MAX, 0.0, 0.0])); + assert!(frame.translate_world([1.0, 2.0, 3.0])); + assert_eq!( + frame.draw.pose().transform_point3(Vec3::ZERO), + Vec3::new(1.0, 2.0, 3.0) + ); + } +} diff --git a/crates/horizon-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 8ea3362b38..a7af080667 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -20,7 +20,7 @@ pub use crate::game::{ ItemEnergyBuffer, NativeBlockEdit, NativeBlockRun, NativeClock, NativeColumnBlocks, NativeConfig, NativeConfigFile, NativeConfigKind, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit, NativeMapDecoration, NativeMapInfo, - NativeMobRender, NativeModEvent, NativePlayerRender, NativeShake, + NativeMobRender, NativeModEvent, NativePlayerRender, NativeShake, NativeWorldDraw, }; pub use crate::gfx::native_render::{ BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, diff --git a/tools/native-hook-fragments/world_canvas.json b/tools/native-hook-fragments/world_canvas.json new file mode 100644 index 0000000000..7905af6206 --- /dev/null +++ b/tools/native-hook-fragments/world_canvas.json @@ -0,0 +1,18 @@ +{ + "hooks": [ + { + "name": "world_submit", + "target": "horizon_client::game::native_hooks::Game<'_>::native_world_submit", + "side": "client", + "cancellable": false, + "mod_api": {"contract": "listen"} + } + ], + "helpers": [ + {"name": "world_quad", "target": "horizon_client::game::native_world_draw::Game<'_>::native_world_quad", "mod_api": {}}, + {"name": "world_text", "target": "horizon_client::game::native_world_draw::Game<'_>::native_world_text", "mod_api": {}} + ], + "rust_summary": "a world-space text/quad canvas (`native_world_submit`, `native_world_quad`, `native_world_text`).", + "doc_summary": "`native_world_submit(draw)` runs once per extracted client world frame. `NativeWorldDraw` supplies the camera, partial tick, game time, light and a pose stack starting camera-relative; `translate_world([x,y,z])` subtracts the f64 camera before converting to f32. `native_world_quad(draw,sprite,corners,uv,rgba)` draws an unshaded atlas quad (UVs in 16 texels per sprite side; RGBA is four bytes). `native_world_text(draw,text,color,outline)` draws the actual styled font at (0,0), with ARGB colors and an optional eight-offset outline, and returns its advance. Both use lightmap light, normal fog, back-face culling, alpha blending and ordinary depth testing/writes. The outlined text's front uses the existing polygon-offset text pipeline. Geometry draws after particles, independently of block entities or source blocks, before the hand/HUD. No GPU objects are exposed. Bundles need \"hooks\": @API:world_canvas@.", + "comments": ["crates/horizon-client/src/game/native_world_draw.rs"] +} From c0591d42152475ed06d83e65e292ad2c5358eeaa Mon Sep 17 00:00:00 2001 From: delete Date: Wed, 4 Nov 2026 04:05:38 +0300 Subject: [PATCH 02/70] Support tickable sounds owned by compiled native code Apply owner-updated gain, pitch and position through ordinary sound-engine channels without attaching vanilla entity or music behavior. Verify paused updates, stop and release with the channel backend. Co-authored-by: Codex --- .../horizon-client-render/src/sound_engine.rs | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client-render/src/sound_engine.rs b/crates/horizon-client-render/src/sound_engine.rs index e2dadb759d..14e81bd3c0 100644 --- a/crates/horizon-client-render/src/sound_engine.rs +++ b/crates/horizon-client-render/src/sound_engine.rs @@ -46,6 +46,10 @@ pub enum Attenuation { pub enum Ticking { /// Not tickable. No, + /// A compiled native owner updates this instance through `instance_mut`. + /// The ordinary engine applies gain, pitch and position each unpaused tick; + /// no vanilla entity behavior overwrites them. Stop and release when done. + Native, /// `BiomeAmbientSoundsHandler.LoopSoundInstance`. BiomeLoop { fade: i32, direction: i32 }, /// `UnderLiquidAmbientSoundInstance`. @@ -550,7 +554,7 @@ impl SoundInstance { /// `TickableSoundInstance.tick`. pub fn tick(&mut self, ctx: &TickContext) { match &mut self.ticking { - Ticking::No | Ticking::MusicLayer => {} + Ticking::No | Ticking::Native | Ticking::MusicLayer => {} Ticking::BiomeLoop { fade, direction } => { let stop = *fade < 0; *fade += *direction; @@ -1464,6 +1468,8 @@ pub mod test_backend { next: ChannelId, pub stopped: HashMap, pub volume: HashMap, + pub pitch: HashMap, + pub position: HashMap, } impl Backend for Null { fn create(&mut self, _streaming: bool) -> Option { @@ -1474,13 +1480,17 @@ pub mod test_backend { fn destroy(&mut self, c: ChannelId) { self.stopped.remove(&c); } - fn set_pitch(&mut self, _: ChannelId, _: f32) {} + fn set_pitch(&mut self, c: ChannelId, value: f32) { + self.pitch.insert(c, value); + } fn set_volume(&mut self, c: ChannelId, v: f32) { self.volume.insert(c, v); } fn set_attenuation(&mut self, _: ChannelId, _: Option) {} fn set_looping(&mut self, _: ChannelId, _: bool) {} - fn set_position(&mut self, _: ChannelId, _: [f64; 3]) {} + fn set_position(&mut self, c: ChannelId, value: [f64; 3]) { + self.position.insert(c, value); + } fn set_relative(&mut self, _: ChannelId, _: bool) {} fn start(&mut self, _: ChannelId, _: &Sound, _: bool) {} fn stop(&mut self, c: ChannelId) { @@ -1498,6 +1508,59 @@ pub mod test_backend { mod tests { use super::*; + #[test] + fn native_owned_loop_applies_updates_and_stops_through_the_ordinary_engine() { + let mut registry = SoundRegistry::default(); + registry.add_file( + "example", + br#"{"loop":{"sounds":["machine/loop"]}}"#, + &|_, _| true, + ); + let mut engine = SoundEngine::new(registry); + let mut backend = test_backend::Null::default(); + let mut instance = SoundInstance::new( + "example:loop", + 4, + 0.1, + 0.5, + LegacyRandom::new(1), + true, + 0, + Attenuation::None, + [0.0; 3], + true, + ); + instance.ticking = Ticking::Native; + let (id, result) = engine.play_new(instance, true, &mut backend); + assert_eq!(result, PlayResult::Started); + let channel = engine.channel(id).unwrap(); + assert_eq!(backend.volume[&channel], 0.1); + let owned = engine.instance_mut(id).unwrap(); + owned.volume = 0.4; + owned.pitch = 1.25; + owned.position = [2.0, 3.0, 4.0]; + engine.tick(true, &TickContext::default(), &mut backend); + assert_eq!( + backend.volume[&channel], 0.1, + "paused world sounds retain channel values" + ); + engine.tick(false, &TickContext::default(), &mut backend); + assert_eq!(backend.volume[&channel], 0.4); + assert_eq!(backend.pitch[&channel], 1.25); + assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]); + let owned = engine.instance(id).unwrap(); + assert_eq!((owned.pitch, owned.position), (1.25, [2.0, 3.0, 4.0])); + assert!(engine.is_active(id)); + engine.instance_mut(id).unwrap().stop(); + engine.tick(false, &TickContext::default(), &mut backend); + assert!(backend.stopped.get(&channel).copied().unwrap_or(true)); + engine.release(id); + for _ in 0..20 { + engine.tick(false, &TickContext::default(), &mut backend); + } + assert!(engine.instance(id).is_none()); + } + #[test] fn happy_ghast_riding_sound_follows_the_ghast_speed() { let ghast = RiddenSound { From 77bb0af3b124176d04b3326b3b4666dff70223eb Mon Sep 17 00:00:00 2001 From: delete Date: Wed, 4 Nov 2026 05:01:36 +0300 Subject: [PATCH 03/70] Exercise generated world canvas dispatch with shared draw state Add the world canvas fixture type to workspace dispatch tests and verify ordered mutation through the generated event. Co-authored-by: Codex --- .../dispatch-tests/src/lib.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/horizon-mod-api-gen/dispatch-tests/src/lib.rs b/crates/horizon-mod-api-gen/dispatch-tests/src/lib.rs index a2f7721411..a1724aa138 100644 --- a/crates/horizon-mod-api-gen/dispatch-tests/src/lib.rs +++ b/crates/horizon-mod-api-gen/dispatch-tests/src/lib.rs @@ -8,6 +8,7 @@ mod tests { count: i32, } struct BlockEntityDraw(Vec); + struct NativeWorldDraw(Vec); #[allow(dead_code)] mod stubs { #[derive(Clone, Copy, Debug, PartialEq)] @@ -144,6 +145,9 @@ mod tests { calls: Arc>>, } impl Hooks for Mod { + fn world_submit(&self, _: &dyn Host, event: &mut WorldSubmitEvent<'_>) { + event.draw.0.push(self.id); + } fn block_entity_submit(&self, _: &dyn Host, event: &mut BlockEntitySubmitEvent<'_>) { assert_eq!(event.pos, [1, 2, 3]); assert_eq!(event.block, "example:machine"); @@ -225,6 +229,22 @@ mod tests { } } } + #[test] + fn world_submit_dispatches_in_order_with_shared_draw() { + let mods: Vec> = vec![1, 2] + .into_iter() + .map(|id| { + Box::new(Mod { + id, + calls: Arc::new(Mutex::new(Vec::new())), + }) as Box + }) + .collect(); + let mut draw = NativeWorldDraw(Vec::new()); + dispatch_world_submit(&TestHost, &mods, &mut draw); + assert_eq!(draw.0, [1, 2]); + } + #[test] fn block_entity_submit_dispatches_in_order_with_shared_draw() { let calls = Arc::new(Mutex::new(Vec::new())); From a225724ed4f9e2f0a3cbeb760f74a66e8317ed5d Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT <212331760+TiggerTakkyTTT@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:20:04 +0700 Subject: [PATCH 04/70] Server API: pin config replies Add JSON golden cases for redacted config reads, stopped and running config patches, protected settings, malformed bodies, invalid ranges, invalid value shapes, and unknown settings. Fixes #9056 Co-Authored-By: Codex (GPT-5.6) --- crates/horizon-server/src/api.rs | 92 +++++++++++++++++ .../testdata/api-v1-config.json | 99 +++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 crates/horizon-server/testdata/api-v1-config.json diff --git a/crates/horizon-server/src/api.rs b/crates/horizon-server/src/api.rs index f3bfb1f68e..a4842943b2 100644 --- a/crates/horizon-server/src/api.rs +++ b/crates/horizon-server/src/api.rs @@ -1203,4 +1203,96 @@ mod tests { assert!(shared.restart.load(Ordering::SeqCst)); std::fs::remove_dir_all(&dir).unwrap(); } + + fn config_reply(response: Response) -> Value { + assert!(response.stream.is_none()); + json!({ + "status": response.status, + "content_type": response.content_type, + "headers": response.headers, + "body": serde_json::from_str::(&response.body).unwrap(), + }) + } + + #[test] + fn config_replies_match_api_v1_goldens() { + let golden: Value = + serde_json::from_str(include_str!("../testdata/api-v1-config.json")).unwrap(); + let dir = std::env::temp_dir().join(format!( + "horizon-server-api-config-goldens-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let toml = crate::config::TEMPLATE + .replace("token = \"\"", "token = \"a-token-for-the-config-test\"") + .replace( + "webhook = \"\"", + "webhook = \"https://discord.com/api/webhooks/123/config-test\"", + ); + std::fs::write(dir.join(crate::config::FILE), toml).unwrap(); + let shared = Shared::with_install(&dir, None); + shared.set_phase(Phase::Stopped); + let call = |method: &str, body: &[u8]| { + config_reply(route( + &shared, + &Request { + method: method.into(), + path: "/api/v1/config".into(), + query: String::new(), + headers: Vec::new(), + body: body.to_vec(), + }, + )) + }; + let check = |case: &str, method: &str, body: &[u8]| { + assert_eq!(call(method, body), golden[case], "{case}"); + }; + + check("get", "GET", b""); + check("malformed", "PATCH", b"not json"); + check( + "protected", + "PATCH", + br#"{"changes":{"api.token":"replacement"}}"#, + ); + check( + "invalid_range", + "PATCH", + br#"{"changes":{"server.max_players":0}}"#, + ); + check( + "invalid_shape", + "PATCH", + br#"{"changes":{"world.seed":[1,2]}}"#, + ); + check( + "unknown", + "PATCH", + br#"{"changes":{"server.unknown":true}}"#, + ); + check( + "patch_stopped", + "PATCH", + br#"{"changes":{"world.difficulty":"hard","server.max_players":50}}"#, + ); + + shared.set_phase(Phase::Running); + check( + "patch_running", + "PATCH", + br#"{"changes":{"server.port":25566,"backups.keep":7}}"#, + ); + let saved = crate::config::Config::load(&dir).unwrap(); + assert_eq!( + ( + saved.world.difficulty.as_str(), + saved.server.max_players, + saved.server.port, + saved.backups.keep, + ), + ("hard", 50, 25566, 7) + ); + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/crates/horizon-server/testdata/api-v1-config.json b/crates/horizon-server/testdata/api-v1-config.json new file mode 100644 index 0000000000..802f8fa2cb --- /dev/null +++ b/crates/horizon-server/testdata/api-v1-config.json @@ -0,0 +1,99 @@ +{ + "get": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "config": { + "api": {"allow_public": false, "bind": "", "token": "(set)"}, + "backups": {"every": "6h", "keep": 12}, + "discord": { + "enabled": true, + "events": ["chat", "join", "leave", "death"], + "webhook": "(set)" + }, + "engine": { + "low_memory": false, + "path": "", + "resources": "", + "restart_on_crash": true + }, + "panel": {"allow_public": false, "bind": "127.0.0.1:8130"}, + "properties": {}, + "server": { + "admin_feedback_to_ops": true, + "bind": "", + "enforce_whitelist": false, + "max_players": 20, + "max_view_distance": 0, + "motd": "A Minecraft Server", + "online_mode": true, + "port": 25565, + "simulation_distance": 10, + "view_distance": 10, + "whitelist": true + }, + "updates": {"keep": 1}, + "world": { + "difficulty": "easy", + "game_mode": "survival", + "hardcore": false, + "name": "world", + "seed": "" + } + } + } + }, + "malformed": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "the body must be {\"changes\": {\"section.name\": value, ...}}"} + }, + "protected": { + "status": 403, + "content_type": "application/json", + "headers": [], + "body": {"error": "api.token can only be changed in server.toml"} + }, + "invalid_range": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "server.max_players is 0: use 1 to 1000"} + }, + "invalid_shape": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "world.seed: a value is one line of text, a number or true/false"} + }, + "unknown": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "server.unknown is not a setting (`horizon-server config` lists them)"} + }, + "patch_stopped": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "saved": ["server.max_players", "world.difficulty"], + "applied": [], + "restart": [], + "running": false + } + }, + "patch_running": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "saved": ["backups.keep", "server.port"], + "applied": ["backups.keep"], + "restart": ["server.port"], + "running": true + } + } +} From 4d260ccdac4e0b49cff8207d01745611f0c3f73c Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT <212331760+TiggerTakkyTTT@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:33:56 +0700 Subject: [PATCH 05/70] Server API: pin event replies Add JSON golden cases for event polling, cursors, type filters, Last-Event-ID resumption, invalid selectors, and exact server-sent event framing. Fixes #9066 Co-Authored-By: Codex (GPT-5.6) --- crates/horizon-server/src/events.rs | 73 +++++++++++++++++++ .../testdata/api-v1-events.json | 70 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 crates/horizon-server/testdata/api-v1-events.json diff --git a/crates/horizon-server/src/events.rs b/crates/horizon-server/src/events.rs index 3445ea40b9..acf7ed3f7f 100644 --- a/crates/horizon-server/src/events.rs +++ b/crates/horizon-server/src/events.rs @@ -193,6 +193,16 @@ pub fn route(shared: &Arc, request: &Request) -> Response { mod tests { use super::*; + fn event_reply(response: Response) -> Value { + assert!(response.stream.is_none()); + json!({ + "status": response.status, + "content_type": response.content_type, + "headers": response.headers, + "body": serde_json::from_str::(&response.body).unwrap(), + }) + } + #[test] fn events_after_a_number_by_type() { let mut events = Events::default(); @@ -283,4 +293,67 @@ mod tests { assert_eq!(polled["events"][0]["line"], "not a join"); std::fs::remove_dir_all(&dir).unwrap(); } + + #[test] + fn event_replies_match_api_v1_goldens() { + use std::io::Read; + + let golden: Value = + serde_json::from_str(include_str!("../testdata/api-v1-events.json")).unwrap(); + let shared = Shared::with_install(std::path::Path::new("unused"), None); + shared.log_line("ready".into()); + shared.emit("join", json!({ "name": "Alex" })); + shared.emit("chat", json!({ "name": "Alex", "message": "hi" })); + let poll = |query: &str, headers: Vec<(String, String)>| { + event_reply(route( + &shared, + &Request { + method: "GET".into(), + path: "/api/v1/events".into(), + query: query.into(), + headers, + body: Vec::new(), + }, + )) + }; + assert_eq!(poll("", Vec::new()), golden["all"]); + assert_eq!(poll("since=2", Vec::new()), golden["since"]); + assert_eq!(poll("types=join,leave", Vec::new()), golden["types"]); + assert_eq!( + poll("", vec![("Last-Event-ID".into(), "1".into())]), + golden["last_event_id"] + ); + assert_eq!(poll("since=not-a-number", Vec::new()), golden["bad_since"]); + assert_eq!(poll("types=unknown", Vec::new()), golden["unknown_type"]); + + let mut response = route( + &shared, + &Request { + method: "GET".into(), + path: "/api/v1/events".into(), + query: "types=join,chat".into(), + headers: vec![ + ("Accept".into(), "text/event-stream".into()), + ("Last-Event-ID".into(), "1".into()), + ], + body: Vec::new(), + }, + ); + assert_eq!(response.status, golden["stream"]["status"]); + assert_eq!(response.content_type, golden["stream"]["content_type"]); + assert_eq!(response.headers, Vec::<(&str, String)>::new()); + let expected = golden["stream"]["body"].as_str().unwrap().as_bytes(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server, _) = listener.accept().unwrap(); + let control = server.try_clone().unwrap(); + let write = response.stream.take().unwrap(); + let writer = std::thread::spawn(move || write(&server)); + let mut body = vec![0; expected.len()]; + client.read_exact(&mut body).unwrap(); + assert_eq!(body, expected); + control.shutdown(std::net::Shutdown::Both).unwrap(); + shared.emit("join", json!({ "name": "Wake" })); + writer.join().unwrap(); + } } diff --git a/crates/horizon-server/testdata/api-v1-events.json b/crates/horizon-server/testdata/api-v1-events.json new file mode 100644 index 0000000000..aa0b83e1f4 --- /dev/null +++ b/crates/horizon-server/testdata/api-v1-events.json @@ -0,0 +1,70 @@ +{ + "all": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "next": 3, + "events": [ + {"id": 1, "line": "ready", "type": "log"}, + {"id": 2, "name": "Alex", "type": "join"}, + {"id": 3, "message": "hi", "name": "Alex", "type": "chat"} + ] + } + }, + "since": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "next": 3, + "events": [{"id": 3, "message": "hi", "name": "Alex", "type": "chat"}] + } + }, + "types": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "next": 3, + "events": [{"id": 2, "name": "Alex", "type": "join"}] + } + }, + "last_event_id": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "next": 3, + "events": [ + {"id": 2, "name": "Alex", "type": "join"}, + {"id": 3, "message": "hi", "name": "Alex", "type": "chat"} + ] + } + }, + "bad_since": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "next": 3, + "events": [ + {"id": 1, "line": "ready", "type": "log"}, + {"id": 2, "name": "Alex", "type": "join"}, + {"id": 3, "message": "hi", "name": "Alex", "type": "chat"} + ] + } + }, + "unknown_type": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"next": 3, "events": []} + }, + "stream": { + "status": 200, + "content_type": "text/event-stream", + "headers": [], + "body": "id: 2\nevent: join\ndata: {\"id\":2,\"name\":\"Alex\",\"type\":\"join\"}\n\nid: 3\nevent: chat\ndata: {\"id\":3,\"message\":\"hi\",\"name\":\"Alex\",\"type\":\"chat\"}\n\n" + } +} From 1ddca546f073d6d82160d5b89a4757d837efd19d Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT <212331760+TiggerTakkyTTT@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:50:43 +0700 Subject: [PATCH 06/70] Server API: pin moderation replies Add JSON golden cases for stopped-server operator and whitelist edits, no-op outcomes, unavailable running-only actions, and representative validation errors. Fixes #9073 Co-Authored-By: Codex (GPT-5.6) --- crates/horizon-server/src/admin.rs | 112 ++++++++++++++++ .../testdata/api-v1-moderation.json | 122 ++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 crates/horizon-server/testdata/api-v1-moderation.json diff --git a/crates/horizon-server/src/admin.rs b/crates/horizon-server/src/admin.rs index f09c0795ea..f927e671b7 100644 --- a/crates/horizon-server/src/admin.rs +++ b/crates/horizon-server/src/admin.rs @@ -520,6 +520,118 @@ mod tests { std::fs::remove_dir_all(&dir).unwrap(); } + fn moderation_reply(response: Response) -> Value { + assert!(response.stream.is_none()); + json!({ + "status": response.status, + "content_type": response.content_type, + "headers": response.headers, + "body": serde_json::from_str::(&response.body).unwrap(), + }) + } + + #[test] + fn moderation_replies_match_api_v1_goldens() { + let golden: Value = + serde_json::from_str(include_str!("../testdata/api-v1-moderation.json")).unwrap(); + let dir = std::env::temp_dir().join(format!( + "horizon-server-api-moderation-goldens-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(crate::config::FILE), crate::config::TEMPLATE).unwrap(); + crate::config::update(&dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap(); + crate::config::update(&dir, "server", "whitelist", toml::Value::Boolean(false)).unwrap(); + let shared = Shared::with_install(&dir, None); + shared.set_phase(Phase::Stopped); + let call = |path: &str, body: &[u8]| { + moderation_reply(crate::api::route( + &shared, + &crate::http::Request { + method: "POST".into(), + path: path.into(), + query: String::new(), + headers: Vec::new(), + body: body.to_vec(), + }, + )) + }; + let check = |case: &str, path: &str, body: &[u8]| { + assert_eq!(call(path, body), golden[case], "{case}"); + }; + + check("op", "/api/v1/players/op", br#"{"name":"Alex"}"#); + check("op_again", "/api/v1/players/op", br#"{"name":"Alex"}"#); + check("deop", "/api/v1/players/deop", br#"{"name":"Alex"}"#); + check("deop_again", "/api/v1/players/deop", br#"{"name":"Alex"}"#); + check( + "whitelist_add", + "/api/v1/whitelist/add", + br#"{"name":"Alex"}"#, + ); + check( + "whitelist_add_again", + "/api/v1/whitelist/add", + br#"{"name":"Alex"}"#, + ); + check("whitelist_on", "/api/v1/whitelist", br#"{"enabled":true}"#); + check( + "whitelist_on_again", + "/api/v1/whitelist", + br#"{"enabled":true}"#, + ); + check( + "whitelist_remove", + "/api/v1/whitelist/remove", + br#"{"name":"Alex"}"#, + ); + check( + "whitelist_remove_again", + "/api/v1/whitelist/remove", + br#"{"name":"Alex"}"#, + ); + for (case, path, body) in [ + ( + "kick_stopped", + "/api/v1/players/kick", + br#"{"name":"Alex"}"#.as_slice(), + ), + ( + "ban_stopped", + "/api/v1/players/ban", + br#"{"name":"Alex"}"#.as_slice(), + ), + ( + "pardon_stopped", + "/api/v1/players/pardon", + br#"{"name":"Alex"}"#.as_slice(), + ), + ( + "ban_ip_stopped", + "/api/v1/players/ban-ip", + br#"{"ip":"203.0.113.7"}"#.as_slice(), + ), + ( + "pardon_ip_stopped", + "/api/v1/players/pardon-ip", + br#"{"ip":"203.0.113.7"}"#.as_slice(), + ), + ] { + check(case, path, body); + } + check("malformed", "/api/v1/players/op", b"not json"); + check("bad_name", "/api/v1/players/op", br#"{"name":"a b"}"#); + check( + "bad_reason", + "/api/v1/players/kick", + br#"{"name":"Alex","reason":"first\nsecond"}"#, + ); + check("bad_ip", "/api/v1/players/pardon-ip", br#"{"ip":"Alex"}"#); + check("bad_whitelist", "/api/v1/whitelist", br#"{}"#); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn mods_come_from_the_build_record() { let none = built_with(None); diff --git a/crates/horizon-server/testdata/api-v1-moderation.json b/crates/horizon-server/testdata/api-v1-moderation.json new file mode 100644 index 0000000000..fa3aed15a0 --- /dev/null +++ b/crates/horizon-server/testdata/api-v1-moderation.json @@ -0,0 +1,122 @@ +{ + "op": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": true, "output": ["Made Alex a server operator"]} + }, + "op_again": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": false, "output": ["Nothing changed. The player already is an operator"]} + }, + "deop": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": true, "output": ["Made Alex no longer a server operator"]} + }, + "deop_again": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": false, "output": ["Nothing changed. The player is not an operator"]} + }, + "whitelist_add": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": true, "output": ["Added Alex to the whitelist"]} + }, + "whitelist_add_again": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": false, "output": ["Player is already whitelisted"]} + }, + "whitelist_on": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": true, "output": ["Whitelist is now turned on"]} + }, + "whitelist_on_again": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": false, "output": ["Whitelist is already turned on"]} + }, + "whitelist_remove": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": true, "output": ["Removed Alex from the whitelist"]} + }, + "whitelist_remove_again": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": {"changed": false, "output": ["Player is not whitelisted"]} + }, + "kick_stopped": { + "status": 409, + "content_type": "application/json", + "headers": [], + "body": {"error": "the game isn't running right now"} + }, + "ban_stopped": { + "status": 409, + "content_type": "application/json", + "headers": [], + "body": {"error": "the game isn't running right now"} + }, + "pardon_stopped": { + "status": 409, + "content_type": "application/json", + "headers": [], + "body": {"error": "the game isn't running right now"} + }, + "ban_ip_stopped": { + "status": 409, + "content_type": "application/json", + "headers": [], + "body": {"error": "the game isn't running right now"} + }, + "pardon_ip_stopped": { + "status": 409, + "content_type": "application/json", + "headers": [], + "body": {"error": "the game isn't running right now"} + }, + "malformed": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "the body needs \"name\": a player's name (1 to 16 characters, no spaces)"} + }, + "bad_name": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "the body needs \"name\": a player's name (1 to 16 characters, no spaces)"} + }, + "bad_reason": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "\"reason\" is one line of at most 256 characters"} + }, + "bad_ip": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "the body needs \"ip\": an IPv4 or IPv6 address"} + }, + "bad_whitelist": { + "status": 400, + "content_type": "application/json", + "headers": [], + "body": {"error": "the body needs \"enabled\": true or false"} + } +} From ce098ebe9df255171ba782855db46aeece3a2880 Mon Sep 17 00:00:00 2001 From: TiggerTakkyTTT <212331760+TiggerTakkyTTT@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:31:18 +0700 Subject: [PATCH 07/70] Server API: pin observability replies Add JSON and Prometheus golden cases for status, logs, sampled metrics, cursor handling, and stopped or running health output. Fixes #9083 Co-Authored-By: Codex (GPT-5.6) --- crates/horizon-server/src/api.rs | 122 +++++++++++++++++ .../testdata/api-v1-observability.json | 129 ++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 crates/horizon-server/testdata/api-v1-observability.json diff --git a/crates/horizon-server/src/api.rs b/crates/horizon-server/src/api.rs index d31837957e..9d69442a9d 100644 --- a/crates/horizon-server/src/api.rs +++ b/crates/horizon-server/src/api.rs @@ -948,6 +948,128 @@ impl Client { mod tests { use super::*; + fn observability_reply(mut response: Response, dynamic: &[&str]) -> Value { + assert!(response.stream.is_none()); + let mut body = serde_json::from_str::(&response.body) + .unwrap_or_else(|_| Value::String(response.body.clone())); + for field in dynamic { + if let Some(value) = body.get_mut(*field) { + *value = json!(""); + } + } + if let Some(text) = body.as_str() { + let text = text + .lines() + .map(|line| { + if line.starts_with("horizon_supervisor_uptime_seconds ") { + "horizon_supervisor_uptime_seconds " + } else { + line + } + }) + .collect::>() + .join("\n"); + body = Value::String(if response.body.ends_with('\n') { + format!("{text}\n") + } else { + text + }); + } + json!({ + "status": response.status, + "content_type": response.content_type, + "headers": std::mem::take(&mut response.headers), + "body": body, + }) + } + + #[test] + fn observability_replies_match_api_v1_goldens() { + let golden: Value = + serde_json::from_str(include_str!("../testdata/api-v1-observability.json")).unwrap(); + let dir = std::env::temp_dir().join(format!( + "horizon-server-api-observability-goldens-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let shared = Shared::with_install(&dir, None); + shared.set_phase(Phase::Stopped); + { + let mut state = shared.state.lock().unwrap(); + state.crashes = 2; + state.last_crash = Some("exit status: 1".into()); + state.sample = json!({ + "t": 200, + "rss_bytes": 536_870_912u64, + "cpu_percent": 12.5, + "game": { + "tps": 19.9, + "target_tps": 20.0, + "mspt": { "average": 8.25, "p50": 7.0, "p95": 12.5, "p99": 18.0 }, + "players": [{ "name": "Alex" }, { "name": "Steve" }], + "max_players": 20, + "chunks": 321, + }, + }); + } + shared.record(json!({ "t": 100, "game": { "tps": 20.0 } })); + shared.record(shared.state.lock().unwrap().sample.clone()); + shared.log.lock().unwrap().push("first line".into()); + shared.log.lock().unwrap().push("second line".into()); + + let call = |path: &str, query: &str, dynamic: &[&str]| { + observability_reply( + route( + &shared, + &Request { + method: "GET".into(), + path: path.into(), + query: query.into(), + headers: Vec::new(), + body: Vec::new(), + }, + ), + dynamic, + ) + }; + let cases = [ + ( + "status_stopped", + "/api/v1/status", + "", + &["pid", "uptime_seconds"][..], + ), + ("logs_all", "/api/v1/logs", "", &[][..]), + ("logs_since", "/api/v1/logs", "since=1", &[][..]), + ("logs_invalid_since", "/api/v1/logs", "since=nope", &[][..]), + ("metrics_all", "/api/v1/metrics", "", &[][..]), + ("metrics_since", "/api/v1/metrics", "since=100", &[][..]), + ( + "metrics_invalid_since", + "/api/v1/metrics", + "since=nope", + &[][..], + ), + ( + "prometheus_stopped", + "/api/v1/metrics/prometheus", + "", + &[][..], + ), + ]; + for (case, path, query, dynamic) in cases { + assert_eq!(call(path, query, dynamic), golden[case], "{case}"); + } + shared.set_phase(Phase::Running); + assert_eq!( + call("/api/v1/metrics/prometheus", "", &[]), + golden["prometheus_running"], + "prometheus_running" + ); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn log_keeps_the_last_lines_numbered() { let mut log = Log::default(); diff --git a/crates/horizon-server/testdata/api-v1-observability.json b/crates/horizon-server/testdata/api-v1-observability.json new file mode 100644 index 0000000000..a01b89ae7a --- /dev/null +++ b/crates/horizon-server/testdata/api-v1-observability.json @@ -0,0 +1,129 @@ +{ + "status_stopped": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "phase": "stopped", + "version": "0.1.0", + "pid": "", + "uptime_seconds": "", + "engine_pid": null, + "engine_uptime_seconds": null, + "crashes": 2, + "last_crash": "exit status: 1", + "sample": { + "t": 200, + "rss_bytes": 536870912, + "cpu_percent": 12.5, + "game": { + "tps": 19.9, + "target_tps": 20.0, + "mspt": { "average": 8.25, "p50": 7.0, "p95": 12.5, "p99": 18.0 }, + "players": [{ "name": "Alex" }, { "name": "Steve" }], + "max_players": 20, + "chunks": 321 + } + }, + "update": null + } + }, + "logs_all": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { "next": 2, "lines": ["first line", "second line"] } + }, + "logs_since": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { "next": 2, "lines": ["second line"] } + }, + "logs_invalid_since": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { "next": 2, "lines": ["first line", "second line"] } + }, + "metrics_all": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "samples": [ + { "t": 100, "game": { "tps": 20.0 } }, + { + "t": 200, + "rss_bytes": 536870912, + "cpu_percent": 12.5, + "game": { + "tps": 19.9, + "target_tps": 20.0, + "mspt": { "average": 8.25, "p50": 7.0, "p95": 12.5, "p99": 18.0 }, + "players": [{ "name": "Alex" }, { "name": "Steve" }], + "max_players": 20, + "chunks": 321 + } + } + ] + } + }, + "metrics_since": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "samples": [ + { + "t": 200, + "rss_bytes": 536870912, + "cpu_percent": 12.5, + "game": { + "tps": 19.9, + "target_tps": 20.0, + "mspt": { "average": 8.25, "p50": 7.0, "p95": 12.5, "p99": 18.0 }, + "players": [{ "name": "Alex" }, { "name": "Steve" }], + "max_players": 20, + "chunks": 321 + } + } + ] + } + }, + "metrics_invalid_since": { + "status": 200, + "content_type": "application/json", + "headers": [], + "body": { + "samples": [ + { "t": 100, "game": { "tps": 20.0 } }, + { + "t": 200, + "rss_bytes": 536870912, + "cpu_percent": 12.5, + "game": { + "tps": 19.9, + "target_tps": 20.0, + "mspt": { "average": 8.25, "p50": 7.0, "p95": 12.5, "p99": 18.0 }, + "players": [{ "name": "Alex" }, { "name": "Steve" }], + "max_players": 20, + "chunks": 321 + } + } + ] + } + }, + "prometheus_stopped": { + "status": 200, + "content_type": "text/plain; version=0.0.4", + "headers": [], + "body": "# HELP horizon_up 1 while the game runs and answers\n# TYPE horizon_up gauge\nhorizon_up 0\n# HELP horizon_supervisor_uptime_seconds Seconds since horizon-server started\n# TYPE horizon_supervisor_uptime_seconds gauge\nhorizon_supervisor_uptime_seconds \n# HELP horizon_crashes Game crashes since horizon-server started\n# TYPE horizon_crashes gauge\nhorizon_crashes 2\n# HELP horizon_tps Ticks per second over the last 5 seconds\n# TYPE horizon_tps gauge\nhorizon_tps 19.9\n# HELP horizon_tps_target The tick rate the game aims for (/tick rate)\n# TYPE horizon_tps_target gauge\nhorizon_tps_target 20\n# HELP horizon_mspt Milliseconds per tick: the average and the percentiles /tick query reports\n# TYPE horizon_mspt gauge\nhorizon_mspt{stat=\"average\"} 8.25\nhorizon_mspt{stat=\"p50\"} 7\nhorizon_mspt{stat=\"p95\"} 12.5\nhorizon_mspt{stat=\"p99\"} 18\n# HELP horizon_players_online Players online\n# TYPE horizon_players_online gauge\nhorizon_players_online 2\n# HELP horizon_players_max max-players\n# TYPE horizon_players_max gauge\nhorizon_players_max 20\n# HELP horizon_chunks_loaded Chunks loaded\n# TYPE horizon_chunks_loaded gauge\nhorizon_chunks_loaded 321\n# HELP horizon_memory_rss_bytes The game process's resident memory\n# TYPE horizon_memory_rss_bytes gauge\nhorizon_memory_rss_bytes 536870912\n# HELP horizon_cpu_percent The game process's CPU use (100 = one core)\n# TYPE horizon_cpu_percent gauge\nhorizon_cpu_percent 12.5\n" + }, + "prometheus_running": { + "status": 200, + "content_type": "text/plain; version=0.0.4", + "headers": [], + "body": "# HELP horizon_up 1 while the game runs and answers\n# TYPE horizon_up gauge\nhorizon_up 1\n# HELP horizon_supervisor_uptime_seconds Seconds since horizon-server started\n# TYPE horizon_supervisor_uptime_seconds gauge\nhorizon_supervisor_uptime_seconds \n# HELP horizon_crashes Game crashes since horizon-server started\n# TYPE horizon_crashes gauge\nhorizon_crashes 2\n# HELP horizon_tps Ticks per second over the last 5 seconds\n# TYPE horizon_tps gauge\nhorizon_tps 19.9\n# HELP horizon_tps_target The tick rate the game aims for (/tick rate)\n# TYPE horizon_tps_target gauge\nhorizon_tps_target 20\n# HELP horizon_mspt Milliseconds per tick: the average and the percentiles /tick query reports\n# TYPE horizon_mspt gauge\nhorizon_mspt{stat=\"average\"} 8.25\nhorizon_mspt{stat=\"p50\"} 7\nhorizon_mspt{stat=\"p95\"} 12.5\nhorizon_mspt{stat=\"p99\"} 18\n# HELP horizon_players_online Players online\n# TYPE horizon_players_online gauge\nhorizon_players_online 2\n# HELP horizon_players_max max-players\n# TYPE horizon_players_max gauge\nhorizon_players_max 20\n# HELP horizon_chunks_loaded Chunks loaded\n# TYPE horizon_chunks_loaded gauge\nhorizon_chunks_loaded 321\n# HELP horizon_memory_rss_bytes The game process's resident memory\n# TYPE horizon_memory_rss_bytes gauge\nhorizon_memory_rss_bytes 536870912\n# HELP horizon_cpu_percent The game process's CPU use (100 = one core)\n# TYPE horizon_cpu_percent gauge\nhorizon_cpu_percent 12.5\n" + } +} From aa4869c86c3ff3b7488424de6ef68dbf9becd5e6 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Tue, 3 Nov 2026 22:44:43 +0100 Subject: [PATCH 08/70] dedicated: player-expect and player-export on the control endpoint The read side of a seamless transfer (#7484, step 2). A proxy moving a player between two servers tells the target who is coming and reads the player out of the source. `player-expect [seconds]` records that the player will log in within that time (10 s by default, 60 at most, 64 players at once). It refuses a bad id, a time that is not a whole number above zero, a player already connected and a full table. The login uses the expectation up; one no login used is reported when it runs out. The player still logs in as anyone else: holding them is a later step. `player-export [freeze]` closes the player's menu as vanilla does (AbstractContainerMenu.removed puts the carried stack back into a connected player's inventory) and answers with record version 1: the player compound as a save writes it (base64 NBT), the advancements JSON and a few plain fields. It refuses a player who is not connected, has not finished joining, is riding, asleep or dead. With `freeze`, the player's own packets are dropped and its acknowledgements still handled, until the freeze is let go after 600 ticks with a warning. docs/server.md gives both requests and the record's shape. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dedicated.rs | 1 + .../src/game/dedicated/status.rs | 6 + .../src/game/dedicated/transfer.rs | 79 ++ crates/horizon-client/src/lan/arrivals.rs | 713 ++++++++++++++++++ crates/horizon-client/src/lan/guest.rs | 5 + crates/horizon-client/src/lan/hub.rs | 60 ++ crates/horizon-client/src/lan/mod.rs | 1 + crates/horizon-client/src/lan/playerdata.rs | 5 +- docs/server.md | 45 ++ 9 files changed, 913 insertions(+), 2 deletions(-) create mode 100644 crates/horizon-client/src/game/dedicated/transfer.rs create mode 100644 crates/horizon-client/src/lan/arrivals.rs diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 5dff2f7d42..0dd86e1c63 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -38,6 +38,7 @@ pub(super) mod events; mod management; mod report; mod status; +mod transfer; /// What the tick loop tells `ServerWatchdog`: when the tick in progress was /// due (`getNextTickTime`), how long a tick is, and whether the server runs. diff --git a/crates/horizon-client/src/game/dedicated/status.rs b/crates/horizon-client/src/game/dedicated/status.rs index 53d70576fd..5cb360dc76 100644 --- a/crates/horizon-client/src/game/dedicated/status.rs +++ b/crates/horizon-client/src/game/dedicated/status.rs @@ -3,6 +3,8 @@ //! (including their positions) and the loaded chunks; `console ` runs a console command and answers with the lines it logged; //! `server-events ` answers the typed events after that number (events.rs); `management //! ` edits the lists as vanilla's management protocol does (management.rs). +//! `player-expect [seconds]` and `player-export [freeze]` are a proxy's, for a player it moves +//! between two servers (transfer.rs). use super::{ServerState, capture_log}; use crate::game::Game; use serde_json::{Value, json}; @@ -47,6 +49,10 @@ impl Game<'_> { super::events::since(since.trim().parse().unwrap_or(0)) } Some(("management", rest)) => self.management(state, rest), + // A proxy moving a player here or away (transfer.rs, lan/arrivals.rs). + Some((verb, rest)) if verb.starts_with("player-") => { + self.player_request(verb, rest) + } Some(("console", line)) => { // Command feedback is logged as system chat; the reply carries the text alone. let output: Vec = capture_log(|| self.console_command(state, line)) diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs new file mode 100644 index 0000000000..72ceaa1be1 --- /dev/null +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -0,0 +1,79 @@ +//! A proxy's requests on the control endpoint, for a player it moves between two servers: +//! `player-expect [seconds]` (the target is told who is coming) and +//! `player-export [freeze]` (the source answers with the player's record). They run +//! between two ticks, on the server thread, so the player is never caught half-way through a +//! tick. The rules are `lan/arrivals.rs`'s. +use crate::game::{Game, lan::LanHost}; +use horizon_client::lan::arrivals; +use horizon_protocol::packets::play::{self, Clientbound as C}; +use serde_json::{Value, json}; +use std::time::Instant; + +impl Game<'_> { + /// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or + /// `{"error": "..."}`. + pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value { + match verb { + "player-expect" => match self.lan.as_mut() { + Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()), + None => json!({ "error": "the server is not open to players" }), + }, + "player-export" => self.player_export(args), + other => json!({ "error": format!("unknown request {other:?}") }), + } + } + + /// `player-export [freeze]`: closes the player's menus as vanilla does when a menu closes + /// (the cursor stack and the crafting grids go back into the inventory), then answers with the + /// player's record (`arrivals::export_record`). With `freeze`, the player's own inputs stop being + /// acted on until the freeze is let go. + fn player_export(&mut self, args: &str) -> Value { + let mut parts = args.split_whitespace(); + let Some(uuid) = parts.next().and_then(arrivals::parse_uuid) else { + return json!({ "error": "player-export needs the player's unique id" }); + }; + let freeze = match parts.next() { + None => false, + Some("freeze") => true, + Some(_) => return json!({ "error": "the only option of player-export is freeze" }), + }; + if parts.next().is_some() { + return json!({ "error": "player-export takes a unique id and at most freeze" }); + } + let Some(mut host) = self.lan.take() else { + return json!({ "error": "the server is not open to players" }); + }; + let reply = self.export_from(&mut host, uuid, freeze); + self.lan = Some(host); + reply + } + + fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value { + let i = match arrivals::check_exportable(&host.hub, uuid) { + Ok(i) => i, + Err(why) => return json!({ "error": why }), + }; + let conn = host.hub.guests[i].conn; + let window = host.hub.guests[i].menu.as_ref().map(|m| m.window); + // Nothing is sent to the client by this: it is for a player who is leaving. + self.guest_close_all(host, conn); + // The player may stay (a hand-over that does not finish): its client closes the menu too + // and shows the inventory as it now is. + if let Some(container) = window { + host.hub + .send_to(conn, &C::ContainerClose(play::ContainerClose { container })); + } + host.hub.send_inventory(i); + let advancements = if self.adv.tree.list.is_empty() { + Value::Null + } else { + host.hub.guests[i] + .advancements + .to_json(&self.adv.tree, horizon_world::DATA_VERSION) + }; + match arrivals::export_record(&mut host.hub, i, freeze, advancements) { + Ok(record) => json!({ "ok": true, "record": record }), + Err(e) => json!({ "error": e }), + } + } +} diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs new file mode 100644 index 0000000000..b42f9fd28a --- /dev/null +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -0,0 +1,713 @@ +//! Players that are about to arrive from another server, and a connected +//! player read out of this one: the control endpoint's `player-expect` and +//! `player-export` (`game/dedicated/transfer.rs` runs them). +//! +//! A proxy that moves a player between two servers first tells the target +//! server who is coming (`player-expect `), and reads the player out of +//! the source (`player-export [freeze]`). Nothing here may last without +//! bound, because a proxy that dies in the middle of a move leaves its +//! expectations and its freezes behind: every expectation expires, the table +//! has a size cap, and a freeze is let go by itself. +use super::hub::Hub; +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +/// How long an expectation lives when the request names no time. +pub const DEFAULT_TTL: Duration = Duration::from_secs(10); +/// The longest an expectation may be asked to live. +pub const MAX_TTL: Duration = Duration::from_secs(60); +/// The most expectations the table keeps at once. +pub const MAX_EXPECTED: usize = 64; + +/// Why an expectation could not be recorded. +#[derive(Debug, PartialEq, Eq)] +pub enum ExpectError { + /// The table is full of live expectations. + Full, +} + +/// The players a server was told to expect, by unique id. +#[derive(Debug, Default)] +pub struct Expectations { + until: HashMap, +} + +impl Expectations { + /// Records that `uuid` will log in within `ttl` (cut to [`MAX_TTL`]). A second + /// request for the same player replaces the first one's deadline. Returns + /// whether the player was already expected. + pub fn expect(&mut self, uuid: u128, ttl: Duration, now: Instant) -> Result { + self.expire(now); + if !self.until.contains_key(&uuid) && self.until.len() >= MAX_EXPECTED { + return Err(ExpectError::Full); + } + let deadline = now + ttl.min(MAX_TTL); + Ok(self.until.insert(uuid, deadline).is_some()) + } + + /// Consumes the expectation of `uuid`, and says whether it was still alive: + /// one login takes it, and a second login of the same player finds none. + pub fn take(&mut self, uuid: u128, now: Instant) -> bool { + match self.until.remove(&uuid) { + Some(deadline) => now < deadline, + None => false, + } + } + + /// Drops the expectations whose time has passed and returns them, so the + /// caller can log that the player never came. + pub fn expire(&mut self, now: Instant) -> Vec { + let gone: Vec = self + .until + .iter() + .filter(|(_, deadline)| now >= **deadline) + .map(|(uuid, _)| *uuid) + .collect(); + for uuid in &gone { + self.until.remove(uuid); + } + gone + } + + /// How many expectations are recorded (some may be past their time but not + /// yet swept). + pub fn len(&self) -> usize { + self.until.len() + } + + pub fn is_empty(&self) -> bool { + self.until.is_empty() + } +} + +/// Server ticks a player may stay frozen for a hand-over (thirty seconds): a proxy +/// that dies in the middle of one must not leave the player stuck. +pub const FROZEN_MAX_TICKS: u64 = 20 * 30; +/// The version of the record `player-export` answers with. +pub const RECORD_VERSION: i64 = 1; + +/// Whether a player frozen at tick `since` has been frozen too long at tick `now`. +pub fn frozen_too_long(since: u64, now: u64) -> bool { + now.saturating_sub(since) >= FROZEN_MAX_TICKS +} + +/// Why the connected player `uuid` cannot be exported now, or the index of its +/// guest. +pub fn check_exportable(hub: &Hub, uuid: u128) -> Result { + let Some(i) = hub.guests.iter().position(|g| g.uuid == uuid) else { + return Err("that player is not connected to this server"); + }; + let g = &hub.guests[i]; + if !g.placed { + Err("that player has not finished joining") + } else if g.vehicle.is_some() { + Err("that player is riding") + } else if g.sleeping { + Err("that player is asleep") + } else if g.dead { + Err("that player is dead") + } else { + Ok(i) + } +} + +/// The record of the player at guest index `i` (which [`check_exportable`] gave, +/// with its menus closed): the player compound as the server saves it, as the +/// uncompressed NBT of the compound in base64, and the few values a proxy reads +/// without opening it. `advancements` is the JSON the server keeps for the +/// player, or null. With `freeze`, the player's own inputs stop being acted on +/// until the freeze is let go. +pub fn export_record( + hub: &mut Hub, + i: usize, + freeze: bool, + advancements: serde_json::Value, +) -> Result { + use horizon_world::nbt::{Limits, NamedTag, write_named}; + let g = &hub.guests[i]; + let tag = super::playerdata::player_tag(g); + let bytes = write_named( + &NamedTag { + name: "".into(), + tag, + }, + Limits::saved(), + ) + .map_err(|e| e.to_string())?; + let velocity = g + .motion + .as_ref() + .map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]); + let record = serde_json::json!({ + "version": RECORD_VERSION, + "data_version": horizon_world::DATA_VERSION, + "uuid": horizon_protocol::crypto::uuid_string(g.uuid), + "name": g.name, + "dimension": g.dimension, + "position": g.position, + "rotation": [g.yaw, g.pitch], + "velocity": velocity, + "game_mode": g.game_mode, + "player": super::base64(&bytes), + "advancements": advancements, + }); + if freeze { + let since = hub.ticks; + hub.guests[i].frozen_since = Some(since); + } + Ok(record) +} + +/// A unique id as text, with or without dashes. +pub fn parse_uuid(text: &str) -> Option { + let plain: String = text.chars().filter(|c| *c != '-').collect(); + if plain.len() != 32 || !plain.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u128::from_str_radix(&plain, 16).ok() +} + +fn error(message: &str) -> serde_json::Value { + serde_json::json!({ "error": message }) +} + +/// `player-expect [seconds]`: the player will log in within that time +/// (10 seconds by default, 60 at most). The reply says whether an earlier +/// expectation was renewed. The login uses the expectation up; one that no +/// login used is reported when it runs out. +pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Value { + let mut parts = args.split_whitespace(); + let Some(uuid) = parts.next().and_then(parse_uuid) else { + return error("player-expect needs the player's unique id"); + }; + let ttl = match parts.next() { + None => DEFAULT_TTL, + Some(seconds) => match seconds.parse::() { + Ok(s) if s > 0 => Duration::from_secs(s), + _ => return error("the time must be a number of seconds above zero"), + }, + }; + if parts.next().is_some() { + return error("player-expect takes a unique id and at most a time"); + } + if hub.guests.iter().any(|g| g.uuid == uuid) { + return error("that player is already connected to this server"); + } + match hub.arrivals.expect(uuid, ttl, now) { + Ok(renewed) => serde_json::json!({ + "ok": true, + "renewed": renewed, + "ttl_seconds": ttl.min(MAX_TTL).as_secs(), + }), + Err(ExpectError::Full) => error("too many players are expected at once"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: u128 = 0xA; + const B: u128 = 0xB; + + fn secs(n: u64) -> Duration { + Duration::from_secs(n) + } + + #[test] + fn an_expected_player_is_taken_once_before_the_deadline() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + assert_eq!(e.expect(A, secs(10), t0), Ok(false)); + assert!(e.take(A, t0 + secs(9))); + // The login consumed it: a second login of the same player is ordinary. + assert!(!e.take(A, t0 + secs(9))); + assert!(e.is_empty()); + } + + #[test] + fn an_expectation_is_dead_at_its_deadline() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(10), t0).unwrap(); + // Exactly at the deadline is already too late. + assert!(!e.take(A, t0 + secs(10))); + assert!(e.is_empty()); + } + + #[test] + fn a_player_nobody_expected_has_no_expectation() { + let mut e = Expectations::default(); + assert!(!e.take(B, Instant::now())); + } + + #[test] + fn asking_again_moves_the_deadline_and_says_so() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(5), t0).unwrap(); + assert_eq!(e.expect(A, secs(30), t0 + secs(4)), Ok(true)); + assert!(e.take(A, t0 + secs(20))); + } + + #[test] + fn a_long_wait_is_cut_to_the_maximum() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(3600), t0).unwrap(); + assert!(!e.take(A, t0 + MAX_TTL)); + } + + #[test] + fn expiry_returns_the_players_that_never_came_and_keeps_the_others() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(5), t0).unwrap(); + e.expect(B, secs(30), t0).unwrap(); + assert_eq!(e.expire(t0 + secs(6)), vec![A]); + assert_eq!(e.len(), 1); + assert!(e.take(B, t0 + secs(7))); + } + + #[test] + fn the_table_refuses_new_players_when_full_but_expired_ones_make_room() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + for n in 0..MAX_EXPECTED as u128 { + e.expect(100 + n, secs(10), t0).unwrap(); + } + assert_eq!(e.expect(A, secs(10), t0), Err(ExpectError::Full)); + // A player already in the table can still be renewed when it is full. + assert_eq!(e.expect(100, secs(10), t0), Ok(true)); + // Once the old ones are past their time, there is room again. + assert_eq!(e.expect(A, secs(10), t0 + secs(11)), Ok(false)); + assert_eq!(e.len(), 1); + } +} + +/// The hub's side: the requests, the record and the freeze. +#[cfg(test)] +mod hub_tests { + use crate::lan::{ + Joined, Lan, NetConfig, Out, PlayerSender, + chunks::ChunkEncoder, + data, + guest::Guest, + hub::{Hub, HubConfig, TickCtx}, + }; + use std::sync::mpsc::{Receiver, channel}; + use std::time::{Duration, Instant}; + + fn hub() -> Hub { + let config = NetConfig { + announce: false, + ..NetConfig::lan(0, "ArrivalsTest".into(), 4) + }; + let mut sync = data::SyncData::empty(); + // The entity types the tracker names: the player, as id 0. + sync.builtin.registries.insert( + "minecraft:entity_type".to_string(), + horizon_protocol::registry::Ids::from_names(vec!["minecraft:player".to_string()]), + ); + let sync = std::sync::Arc::new(sync); + let lan = Lan::start_with(sync.clone(), config).unwrap(); + let encoder = ChunkEncoder::new( + [] as [(std::sync::Arc, bool); 0], + &horizon_protocol::registry::Builtin::default(), + &[], + &[], + ); + let config = HubConfig { + default_game_mode: 0, + guest_commands: false, + force_game_mode: false, + hardcore: false, + view_distance: 4, + simulation_distance: 4, + dimension: "minecraft:overworld".into(), + allow_flight: false, + seed: 0, + max_players: 4, + integrated: false, + levels: Default::default(), + }; + Hub::new(lan, sync, encoder, config) + } + + /// A guest in the overworld and the end of the channel its packets go to. + fn guest(conn: u32, uuid: u128, name: &str) -> (Guest, Receiver) { + let (tx, rx) = channel(); + let mut g = Guest::new( + Joined { + conn, + uuid, + name: name.into(), + properties: Vec::new(), + info: horizon_protocol::packets::configuration::ClientInformation::new(4), + brand: "vanilla".into(), + sender: PlayerSender::new(tx, std::sync::Arc::default()), + latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)), + address: "127.0.0.1:25565".parse().unwrap(), + }, + conn as i32, + [0.5, 64.0, 0.5], + 4, + ); + g.dimension = "minecraft:overworld".into(); + (g, rx) + } + + fn place(hub: &mut Hub, conn: u32) { + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = TickCtx { + world: &mut world, + entities: &[], + next_entity: &mut next, + spawn: [0.5, 64.0, 0.5], + difficulty: 2, + difficulty_locked: false, + clock_rate: 1.0, + tick_rate: 20.0, + tick_frozen: false, + tick_steps: Default::default(), + collision: None, + border: &border, + streaming: None, + }; + hub.place_new_player(conn, &mut ctx); + } + + fn tick_context<'a>( + world: &'a mut crate::world::World, + border: &'a crate::worldborder::WorldBorder, + next: &'a mut u32, + ) -> TickCtx<'a> { + TickCtx { + world, + entities: &[], + next_entity: next, + spawn: [0.5, 64.0, 0.5], + difficulty: 2, + difficulty_locked: false, + clock_rate: 1.0, + tick_rate: 20.0, + tick_frozen: false, + tick_steps: Default::default(), + collision: None, + border, + streaming: None, + } + } + + fn is_warn(event: &crate::lan::hub::HostEvent, needle: &str) -> bool { + matches!(event, crate::lan::hub::HostEvent::Warn(w) if w.contains(needle)) + } + + /// An expectation nobody came for is reported once and forgotten. + #[test] + fn an_expectation_nobody_came_for_is_reported_and_swept() { + let mut hub = hub(); + // Already past its time when the hub next ticks. + hub.arrivals + .expect(7, Duration::ZERO, Instant::now()) + .unwrap(); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + let events = hub.poll(&mut ctx); + assert_eq!( + events.iter().filter(|e| is_warn(e, "never came")).count(), + 1 + ); + assert!(hub.arrivals.is_empty()); + } + + /// The login of an expected player uses the expectation up, so nothing is + /// reported when its time would have run out; another player's stays. + #[test] + fn a_login_uses_up_the_expectation_of_that_player_only() { + let mut hub = hub(); + let (newcomer, _mine) = guest(2, 2, "Newcomer"); + hub.guests.push(newcomer); + let now = Instant::now(); + hub.arrivals + .expect(2, Duration::from_secs(10), now) + .unwrap(); + hub.arrivals + .expect(3, Duration::from_secs(10), now) + .unwrap(); + place(&mut hub, 2); + assert!(hub.guests[0].placed); + assert_eq!(hub.arrivals.len(), 1, "the other player is still expected"); + assert!(hub.arrivals.take(3, now)); + } + + const UUID: &str = "123e4567-e89b-12d3-a456-426614174000"; + const UUID_N: u128 = 0x123e4567_e89b_12d3_a456_426614174000; + + #[test] + fn a_unique_id_is_read_with_or_without_dashes_and_nothing_else() { + assert_eq!(super::parse_uuid(UUID), Some(UUID_N)); + assert_eq!( + super::parse_uuid("123e4567e89b12d3a456426614174000"), + Some(UUID_N) + ); + assert_eq!( + super::parse_uuid("123E4567-E89B-12D3-A456-426614174000"), + Some(UUID_N) + ); + assert_eq!( + super::parse_uuid("123e4567-e89b-12d3-a456-42661417400"), + None + ); + assert_eq!( + super::parse_uuid("123e4567-e89b-12d3-a456-42661417400g"), + None + ); + assert_eq!(super::parse_uuid(""), None); + } + + #[test] + fn player_expect_records_the_player_and_says_for_how_long() { + let mut hub = hub(); + let now = Instant::now(); + let reply = super::expect_request(&mut hub, UUID, now); + assert_eq!(reply["ok"], true); + assert_eq!(reply["renewed"], false); + assert_eq!(reply["ttl_seconds"], 10); + // Asking again renews, with the time asked. + let reply = super::expect_request(&mut hub, &format!("{UUID} 30"), now); + assert_eq!( + (reply["renewed"].as_bool(), reply["ttl_seconds"].as_u64()), + (Some(true), Some(30)) + ); + // A time past the maximum is cut, and the reply says what it became. + let reply = super::expect_request(&mut hub, &format!("{UUID} 3600"), now); + assert_eq!(reply["ttl_seconds"], 60); + assert_eq!(hub.arrivals.len(), 1); + assert!(hub.arrivals.take(UUID_N, now)); + } + + #[test] + fn player_expect_refuses_what_it_cannot_do_and_changes_nothing() { + let mut hub = hub(); + let now = Instant::now(); + for args in [ + "", + "not-a-uuid", + &format!("{UUID} 0"), + &format!("{UUID} soon"), + &format!("{UUID} 5 6"), + ] { + let reply = super::expect_request(&mut hub, args, now); + assert!(reply["error"].is_string(), "{args:?} gave {reply}"); + } + // A player who is on the server already cannot be expected. + let (online, _rx) = guest(1, UUID_N, "Online"); + hub.guests.push(online); + let reply = super::expect_request(&mut hub, UUID, now); + assert!( + reply["error"] + .as_str() + .unwrap() + .contains("already connected"), + "{reply}" + ); + assert!(hub.arrivals.is_empty()); + } + + #[test] + fn player_expect_refuses_when_the_table_is_full() { + let mut hub = hub(); + let now = Instant::now(); + for n in 0..super::MAX_EXPECTED as u128 { + let reply = super::expect_request(&mut hub, &format!("{:032x}", 1000 + n), now); + assert_eq!(reply["ok"], true); + } + let reply = super::expect_request(&mut hub, UUID, now); + assert!( + reply["error"].as_str().unwrap().contains("too many"), + "{reply}" + ); + } + + /// The keys a vanilla server writes in every player's file, read with `javap` from the + /// 26.3 server jar: `Player.addAdditionalSaveData` (Inventory, SelectedItemSlot, + /// EnderItems, XpLevel, XpP, XpTotal, XpSeed, abilities), `FoodData` (foodLevel, + /// foodSaturationLevel, foodExhaustionLevel, foodTickTimer), `ServerPlayer` (Dimension), + /// `Entity.saveWithoutId` (Pos, Motion, Rotation, OnGround, Air, Fire) and + /// `LivingEntity` (Health, AbsorptionAmount). + const VANILLA_PLAYER_KEYS: [&str; 21] = [ + "Inventory", + "SelectedItemSlot", + "EnderItems", + "XpLevel", + "XpP", + "XpTotal", + "XpSeed", + "abilities", + "foodLevel", + "foodSaturationLevel", + "foodExhaustionLevel", + "foodTickTimer", + "Dimension", + "Pos", + "Motion", + "Rotation", + "OnGround", + "Air", + "Fire", + "Health", + "AbsorptionAmount", + ]; + + /// A guest standing at a known place with known values. + fn exported_guest() -> (Hub, usize) { + use crate::items::ItemStack; + let mut hub = hub(); + let (mut g, _rx) = guest(5, UUID_N, "Alex"); + g.placed = true; + g.position = [10.5, 70.0, -3.25]; + g.yaw = 90.0; + g.pitch = 15.0; + g.health = 14.0; + g.food = 12; + g.xp_level = 7; + g.game_mode = 1; + g.selected = 3; + g.inventory[0] = Some(ItemStack { + item: std::sync::Arc::from("minecraft:stone"), + count: 5, + damage: 0, + extra: None, + }); + hub.guests.push(g); + (hub, 0) + } + + /// Decodes the compound of a record back into its tag. + fn compound_of(record: &serde_json::Value) -> horizon_world::nbt::Tag { + use horizon_world::nbt::{Limits, read_named}; + let bytes = crate::skins::base64_decode(record["player"].as_str().unwrap()).unwrap(); + read_named(&bytes, Limits::saved()).unwrap().0.tag + } + + #[test] + fn a_player_who_cannot_be_exported_is_refused_with_the_reason() { + let mut hub = hub(); + let expect = |hub: &Hub| super::check_exportable(hub, UUID_N); + assert_eq!( + expect(&hub), + Err("that player is not connected to this server") + ); + let (g, _rx) = guest(5, UUID_N, "Alex"); + hub.guests.push(g); + assert_eq!(expect(&hub), Err("that player has not finished joining")); + hub.guests[0].placed = true; + assert_eq!(expect(&hub), Ok(0)); + hub.guests[0].vehicle = Some(77); + assert_eq!(expect(&hub), Err("that player is riding")); + hub.guests[0].vehicle = None; + hub.guests[0].sleeping = true; + assert_eq!(expect(&hub), Err("that player is asleep")); + hub.guests[0].sleeping = false; + hub.guests[0].dead = true; + assert_eq!(expect(&hub), Err("that player is dead")); + } + + #[test] + fn the_record_carries_what_a_proxy_reads_and_the_vanilla_player_compound() { + use crate::save::{float, number}; + let (mut hub, i) = exported_guest(); + let record = + super::export_record(&mut hub, i, false, serde_json::json!({"done": []})).unwrap(); + assert_eq!(record["version"], 1); + assert_eq!(record["data_version"], horizon_world::DATA_VERSION); + assert_eq!(record["uuid"], UUID); + assert_eq!(record["name"], "Alex"); + assert_eq!(record["dimension"], "minecraft:overworld"); + assert_eq!(record["position"], serde_json::json!([10.5, 70.0, -3.25])); + assert_eq!(record["rotation"], serde_json::json!([90.0, 15.0])); + assert_eq!(record["game_mode"], 1); + assert_eq!(record["advancements"], serde_json::json!({"done": []})); + assert_eq!(hub.guests[i].frozen_since, None, "no freeze was asked for"); + + let tag = compound_of(&record); + for key in VANILLA_PLAYER_KEYS { + assert!(tag.get(key).is_some(), "the compound has no {key}"); + } + assert_eq!(number(tag.get("XpLevel")), Some(7)); + assert_eq!(number(tag.get("foodLevel")), Some(12)); + assert_eq!(number(tag.get("SelectedItemSlot")), Some(3)); + assert_eq!(float(tag.get("Health")), Some(14.0)); + let horizon_world::nbt::Tag::String(dimension) = tag.get("Dimension").unwrap() else { + panic!("Dimension is not a string"); + }; + assert_eq!(dimension.to_string_lossy(), "minecraft:overworld"); + // The stone in the first slot, as vanilla writes a stack: Slot, id, count. + let horizon_world::nbt::Tag::List(items) = tag.get("Inventory").unwrap() else { + panic!("Inventory is not a list"); + }; + let first = &items[0]; + assert_eq!(number(first.get("Slot")), Some(0)); + assert_eq!(number(first.get("count")), Some(5)); + } + + /// A frozen player's inputs are not acted on, its acknowledgements are, and a freeze + /// nobody finishes is let go. + #[test] + fn a_frozen_player_is_inert_until_it_is_let_go() { + use horizon_protocol::packets::play::{ChunkBatchReceived, MovePlayerPos, Serverbound}; + let (mut hub, i) = exported_guest(); + super::export_record(&mut hub, i, true, serde_json::Value::Null).unwrap(); + assert_eq!(hub.guests[i].frozen_since, Some(hub.ticks)); + let before = hub.guests[i].position; + let g = &mut hub.guests[i]; + g.unacknowledged = 2; + g.inbox.push_back(( + Serverbound::MovePlayerPos(MovePlayerPos { + x: before[0] + 30.0, + y: before[1], + z: before[2], + flags: 1, + }), + 10, + )); + g.inbox.push_back(( + Serverbound::ChunkBatchReceived(ChunkBatchReceived { + chunks_per_tick: 9.0, + }), + 5, + )); + g.inbox_bytes = 15; + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + hub.drain_packets(&mut ctx); + assert_eq!( + hub.guests[i].position, before, + "a frozen player does not move" + ); + assert_eq!( + hub.guests[i].unacknowledged, 1, + "its acknowledgement was handled" + ); + // Nobody finishes the hand-over: thirty seconds of ticks later it is let go. + hub.ticks = hub.guests[i].frozen_since.unwrap() + super::FROZEN_MAX_TICKS - 2; + assert!(!hub.poll(&mut ctx).iter().any(|e| is_warn(e, "frozen"))); + assert!(hub.guests[i].frozen_since.is_some()); + assert!(hub.poll(&mut ctx).iter().any(|e| is_warn(e, "frozen"))); + assert_eq!(hub.guests[i].frozen_since, None); + } + + #[test] + fn the_freeze_lasts_thirty_seconds_of_ticks() { + assert!(!super::frozen_too_long(10, 10 + 599)); + assert!(super::frozen_too_long(10, 10 + 600)); + assert!(!super::frozen_too_long(500, 100)); + } +} diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 28d2993450..c21f0472fb 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -336,6 +336,10 @@ pub struct Guest { pub death_time: u32, /// The join packets were sent (PlayerList.placeNewPlayer ran). pub placed: bool, + /// The hub tick at which a proxy froze it for a hand-over (`player-export + /// freeze`): what it sends, other than its acknowledgements, is not + /// acted on until the freeze is let go (`arrivals::FROZEN_MAX_TICKS`). + pub frozen_since: Option, /// Root command names the mods took out of this player's command tree /// (`native_player_commands_sent`); their tree is sent without them. pub hidden_commands: Vec, @@ -509,6 +513,7 @@ impl Guest { dead: false, death_time: 0, placed: false, + frozen_since: None, hidden_commands: Vec::new(), flying_speed: 0.05, dimension: "minecraft:overworld".into(), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 41a410628e..f92a1b6428 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -269,6 +269,8 @@ pub struct Hub { pub native_menu_events: Vec, /// Kicks waiting for the game thread to notify native mods. pending_kicks: Vec, + /// The players a proxy said would arrive (`player-expect`). + pub arrivals: super::arrivals::Expectations, /// The entities players started and stopped tracking, collected by /// `collect_tracking`, for the game to tell the mods. pub native_tracking: Vec, @@ -668,6 +670,7 @@ impl Hub { mod_commands: (serde_json::Value::Null, None), native_menu_events: Vec::new(), pending_kicks: Vec::new(), + arrivals: Default::default(), native_tracking: Vec::new(), attachment_snapshots: Vec::new(), sync_attachments: false, @@ -991,6 +994,30 @@ impl Hub { ); } + /// Lets go of what a proxy that stopped answering left behind: the players + /// it said would come and did not, and the freezes no hand-over ended. + /// Neither may last for ever. + fn expire_arrivals(&mut self, events: &mut Vec) { + for uuid in self.arrivals.expire(std::time::Instant::now()) { + events.push(HostEvent::Warn(format!( + "player {} was expected from another server and never came", + horizon_protocol::crypto::uuid_string(uuid) + ))); + } + for g in &mut self.guests { + if g.frozen_since + .is_some_and(|since| super::arrivals::frozen_too_long(since, self.ticks)) + { + g.frozen_since = None; + events.push(HostEvent::Warn(format!( + "{} was frozen for a hand-over for {} ticks and nothing finished it: let go", + g.name, + super::arrivals::FROZEN_MAX_TICKS + ))); + } + } + } + /// Whether a player of `permission` sees the player `uuid`: not one a /// native mod hid from players below a higher level. pub fn sees(&self, permission: u8, uuid: u128) -> bool { @@ -1157,6 +1184,9 @@ impl Hub { let g = &self.guests[i]; (g.name.clone(), g.uuid, g.entity_id, g.game_mode) }; + // The login of a player a proxy said would arrive uses up the + // expectation (`arrivals`), so it is not reported as never come. + self.arrivals.take(uuid, std::time::Instant::now()); let g = &self.guests[i]; g.send(&C::Login(play::Login { player_id: entity_id, @@ -1694,6 +1724,7 @@ impl Hub { self.ticks += 1; self.anticheat_tick(ctx.tick_rate); let mut events = std::mem::take(&mut self.pending_kicks); + self.expire_arrivals(&mut events); for event in self.lan.poll() { match event { NetEvent::Joined(j) => { @@ -1768,6 +1799,31 @@ impl Hub { events } + /// Keeps, in a frozen player's inbox, the packets that answer the server + /// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client + /// information) and drops the rest. + fn keep_acknowledgements(&mut self, i: usize) { + let g = &mut self.guests[i]; + let mut bytes = 0; + g.inbox.retain(|(packet, size)| { + let keep = matches!( + packet, + S::AcceptTeleportation(_) + | S::PlayerLoaded(_) + | S::ChunkBatchReceived(_) + | S::ClientInformation(_) + | S::ClientTickEnd(_) + | S::KeepAlive(_) + | S::Pong(_) + ); + if keep { + bytes += *size; + } + keep + }); + g.inbox_bytes = bytes; + } + /// Handle the players in this level's packets (`ServerGamePacketListenerImpl` /// for each), before the level's scheduled block ticks so a place landed in /// the same tick as a falling block's schedule still sees the support @@ -1778,6 +1834,10 @@ impl Hub { let timed = crate::spans::recording_here(); for i in 0..self.guests.len() { if self.here(&self.guests[i]) { + if self.guests[i].frozen_since.is_some() { + // Frozen for a hand-over: it answers the server and nothing more. + self.keep_acknowledgements(i); + } let started = timed.then(std::time::Instant::now); let waiting = self.guests[i].inbox.len(); self.process_inbox(i, ctx, &mut events); diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index c14e343aa8..4368c7f1aa 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -7,6 +7,7 @@ //! network pays nothing. mod advancements; pub mod anticheat; +pub mod arrivals; pub mod attributes; pub mod border; pub mod chunks; diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 6b9aadd32f..b0ba4f2c1d 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -212,8 +212,9 @@ pub fn apply(guest: &mut Guest, level: &Level) { } } -/// The shared player compound for logout and autosave snapshots. -fn player_tag(guest: &Guest) -> Tag { +/// The shared player compound for logout and autosave snapshots, and for +/// `player-export`'s record (`arrivals::export_record`). +pub fn player_tag(guest: &Guest) -> Tag { let mut level = Level { player_uuid: Some(uuid_ints(guest.uuid)), ..Level::default() diff --git a/docs/server.md b/docs/server.md index a4777a6e2f..996d116c11 100644 --- a/docs/server.md +++ b/docs/server.md @@ -144,6 +144,51 @@ every OS), the dedicated loop answers `server-status` with TPS, MSPT (average, P clock `/tick query` uses), players (name, UUID, address, ping, position), loaded chunks and uptime. It answers `console ` with the command's output. +**A proxy moving a player between two servers** uses two more requests on this endpoint (`lan/arrivals.rs`, +`game/dedicated/transfer.rs`). Their answers are one JSON line, `{"ok": true, ...}` or `{"error": "..."}`, and a +refused request changes nothing. A unique id is written as 32 hexadecimal digits with or without dashes in a +request, and as lowercase text with dashes in an answer (`123e4567-e89b-12d3-a456-426614174000`). + +`player-expect [seconds]` tells the target server that the player is about to log in: within 10 seconds by +default, 60 at most (a longer time is cut to 60), and at most 64 players are expected at once. The reply is +`{"ok": true, "renewed": false, "ttl_seconds": 10}`; asking again for the same player renews the time and says +`"renewed": true`. It is refused for a missing or malformed id, a time that is not a whole number of seconds above +zero, a player already connected, and a full table. The player's next login uses the expectation up, and an +expectation no login used is reported in the log when its time runs out. For now that is all an expectation does: +the player still logs in as any other. + +`player-export [freeze]` reads a connected player out of the source server. It first closes the player's open +menu as vanilla does when a menu closes (the cursor stack and the crafting grids go back into the inventory; the +client is told to close it and is sent its inventory), then answers: + +```json +{"ok": true, "record": { + "version": 1, + "data_version": 5023, + "uuid": "123e4567-e89b-12d3-a456-426614174000", + "name": "Alex", + "dimension": "minecraft:overworld", + "position": [10.5, 70.0, -3.25], + "rotation": [90.0, 15.0], + "velocity": [0.0, 0.0, 0.0], + "game_mode": 0, + "player": "", + "advancements": { } +}} +``` + +`player` is the base64 of the uncompressed named NBT (root name empty) of the player compound: what the server +writes to `players/data/.dat` before it gzips it, taken from the connected player at that moment. +`advancements` is the JSON the server keeps for the player, or null on a server with no advancements. `version` is +the version of this record (1); `data_version` is the world data version of the server that made it (5023 for +26.3). The other fields are a few of the compound's values, for a proxy that does not read NBT. + +With `freeze`, the player's own inputs (moves, clicks, commands) stop being acted on, while what answers the +server (teleport confirmations, chunk batch acknowledgements, keep-alives, pings, tick ends, client information) +still is. A freeze is let go after thirty seconds (600 ticks), with a warning in the log. A frozen player is still in +the world: a mob can still hurt it, and the record is its state at the export. The request is refused for a player +who is not connected, has not finished joining, is riding, is asleep or is dead. + **The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the game built in `Game::new`, the integrated server on its own thread. On that base: From 020dc51cfeb836bfb5d208f9801b82c4ee5a833b Mon Sep 17 00:00:00 2001 From: Akirabane Date: Wed, 4 Nov 2026 13:22:25 +0100 Subject: [PATCH 09/70] transfer: an opt-in [transfer] module, a hand-over id, and the review's fixes Kacper's review of #8700 and martipops's on #8700 and #8730. The module is off by default. `server.toml` gets a `[transfer]` section (enabled = false, expect_seconds = 10, max_expect_seconds = 60, max_expected = 64, max_freeze_seconds = 30) in place of the constants in lan/arrivals.rs. horizon-server passes it to the game as `--transfers`, beside `--control-socket`, not as a server.properties key. While it is off every player-* request is answered "transfers are off on this server", the hub has no table and a tick does nothing for transfers. With it on, the game refuses to start without Velocity forwarding, so a player can only come in through the proxy. The section is kept apart from vanilla's accepts-transfers. Record version 1 gets a `handover` id. An export with `freeze` starts a hand-over: a random id, and the record kept with the player. A repeat export while it lasts answers with the same id and the same bytes and does not restart the freeze, so a proxy that lost the reply can ask again. An export without `freeze` is a read, with a null id. The record also carries the player's statistics (`stats`), as the stats file has them. The export now closes the player's menu in the player's own level, as the close of every guest does on leaving, and refuses when that level cannot be opened. It copies the live mod attachments into the player first, so the compound is what a save would write. A player who is leaving cannot be exported. An expectation is used up when the login starts, on the network thread, once the bans, the whitelist and the player limit let it in. A login still in configuration when the time runs out keeps it. A frozen player is not kicked for floating, and its chat and signed commands are dropped after the messages they say the client has seen are applied, so its chat still validates after the freeze; chat acknowledgements are kept. docs/server.md gives the section, who can ask (the endpoint's limits, the token being all or nothing) and how a server reads a record: another version or data version is refused, the NBT in `player` wins over the plain fields, unknown fields are ignored. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 10 +- crates/horizon-client/src/game/dedicated.rs | 28 + .../src/game/dedicated/transfer.rs | 44 +- .../horizon-client/src/game/lan_particles.rs | 2 + crates/horizon-client/src/game/scoreboard.rs | 1 + crates/horizon-client/src/game/stats.rs | 13 +- crates/horizon-client/src/lan/arrivals.rs | 484 +++++++++++++++--- crates/horizon-client/src/lan/conn.rs | 14 +- crates/horizon-client/src/lan/guest.rs | 16 +- crates/horizon-client/src/lan/hub.rs | 85 +-- crates/horizon-client/src/lan/menu.rs | 1 + crates/horizon-client/src/lan/mod.rs | 8 + crates/horizon-client/src/lan/playerdata.rs | 1 + crates/horizon-server/src/config.rs | 100 ++++ crates/horizon-server/src/engine.rs | 34 +- crates/horizon-server/src/supervisor.rs | 11 +- docs/api/server-reference.md | 12 + docs/server.md | 87 +++- 18 files changed, 807 insertions(+), 144 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 1993c8254f..e16cecd644 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -508,6 +508,9 @@ struct Options { /// `--dedicated`: run as a dedicated server; vanilla's server arguments /// (`--nogui`, `--port`, `--world`, `--universe`, `--initSettings`) follow. dedicated: Option>, + /// `--transfers SETTINGS`: a dedicated server takes a proxy's hand-over requests + /// (`server.toml`'s `[transfer]`, which horizon-server passes; lan/arrivals.rs). + transfers: Option, /// `--benchmark SCENARIO`: run the benchmark, write its report and quit. benchmark: Option, /// `--dev-mod PATH` (repeatable): content mods always on. @@ -643,6 +646,7 @@ impl Options { .ok() .filter(|n| !n.is_empty()), dedicated: None, + transfers: None, benchmark: None, dev_mods: Vec::new(), execution_role: execution_role::ExecutionRole::Client, @@ -656,7 +660,7 @@ impl Options { match arg.as_str() { "--help" | "-h" => { println!( - "Horizon โ€” Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.", + "Horizon โ€” Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.", horizon_core::VANILLA_VERSION ); return Ok(None); @@ -759,6 +763,10 @@ impl Options { "--name" => o.player_name = Some(value), "--session-file" => o.session_file = Some(value), "--control-socket" => o.control = Some(value.into()), + "--transfers" => { + o.transfers = + Some(horizon_client::lan::arrivals::Settings::parse(&value)?) + } "--time" => o.time = Some(value.parse()?), "--weather" => o.weather = Some(value), "--dimension" => { diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 0dd86e1c63..e3ff7e8473 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -1026,6 +1026,17 @@ impl Game<'_> { .filter(|b| png_size(b) == Some((64, 64))); // Behind a Velocity proxy: `forwarding.secret` in the server folder, like the proxy's own file. let forwarding_secret = forwarding_secret(online.is_some())?; + // `[transfer]` on: a player's only way in must be the proxy, or one who joins this server + // directly finds the older copy of itself that a hand-over left here, and items double. + let transfers = self + .options + .transfers + .clone() + .map(horizon_client::lan::arrivals::Transfers::new); + if transfers.is_some() && forwarding_secret.is_none() { + return Err("server.toml's [transfer] is on, but players can join this server without the proxy: turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off" + .into()); + } let net = NetConfig { bind: (!props.server_ip.is_empty()) .then(|| props.server_ip.parse().ok()) @@ -1054,6 +1065,7 @@ impl Game<'_> { gate: Some(gate), online, forwarding_secret, + arrivals: transfers.as_ref().map(|t| t.expected.clone()), log: Some(NetLog { write: Arc::new(log_from), lang: Arc::new(horizon_client::net::load_language(&self.options.resources)), @@ -1115,6 +1127,22 @@ impl Game<'_> { log("WARN", "Perhaps a server is already running on that port?"); return Ok(()); } + if let Some(transfers) = transfers + && let Some(host) = self.lan.as_mut() + { + let s = &transfers.settings; + log( + "INFO", + &format!( + "Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most", + s.expect.as_secs(), + s.max_expect.as_secs(), + s.max_expected, + s.max_freeze_ticks + ), + ); + host.hub.transfers = Some(transfers); + } self.sync_ops(&lists); let anticheat = load_anticheat(); let chat_filter = load_chat_filter(); diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index 72ceaa1be1..27794eeb3a 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -2,8 +2,9 @@ //! `player-expect [seconds]` (the target is told who is coming) and //! `player-export [freeze]` (the source answers with the player's record). They run //! between two ticks, on the server thread, so the player is never caught half-way through a -//! tick. The rules are `lan/arrivals.rs`'s. -use crate::game::{Game, lan::LanHost}; +//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s +//! `[transfer]` is off. +use crate::game::{Dim, Game, lan::LanHost}; use horizon_client::lan::arrivals; use horizon_protocol::packets::play::{self, Clientbound as C}; use serde_json::{Value, json}; @@ -13,6 +14,12 @@ impl Game<'_> { /// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or /// `{"error": "..."}`. pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value { + let Some(host) = self.lan.as_ref() else { + return json!({ "error": "the server is not open to players" }); + }; + if host.hub.transfers.is_none() { + return json!({ "error": arrivals::OFF }); + } match verb { "player-expect" => match self.lan.as_mut() { Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()), @@ -25,8 +32,8 @@ impl Game<'_> { /// `player-export [freeze]`: closes the player's menus as vanilla does when a menu closes /// (the cursor stack and the crafting grids go back into the inventory), then answers with the - /// player's record (`arrivals::export_record`). With `freeze`, the player's own inputs stop being - /// acted on until the freeze is let go. + /// player's record (`arrivals::export_record`). With `freeze`, a hand-over starts: the player's + /// own inputs stop being acted on until it ends, and a later export answers with the same record. fn player_export(&mut self, args: &str) -> Value { let mut parts = args.split_whitespace(); let Some(uuid) = parts.next().and_then(arrivals::parse_uuid) else { @@ -53,10 +60,27 @@ impl Game<'_> { Ok(i) => i, Err(why) => return json!({ "error": why }), }; + // A repeat while the hand-over lasts: the same record, and nothing touched. + if let Some(h) = &host.hub.guests[i].handover { + return json!({ "ok": true, "record": h.record }); + } let conn = host.hub.guests[i].conn; let window = host.hub.guests[i].menu.as_ref().map(|m| m.window); + // The menu closes in the player's own level, as when it leaves (`game/lan.rs`'s close of + // every guest): a chest's lid, a villager's trade and a dropped cursor stack belong there. + let back = self.dim; + let level = Dim::from_id(&host.hub.guests[i].dimension); + if let Err(e) = self.enter_level(Some(host), level) { + return json!({ "error": format!("the player's level could not be opened: {e}") }); + } // Nothing is sent to the client by this: it is for a player who is leaving. self.guest_close_all(host, conn); + if let Err(e) = self.enter_level(Some(host), back) { + log::error!("could not reopen {}: {e}", back.id()); + } + let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else { + return json!({ "error": "that player is not connected to this server" }); + }; // The player may stay (a hand-over that does not finish): its client closes the menu too // and shows the inventory as it now is. if let Some(container) = window { @@ -64,6 +88,8 @@ impl Game<'_> { .send_to(conn, &C::ContainerClose(play::ContainerClose { container })); } host.hub.send_inventory(i); + // What a mod attached to the player since it joined, as a save writes it. + self.guest_attachments_into(&mut host.hub.guests[i]); let advancements = if self.adv.tree.list.is_empty() { Value::Null } else { @@ -71,7 +97,15 @@ impl Game<'_> { .advancements .to_json(&self.adv.tree, horizon_world::DATA_VERSION) }; - match arrivals::export_record(&mut host.hub, i, freeze, advancements) { + let stats = host + .play + .get(&conn) + .map_or(Value::Null, |p| p.stats.to_json()); + let records = arrivals::Records { + advancements, + stats, + }; + match arrivals::export_record(&mut host.hub, i, freeze, records) { Ok(record) => json!({ "ok": true, "record": record }), Err(e) => json!({ "error": e }), } diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 30162ed2bd..97e6553e6c 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -951,6 +951,7 @@ mod tests { sender: PlayerSender::new(tx_near, std::sync::Arc::default()), latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)), address: "127.0.0.1:25565".parse().unwrap(), + expected: false, }, 1, [5.0, 64.0, 5.0], @@ -972,6 +973,7 @@ mod tests { sender: PlayerSender::new(tx_far, std::sync::Arc::default()), latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)), address: "127.0.0.1:25566".parse().unwrap(), + expected: false, }, 2, [100.0, 64.0, 100.0], diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 6cebf99655..67f3e6ede4 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -966,6 +966,7 @@ mod tab_list_tests { sender: PlayerSender::new(tx, Arc::default()), latency: Arc::new(AtomicI32::new(42)), address: "127.0.0.1:25565".parse().unwrap(), + expected: false, }, uuid as i32, [0.0, 64.0, 0.0], diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 4115bd62d1..73ecb223d6 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -135,8 +135,9 @@ impl Stats { } stats } - /// `ServerStatsCounter.save`. - pub(super) fn write(&self, path: &Path) { + /// `ServerStatsCounter.toJson`: what `write` puts in the file, and what a + /// hand-over's record carries (`player-export`). + pub(super) fn to_json(&self) -> serde_json::Value { let stats: serde_json::Map = self .values .iter() @@ -147,10 +148,14 @@ impl Stats { (ty.clone(), serde_json::Value::Object(entries)) }) .collect(); - let json = serde_json::json!({ + serde_json::json!({ "stats": stats, "DataVersion": horizon_world::DATA_VERSION, - }); + }) + } + /// `ServerStatsCounter.save`. + pub(super) fn write(&self, path: &Path) { + let json = self.to_json(); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index b42f9fd28a..4b3e1686db 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -8,16 +8,104 @@ //! bound, because a proxy that dies in the middle of a move leaves its //! expectations and its freezes behind: every expectation expires, the table //! has a size cap, and a freeze is let go by itself. +//! +//! All of it is off unless `server.toml`'s `[transfer]` turns it on +//! (horizon-server passes it to the game as `--transfers`, [`Settings`]). While +//! it is off the hub has no [`Transfers`], every request is refused and nothing +//! is done on a tick. It has nothing to do with vanilla's `accepts-transfers`, +//! which is about the transfer packet a server sends a client. use super::hub::Hub; use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -/// How long an expectation lives when the request names no time. -pub const DEFAULT_TTL: Duration = Duration::from_secs(10); -/// The longest an expectation may be asked to live. -pub const MAX_TTL: Duration = Duration::from_secs(60); -/// The most expectations the table keeps at once. -pub const MAX_EXPECTED: usize = 64; +/// `server.toml`'s `[transfer]`, as horizon-server passes it: `--transfers +/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30`. +/// A key left out keeps its default. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Settings { + /// How long an expectation lives when the request names no time. + pub expect: Duration, + /// The longest an expectation may be asked to live. + pub max_expect: Duration, + /// The most expectations the table keeps at once. + pub max_expected: usize, + /// Server ticks a player may stay frozen for a hand-over: a proxy that dies + /// in the middle of one must not leave the player stuck. + pub max_freeze_ticks: u64, +} + +impl Default for Settings { + fn default() -> Self { + Self { + expect: Duration::from_secs(10), + max_expect: Duration::from_secs(60), + max_expected: 64, + max_freeze_ticks: 20 * 30, + } + } +} + +impl Settings { + /// Reads `--transfers`' value: `key=value` pairs separated by commas, each a + /// whole number above zero. + pub fn parse(text: &str) -> Result { + let mut s = Self::default(); + for pair in text.split(',').map(str::trim).filter(|p| !p.is_empty()) { + let (key, value) = pair + .split_once('=') + .ok_or_else(|| format!("{pair:?} is not key=value"))?; + let key = key.trim(); + let n: u64 = value + .trim() + .parse() + .ok() + .filter(|n| *n > 0) + .ok_or_else(|| format!("{key} must be a whole number above zero"))?; + match key { + "expect_seconds" => s.expect = Duration::from_secs(n), + "max_expect_seconds" => s.max_expect = Duration::from_secs(n), + "max_expected" => { + s.max_expected = usize::try_from(n).map_err(|_| "max_expected is too large")? + } + "max_freeze_seconds" => { + s.max_freeze_ticks = + n.checked_mul(20).ok_or("max_freeze_seconds is too large")? + } + other => return Err(format!("unknown setting {other:?}")), + } + } + if s.expect > s.max_expect { + return Err("expect_seconds is above max_expect_seconds".into()); + } + Ok(s) + } +} + +/// What a server with transfers on keeps: its settings and the players it was +/// told to expect. The table is shared with the network threads, which use an +/// expectation up when a login starts. +#[derive(Debug)] +pub struct Transfers { + pub settings: Settings, + pub expected: Arc>, +} + +impl Transfers { + pub fn new(settings: Settings) -> Self { + let expected = Arc::new(Mutex::new(Expectations::new( + settings.max_expected, + settings.max_expect, + ))); + Self { settings, expected } + } + + /// The table, locked. A thread that panicked while holding it left it + /// whole: every change is one map operation. + pub fn table(&self) -> std::sync::MutexGuard<'_, Expectations> { + self.expected.lock().unwrap_or_else(|e| e.into_inner()) + } +} /// Why an expectation could not be recorded. #[derive(Debug, PartialEq, Eq)] @@ -27,26 +115,44 @@ pub enum ExpectError { } /// The players a server was told to expect, by unique id. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct Expectations { until: HashMap, + max: usize, + max_ttl: Duration, +} + +impl Default for Expectations { + fn default() -> Self { + let s = Settings::default(); + Self::new(s.max_expected, s.max_expect) + } } impl Expectations { - /// Records that `uuid` will log in within `ttl` (cut to [`MAX_TTL`]). A second - /// request for the same player replaces the first one's deadline. Returns - /// whether the player was already expected. + pub fn new(max: usize, max_ttl: Duration) -> Self { + Self { + until: HashMap::new(), + max, + max_ttl, + } + } + + /// Records that `uuid` will log in within `ttl` (cut to the longest time + /// allowed). A second request for the same player replaces the first one's + /// deadline. Returns whether the player was already expected. pub fn expect(&mut self, uuid: u128, ttl: Duration, now: Instant) -> Result { self.expire(now); - if !self.until.contains_key(&uuid) && self.until.len() >= MAX_EXPECTED { + if !self.until.contains_key(&uuid) && self.until.len() >= self.max { return Err(ExpectError::Full); } - let deadline = now + ttl.min(MAX_TTL); + let deadline = now + ttl.min(self.max_ttl); Ok(self.until.insert(uuid, deadline).is_some()) } - /// Consumes the expectation of `uuid`, and says whether it was still alive: - /// one login takes it, and a second login of the same player finds none. + /// Uses up the expectation of `uuid`, and says whether it was still alive: + /// the login that starts takes it, and a second login of the same player + /// finds none. pub fn take(&mut self, uuid: u128, now: Instant) -> bool { match self.until.remove(&uuid) { Some(deadline) => now < deadline, @@ -69,6 +175,11 @@ impl Expectations { gone } + /// The longest an expectation may live. + pub fn max_ttl(&self) -> Duration { + self.max_ttl + } + /// How many expectations are recorded (some may be past their time but not /// yet swept). pub fn len(&self) -> usize { @@ -80,15 +191,45 @@ impl Expectations { } } -/// Server ticks a player may stay frozen for a hand-over (thirty seconds): a proxy -/// that dies in the middle of one must not leave the player stuck. -pub const FROZEN_MAX_TICKS: u64 = 20 * 30; /// The version of the record `player-export` answers with. pub const RECORD_VERSION: i64 = 1; -/// Whether a player frozen at tick `since` has been frozen too long at tick `now`. -pub fn frozen_too_long(since: u64, now: u64) -> bool { - now.saturating_sub(since) >= FROZEN_MAX_TICKS +/// A hand-over in progress on the source: the player was exported with +/// `freeze`. Its id names it in the later requests, and its record is what +/// every later export answers with until it ends, the same bytes each time, +/// so a proxy that lost the reply can ask again. +#[derive(Clone, Debug, PartialEq)] +pub struct Handover { + pub id: u128, + /// The hub tick the freeze started at. + pub since: u64, + pub record: serde_json::Value, +} + +/// A hand-over id as a reply writes it: 32 lowercase hexadecimal digits. +pub fn handover_text(id: u128) -> String { + format!("{id:032x}") +} + +/// A hand-over id from a request: 32 hexadecimal digits. +pub fn parse_handover(text: &str) -> Option { + if text.len() != 32 || !text.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + u128::from_str_radix(text, 16).ok() +} + +/// A new hand-over id, from the operating system's secure randomness. +fn new_handover_id() -> Result { + let mut bytes = [0u8; 16]; + horizon_protocol::crypto::random_bytes(&mut bytes).map_err(|e| e.to_string())?; + Ok(u128::from_le_bytes(bytes)) +} + +/// Whether a player frozen at tick `since` has been frozen too long at tick +/// `now`, for a freeze of at most `max` ticks. +pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool { + now.saturating_sub(since) >= max } /// Why the connected player `uuid` cannot be exported now, or the index of its @@ -100,6 +241,8 @@ pub fn check_exportable(hub: &Hub, uuid: u128) -> Result { let g = &hub.guests[i]; if !g.placed { Err("that player has not finished joining") + } else if !g.left_reason.is_empty() { + Err("that player is leaving") } else if g.vehicle.is_some() { Err("that player is riding") } else if g.sleeping { @@ -111,19 +254,33 @@ pub fn check_exportable(hub: &Hub, uuid: u128) -> Result { } } +/// What a record carries besides the player compound: the JSON the server keeps +/// for the player's advancements and statistics (null where it keeps none). +#[derive(Clone, Debug, Default)] +pub struct Records { + pub advancements: serde_json::Value, + pub stats: serde_json::Value, +} + /// The record of the player at guest index `i` (which [`check_exportable`] gave, -/// with its menus closed): the player compound as the server saves it, as the -/// uncompressed NBT of the compound in base64, and the few values a proxy reads -/// without opening it. `advancements` is the JSON the server keeps for the -/// player, or null. With `freeze`, the player's own inputs stop being acted on -/// until the freeze is let go. +/// with its menus closed and its attachments in place): the player compound as +/// the server saves it, as the uncompressed NBT of the compound in base64, its +/// advancements and statistics, and the few values a proxy reads without opening +/// it. With `freeze`, a hand-over starts: the record gets an id, the player's +/// own inputs stop being acted on until the hand-over ends, and every later +/// export answers with this same record. A player already frozen is answered +/// with its hand-over's record, whatever `freeze` says. pub fn export_record( hub: &mut Hub, i: usize, freeze: bool, - advancements: serde_json::Value, + records: Records, ) -> Result { use horizon_world::nbt::{Limits, NamedTag, write_named}; + if let Some(h) = &hub.guests[i].handover { + return Ok(h.record.clone()); + } + let id = freeze.then(new_handover_id).transpose()?; let g = &hub.guests[i]; let tag = super::playerdata::player_tag(g); let bytes = write_named( @@ -141,6 +298,7 @@ pub fn export_record( let record = serde_json::json!({ "version": RECORD_VERSION, "data_version": horizon_world::DATA_VERSION, + "handover": id.map(handover_text), "uuid": horizon_protocol::crypto::uuid_string(g.uuid), "name": g.name, "dimension": g.dimension, @@ -149,11 +307,16 @@ pub fn export_record( "velocity": velocity, "game_mode": g.game_mode, "player": super::base64(&bytes), - "advancements": advancements, + "advancements": records.advancements, + "stats": records.stats, }); - if freeze { + if let Some(id) = id { let since = hub.ticks; - hub.guests[i].frozen_since = Some(since); + hub.guests[i].handover = Some(Handover { + id, + since, + record: record.clone(), + }); } Ok(record) } @@ -171,17 +334,24 @@ fn error(message: &str) -> serde_json::Value { serde_json::json!({ "error": message }) } +/// The reply to every `player-*` request on a server with transfers off. +pub const OFF: &str = "transfers are off on this server"; + /// `player-expect [seconds]`: the player will log in within that time -/// (10 seconds by default, 60 at most). The reply says whether an earlier -/// expectation was renewed. The login uses the expectation up; one that no -/// login used is reported when it runs out. +/// (`[transfer]`'s `expect_seconds` by default, `max_expect_seconds` at most). +/// The reply says whether an earlier expectation was renewed. The login uses +/// the expectation up when it starts; one that no login used is reported when +/// it runs out. pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Value { + let Some(transfers) = &hub.transfers else { + return error(OFF); + }; let mut parts = args.split_whitespace(); let Some(uuid) = parts.next().and_then(parse_uuid) else { return error("player-expect needs the player's unique id"); }; let ttl = match parts.next() { - None => DEFAULT_TTL, + None => transfers.settings.expect, Some(seconds) => match seconds.parse::() { Ok(s) if s > 0 => Duration::from_secs(s), _ => return error("the time must be a number of seconds above zero"), @@ -193,11 +363,12 @@ pub fn expect_request(hub: &mut Hub, args: &str, now: Instant) -> serde_json::Va if hub.guests.iter().any(|g| g.uuid == uuid) { return error("that player is already connected to this server"); } - match hub.arrivals.expect(uuid, ttl, now) { + let mut table = transfers.table(); + match table.expect(uuid, ttl, now) { Ok(renewed) => serde_json::json!({ "ok": true, "renewed": renewed, - "ttl_seconds": ttl.min(MAX_TTL).as_secs(), + "ttl_seconds": ttl.min(table.max_ttl()).as_secs(), }), Err(ExpectError::Full) => error("too many players are expected at once"), } @@ -255,7 +426,7 @@ mod tests { let t0 = Instant::now(); let mut e = Expectations::default(); e.expect(A, secs(3600), t0).unwrap(); - assert!(!e.take(A, t0 + MAX_TTL)); + assert!(!e.take(A, t0 + Settings::default().max_expect)); } #[test] @@ -273,7 +444,7 @@ mod tests { fn the_table_refuses_new_players_when_full_but_expired_ones_make_room() { let t0 = Instant::now(); let mut e = Expectations::default(); - for n in 0..MAX_EXPECTED as u128 { + for n in 0..Settings::default().max_expected as u128 { e.expect(100 + n, secs(10), t0).unwrap(); } assert_eq!(e.expect(A, secs(10), t0), Err(ExpectError::Full)); @@ -283,6 +454,58 @@ mod tests { assert_eq!(e.expect(A, secs(10), t0 + secs(11)), Ok(false)); assert_eq!(e.len(), 1); } + + #[test] + fn the_settings_are_read_from_the_supervisors_line() { + assert_eq!(Settings::parse(""), Ok(Settings::default())); + let s = Settings::parse( + "expect_seconds=5,max_expect_seconds=20,max_expected=3,max_freeze_seconds=12", + ) + .unwrap(); + assert_eq!(s.expect, secs(5)); + assert_eq!(s.max_expect, secs(20)); + assert_eq!(s.max_expected, 3); + assert_eq!(s.max_freeze_ticks, 240); + // One key alone keeps the others' defaults. + assert_eq!( + Settings::parse("max_expected=8").unwrap(), + Settings { + max_expected: 8, + ..Settings::default() + } + ); + for bad in [ + "expect_seconds=0", + "expect_seconds=-1", + "expect_seconds", + "colour=blue", + "expect_seconds=90", + ] { + assert!(Settings::parse(bad).is_err(), "{bad:?} was taken"); + } + } + + #[test] + fn the_table_follows_the_settings() { + let t0 = Instant::now(); + let mut e = Expectations::new(2, secs(5)); + e.expect(A, secs(60), t0).unwrap(); + assert!(!e.take(A, t0 + secs(5)), "cut to five seconds"); + e.expect(A, secs(1), t0).unwrap(); + e.expect(B, secs(1), t0).unwrap(); + assert_eq!(e.expect(3, secs(1), t0), Err(ExpectError::Full)); + } + + #[test] + fn a_hand_over_id_is_32_hexadecimal_digits() { + let id = 0x0123_4567_89ab_cdef_0011_2233_4455_6677; + assert_eq!(handover_text(id), "0123456789abcdef0011223344556677"); + assert_eq!(parse_handover(&handover_text(id)), Some(id)); + assert_eq!(parse_handover("0123456789ABCDEF0011223344556677"), Some(id)); + assert_eq!(parse_handover("0123456789abcdef001122334455667"), None); + assert_eq!(parse_handover("01234567-89ab-cdef-0011-223344556677"), None); + assert_ne!(new_handover_id().unwrap(), new_handover_id().unwrap()); + } } /// The hub's side: the requests, the record and the freeze. @@ -331,7 +554,14 @@ mod hub_tests { integrated: false, levels: Default::default(), }; - Hub::new(lan, sync, encoder, config) + let mut hub = Hub::new(lan, sync, encoder, config); + hub.transfers = Some(super::Transfers::new(super::Settings::default())); + hub + } + + /// The table a server with transfers on keeps. + fn table(hub: &Hub) -> std::sync::MutexGuard<'_, super::Expectations> { + hub.transfers.as_ref().unwrap().table() } /// A guest in the overworld and the end of the channel its packets go to. @@ -348,6 +578,7 @@ mod hub_tests { sender: PlayerSender::new(tx, std::sync::Arc::default()), latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)), address: "127.0.0.1:25565".parse().unwrap(), + expected: false, }, conn as i32, [0.5, 64.0, 0.5], @@ -410,7 +641,7 @@ mod hub_tests { fn an_expectation_nobody_came_for_is_reported_and_swept() { let mut hub = hub(); // Already past its time when the hub next ticks. - hub.arrivals + table(&hub) .expect(7, Duration::ZERO, Instant::now()) .unwrap(); let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); @@ -422,27 +653,42 @@ mod hub_tests { events.iter().filter(|e| is_warn(e, "never came")).count(), 1 ); - assert!(hub.arrivals.is_empty()); + assert!(table(&hub).is_empty()); } - /// The login of an expected player uses the expectation up, so nothing is - /// reported when its time would have run out; another player's stays. + /// A login takes the expectation from the table the network threads share + /// with the hub (`lan/conn.rs`, as the login starts), so nothing is reported + /// when its time would have run out; another player's stays. #[test] fn a_login_uses_up_the_expectation_of_that_player_only() { let mut hub = hub(); - let (newcomer, _mine) = guest(2, 2, "Newcomer"); - hub.guests.push(newcomer); let now = Instant::now(); - hub.arrivals - .expect(2, Duration::from_secs(10), now) - .unwrap(); - hub.arrivals - .expect(3, Duration::from_secs(10), now) - .unwrap(); + table(&hub).expect(2, Duration::from_secs(10), now).unwrap(); + table(&hub).expect(3, Duration::from_secs(10), now).unwrap(); + let shared = hub.transfers.as_ref().unwrap().expected.clone(); + assert!(shared.lock().unwrap().take(2, now)); + let (mut newcomer, _mine) = guest(2, 2, "Newcomer"); + newcomer.expected = true; + hub.guests.push(newcomer); place(&mut hub, 2); assert!(hub.guests[0].placed); - assert_eq!(hub.arrivals.len(), 1, "the other player is still expected"); - assert!(hub.arrivals.take(3, now)); + assert_eq!(table(&hub).len(), 1, "the other player is still expected"); + assert!(table(&hub).take(3, now)); + } + + /// While `[transfer]` is off every request is refused, there is no table, + /// and a tick does nothing for transfers. + #[test] + fn with_transfers_off_every_request_is_refused() { + let mut hub = hub(); + hub.transfers = None; + let reply = super::expect_request(&mut hub, UUID, Instant::now()); + assert_eq!(reply["error"], super::OFF); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + assert!(hub.poll(&mut ctx).is_empty()); } const UUID: &str = "123e4567-e89b-12d3-a456-426614174000"; @@ -487,8 +733,15 @@ mod hub_tests { // A time past the maximum is cut, and the reply says what it became. let reply = super::expect_request(&mut hub, &format!("{UUID} 3600"), now); assert_eq!(reply["ttl_seconds"], 60); - assert_eq!(hub.arrivals.len(), 1); - assert!(hub.arrivals.take(UUID_N, now)); + assert_eq!(table(&hub).len(), 1); + assert!(table(&hub).take(UUID_N, now)); + // The default time is the server's own. + hub.transfers = Some(super::Transfers::new(super::Settings { + expect: Duration::from_secs(4), + ..super::Settings::default() + })); + let reply = super::expect_request(&mut hub, UUID, now); + assert_eq!(reply["ttl_seconds"], 4); } #[test] @@ -516,14 +769,14 @@ mod hub_tests { .contains("already connected"), "{reply}" ); - assert!(hub.arrivals.is_empty()); + assert!(table(&hub).is_empty()); } #[test] fn player_expect_refuses_when_the_table_is_full() { let mut hub = hub(); let now = Instant::now(); - for n in 0..super::MAX_EXPECTED as u128 { + for n in 0..super::Settings::default().max_expected as u128 { let reply = super::expect_request(&mut hub, &format!("{:032x}", 1000 + n), now); assert_eq!(reply["ok"], true); } @@ -622,8 +875,11 @@ mod hub_tests { fn the_record_carries_what_a_proxy_reads_and_the_vanilla_player_compound() { use crate::save::{float, number}; let (mut hub, i) = exported_guest(); - let record = - super::export_record(&mut hub, i, false, serde_json::json!({"done": []})).unwrap(); + let records = super::Records { + advancements: serde_json::json!({"done": []}), + stats: serde_json::json!({"stats": {"minecraft:custom": {"minecraft:jump": 3}}}), + }; + let record = super::export_record(&mut hub, i, false, records).unwrap(); assert_eq!(record["version"], 1); assert_eq!(record["data_version"], horizon_world::DATA_VERSION); assert_eq!(record["uuid"], UUID); @@ -633,7 +889,13 @@ mod hub_tests { assert_eq!(record["rotation"], serde_json::json!([90.0, 15.0])); assert_eq!(record["game_mode"], 1); assert_eq!(record["advancements"], serde_json::json!({"done": []})); - assert_eq!(hub.guests[i].frozen_since, None, "no freeze was asked for"); + assert_eq!( + record["stats"]["stats"]["minecraft:custom"]["minecraft:jump"], + 3 + ); + // A read, not a hand-over: no id, and the player goes on. + assert_eq!(record["handover"], serde_json::Value::Null); + assert_eq!(hub.guests[i].handover, None, "no freeze was asked for"); let tag = compound_of(&record); for key in VANILLA_PLAYER_KEYS { @@ -662,8 +924,13 @@ mod hub_tests { fn a_frozen_player_is_inert_until_it_is_let_go() { use horizon_protocol::packets::play::{ChunkBatchReceived, MovePlayerPos, Serverbound}; let (mut hub, i) = exported_guest(); - super::export_record(&mut hub, i, true, serde_json::Value::Null).unwrap(); - assert_eq!(hub.guests[i].frozen_since, Some(hub.ticks)); + let record = super::export_record(&mut hub, i, true, Default::default()).unwrap(); + let handover = hub.guests[i].handover.clone().unwrap(); + assert_eq!(handover.since, hub.ticks); + assert_eq!( + record["handover"].as_str(), + Some(super::handover_text(handover.id).as_str()) + ); let before = hub.guests[i].position; let g = &mut hub.guests[i]; g.unacknowledged = 2; @@ -697,17 +964,98 @@ mod hub_tests { "its acknowledgement was handled" ); // Nobody finishes the hand-over: thirty seconds of ticks later it is let go. - hub.ticks = hub.guests[i].frozen_since.unwrap() + super::FROZEN_MAX_TICKS - 2; + let max = super::Settings::default().max_freeze_ticks; + hub.ticks = handover.since + max - 2; assert!(!hub.poll(&mut ctx).iter().any(|e| is_warn(e, "frozen"))); - assert!(hub.guests[i].frozen_since.is_some()); + assert!(hub.guests[i].handover.is_some()); assert!(hub.poll(&mut ctx).iter().any(|e| is_warn(e, "frozen"))); - assert_eq!(hub.guests[i].frozen_since, None); + assert_eq!(hub.guests[i].handover, None, "the hand-over is over"); } #[test] - fn the_freeze_lasts_thirty_seconds_of_ticks() { - assert!(!super::frozen_too_long(10, 10 + 599)); - assert!(super::frozen_too_long(10, 10 + 600)); - assert!(!super::frozen_too_long(500, 100)); + fn the_freeze_lasts_the_settings_ticks() { + assert_eq!(super::Settings::default().max_freeze_ticks, 600); + assert!(!super::frozen_too_long(10, 10 + 599, 600)); + assert!(super::frozen_too_long(10, 10 + 600, 600)); + assert!(!super::frozen_too_long(500, 100, 600)); + assert!(super::frozen_too_long(10, 50, 40)); + } + + /// A proxy that lost the reply asks again: the same hand-over, the same + /// bytes, and the freeze is not restarted, however the player changed since. + #[test] + fn a_repeated_export_answers_with_the_first_record() { + let (mut hub, i) = exported_guest(); + let first = super::export_record(&mut hub, i, true, Default::default()).unwrap(); + let since = hub.guests[i].handover.as_ref().unwrap().since; + hub.ticks += 50; + hub.guests[i].health = 3.0; + hub.guests[i].position[0] += 4.0; + for freeze in [true, false] { + let again = super::export_record(&mut hub, i, freeze, Default::default()).unwrap(); + assert_eq!( + serde_json::to_string(&again).unwrap(), + serde_json::to_string(&first).unwrap() + ); + } + assert_eq!(hub.guests[i].handover.as_ref().unwrap().since, since); + // Once it is over, a new freeze is a new hand-over. + hub.guests[i].handover = None; + let next = super::export_record(&mut hub, i, true, Default::default()).unwrap(); + assert_ne!(next["handover"], first["handover"]); + assert_eq!(next["position"][0], 14.5); + } + + /// A player frozen in the rising part of a jump is not kicked for flying: + /// its moves are dropped, so the last one's floating is stale. + #[test] + fn a_frozen_player_is_not_kicked_for_floating() { + let (mut hub, i) = exported_guest(); + super::export_record(&mut hub, i, true, Default::default()).unwrap(); + hub.guests[i].client_is_floating = true; + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + let border = crate::worldborder::WorldBorder::default(); + let mut next = 100; + let mut ctx = tick_context(&mut world, &border, &mut next); + for _ in 0..200 { + hub.drain_packets(&mut ctx); + } + assert_eq!(hub.guests[i].above_ground_ticks, 0); + assert!(hub.guests[i].left_reason.is_empty(), "not kicked"); + } + + /// A frozen player's chat acknowledgements are still taken, and its chat + /// message is dropped without a kick. + #[test] + fn a_frozen_player_keeps_acknowledging_chat() { + use horizon_protocol::packets::play::{Chat, ChatAck, LastSeenUpdate, Serverbound}; + let (mut hub, i) = exported_guest(); + super::export_record(&mut hub, i, true, Default::default()).unwrap(); + let g = &mut hub.guests[i]; + g.inbox.push_back(( + Serverbound::Chat(Chat { + message: "hello".into(), + timestamp: 0, + salt: 0, + signature: None, + last_seen: LastSeenUpdate { + offset: 0, + acknowledged: [0; 3], + checksum: 0, + }, + }), + 10, + )); + g.inbox + .push_back((Serverbound::ChatAck(ChatAck { offset: 0 }), 2)); + g.inbox_bytes = 12; + hub.keep_acknowledgements(i, &mut Vec::new()); + let kept: Vec<_> = hub.guests[i].inbox.iter().map(|(p, _)| p.clone()).collect(); + assert!( + matches!(kept.as_slice(), [Serverbound::ChatAck(_)]), + "{kept:?}" + ); + assert_eq!(hub.guests[i].inbox_bytes, 2); + assert!(hub.guests[i].left_reason.is_empty(), "not kicked"); } } diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index 7db181851d..b33aa95827 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -771,10 +771,20 @@ fn serve_login( lost(shared, &user, &text); return Ok(()); } + // A player a proxy said would arrive (`player-expect`) uses the expectation up + // here, as the login starts: one that ran out while the player was still in + // configuration (a resource pack prompt) would otherwise make it an ordinary + // login from this server's own file. + let expected = shared.config.arrivals.as_ref().is_some_and(|table| { + table + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(uuid, std::time::Instant::now()) + }); let result = login_and_configure(&mut c, shared, uuid, &name, properties); match result { Ok(Some(parts)) => { - play(c, shared, parts, address, counted); + play(c, shared, parts, address, counted, expected); Ok(()) } Ok(None) => { @@ -1227,6 +1237,7 @@ fn play( parts: Parts, address: std::net::SocketAddr, counted: Option, + expected: bool, ) { shared.entered_play(); let conn = shared.next_id(); @@ -1277,6 +1288,7 @@ fn play( sender, latency, address, + expected, }; let mut session = Box::new(session); if shared diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index c21f0472fb..601b863174 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -336,10 +336,14 @@ pub struct Guest { pub death_time: u32, /// The join packets were sent (PlayerList.placeNewPlayer ran). pub placed: bool, - /// The hub tick at which a proxy froze it for a hand-over (`player-export - /// freeze`): what it sends, other than its acknowledgements, is not - /// acted on until the freeze is let go (`arrivals::FROZEN_MAX_TICKS`). - pub frozen_since: Option, + /// The hand-over a proxy started by exporting it with `freeze` + /// (`arrivals::Handover`): while it lasts, what it sends other than its + /// acknowledgements is not acted on, and every export answers with the + /// same record. It ends after `[transfer]`'s `max_freeze_seconds`. + pub handover: Option, + /// Its login started while a proxy said it would arrive from another + /// server, and used that expectation up (`player-expect`). + pub expected: bool, /// Root command names the mods took out of this player's command tree /// (`native_player_commands_sent`); their tree is sent without them. pub hidden_commands: Vec, @@ -513,7 +517,8 @@ impl Guest { dead: false, death_time: 0, placed: false, - frozen_since: None, + handover: None, + expected: j.expected, hidden_commands: Vec::new(), flying_speed: 0.05, dimension: "minecraft:overworld".into(), @@ -1519,6 +1524,7 @@ mod tests { sender: PlayerSender::new(tx, Arc::new(std::sync::atomic::AtomicUsize::new(0))), latency: Arc::new(AtomicI32::new(0)), address: "127.0.0.1:1".parse().unwrap(), + expected: false, }, 1, position, diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index f92a1b6428..060404042e 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -269,8 +269,9 @@ pub struct Hub { pub native_menu_events: Vec, /// Kicks waiting for the game thread to notify native mods. pending_kicks: Vec, - /// The players a proxy said would arrive (`player-expect`). - pub arrivals: super::arrivals::Expectations, + /// `[transfer]` on (a dedicated server's `--transfers`): the players a proxy + /// said would arrive (`player-expect`). None while it is off. + pub transfers: Option, /// The entities players started and stopped tracking, collected by /// `collect_tracking`, for the game to tell the mods. pub native_tracking: Vec, @@ -670,7 +671,7 @@ impl Hub { mod_commands: (serde_json::Value::Null, None), native_menu_events: Vec::new(), pending_kicks: Vec::new(), - arrivals: Default::default(), + transfers: None, native_tracking: Vec::new(), attachment_snapshots: Vec::new(), sync_attachments: false, @@ -998,21 +999,26 @@ impl Hub { /// it said would come and did not, and the freezes no hand-over ended. /// Neither may last for ever. fn expire_arrivals(&mut self, events: &mut Vec) { - for uuid in self.arrivals.expire(std::time::Instant::now()) { + // Off: nothing to do (and no player can be frozen). + let Some(transfers) = &self.transfers else { + return; + }; + for uuid in transfers.table().expire(std::time::Instant::now()) { events.push(HostEvent::Warn(format!( "player {} was expected from another server and never came", horizon_protocol::crypto::uuid_string(uuid) ))); } + let max = transfers.settings.max_freeze_ticks; for g in &mut self.guests { - if g.frozen_since - .is_some_and(|since| super::arrivals::frozen_too_long(since, self.ticks)) + if g.handover + .as_ref() + .is_some_and(|h| super::arrivals::frozen_too_long(h.since, self.ticks, max)) { - g.frozen_since = None; + g.handover = None; events.push(HostEvent::Warn(format!( - "{} was frozen for a hand-over for {} ticks and nothing finished it: let go", + "{} was frozen for a hand-over for {max} ticks and nothing finished it: let go", g.name, - super::arrivals::FROZEN_MAX_TICKS ))); } } @@ -1184,9 +1190,6 @@ impl Hub { let g = &self.guests[i]; (g.name.clone(), g.uuid, g.entity_id, g.game_mode) }; - // The login of a player a proxy said would arrive uses up the - // expectation (`arrivals`), so it is not reported as never come. - self.arrivals.take(uuid, std::time::Instant::now()); let g = &self.guests[i]; g.send(&C::Login(play::Login { player_id: entity_id, @@ -1801,27 +1804,37 @@ impl Hub { /// Keeps, in a frozen player's inbox, the packets that answer the server /// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client - /// information) and drops the rest. - fn keep_acknowledgements(&mut self, i: usize) { - let g = &mut self.guests[i]; + /// information, chat acknowledgement) and drops the rest. A chat message or + /// signed command is dropped too, but the messages it says the client has + /// seen are taken first (`unpackAndApplyLastSeen`): the client counts them + /// as acknowledged, and its next message after the freeze would not + /// validate otherwise. + pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec) { + let inbox = std::mem::take(&mut self.guests[i].inbox); let mut bytes = 0; - g.inbox.retain(|(packet, size)| { - let keep = matches!( - packet, + for (packet, size) in inbox { + match &packet { + S::Chat(c) => { + self.apply_last_seen(i, &c.last_seen, events); + } + S::ChatCommandSigned(c) => { + self.apply_last_seen(i, &c.last_seen, events); + } S::AcceptTeleportation(_) - | S::PlayerLoaded(_) - | S::ChunkBatchReceived(_) - | S::ClientInformation(_) - | S::ClientTickEnd(_) - | S::KeepAlive(_) - | S::Pong(_) - ); - if keep { - bytes += *size; + | S::PlayerLoaded(_) + | S::ChunkBatchReceived(_) + | S::ClientInformation(_) + | S::ClientTickEnd(_) + | S::KeepAlive(_) + | S::Pong(_) + | S::ChatAck(_) => { + bytes += size; + self.guests[i].inbox.push_back((packet, size)); + } + _ => {} } - keep - }); - g.inbox_bytes = bytes; + } + self.guests[i].inbox_bytes = bytes; } /// Handle the players in this level's packets (`ServerGamePacketListenerImpl` @@ -1834,14 +1847,19 @@ impl Hub { let timed = crate::spans::recording_here(); for i in 0..self.guests.len() { if self.here(&self.guests[i]) { - if self.guests[i].frozen_since.is_some() { + let frozen = self.guests[i].handover.is_some(); + if frozen { // Frozen for a hand-over: it answers the server and nothing more. - self.keep_acknowledgements(i); + self.keep_acknowledgements(i, &mut events); } let started = timed.then(std::time::Instant::now); let waiting = self.guests[i].inbox.len(); self.process_inbox(i, ctx, &mut events); - self.tick_floating(i, &mut events); + // Its moves are dropped, so whether the last one left it floating is + // stale: a player frozen in a jump is not kicked for flying. + if !frozen { + self.tick_floating(i, &mut events); + } // A slow tick's line says whose packets took it (`spans::note`). if let Some(started) = started { let g = &self.guests[i]; @@ -4525,6 +4543,7 @@ mod tests { sender: crate::lan::PlayerSender::new(tx, std::sync::Arc::default()), latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)), address: "127.0.0.1:25565".parse().unwrap(), + expected: false, }, 1, [0.5, 64.0, 0.5], diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 5d79a9247f..707362e603 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -903,6 +903,7 @@ mod tests { sender: PlayerSender::new(tx, Arc::default()), latency: Arc::new(AtomicI32::new(0)), address: "127.0.0.1:25565".parse().unwrap(), + expected: false, }, 2, [0.0, 64.0, 0.0], diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 4368c7f1aa..7115c27a11 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -80,6 +80,9 @@ pub struct Joined { /// Smoothed keep-alive round trip in milliseconds. pub latency: Arc, pub address: SocketAddr, + /// The login used up an expectation of a proxy's (`player-expect`) when it + /// started (`arrivals`). + pub expected: bool, } /// What a [`PlayerSender`] made for a test's channel gives it. pub enum Out { @@ -182,6 +185,10 @@ pub struct NetConfig { /// Behind a Velocity proxy: the secret its forwarded player information is signed with. Only an offline /// server (the proxy authenticates) uses it; without it a login is the plain offline one. pub forwarding_secret: Option>, + /// `[transfer]` on: the players a proxy said would arrive + /// (`arrivals::Transfers`), shared with the hub. A login uses its + /// expectation up as it starts, once the bans and the whitelist let it in. + pub arrivals: Option>>, pub log: Option, /// server.properties `resource-pack`: offered to every player during /// configuration (a dedicated server's; MinecraftServer.getServerResourcePack). @@ -234,6 +241,7 @@ impl NetConfig { gate: None, online: None, forwarding_secret: None, + arrivals: None, log: None, resource_pack: None, code_of_conducts: Vec::new(), diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index b0ba4f2c1d..8360170ef1 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -539,6 +539,7 @@ mod tests { sender: PlayerSender::new(tx, Arc::default()), latency: Arc::new(AtomicI32::new(0)), address: "127.0.0.1:25565".parse().unwrap(), + expected: false, }, 2, [0.0, 64.0, 0.0], diff --git a/crates/horizon-server/src/config.rs b/crates/horizon-server/src/config.rs index 10c8c28a77..11ad2f4527 100644 --- a/crates/horizon-server/src/config.rs +++ b/crates/horizon-server/src/config.rs @@ -64,6 +64,14 @@ bind = "" # the control API: "" a free port on 127.0.0.1; or token = "" # its token (16 characters or more); "" a new random one at every start allow_public = false # true lets bind be any address; the token is then all that guards the server +# A proxy handing players over between servers (docs/server.md). Not vanilla's accepts-transfers (the transfer packet). +[transfer] +enabled = false # true: take a proxy's player-* requests; needs Velocity forwarding (forwarding.secret) +expect_seconds = 10 # how long a player the proxy announced is waited for, when it names no time +max_expect_seconds = 60 # the longest the proxy may ask for +max_expected = 64 # players waited for at once +max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over + # Post the chat, joins, leaves and deaths to a Discord channel, drawn like the in-game chat (docs/hosting.md) [discord] webhook = "" # the channel's webhook URL (a secret: anyone with it can post there); "" off @@ -137,6 +145,9 @@ pub struct Config { pub panel: Panel, #[serde(default)] pub api: Api, + /// A proxy's hand-overs (docs/server.md), passed to the game as `--transfers`. + #[serde(default)] + pub transfer: Transfer, /// Chat and players posted to a Discord channel (discord.rs). #[serde(default)] pub discord: crate::discord::Settings, @@ -287,6 +298,64 @@ pub fn private(ip: std::net::IpAddr) -> bool { } } +/// `[transfer]`: a proxy moving players between this server and others (docs/server.md, the game's +/// `lan/arrivals.rs`). Off by default. The supervisor passes it to the game as `--transfers`, not as a +/// server.properties key, which stays vanilla's; and it is not vanilla's `accepts-transfers`, which is about the +/// transfer packet. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Transfer { + pub enabled: bool, + pub expect_seconds: u32, + pub max_expect_seconds: u32, + pub max_expected: u32, + pub max_freeze_seconds: u32, +} + +impl Default for Transfer { + fn default() -> Self { + Self { + enabled: false, + expect_seconds: 10, + max_expect_seconds: 60, + max_expected: 64, + max_freeze_seconds: 30, + } + } +} + +impl Transfer { + fn check(&self) -> Result<(), String> { + for (name, n) in [ + ("expect_seconds", self.expect_seconds), + ("max_expect_seconds", self.max_expect_seconds), + ("max_expected", self.max_expected), + ("max_freeze_seconds", self.max_freeze_seconds), + ] { + if n == 0 { + return Err(format!("transfer.{name} must be above zero")); + } + } + if self.expect_seconds > self.max_expect_seconds { + return Err("transfer.expect_seconds is above transfer.max_expect_seconds".into()); + } + Ok(()) + } + + /// The game's `--transfers` value, when the section turns transfers on. + pub fn engine_arg(&self) -> Option { + self.enabled.then(|| { + format!( + "expect_seconds={},max_expect_seconds={},max_expected={},max_freeze_seconds={}", + self.expect_seconds, + self.max_expect_seconds, + self.max_expected, + self.max_freeze_seconds + ) + }) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Backups { @@ -408,6 +477,7 @@ impl Config { crate::backup::interval(&self.backups.every)?; self.panel.address()?; self.api.check()?; + self.transfer.check()?; self.discord.check()?; self.anticheat.check()?; self.chat_filter.check()?; @@ -519,6 +589,11 @@ pub fn render(config: &Config) -> String { ("api", "bind"), ("api", "token"), ("api", "allow_public"), + ("transfer", "enabled"), + ("transfer", "expect_seconds"), + ("transfer", "max_expect_seconds"), + ("transfer", "max_expected"), + ("transfer", "max_freeze_seconds"), ] { set(&mut doc, section, field, &table[section][field]); } @@ -668,6 +743,31 @@ mod tests { assert_eq!(render(&Config::default()), TEMPLATE); } + /// `[transfer]` is off by default, passes nothing to the game then, and is checked when it is read. + #[test] + fn transfers_are_off_by_default_and_checked() { + assert_eq!(Config::default().transfer.engine_arg(), None); + let on = TEMPLATE.replace( + "enabled = false # true: take", + "enabled = true # true: take", + ); + let config = Config::parse(&on).unwrap(); + assert_eq!( + config.transfer.engine_arg().as_deref(), + Some("expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30") + ); + assert_eq!(Config::parse(&render(&config)).unwrap(), config); + for (from, to) in [ + ("expect_seconds = 10 ", "expect_seconds = 0 "), + ("max_expect_seconds = 60", "max_expect_seconds = 5 "), + ("max_freeze_seconds = 30", "max_freeze_seconds = 0 "), + ] { + let bad = on.replace(from, to); + assert_ne!(bad, on, "{from} is in the template"); + assert!(Config::parse(&bad).is_err(), "{to} was taken"); + } + } + #[test] fn the_chat_filter_is_on_unless_its_section_turns_it_off() { assert!(Config::default().chat_filter.enabled); diff --git a/crates/horizon-server/src/engine.rs b/crates/horizon-server/src/engine.rs index f145f4a681..3dcf0e7434 100644 --- a/crates/horizon-server/src/engine.rs +++ b/crates/horizon-server/src/engine.rs @@ -238,8 +238,9 @@ pub fn spawn( resources: &Path, dir: &Path, low_memory: bool, + transfers: Option<&str>, ) -> std::io::Result { - command(program, resources, dir, low_memory)?.spawn() + command(program, resources, dir, low_memory, transfers)?.spawn() } /// The command `spawn` runs. @@ -248,6 +249,7 @@ fn command( resources: &Path, dir: &Path, low_memory: bool, + transfers: Option<&str>, ) -> std::io::Result { // The game runs in the server folder, so a folder given relative to ours (`--dir srv`) would put its // control endpoint in `srv/srv/.horizon`, which doesn't exist. @@ -258,7 +260,12 @@ fn command( .arg("--resources") .arg(resources) .arg("--control-socket") - .arg(control_path(&dir)) + .arg(control_path(&dir)); + // `[transfer]` on: a proxy's hand-over requests on that endpoint (docs/server.md). + if let Some(transfers) = transfers { + command.arg("--transfers").arg(transfers); + } + command .args(["--dedicated", "--nogui"]) .current_dir(&dir) .env("HORIZON_NO_AUDIO", "1") @@ -339,7 +346,8 @@ mod tests { fn low_memory_turns_huge_pages_off_for_the_game() { let thp = |low_memory| { let dir = std::env::temp_dir(); - let command = command(Path::new("horizon-client"), &dir, &dir, low_memory).unwrap(); + let command = + command(Path::new("horizon-client"), &dir, &dir, low_memory, None).unwrap(); command .get_envs() .find(|(k, _)| *k == "MIMALLOC_ALLOW_THP") @@ -349,6 +357,25 @@ mod tests { assert_eq!(thp(false), None); } + #[test] + fn transfers_reach_the_game_only_when_they_are_on() { + let args = |transfers: Option<&str>| { + let dir = std::env::temp_dir(); + let command = + command(Path::new("horizon-client"), &dir, &dir, false, transfers).unwrap(); + command + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>() + }; + assert!(!args(None).iter().any(|a| a == "--transfers")); + let on = args(Some("expect_seconds=10")); + let at = on.iter().position(|a| a == "--transfers").unwrap(); + assert_eq!(on[at + 1], "expect_seconds=10"); + // Before `--dedicated`: what follows it is vanilla's server arguments. + assert!(at < on.iter().position(|a| a == "--dedicated").unwrap()); + } + #[test] fn resources_are_searched_in_order() { let mut config = Config::default(); @@ -515,6 +542,7 @@ mod tests { &root.join("res"), &server, false, + None, ) { Err(e) if e.raw_os_error() == Some(26) && tries < 50 => { tries += 1; diff --git a/crates/horizon-server/src/supervisor.rs b/crates/horizon-server/src/supervisor.rs index d7ff635351..0af7dd13b2 100644 --- a/crates/horizon-server/src/supervisor.rs +++ b/crates/horizon-server/src/supervisor.rs @@ -170,8 +170,15 @@ pub fn run(dir: &Path) -> Result<(), String> { ), ); shared.set_phase(Phase::Starting); - let mut child = engine::spawn(&program, &resources, dir, config.engine.low_memory) - .map_err(|e| format!("{}: {e}", program.display()))?; + let transfers = config.transfer.engine_arg(); + let mut child = engine::spawn( + &program, + &resources, + dir, + config.engine.low_memory, + transfers.as_deref(), + ) + .map_err(|e| format!("{}: {e}", program.display()))?; { let mut state = shared.state.lock().unwrap(); state.engine_pid = Some(child.id()); diff --git a/docs/api/server-reference.md b/docs/api/server-reference.md index 99898b65fe..3d8a9dd827 100644 --- a/docs/api/server-reference.md +++ b/docs/api/server-reference.md @@ -144,6 +144,18 @@ Any other server.properties key, spelled as vanilla spells it, for example: spaw | `token` | `""` | | its token (16 characters or more); "" a new random one at every start | | `allow_public` | `false` | | true lets bind be any address; the token is then all that guards the server | +### `[transfer]` + +A proxy handing players over between servers (docs/server.md). Not vanilla's accepts-transfers (the transfer packet). + +| Key | Default | In `server.properties` | What it does | +| --- | --- | --- | --- | +| `enabled` | `false` | | true: take a proxy's player-* requests; needs Velocity forwarding (forwarding.secret) | +| `expect_seconds` | `10` | | how long a player the proxy announced is waited for, when it names no time | +| `max_expect_seconds` | `60` | | the longest the proxy may ask for | +| `max_expected` | `64` | | players waited for at once | +| `max_freeze_seconds` | `30` | | the longest a player stays frozen while it is handed over | + ### `[discord]` Post the chat, joins, leaves and deaths to a Discord channel, drawn like the in-game chat (docs/hosting.md) diff --git a/docs/server.md b/docs/server.md index 996d116c11..7e96f8b61f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -144,27 +144,59 @@ every OS), the dedicated loop answers `server-status` with TPS, MSPT (average, P clock `/tick query` uses), players (name, UUID, address, ping, position), loaded chunks and uptime. It answers `console ` with the command's output. -**A proxy moving a player between two servers** uses two more requests on this endpoint (`lan/arrivals.rs`, -`game/dedicated/transfer.rs`). Their answers are one JSON line, `{"ok": true, ...}` or `{"error": "..."}`, and a -refused request changes nothing. A unique id is written as 32 hexadecimal digits with or without dashes in a -request, and as lowercase text with dashes in an answer (`123e4567-e89b-12d3-a456-426614174000`). +**A proxy moving a player between two servers** uses more requests on this endpoint (`lan/arrivals.rs`, +`game/dedicated/transfer.rs`). They are a built-in module, **off by default**, that the owner turns on in +`server.toml`: -`player-expect [seconds]` tells the target server that the player is about to log in: within 10 seconds by -default, 60 at most (a longer time is cut to 60), and at most 64 players are expected at once. The reply is -`{"ok": true, "renewed": false, "ttl_seconds": 10}`; asking again for the same player renews the time and says -`"renewed": true`. It is refused for a missing or malformed id, a time that is not a whole number of seconds above -zero, a player already connected, and a full table. The player's next login uses the expectation up, and an +```toml +[transfer] +enabled = false # true: take a proxy's player-* requests; needs Velocity forwarding (forwarding.secret) +expect_seconds = 10 # how long a player the proxy announced is waited for, when it names no time +max_expect_seconds = 60 # the longest the proxy may ask for +max_expected = 64 # players waited for at once +max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over +``` + +The supervisor passes the section to the engine as `--transfers expect_seconds=10,...`, beside +`--control-socket`, and not as a `server.properties` key: that file stays vanilla's. The section has nothing to do +with vanilla's `accepts-transfers`, which is about the transfer packet a server sends a client. While it is off, +every `player-*` request is answered `{"error": "transfers are off on this server"}`, the engine keeps no table +and does nothing for transfers on a tick. + +With it on, the engine refuses to start unless Velocity forwarding is on (`forwarding.secret`, +[velocity-forwarding.md](velocity-forwarding.md)). The proxy must be the only way in: a player who joined a +source server directly would find there the older copy of itself that a hand-over left behind, and items would +double. + +**Who can ask.** The requests do not widen who can reach the engine: they use the control endpoint, a Unix socket +or TCP on 127.0.0.1 only (horizon-server starts it on TCP with a token). A proxy on another machine needs +something local to each engine to relay for it. The token is all or nothing: whoever can move players can also +run any console command. + +Answers are one JSON line, `{"ok": true, ...}` or `{"error": "..."}`, and a refused request changes nothing. A +unique id is written as 32 hexadecimal digits with or without dashes in a request, and as lowercase text with +dashes in an answer (`123e4567-e89b-12d3-a456-426614174000`). A hand-over id is 32 lowercase hexadecimal digits. + +`player-expect [seconds]` tells the target server that the player is about to log in: within +`expect_seconds` by default, `max_expect_seconds` at most (a longer time is cut to it), and at most +`max_expected` players are expected at once. The reply is `{"ok": true, "renewed": false, "ttl_seconds": 10}`; +asking again for the same player renews the time and says `"renewed": true`. It is refused for a missing or +malformed id, a time that is not a whole number of seconds above zero, a player already connected, and a full +table. The player's next login uses the expectation up as it starts, once the bans, the whitelist and the player +limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. An expectation no login used is reported in the log when its time runs out. For now that is all an expectation does: the player still logs in as any other. `player-export [freeze]` reads a connected player out of the source server. It first closes the player's open -menu as vanilla does when a menu closes (the cursor stack and the crafting grids go back into the inventory; the -client is told to close it and is sent its inventory), then answers: +menu as vanilla does when a menu closes, in the player's own level (the cursor stack and the crafting grids go back +into the inventory, a trade back to its villager; the client is told to close it and is sent its inventory), then +answers: ```json {"ok": true, "record": { "version": 1, "data_version": 5023, + "handover": "9f2c4be0a1d34e6b8c0f1e2d3c4b5a69", "uuid": "123e4567-e89b-12d3-a456-426614174000", "name": "Alex", "dimension": "minecraft:overworld", @@ -173,21 +205,32 @@ client is told to close it and is sent its inventory), then answers: "velocity": [0.0, 0.0, 0.0], "game_mode": 0, "player": "", - "advancements": { } + "advancements": { }, + "stats": {"stats": { }, "DataVersion": 5023} }} ``` `player` is the base64 of the uncompressed named NBT (root name empty) of the player compound: what the server -writes to `players/data/.dat` before it gzips it, taken from the connected player at that moment. -`advancements` is the JSON the server keeps for the player, or null on a server with no advancements. `version` is -the version of this record (1); `data_version` is the world data version of the server that made it (5023 for -26.3). The other fields are a few of the compound's values, for a proxy that does not read NBT. +writes to `players/data/.dat` before it gzips it, taken from the connected player at that moment, with the +values mods attached to the player. `advancements` and `stats` are the JSON the server writes to +`players/advancements/.json` and `players/stats/.json`, or null on a server that keeps none. +`version` is the version of this record (1); `data_version` is the world data version of the server that made it +(5023 for 26.3). The other fields are a few of the compound's values, for a proxy that does not read NBT. -With `freeze`, the player's own inputs (moves, clicks, commands) stop being acted on, while what answers the -server (teleport confirmations, chunk batch acknowledgements, keep-alives, pings, tick ends, client information) -still is. A freeze is let go after thirty seconds (600 ticks), with a warning in the log. A frozen player is still in -the world: a mob can still hurt it, and the record is its state at the export. The request is refused for a player -who is not connected, has not finished joining, is riding, is asleep or is dead. +How a server reads a record (the target's `player-import`): +- A record of another `version`, or of another `data_version` than the server's own, is refused. +- The NBT in `player` wins over the plain fields: they are copies of its values, for a proxy. +- Fields the reader does not know are ignored, so a later version 1 record may carry more. + +**The hand-over.** `freeze` starts one: the record gets a `handover` id, which the later requests name, and the +player's own inputs (moves, clicks, chat, commands) stop being acted on, while what answers the server (teleport +confirmations, chunk batch acknowledgements, keep-alives, pings, tick ends, client information and chat +acknowledgements, with the messages a chat packet says the client has seen) still is. A frozen player is not +kicked for floating. Every later export of that player, with or without `freeze`, answers with the same record, +byte for byte, and does not restart the freeze: a proxy that lost the reply asks again. The hand-over ends after +`max_freeze_seconds` (counted in ticks, 600 by default) with a warning in the log, and the id is then forgotten. +Without `freeze` the export is a read: `handover` is null and the player goes on. The request is refused for a +player who is not connected, has not finished joining, is leaving, is riding, is asleep or is dead. **The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the game built in `Game::new`, the integrated server on its own thread. On that base: @@ -345,7 +388,7 @@ keep = 1 # other releases kept by updates, besides the one i ``` This is the file `init` writes today, and it also writes `[panel]` (`bind = "127.0.0.1:8130"` and -`allow_public = false`, section 6), `[api]` and `[discord]`. The defaults are vanilla 26.3's (a test checks +`allow_public = false`, section 6), `[api]`, `[transfer]` (off; a proxy's hand-overs, section 2) and `[discord]`. The defaults are vanilla 26.3's (a test checks each one). Unknown keys are errors, as are a typed key set again under `[properties]` and values vanilla would refuse. There is no `backups.before_update` setting: an update always backs up the world first (section 7). From 648630f0d37167eb5846db3cf71a65911877eecf Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:00:30 +0100 Subject: [PATCH 10/70] dedicated: the [transfer] start-up error reads as one sentence The message had a run of 18 spaces in the middle of the string (a line break that was not a string continuation); a backslash continuation keeps the source wrapped and the message on one line. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/dedicated.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index e3ff7e8473..395c76d977 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -1034,7 +1034,8 @@ impl Game<'_> { .clone() .map(horizon_client::lan::arrivals::Transfers::new); if transfers.is_some() && forwarding_secret.is_none() { - return Err("server.toml's [transfer] is on, but players can join this server without the proxy: turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off" + return Err("server.toml's [transfer] is on, but players can join this server without the proxy: \ + turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off" .into()); } let net = NetConfig { From a50d9bbef4fc3cbfe7591b3b787b0cef7f9f5747 Mon Sep 17 00:00:00 2001 From: Arslan Armanuly <72556944+arslanarm@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:00:21 +0500 Subject: [PATCH 11/70] Shader packs: draw solid and cutout terrain with gbuffers_terrain_solid and gbuffers_terrain_cutout A pack with either program now draws terrain in two parts over the same draws: the quads over opaque texels (and lava) through gbuffers_terrain_solid, the cutout ones through gbuffers_terrain_cutout, each falling back to gbuffers_terrain's chain. Solid terrain has no default alpha test; cutout terrain keeps the 0.1 one. Packs with neither program keep the single draw. This is what Iris 1.11.7 drew with our probe packs (both programs, only one of them, alpha 0.05 outputs, renderStage). Fixes #9137 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/gfx.rs | 1 + .../src/gfx/pack_atlas.rs | 22 ++ .../src/gfx/pack_post.rs | 75 +++++-- .../src/gfx/pack_post/depth_test.rs | 18 +- .../src/gfx/pack_post/terrain_parts_tests.rs | 202 ++++++++++++++++++ crates/horizon-shaders/src/post.rs | 142 ++++++++++-- crates/horizon-shaders/tests/real_packs.rs | 3 + 7 files changed, 427 insertions(+), 36 deletions(-) create mode 100644 crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 397c7423d0..34e098a784 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -6144,6 +6144,7 @@ impl Renderer { } }); horizon_shaders::GameInputs { + terrain_parts: terrain.as_ref().map(pack_atlas::terrain_parts), terrain, terrain_geometry, entity_geometry: Some(pack_entities::geometry_inputs()), diff --git a/crates/horizon-client-render/src/gfx/pack_atlas.rs b/crates/horizon-client-render/src/gfx/pack_atlas.rs index 4e692f4073..5c1a2cdd92 100644 --- a/crates/horizon-client-render/src/gfx/pack_atlas.rs +++ b/crates/horizon-client-render/src/gfx/pack_atlas.rs @@ -185,6 +185,28 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \ }\n \ }\n"; +/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain: +/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque +/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID` +/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava (a +/// fluid face, `mesher::fluid_face`, among the solid draws), which Iris 1.11.7 drew with +/// `gbuffers_terrain_solid` in our probe. +pub fn terrain_parts( + terrain: &horizon_shaders::GbuffersInputs, +) -> [horizon_shaders::GbuffersInputs; 2] { + let opaque = crate::mesher::OPAQUE_LAYER; + let part = |solid: bool| { + let mut inputs = terrain.clone(); + inputs.after.push_str(&format!( + " uint hz_part = uint(round(hz_v_color.a * 255.0)) >> 4u;\n \ + bool hz_solid = (hz_v_layer & {opaque}u) != 0u || (hz_part >= 8u && (hz_part & 7u) < 6u);\n \ + if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n" + )); + inputs + }; + [part(true), part(false)] +} + impl PackAtlas { /// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's /// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates. diff --git a/crates/horizon-client-render/src/gfx/pack_post.rs b/crates/horizon-client-render/src/gfx/pack_post.rs index 2fd0c3efff..4c6a439290 100644 --- a/crates/horizon-client-render/src/gfx/pack_post.rs +++ b/crates/horizon-client-render/src/gfx/pack_post.rs @@ -508,8 +508,12 @@ pub struct PackPost { passes: Vec, /// Shadow cache for temporal reprojection. shadow_cache: ShadowCache, - /// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`. + /// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`; + /// with `terrain_cutout`, solid terrain only (`gbuffers_terrain_solid` or its fallback). terrain: Option, + /// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`, + /// when the pack draws terrain in two parts. + terrain_cutout: Option, /// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`. water: Option, /// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices. @@ -2072,6 +2076,10 @@ impl PackPost { .terrain .zip(terrain_geometry.as_ref()) .and_then(|(p, g)| build(p, Some(g))); + let mut terrain_cutout = post + .terrain_cutout + .zip(terrain_geometry.as_ref()) + .and_then(|(p, g)| build(p, Some(g))); let water = post .water .zip(terrain_geometry.as_ref()) @@ -2249,8 +2257,19 @@ impl PackPost { .filter_map(|p| build(p, None)) .collect(); let stand_ins = pack_images::stand_ins(device, stand_ins.take()); - if let Some(t) = &terrain { - notes.push(format!("terrain draws with {}", t.name)); + match (&terrain, &mut terrain_cutout) { + (Some(t), None) => notes.push(format!("terrain draws with {}", t.name)), + (t, Some(c)) => { + if let Some(t) = t { + notes.push(format!("solid terrain draws with {}", t.name)); + } + notes.push(format!("cutout terrain draws with {}", c.name)); + // Timed (and labelled) apart from the solid part when it runs the same program. + if t.as_ref().is_some_and(|t| t.name == c.name) { + c.name.push_str(" (cutout)"); + } + } + (None, None) => {} } for (part, pass) in [ ("terrain", &shadow_terrain), @@ -2334,6 +2353,7 @@ impl PackPost { .iter() .chain(&shadow_comp) .chain(&terrain) + .chain(&terrain_cutout) .chain(&water)) .chain(shadow_terrain.iter().chain(&shadow_water)) .chain(sky.iter().flat_map(|s| s.basic.iter().chain(&s.textured))) @@ -2391,6 +2411,7 @@ impl PackPost { .iter() .flat_map(|s| s.basic.iter().chain(&s.textured)) .chain(&terrain) + .chain(&terrain_cutout) .chain(entities.iter().flat_map(|e| e.passes.iter().flatten())) .chain(&clouds) .chain(&lines) @@ -2406,6 +2427,7 @@ impl PackPost { } // Terrain tests against the world's depth (entities...), so the blit copies it then. let blit_depth = terrain.is_some() + || terrain_cutout.is_some() || water.is_some() || sky.is_some() || entities.as_ref().is_some_and(Entities::drawn) @@ -2543,6 +2565,7 @@ impl PackPost { setup_pending: true, passes, terrain, + terrain_cutout, water, sky, entities, @@ -3031,7 +3054,8 @@ impl PackPost { } /// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues - /// the game's draws. + /// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the + /// same draws. fn draw_terrain( &mut self, device: &wgpu::Device, @@ -3041,13 +3065,35 @@ impl PackPost { list: TerrainList, draw: &mut TerrainDraw<'_>, ) { - let slot = match list { + self.draw_terrain_part(device, queue, encoder, size, list, false, draw); + if list == TerrainList::Solid { + self.draw_terrain_part(device, queue, encoder, size, list, true, draw); + } + } + + /// The pass that draws `list`, or the cutout part of the solid list. + fn terrain_slot(&mut self, list: TerrainList, cutout: bool) -> &mut Option { + match list { + TerrainList::Solid if cutout => &mut self.terrain_cutout, TerrainList::Solid => &mut self.terrain, TerrainList::Translucent => &mut self.water, TerrainList::ShadowSolid => &mut self.shadow_terrain, TerrainList::ShadowTranslucent => &mut self.shadow_water, - }; - let Some(mut pass) = slot.take() else { + } + } + + #[allow(clippy::too_many_arguments)] + fn draw_terrain_part( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + size: [u32; 2], + list: TerrainList, + cutout: bool, + draw: &mut TerrainDraw<'_>, + ) { + let Some(mut pass) = self.terrain_slot(list, cutout).take() else { return; }; let translucent = list.translucent(); @@ -3085,12 +3131,7 @@ impl PackPost { if !pass.group.shadow() { self.mips_valid &= !pass.written; } - *match list { - TerrainList::Solid => &mut self.terrain, - TerrainList::Translucent => &mut self.water, - TerrainList::ShadowSolid => &mut self.shadow_terrain, - TerrainList::ShadowTranslucent => &mut self.shadow_water, - } = Some(pass); + *self.terrain_slot(list, cutout) = Some(pass); } /// The sky's parts in vanilla's order, each with the program that draws it; one render pass @@ -3728,7 +3769,7 @@ impl PackPost { /// Whether the pack draws terrain itself. pub fn has_terrain(&self) -> bool { - self.terrain.is_some() + self.terrain.is_some() || self.terrain_cutout.is_some() } /// Whether the pack's shadow pass draws terrain (`shadow_view`). @@ -3779,6 +3820,7 @@ impl PackPost { .passes .iter_mut() .chain(&mut self.terrain) + .chain(&mut self.terrain_cutout) .chain(&mut self.water) .chain(sky) .chain(entities) @@ -4434,6 +4476,7 @@ impl PackPost { let mut shown = false; // A water-only pack still needs the opaque phase to populate requested snapshots. let mut terrain_drawn = self.terrain.is_none() + && self.terrain_cutout.is_none() && self.sky.is_none() && !self.entities.as_ref().is_some_and(Entities::drawn) && self.clouds.is_none() @@ -4614,6 +4657,10 @@ mod snorm_tests; #[path = "pack_post/depth_test.rs"] mod depth_test; +#[cfg(test)] +#[path = "pack_post/terrain_parts_tests.rs"] +mod terrain_parts_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client-render/src/gfx/pack_post/depth_test.rs b/crates/horizon-client-render/src/gfx/pack_post/depth_test.rs index 6c2e30cbbc..f50d57b9af 100644 --- a/crates/horizon-client-render/src/gfx/pack_post/depth_test.rs +++ b/crates/horizon-client-render/src/gfx/pack_post/depth_test.rs @@ -3,15 +3,15 @@ use super::*; use crate::{gfx::Frame, hud::HudBatch}; use wgpu::{TextureFormat as T, util::DeviceExt}; -const SIZE: [u32; 2] = [16, 16]; +pub(super) const SIZE: [u32; 2] = [16, 16]; -struct Gpu { +pub(super) struct Gpu { adapter: wgpu::Adapter, - device: wgpu::Device, + pub(super) device: wgpu::Device, queue: wgpu::Queue, } impl Gpu { - fn new() -> Option { + pub(super) fn new() -> Option { let instance = crate::gfx::test_gpu::instance(); let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { @@ -27,7 +27,7 @@ impl Gpu { queue, }) } - fn texture(&self, format: T) -> wgpu::TextureView { + pub(super) fn texture(&self, format: T) -> wgpu::TextureView { self.device .create_texture(&wgpu::TextureDescriptor { label: Some("Original depth fixture input"), @@ -47,7 +47,7 @@ impl Gpu { }) .create_view(&Default::default()) } - fn load( + pub(super) fn load( &self, files: Vec<(String, String)>, world: World<'_>, @@ -121,7 +121,7 @@ impl Gpu { }); self.queue.submit([encoder.finish()]); } - fn render( + pub(super) fn render( &self, post: &mut PackPost, color: &wgpu::TextureView, @@ -212,7 +212,7 @@ enum Scene { WaterOnly, } -const VERTEX: &str = +pub(super) const VERTEX: &str = "#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}"; fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> { @@ -559,7 +559,7 @@ fn attached_compute_preserves_distinct_hand_snapshots() { } // Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup. -fn fixture_frame(hud: &HudBatch) -> Frame<'_> { +pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> { Frame { eye: glam::DVec3::ZERO, eye_rotation: [0.0; 2], diff --git a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs new file mode 100644 index 0000000000..b72bb0c924 --- /dev/null +++ b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs @@ -0,0 +1,202 @@ +//! Solid and cutout terrain through `gbuffers_terrain_solid` and `gbuffers_terrain_cutout` +//! (#9137). The expected programs are what Iris 1.11.7 drew with our probe packs (the PR has +//! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and +//! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`, +//! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written +//! with alpha 0.05, cutout terrain loses it. The programs here are the probes', cut down. +use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame}; +use super::*; +use crate::hud::HudBatch; +use wgpu::{TextureFormat as T, util::DeviceExt}; + +const RED: [u8; 3] = [255, 0, 0]; +const GREEN: [u8; 3] = [0, 255, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; + +/// A program writing `rgb` with `alpha` into colortex0. +fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] { + let [r, g, b] = rgb.map(|c| f32::from(c) / 255.0); + [ + ( + format!("{name}.vsh"), + "#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(), + ), + ( + format!("{name}.fsh"), + format!( + "#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){{gl_FragData[0]=vec4({r:?},{g:?},{b:?},{alpha:?});}}" + ), + ), + ] +} + +/// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left +/// to right: a quad over opaque texels, a cutout quad, a lava quad. Each strip's colour, `None` +/// where nothing drew; `None` overall without a GPU. +fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> { + let Some(gpu) = Gpu::new() else { + eprintln!("TERRAIN_PARTS_NO_ADAPTER"); + return None; + }; + let mut files = vec![ + ("final.vsh".to_owned(), VERTEX.to_owned()), + ( + "final.fsh".to_owned(), + "#version 120\nuniform sampler2D colortex0;varying vec2 uv;\ + const vec4 colortex0ClearColor = vec4(0.0, 0.0, 0.0, 1.0);\ + void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}" + .to_owned(), + ), + ]; + for &(name, rgb, alpha) in programs { + files.extend(program(name, rgb, alpha)); + } + // The game's terrain inputs as far as the parts read them: the sprite layer and the colour. + let terrain = horizon_shaders::GbuffersInputs { + declarations: "layout(location=0)in vec3 fixture_position;\n\ + layout(location=2)in uint hz_v_layer;\n\ + layout(location=3)in vec4 hz_v_color;\n" + .into(), + body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);\ + hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n" + .into(), + ..Default::default() + }; + let inputs = horizon_shaders::GameInputs { + terrain_parts: Some(super::super::pack_atlas::terrain_parts(&terrain)), + terrain: Some(terrain), + ..Default::default() + }; + const ATTRS: [wgpu::VertexAttribute; 3] = + wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4]; + let layouts = [Some(wgpu::VertexBufferLayout { + array_stride: 20, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &ATTRS, + })]; + let color = gpu.texture(T::Rgba8Unorm); + let depth = gpu.texture(T::Depth32Float); + let mut post = gpu.load( + files, + World { + atlas: None, + material_maps: None, + lightmap: color.clone(), + terrain: Some(Geometry { + buffers: &layouts, + table: None, + depth_write: true, + atlas: None, + material_maps: false, + ids: None, + cull: false, + }), + block_ids: &[], + celestial: None, + gui_size: SIZE, + clouds: None, + weather: false, + hand: false, + }, + inputs, + ); + let opaque = u32::from(crate::mesher::OPAQUE_LAYER); + let lava = crate::mesher::fluid_face(u32::MAX, 1); + let mut vertices: Vec = Vec::new(); + for (i, (layer, color)) in [(opaque, u32::MAX), (0, u32::MAX), (0, lava)] + .into_iter() + .enumerate() + { + let (x0, x1) = ( + -1.0 + i as f32 * 2.0 / 3.0, + -1.0 + (i + 1) as f32 * 2.0 / 3.0, + ); + for [x, y] in [ + [x0, -1.0], + [x1, -1.0], + [x1, 1.0], + [x0, -1.0], + [x1, 1.0], + [x0, 1.0], + ] { + vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]); + } + } + let buffer = gpu + .device + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Terrain parts fixture"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + let hud = HudBatch::new(SIZE); + let frame = fixture_frame(&hud); + let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| { + if list == TerrainList::Solid { + rp.set_vertex_buffer(0, buffer.slice(..)); + rp.draw(0..18, 0..1); + } + }); + let row = SIZE[1] as usize / 2 * SIZE[0] as usize; + Some([2, 8, 13].map(|x| { + let [r, g, b, _] = pixels[row + x]; + Some([r, g, b]).filter(|&c| c != [0, 0, 0]) + })) +} + +#[test] +fn solid_and_cutout_programs_draw_their_own_terrain() { + let programs = [ + ("gbuffers_terrain", RED, 1.0), + ("gbuffers_terrain_solid", GREEN, 1.0), + ("gbuffers_terrain_cutout", BLUE, 1.0), + ]; + let Some(got) = strips(&programs) else { return }; + assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)]); +} + +#[test] +fn without_a_cutout_program_cutout_terrain_falls_back_to_gbuffers_terrain() { + let programs = [ + ("gbuffers_terrain", RED, 1.0), + ("gbuffers_terrain_solid", GREEN, 1.0), + ]; + let Some(got) = strips(&programs) else { return }; + assert_eq!(got, [Some(GREEN), Some(RED), Some(GREEN)]); +} + +#[test] +fn without_a_solid_program_solid_terrain_falls_back_to_gbuffers_terrain() { + let programs = [ + ("gbuffers_terrain", RED, 1.0), + ("gbuffers_terrain_cutout", BLUE, 1.0), + ]; + let Some(got) = strips(&programs) else { return }; + assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]); +} + +#[test] +fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() { + let programs = [ + ("gbuffers_terrain", RED, 1.0), + ("gbuffers_terrain_solid", GREEN, 0.05), + ("gbuffers_terrain_cutout", BLUE, 0.05), + ]; + let Some(got) = strips(&programs) else { return }; + assert_eq!(got, [Some(GREEN), None, Some(GREEN)]); + // The solid part through its fallback, as the probe's `gbuffers_terrain` writing 0.05. + let programs = [ + ("gbuffers_terrain", RED, 0.05), + ("gbuffers_terrain_cutout", BLUE, 1.0), + ]; + let Some(got) = strips(&programs) else { return }; + assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]); +} + +#[test] +fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() { + let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else { + return; + }; + assert_eq!(got, [Some(RED); 3]); +} diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs index a69c49db5c..9b456985f9 100644 --- a/crates/horizon-shaders/src/post.rs +++ b/crates/horizon-shaders/src/post.rs @@ -124,6 +124,25 @@ const TERRAIN: [&str; 4] = [ "gbuffers_basic", ]; +/// `gbuffers_terrain_solid` and its fallbacks (ShaderDoc's "Iris Additional Programs", as +/// #9137 quotes it): solid terrain, the quads over opaque texels. +const TERRAIN_SOLID: [&str; 5] = [ + "gbuffers_terrain_solid", + "gbuffers_terrain", + "gbuffers_textured_lit", + "gbuffers_textured", + "gbuffers_basic", +]; + +/// `gbuffers_terrain_cutout` and its fallbacks (the same): cutout terrain. +const TERRAIN_CUTOUT: [&str; 5] = [ + "gbuffers_terrain_cutout", + "gbuffers_terrain", + "gbuffers_textured_lit", + "gbuffers_textured", + "gbuffers_basic", +]; + /// `gbuffers_line` and its fallback, as in Iris: lines such as the block outline. const LINES: [&str; 2] = ["gbuffers_line", "gbuffers_basic"]; @@ -397,8 +416,13 @@ pub struct Post { /// The flips of the virtual `deferred_pre` and `composite_pre` (6.1.6). pub deferred_pre: Vec<(u32, bool)>, pub composite_pre: Vec<(u32, bool)>, - /// What draws solid and cutout terrain, when the game asked for it and one loads. + /// What draws solid and cutout terrain, when the game asked for it and one loads; with + /// `terrain_cutout`, solid terrain only. pub terrain: Option, + /// What draws cutout terrain when the pack has `gbuffers_terrain_solid` or + /// `gbuffers_terrain_cutout` and the game draws terrain in two parts + /// (`GameInputs::terrain_parts`); `terrain` then draws the solid part. + pub terrain_cutout: Option, /// What draws translucent terrain (water, stained glass) after deferred. pub water: Option, /// What draws the sky (disc, sunset, stars, void) before terrain. @@ -584,6 +608,9 @@ pub struct GameInputs { pub lines: Option, /// The first-person hand and held items. pub hand: Option, + /// `terrain`, drawing only its solid quads, then only its cutout ones: given, a pack with + /// `gbuffers_terrain_solid` or `gbuffers_terrain_cutout` draws terrain in these two parts. + pub terrain_parts: Option<[GbuffersInputs; 2]>, /// Terrain read vertex by vertex, for a shadow program with a geometry stage. pub terrain_geometry: Option, /// Entities and block entities read vertex by vertex, for the same. @@ -605,6 +632,7 @@ pub fn load_post( deferred_pre: flips(properties, "deferred_pre"), composite_pre: flips(properties, "composite_pre"), terrain: None, + terrain_cutout: None, water: None, sky: None, sky_textured: None, @@ -711,6 +739,9 @@ pub fn load_post( } } } + let split_terrain = ["gbuffers_terrain_solid", "gbuffers_terrain_cutout"] + .iter() + .any(|n| loader.has(n)); let mut gbuffers = |names: &[&str], group, inputs: &GbuffersInputs| { names.iter().find_map(|name| { // `alphaTest.` replaces the geometry's default test (OQ-23). @@ -825,23 +856,40 @@ pub fn load_post( p.blend = blend(properties, &p.name).flatten(); p }); - if let Some(inputs) = &inputs.terrain { - // Solid and cutout terrain draw together: the cutout test, unless `alphaTest.*` says - // otherwise (3.2). - let mut cutout = inputs.clone(); - cutout.alpha_test = cutout.alpha_test.or(Some(crate::AlphaTest { - op: ">", - reference: 0.1, - })); - let terrain = gbuffers(&TERRAIN, Group::Gbuffers, &cutout); + if let Some(terrain) = &inputs.terrain { + // Cutout terrain's alpha test, unless `alphaTest.*` says otherwise (3.2). + let cutout = |inputs: &GbuffersInputs| { + let mut cutout = inputs.clone(); + cutout.alpha_test = cutout.alpha_test.or(Some(crate::AlphaTest { + op: ">", + reference: 0.1, + })); + cutout + }; + // A pack with Iris's gbuffers_terrain_solid or gbuffers_terrain_cutout draws solid and + // cutout terrain apart, each falling back to gbuffers_terrain's chain (Iris 1.11.7 with + // probe packs having both, or one of them). Solid terrain gets no default alpha test: + // in those probes Iris kept solid fragments written with alpha 0.05, through either + // program (lower alphas not probed). + // Otherwise solid and cutout terrain draw together, with the cutout test. + let parts = inputs.terrain_parts.as_ref().filter(|_| split_terrain); + let (solid, terrain_cutout) = match parts { + Some([solid, cut]) => ( + gbuffers(&TERRAIN_SOLID, Group::Gbuffers, solid), + gbuffers(&TERRAIN_CUTOUT, Group::Gbuffers, &cutout(cut)), + ), + None => (gbuffers(&TERRAIN, Group::Gbuffers, &cutout(terrain)), None), + }; // gbuffers_water falls back to gbuffers_terrain's chain (2.3). - let water = gbuffers(&WATER, Group::Translucent, inputs); - loader.post.terrain = terrain; + let water = gbuffers(&WATER, Group::Translucent, terrain); + loader.post.terrain = solid; loader.post.water = water.map(|mut p| { p.blend = blend(properties, &p.name).unwrap_or(Some(TRANSLUCENT_BLEND)); p }); - if let Some(p) = &mut loader.post.terrain { + loader.post.terrain_cutout = terrain_cutout; + let programs = [&mut loader.post.terrain, &mut loader.post.terrain_cutout]; + for p in programs.into_iter().flatten() { p.blend = blend(properties, &p.name).flatten(); } } @@ -890,6 +938,7 @@ fn load_shadow( let post = &*loader.post; let gbuffers = [ &post.terrain, + &post.terrain_cutout, &post.water, &post.sky, &post.sky_textured, @@ -1022,6 +1071,14 @@ struct Loader<'a> { } impl Loader<'_> { + /// The pack has both stages of the program `name` in this folder. + fn has(&self, name: &str) -> bool { + ["vsh", "fsh"].iter().all(|ext| match self.folder { + "" => self.pack.exists(&format!("{name}.{ext}")), + f => self.pack.exists(&format!("{f}/{name}.{ext}")), + }) + } + /// The program `name` translated by `translate`, or `None` when it is missing, disabled or /// fails (with a note). fn load( @@ -2462,6 +2519,64 @@ blend.composite1.colortex3 = ONE ONE ONE ONE assert_eq!(clouds(&["gbuffers_terrain", "composite"]), None); } + /// Iris 1.11.7 with our probe packs (#9137): `gbuffers_terrain_solid` draws solid terrain + /// and `gbuffers_terrain_cutout` cutout terrain, each falling back to `gbuffers_terrain`. + #[test] + fn terrain_solid_and_cutout_fall_back_to_terrain() { + let names = |programs: &[&str], parts: bool| { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for name in programs { + for ext in ["vsh", "fsh"] { + let options = zip::write::SimpleFileOptions::default(); + zip.start_file(format!("shaders/{name}.{ext}"), options) + .unwrap(); + let source = "#version 120\nvoid main() { gl_Position = ftransform(); }\n"; + let source = match ext { + "vsh" => source, + _ => "#version 120\nvoid main() { gl_FragData[0] = vec4(1.0); }\n", + }; + std::io::Write::write_all(&mut zip, source.as_bytes()).unwrap(); + } + } + let pack = Pack::from_zip_bytes("terrain-parts", zip.finish().unwrap().into_inner()); + let inputs = crate::GbuffersInputs { + declarations: "layout(location = 0) in vec3 hz_v_pos;".into(), + body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n hz_Color = vec4(1.0);\n".into(), + ..Default::default() + }; + let inputs = GameInputs { + terrain_parts: parts.then(|| [inputs.clone(), inputs.clone()]), + terrain: Some(inputs), + ..Default::default() + }; + let macros = Macros::standard(); + let post = load_post(&pack.unwrap(), "", &Properties::default(), ¯os, &inputs); + let name = |p: Option| p.map(|p| p.name); + (name(post.terrain), name(post.terrain_cutout)) + }; + let some = |name: &str| Some(name.to_owned()); + let (terrain, solid, cutout) = ( + "gbuffers_terrain", + "gbuffers_terrain_solid", + "gbuffers_terrain_cutout", + ); + assert_eq!( + names(&[terrain, solid, cutout], true), + (some(solid), some(cutout)) + ); + assert_eq!(names(&[terrain, solid], true), (some(solid), some(terrain))); + assert_eq!( + names(&[terrain, cutout], true), + (some(terrain), some(cutout)) + ); + assert_eq!(names(&[terrain], true), (some(terrain), None)); + // Without the game's two parts, terrain draws whole. + assert_eq!( + names(&[terrain, solid, cutout], false), + (some(terrain), None) + ); + } + #[test] fn water_falls_back_to_terrain_and_blends() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/packs/terrain-test"); @@ -2481,6 +2596,7 @@ blend.composite1.colortex3 = ONE ONE ONE ONE weather: Some(inputs.clone()), lines: Some(inputs.clone()), hand: Some(inputs), + terrain_parts: None, terrain_geometry: None, entity_geometry: None, }; diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index 568b0723dc..99ae1512c6 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -183,6 +183,8 @@ fn real_packs_read() { item_id_declared: true, ..terrain.clone() }), + // As the game's (`pack_atlas::terrain_parts`): solid, then cutout terrain. + terrain_parts: Some([terrain.clone(), terrain.clone()]), terrain_geometry: Some(horizon_shaders::GeometryInputs { inputs: horizon_shaders::GbuffersInputs { declarations: format!( @@ -294,6 +296,7 @@ fn real_packs_read() { fn backends(post: &horizon_shaders::Post) { let optional = [ &post.terrain, + &post.terrain_cutout, &post.water, &post.sky, &post.sky_textured, From 2f6d9c7dc461e87f00a2077861d56e6c2d58d2e4 Mon Sep 17 00:00:00 2001 From: Mayonesu0 Date: Wed, 4 Nov 2026 14:54:22 +0100 Subject: [PATCH 12/70] mobs: a powered wither refuses a player's wind charge WitherBoss.hurtServer refuses a direct AbstractArrow or WindCharge once isPowered. A wind charge hurts mobs as DamageKind::Thrown, which wither::accepts let through, so every charge took a point off the armor. The check is on the projectile, not the damage kind. A breeze's BreezeWindCharge deals the same damage but is not a WindCharge (both only extend AbstractWindCharge), so it still hurts, unlike what the issue asks. /damage with the wind_charge type has no direct charge and goes through too. tools/test_wither_wind_charge_parity.py compares the three cases against a vanilla dedicated server. Fixes #8779 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/wind.rs | 5 ++ crates/horizon-client/src/mobs/wither.rs | 46 +++++++++++++- tools/test_wither_wind_charge_parity.py | 80 ++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tools/test_wither_wind_charge_parity.py diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index 4894bdd3a6..131be4f260 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -162,6 +162,11 @@ impl Game<'_> { if owner_breeze && m.species() == Species::Breeze { return; } + // Refused before the hurt, so the kill is not credited + // either; the burst of `wind_charge_hit` still goes off. + if horizon_client::mobs::wither::armor_refuses(m, &p.kind) { + return; + } m.damage(1.0, source); } if p.owner == Some(Who::Player) { diff --git a/crates/horizon-client/src/mobs/wither.rs b/crates/horizon-client/src/mobs/wither.rs index d1d78629f1..6a742bdff7 100644 --- a/crates/horizon-client/src/mobs/wither.rs +++ b/crates/horizon-client/src/mobs/wither.rs @@ -1,7 +1,7 @@ //! The wither (`WitherBoss`): eleven seconds of invulnerable charging that //! end in a blast, then flying after its target while three heads fire -//! skulls, breaking blocks around it when hurt, immune to arrows below half -//! health. +//! skulls, breaking blocks around it when hurt, immune to arrows and a +//! player's wind charges below half health. use super::{ DamageKind, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, @@ -449,6 +449,14 @@ pub fn accepts(m: &mut Mob, kind: DamageKind, attacker: Option, others_unde m.extra.ints[5] += 3; true } +/// `WitherBoss.hurtServer`: a powered wither's armor refuses a direct +/// `WindCharge`. A breeze's `BreezeWindCharge` is not one (both only extend +/// `AbstractWindCharge`), so it still hurts. Checked on the projectile, not on +/// `DamageKind`: the `wind_charge` damage type alone has no direct charge +/// (`/damage @s minecraft:wind_charge`) and goes through. +pub fn armor_refuses(m: &Mob, direct: &ProjectileKind) -> bool { + m.species() == Species::Wither && *direct == ProjectileKind::WindCharge && powered(m) +} /// `WitherBossModel.setupAnim`. pub fn anim(m: &Mob, s: &State, p: &mut Poses) { let body = @@ -518,6 +526,40 @@ mod tests { assert!(!shielded_from(Species::Wither, Species::Creeper)); assert!(!shielded_from(Species::Zombie, Species::Wither)); } + /// `WitherBoss.hurtServer` tests `instanceof WindCharge`: a powered wither + /// took a point of damage from every charge (#8779), and a breeze's charge, + /// a sibling class, has to keep hurting it. + #[test] + fn only_a_powered_withers_armor_refuses_a_wind_charge() { + use crate::mobs::Damage; + use crate::mobs::sensor_scan_tests::kind; + use std::sync::Arc; + let mob = |species, health: f32| { + let mut kind = kind(); + Arc::make_mut(&mut kind).species = species; + let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0); + m.attrs.max_health = 300.0; + m.health = health; + m + }; + let (charge, breeze) = (ProjectileKind::WindCharge, ProjectileKind::BreezeWindCharge); + // `isPowered` is `health <= max / 2`: the armor starts at exactly half. + let half = mob(Species::Wither, 150.0); + assert!(armor_refuses(&half, &charge)); + assert!(armor_refuses(&mob(Species::Wither, 1.0), &charge)); + assert!(!armor_refuses(&half, &breeze)); + assert!(!armor_refuses(&mob(Species::Wither, 150.5), &charge)); + assert!(!armor_refuses(&mob(Species::Zombie, 150.0), &charge)); + + // What `wind_charge_damage` lets through still lands on the armor... + let mut m = mob(Species::Wither, 150.0); + assert!(m.damage(1.0, Damage::of(DamageKind::Thrown))); + assert!(m.health < 150.0); + // ...and arrows stay refused by the damage filter. + let mut m = mob(Species::Wither, 150.0); + assert!(!m.damage(1.0, Damage::of(DamageKind::Arrow))); + assert_eq!(m.health, 150.0); + } /// A spawning wither rolled the three particles of `aiStep`'s /// `getInvulnerableTicks() > 0` block and dropped them on the floor: the /// potion-coloured (`0.7, 0.7, 0.9`) cloud around it never appeared (#7486). diff --git a/tools/test_wither_wind_charge_parity.py b/tools/test_wither_wind_charge_parity.py new file mode 100644 index 0000000000..ccd5ef0d9a --- /dev/null +++ b/tools/test_wither_wind_charge_parity.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Wind charges meeting a wither, on a dedicated server, against vanilla's, seen by a vanilla client. + +A vanilla dedicated server and Horizon's (`horizon-client --dedicated --nogui`) each serve a copy of one flat world +vanilla generated first, with Alice an operator in survival. Three summoned withers (NoAI, so none heals, moves or +shoots) each take one charge from above: + +- at half health (`isPowered`), a `wind_charge`: `WitherBoss.hurtServer` refuses a direct `WindCharge`, and the charge + is spent all the same; +- at half health, a `breeze_wind_charge`: not a `WindCharge` (both only extend `AbstractWindCharge`), so it hurts; +- above half health, a `wind_charge`: it hurts, which shows the charge does reach a wither. + +Whether each wither kept its health is compared (vanilla's answer is the oracle). The health a hurt wither is left +with is not asked: only that it changed. +""" +import sys + +try: + from . import test_locator_bar_parity as locator +except ImportError: + import test_locator_bar_parity as locator + +NAME = 'wither-wind-charge-parity' +# server_dir and generate_world write these server.properties. +locator.PROPERTIES = dict(locator.PROPERTIES, gamemode='survival') + + +def wither(tag, x, z, health): + return f'cmd Alice summon minecraft:wither {x} -50 {z} {{NoAI:1b,Health:{health}f,Tags:["{tag}"]}}' + + +def charge(kind, x, z): + # Six blocks above the wither's feet, flying straight down: it hits within a few ticks. + return f'cmd Alice summon minecraft:{kind} {x} -44 {z} {{Motion:[0.0,-1.0,0.0]}}' + + +SCRIPT = [ + ('setup', ['chat on', wither('armored', 12.5, 0.5, 100), wither('breeze', -11.5, 0.5, 100), + wither('unarmored', 0.5, 12.5, 200)]), + ('hit', [charge('wind_charge', 12.5, 0.5), charge('breeze_wind_charge', -11.5, 0.5), + charge('wind_charge', 0.5, 12.5)]), + ('asked', ['cmd Alice execute unless entity @e[type=minecraft:wind_charge]', + 'cmd Alice execute unless entity @e[type=minecraft:breeze_wind_charge]', + 'cmd Alice execute if entity @e[tag=armored,nbt={Health:100.0f}]', + 'cmd Alice execute if entity @e[tag=breeze]', + 'cmd Alice execute unless entity @e[tag=breeze,nbt={Health:100.0f}]', + 'cmd Alice execute if entity @e[tag=unarmored]', + 'cmd Alice execute unless entity @e[tag=unarmored,nbt={Health:200.0f}]']), +] +ANSWERS = 7 + + +def shown(lines): + """The answers about the charges and the withers.""" + out = [] + for line in lines: + parts = line.split(' ', 3) + if len(parts) >= 3 and parts[1] == 'step=asked' and parts[2] == 'chat' and 'conditional' in line: + out.append(f'{parts[1]} {parts[3]}') + return out + + +def check(vanilla, vanilla_all): + if sum('conditional.pass' in line for line in vanilla) != ANSWERS: + return 'a charge did not hit its wither, or a wither did not take it as WitherBoss.hurtServer says' + return None + + +def main(): + lines = locator.one_player(NAME, SCRIPT, shown, check, 'Wind charges flying into summoned withers on a dedicated ' + 'server: a powered wither refuses a wind_charge, and takes a breeze_wind_charge as ' + 'one above half health takes a wind_charge.', step_seconds=1.0) + print(f'wither wind charge: {len(lines)} lines identical') + + +if __name__ == '__main__': + try: + main() + except AssertionError as error: + sys.exit(f'{NAME}: {error}') From 0a9aa16802b595c76aea3d05d25216f33e5445b9 Mon Sep 17 00:00:00 2001 From: degepie Date: Wed, 4 Nov 2026 19:44:12 +0500 Subject: [PATCH 13/70] Name a server's data-pack enchantments by their synced description Tooltips read the enchantment table built from the game's own files, so an enchantment a server's data pack adds fell back to its raw id ("enchex:drill II"). The server sends that entry's definition in the registry sync; join the entries the game lacks to the table the tooltip and the enchanting clue read (Enchantment.getFullname), as client_biomes does for biomes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK --- crates/horizon-client/src/game.rs | 8 ++ crates/horizon-client/src/game/remote.rs | 22 ++++ crates/horizon-client/src/game/stations.rs | 4 +- crates/horizon-items/src/enchant.rs | 124 +++++++++++++++------ 4 files changed, 124 insertions(+), 34 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index bd638da914..999c8f8e00 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -19625,6 +19625,14 @@ impl Game<'_> { None => self.res.stations(), } } + /// The enchantments item names and tooltips read: a server's, with the + /// entries its data packs add, else the world's. + fn enchantments(&self) -> &horizon_client::enchant::Enchantments { + match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) { + Some(e) => e, + None => &self.stations().enchantments, + } + } /// The world's packs' content files, none without packs. /// The biome table the world plays with, `None` before a world opens. fn packed_biomes(&self) -> Option> { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 9b50a812a5..db90926e9f 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -474,6 +474,9 @@ pub(super) struct Remote { pub(super) builtin: Arc, lang: Arc, pub(super) registries: Arc, + /// The game's enchantments and the server's that it lacks (a data + /// pack's), from their synced data; `None` when the server has none. + pub(super) enchantments: Option>, pub(super) joined: bool, /// `ServerReconfigScreen` is up since this tick. pub(super) reconfiguring: Option, @@ -1006,6 +1009,7 @@ impl Game<'_> { .unwrap_or_else(|| "Connecting to the server...".into()), lang, registries: Arc::new(Dynamic::default()), + enchantments: None, joined: false, reconfiguring: None, logged_in: false, @@ -2083,7 +2087,25 @@ impl Game<'_> { .collect() }) .unwrap_or_default(); + let entries: Vec<(&str, serde_json::Value)> = registries + .registries + .get("minecraft:enchantment") + .into_iter() + .flatten() + .filter_map(|(name, data)| { + Some(( + name.as_str(), + horizon_client::scoreboard::tag_to_json(data.as_ref()?), + )) + }) + .collect(); + let enchantments = self + .stations() + .enchantments + .with_entries(entries.iter().map(|(n, v)| (*n, v))) + .map(Arc::new); if let Some(r) = self.remote.as_mut() { + r.enchantments = enchantments; let bits = ceil_log2(biomes.len()); let column = r.chunk_format.layout(); r.chunk_format = diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index aea07169ca..82b53b9bce 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -1720,7 +1720,7 @@ impl Game<'_> { if inside(local, bx, by, bw, 17.0) && let Some((id, l)) = &offers.clues[i] { - let e = &self.stations().enchantments; + let e = self.enchantments(); let (name, _) = e.full_name(id, *l); let clue = self .translate("container.enchant.clue", "%s . . . ?") @@ -2121,7 +2121,7 @@ impl Game<'_> { nested: bool, creative: bool, ) -> Vec<(horizon_client::text::Text, u32)> { - let e: &Enchantments = &self.stations().enchantments; + let e: &Enchantments = self.enchantments(); let color = self.rarity_color(stack); let advanced = !nested && self.options_advanced_tooltips(); let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack); diff --git a/crates/horizon-items/src/enchant.rs b/crates/horizon-items/src/enchant.rs index c60534b400..d690562833 100644 --- a/crates/horizon-items/src/enchant.rs +++ b/crates/horizon-items/src/enchant.rs @@ -63,7 +63,7 @@ pub struct ItemProps { /// `minecraft:brewing_fuel` present. pub brewing_fuel: bool, } -#[derive(Default)] +#[derive(Clone, Default)] pub struct Enchantments { pub list: Vec, index: HashMap, usize>, @@ -130,6 +130,46 @@ pub fn level_name(level: u32) -> String { .map_or_else(|| level.to_string(), |s| (*s).to_owned()) } +/// One registry entry from its definition (`Enchantment.DIRECT_CODEC`). +fn parse_entry( + id: &str, + v: &Value, + tags: &HashMap>>, + lang: &HashMap, +) -> Enchantment { + let exclusive = match &v["exclusive_set"] { + Value::String(s) if s.starts_with('#') => tags.get(&s[1..]).cloned().unwrap_or_default(), + other => string_list(other) + .into_iter() + .map(|s| Arc::from(s.as_str())) + .collect(), + }; + let key = v["description"]["translate"].as_str().unwrap_or(""); + Enchantment { + id: Arc::from(id), + description: lang.get(key).cloned().unwrap_or_else(|| { + // A data pack's entry may name itself with a literal component + // rather than a translation key. + if key.is_empty() && !v["description"].is_null() { + horizon_client_core::scoreboard::comp_text(&v["description"]).plain() + } else { + id.trim_start_matches("minecraft:").replace('_', " ") + } + }), + description_key: key.to_owned(), + max_level: v["max_level"].as_u64().unwrap_or(1) as u32, + weight: v["weight"].as_u64().unwrap_or(1) as u32, + anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32, + min_cost: Cost::parse(&v["min_cost"]), + max_cost: Cost::parse(&v["max_cost"]), + supported: string_list(&v["supported_items"]), + primary: v.get("primary_items").map(string_list), + exclusive: exclusive.into_iter().collect(), + slots: string_list(&v["slots"]), + effects: v["effects"].clone(), + } +} + impl Enchantments { pub fn load(root: &Path) -> Self { Self::load_with(root, &horizon_world::loot::PackOverlay::NONE) @@ -219,37 +259,10 @@ impl Enchantments { || k.starts_with("recipe.toast.") }) .collect(); - let mut list = Vec::new(); - for (id, v) in &files { - let exclusive = match &v["exclusive_set"] { - Value::String(s) if s.starts_with('#') => { - tags.get(&s[1..]).cloned().unwrap_or_default() - } - other => string_list(other) - .into_iter() - .map(|s| Arc::from(s.as_str())) - .collect(), - }; - let key = v["description"]["translate"].as_str().unwrap_or(""); - list.push(Enchantment { - id: Arc::from(id.as_str()), - description: lang - .get(key) - .cloned() - .unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")), - description_key: key.to_owned(), - max_level: v["max_level"].as_u64().unwrap_or(1) as u32, - weight: v["weight"].as_u64().unwrap_or(1) as u32, - anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32, - min_cost: Cost::parse(&v["min_cost"]), - max_cost: Cost::parse(&v["max_cost"]), - supported: string_list(&v["supported_items"]), - primary: v.get("primary_items").map(string_list), - exclusive: exclusive.into_iter().collect(), - slots: string_list(&v["slots"]), - effects: v["effects"].clone(), - }); - } + let list = files + .iter() + .map(|(id, v)| parse_entry(id, v, &tags, &lang)) + .collect::>(); let index = list .iter() .enumerate() @@ -292,6 +305,26 @@ impl Enchantments { pub fn get(&self, id: &str) -> Option<&Enchantment> { self.index.get(id).map(|&i| &self.list[i]) } + /// The table a client plays a server with: this one and the server's + /// entries it lacks (a data pack's), from the definitions the registry + /// sync sent; `None` when there are none. The names then come from the + /// entries' own `description`, as `Enchantment.getFullname` reads it. + pub fn with_entries<'a>( + &self, + entries: impl IntoIterator, + ) -> Option { + let mut table = None::; + for (id, v) in entries { + if self.index.contains_key(id) { + continue; + } + let t = table.get_or_insert_with(|| self.clone()); + let e = parse_entry(id, v, &t.tags, &t.lang); + t.index.insert(e.id.clone(), t.list.len()); + t.list.push(e); + } + table + } /// The longest `minecraft:ignite` the attacker's `minecraft:post_attack` /// effects (Fire Aspect) set on the victim, in seconds: the effects whose /// requirements hold for `ctx` (0 when none). @@ -1195,6 +1228,33 @@ pub mod tests { ); } + /// A server's data-pack enchantment the game lacks is named by the + /// `description` it sent (`Enchantment.getFullname`), with the level + /// numeral unless both the level and the maximum are 1; the game's own + /// entries keep their tables. + #[test] + fn synced_entries_name_themselves() { + let e = sample(); + assert_eq!(e.full_name("enchex:drill", 2).0, "enchex:drill II"); + let drill = json!({"description":{"text":"Drill"},"max_level":3,"supported_items":"#minecraft:enchantable/mining"}); + let hex = json!({"description":"Hex","max_level":1}); + let other = json!({"description":{"text":"Other"},"max_level":5}); + let synced = e + .with_entries([ + ("enchex:drill", &drill), + ("enchex:hex", &hex), + ("minecraft:sharpness", &other), + ]) + .unwrap(); + assert_eq!(synced.full_name("enchex:drill", 2).0, "Drill II"); + assert_eq!(synced.full_name("enchex:drill", 1).0, "Drill I"); + assert_eq!(synced.full_name("enchex:hex", 1).0, "Hex"); + assert_eq!(synced.full_name("minecraft:sharpness", 1).0, "sharpness I"); + assert_eq!(synced.list.len(), e.list.len() + 2); + // Nothing new: the game's table stays in use. + assert!(e.with_entries([("minecraft:sharpness", &other)]).is_none()); + } + /// The enchantment's name and its level come from the selected language, /// with the English name loaded above as the fallback. #[test] From 51cd08cd3b64de31660c2f93f58c1614540fbcb0 Mon Sep 17 00:00:00 2001 From: Akirabane Date: Wed, 4 Nov 2026 15:21:03 +0100 Subject: [PATCH 14/70] transfer: the export empties the 2x2 grid, chat acks in order, a failed login gives its expectation back martipops's second review of #8700. The export closes the inventory's own 2x2 crafting grid too (InventoryMenu.removed): guest_close_all empties it only on a real ContainerClose(0), and a frozen client's is dropped, so its items were in no record and lost with `player-release moved`. A frozen player's chat acknowledgements are handled in the same pass as the last-seen updates of its chat packets, in the order they came, so an ack followed by a message in one tick still validates. A login that used up an expectation and fails before it reaches play (its connection drops in configuration, or the client refuses a required pack) gives it back with its own deadline, so the proxy's next attempt within that time is still expected. The export's reply gives `freeze_ticks_left` beside the record while the player is frozen, so a proxy does not commit on the target too late for its release to come in time. Refs #7484 Co-Authored-By: Claude Opus 5.5 --- .../src/game/dedicated/transfer.rs | 8 +- crates/horizon-client/src/lan/arrivals.rs | 78 ++++++++++++++++--- crates/horizon-client/src/lan/conn.rs | 19 ++++- crates/horizon-client/src/lan/hub.rs | 14 ++-- docs/server.md | 18 +++-- 5 files changed, 107 insertions(+), 30 deletions(-) diff --git a/crates/horizon-client/src/game/dedicated/transfer.rs b/crates/horizon-client/src/game/dedicated/transfer.rs index 27794eeb3a..d51bb2e90b 100644 --- a/crates/horizon-client/src/game/dedicated/transfer.rs +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -62,7 +62,7 @@ impl Game<'_> { }; // A repeat while the hand-over lasts: the same record, and nothing touched. if let Some(h) = &host.hub.guests[i].handover { - return json!({ "ok": true, "record": h.record }); + return arrivals::export_reply(&host.hub, i, h.record.clone()); } let conn = host.hub.guests[i].conn; let window = host.hub.guests[i].menu.as_ref().map(|m| m.window); @@ -75,6 +75,10 @@ impl Game<'_> { } // Nothing is sent to the client by this: it is for a player who is leaving. self.guest_close_all(host, conn); + // The inventory's own 2x2 grid too (`InventoryMenu.removed`): `guest_close_all` empties it + // only with a real `ContainerClose(0)`, and a frozen client's is dropped. Its items are + // not in the player compound, so they would be lost with `player-release moved`. + self.lan_crafting_closed(host, conn, Some(0)); if let Err(e) = self.enter_level(Some(host), back) { log::error!("could not reopen {}: {e}", back.id()); } @@ -106,7 +110,7 @@ impl Game<'_> { stats, }; match arrivals::export_record(&mut host.hub, i, freeze, records) { - Ok(record) => json!({ "ok": true, "record": record }), + Ok(record) => arrivals::export_reply(&host.hub, i, record), Err(e) => json!({ "error": e }), } } diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index 4b3e1686db..d36b0053aa 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -154,9 +154,22 @@ impl Expectations { /// the login that starts takes it, and a second login of the same player /// finds none. pub fn take(&mut self, uuid: u128, now: Instant) -> bool { - match self.until.remove(&uuid) { - Some(deadline) => now < deadline, - None => false, + self.take_until(uuid, now).is_some() + } + + /// [`Self::take`], giving the deadline of the expectation used up, for + /// [`Self::put_back`]. + pub fn take_until(&mut self, uuid: u128, now: Instant) -> Option { + self.until.remove(&uuid).filter(|deadline| now < *deadline) + } + + /// Gives back an expectation a login used up and that did not reach play (it + /// failed in configuration), with its own deadline: the proxy's next attempt + /// within that time is still expected. Nothing is put back once its time has + /// passed, over a newer expectation of the same player, or into a full table. + pub fn put_back(&mut self, uuid: u128, deadline: Instant, now: Instant) { + if now < deadline && !self.until.contains_key(&uuid) && self.until.len() < self.max { + self.until.insert(uuid, deadline); } } @@ -321,6 +334,27 @@ pub fn export_record( Ok(record) } +/// The reply to an export: the record and, while the player is frozen, the ticks left +/// before its hand-over ends by itself (`freeze_ticks_left`), so that a proxy does not +/// commit on the target so late that its `moved` would come after the end. The count is +/// beside the record, which stays the same bytes from one export to the next. +pub fn export_reply(hub: &Hub, i: usize, record: serde_json::Value) -> serde_json::Value { + let left = hub.guests[i] + .handover + .as_ref() + .zip(hub.transfers.as_ref()) + .map(|(h, t)| { + t.settings + .max_freeze_ticks + .saturating_sub(hub.ticks.saturating_sub(h.since)) + }); + let mut reply = serde_json::json!({ "ok": true, "record": record }); + if let Some(left) = left { + reply["freeze_ticks_left"] = left.into(); + } + reply +} + /// A unique id as text, with or without dashes. pub fn parse_uuid(text: &str) -> Option { let plain: String = text.chars().filter(|c| *c != '-').collect(); @@ -496,6 +530,26 @@ mod tests { assert_eq!(e.expect(3, secs(1), t0), Err(ExpectError::Full)); } + /// A login that does not reach play gives its expectation back, with its + /// own deadline, so the proxy's next attempt is still expected. + #[test] + fn an_expectation_a_failed_login_used_is_put_back_with_its_deadline() { + let t0 = Instant::now(); + let mut e = Expectations::default(); + e.expect(A, secs(10), t0).unwrap(); + let deadline = e.take_until(A, t0 + secs(2)).unwrap(); + assert!(e.is_empty()); + e.put_back(A, deadline, t0 + secs(4)); + assert!(e.take(A, t0 + secs(9)), "the same deadline, not a new one"); + // Not once its time has passed. + e.put_back(A, deadline, t0 + secs(10)); + assert!(e.is_empty()); + // Not over a newer expectation of the same player. + e.expect(A, secs(30), t0 + secs(5)).unwrap(); + e.put_back(A, deadline, t0 + secs(6)); + assert!(e.take(A, t0 + secs(20)), "the newer deadline stands"); + } + #[test] fn a_hand_over_id_is_32_hexadecimal_digits() { let id = 0x0123_4567_89ab_cdef_0011_2233_4455_6677; @@ -999,6 +1053,10 @@ mod hub_tests { ); } assert_eq!(hub.guests[i].handover.as_ref().unwrap().since, since); + // The reply says how long the freeze still lasts: 50 ticks have gone. + let reply = super::export_reply(&hub, i, first.clone()); + assert_eq!(reply["freeze_ticks_left"], 550); + assert_eq!(reply["record"], first); // Once it is over, a new freeze is a new hand-over. hub.guests[i].handover = None; let next = super::export_record(&mut hub, i, true, Default::default()).unwrap(); @@ -1024,8 +1082,9 @@ mod hub_tests { assert!(hub.guests[i].left_reason.is_empty(), "not kicked"); } - /// A frozen player's chat acknowledgements are still taken, and its chat - /// message is dropped without a kick. + /// A frozen player's chat acknowledgements are still taken, in order with + /// its chat message's last-seen update, and the message is dropped without + /// a kick. #[test] fn a_frozen_player_keeps_acknowledging_chat() { use horizon_protocol::packets::play::{Chat, ChatAck, LastSeenUpdate, Serverbound}; @@ -1050,12 +1109,9 @@ mod hub_tests { .push_back((Serverbound::ChatAck(ChatAck { offset: 0 }), 2)); g.inbox_bytes = 12; hub.keep_acknowledgements(i, &mut Vec::new()); - let kept: Vec<_> = hub.guests[i].inbox.iter().map(|(p, _)| p.clone()).collect(); - assert!( - matches!(kept.as_slice(), [Serverbound::ChatAck(_)]), - "{kept:?}" - ); - assert_eq!(hub.guests[i].inbox_bytes, 2); + // Both were handled here, in the order they came; nothing is left for the inbox. + assert!(hub.guests[i].inbox.is_empty()); + assert_eq!(hub.guests[i].inbox_bytes, 0); assert!(hub.guests[i].left_reason.is_empty(), "not kicked"); } } diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index b33aa95827..5a26e09851 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -775,23 +775,36 @@ fn serve_login( // here, as the login starts: one that ran out while the player was still in // configuration (a resource pack prompt) would otherwise make it an ordinary // login from this server's own file. - let expected = shared.config.arrivals.as_ref().is_some_and(|table| { + let expected = shared.config.arrivals.as_ref().and_then(|table| { table .lock() .unwrap_or_else(|e| e.into_inner()) - .take(uuid, std::time::Instant::now()) + .take_until(uuid, std::time::Instant::now()) }); + // A login that does not reach play (it failed in configuration) gives the + // expectation back, so the proxy's next attempt is still expected. + let put_back = || { + if let (Some(deadline), Some(table)) = (expected, shared.config.arrivals.as_ref()) { + table.lock().unwrap_or_else(|e| e.into_inner()).put_back( + uuid, + deadline, + std::time::Instant::now(), + ); + } + }; let result = login_and_configure(&mut c, shared, uuid, &name, properties); match result { Ok(Some(parts)) => { - play(c, shared, parts, address, counted, expected); + play(c, shared, parts, address, counted, expected.is_some()); Ok(()) } Ok(None) => { + put_back(); shared.release(uuid); Ok(()) } Err(err) => { + put_back(); shared.release(uuid); // ServerConfigurationPacketListenerImpl.onDisconnect. let profile = format!("{name} ({})", uuid_string(uuid)); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 060404042e..79bf4bf660 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1804,11 +1804,11 @@ impl Hub { /// Keeps, in a frozen player's inbox, the packets that answer the server /// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client - /// information, chat acknowledgement) and drops the rest. A chat message or - /// signed command is dropped too, but the messages it says the client has - /// seen are taken first (`unpackAndApplyLastSeen`): the client counts them - /// as acknowledged, and its next message after the freeze would not - /// validate otherwise. + /// information) and drops the rest. A chat message or signed command is + /// dropped too, but the messages it says the client has seen are taken + /// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled + /// here, in the order they came: the client counts them as acknowledged, + /// and its next message after the freeze would not validate otherwise. pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec) { let inbox = std::mem::take(&mut self.guests[i].inbox); let mut bytes = 0; @@ -1820,14 +1820,14 @@ impl Hub { S::ChatCommandSigned(c) => { self.apply_last_seen(i, &c.last_seen, events); } + S::ChatAck(c) => self.chat_ack(i, c.offset, events), S::AcceptTeleportation(_) | S::PlayerLoaded(_) | S::ChunkBatchReceived(_) | S::ClientInformation(_) | S::ClientTickEnd(_) | S::KeepAlive(_) - | S::Pong(_) - | S::ChatAck(_) => { + | S::Pong(_) => { bytes += size; self.guests[i].inbox.push_back((packet, size)); } diff --git a/docs/server.md b/docs/server.md index 7e96f8b61f..ce20747233 100644 --- a/docs/server.md +++ b/docs/server.md @@ -183,14 +183,16 @@ dashes in an answer (`123e4567-e89b-12d3-a456-426614174000`). A hand-over id is asking again for the same player renews the time and says `"renewed": true`. It is refused for a missing or malformed id, a time that is not a whole number of seconds above zero, a player already connected, and a full table. The player's next login uses the expectation up as it starts, once the bans, the whitelist and the player -limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. An -expectation no login used is reported in the log when its time runs out. For now that is all an expectation does: +limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. A +login that then fails before it reaches play (its connection drops in configuration, or the client refuses a +required pack) gives the expectation back with its own deadline, so the proxy's next attempt within that time is +still expected. An expectation no login used is reported in the log when its time runs out. For now that is all an expectation does: the player still logs in as any other. `player-export [freeze]` reads a connected player out of the source server. It first closes the player's open -menu as vanilla does when a menu closes, in the player's own level (the cursor stack and the crafting grids go back -into the inventory, a trade back to its villager; the client is told to close it and is sent its inventory), then -answers: +menu as vanilla does when a menu closes, in the player's own level (the cursor stack, a crafting table's grid and +the inventory's own 2x2 grid go back into the inventory, a trade back to its villager; the client is told to close +it and is sent its inventory), then answers: ```json {"ok": true, "record": { @@ -215,7 +217,9 @@ writes to `players/data/.dat` before it gzips it, taken from the connected values mods attached to the player. `advancements` and `stats` are the JSON the server writes to `players/advancements/.json` and `players/stats/.json`, or null on a server that keeps none. `version` is the version of this record (1); `data_version` is the world data version of the server that made it -(5023 for 26.3). The other fields are a few of the compound's values, for a proxy that does not read NBT. +(5023 for 26.3). The other fields are a few of the compound's values, for a proxy that does not read NBT. While the +player is frozen, the reply also gives `"freeze_ticks_left"` beside the record: the ticks before the hand-over ends +by itself, so that a proxy does not commit on the target too late for its release to come in time. How a server reads a record (the target's `player-import`): - A record of another `version`, or of another `data_version` than the server's own, is refused. @@ -225,7 +229,7 @@ How a server reads a record (the target's `player-import`): **The hand-over.** `freeze` starts one: the record gets a `handover` id, which the later requests name, and the player's own inputs (moves, clicks, chat, commands) stop being acted on, while what answers the server (teleport confirmations, chunk batch acknowledgements, keep-alives, pings, tick ends, client information and chat -acknowledgements, with the messages a chat packet says the client has seen) still is. A frozen player is not +acknowledgements, and the messages a chat packet says the client has seen, in the order they came) still is. A frozen player is not kicked for floating. Every later export of that player, with or without `freeze`, answers with the same record, byte for byte, and does not restart the freeze: a proxy that lost the reply asks again. The hand-over ends after `max_freeze_seconds` (counted in ticks, 600 by default) with a warning in the log, and the id is then forgotten. From 189b8b2c9525cdf6a0f573d05171caf1212533f0 Mon Sep 17 00:00:00 2001 From: degepie Date: Wed, 4 Nov 2026 20:11:18 +0500 Subject: [PATCH 15/70] Ender dragon: post GameEvent.ENTITY_DIE when it is removed (#8740) EnderDragon.tickDeath (at 200 death ticks) and EnderDragon.kill (/kill) both end with gameEvent(ENTITY_DIE) at the dragon; Horizon posted none. Sculk sensors now hear the death, a catalyst within reach blooms (no charge: the dragon's xpReward is 0), and on /kill a warden can hear it too (after the death flight Warden.canTargetEntity refuses a dragon with no health left). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK --- crates/horizon-client/src/game/dragon.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 0d657d6eb9..98bd6d57ae 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2422,10 +2422,29 @@ impl Game<'_> { if drops { self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.2) as u32); } + let (id, at) = self.dragon.as_ref().map_or((0, p), |d| (d.id, d.position)); self.dragon = None; self.dragon_killed(); + // remove, then gameEvent(ENTITY_DIE): with no health left, + // `Warden.canTargetEntity` refuses the dragon, so no warden hears it. + self.dragon_died_event(id, at, false); } } + /// `Entity.gameEvent(GameEvent.ENTITY_DIE)` for the removed dragon: sculk + /// sensors (and, while it has health, wardens) hear it, and the nearest + /// catalyst blooms (`CatalystListener`). It takes no experience: the + /// dragon's `xpReward` is 0, its orbs come from `tickDeath`. + fn dragon_died_event(&mut self, id: u32, at: Vec3, targetable: bool) { + let at = [at.x, at.y, at.z]; + let ctx = horizon_client::sculk::Context { + source: Some(Who::Mob(id)), + targetable, + living: true, + ..Default::default() + }; + self.post_game_event("minecraft:entity_die", at, ctx); + self.catalyst_takes_experience(at, |_| 0); + } /// `EnderDragonFight.setDragonKilled`. fn dragon_killed(&mut self) { let _ = self.activate_exit_portal(); @@ -3482,8 +3501,9 @@ impl Game<'_> { /// first dragon's egg, and no new dragon. No experience: `tickDeath` /// drops it, and a killed dragon never gets there. pub(super) fn kill_dragon(&mut self, id: u32) { - if self.dragon.as_ref().is_some_and(|d| d.id == id) { - self.dragon = None; + if let Some(d) = self.dragon.take_if(|d| d.id == id) { + // remove, then gameEvent(ENTITY_DIE), before the fight hears of it. + self.dragon_died_event(d.id, d.position, d.alive()); // ServerLevel.getDragonFight: only the End has a fight. if self.dim == Dim::End { self.dragon_killed(); From 6535ef4545733673fb64e5f2c77eb3f9c223b898 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:12:47 +0100 Subject: [PATCH 16/70] transfer: the frozen-chat test sends an ack that moves the window before a chat that counts on it The test sent a chat, then an ack of offset 0, which validates in either order. It now sends an ack of 1 and then a chat acknowledging the message the ack brought into the window, which fails validation (and kicks) when the ack is applied after the chat. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/lan/arrivals.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs index d36b0053aa..ca278a9017 100644 --- a/crates/horizon-client/src/lan/arrivals.rs +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -1091,6 +1091,14 @@ mod hub_tests { let (mut hub, i) = exported_guest(); super::export_record(&mut hub, i, true, Default::default()).unwrap(); let g = &mut hub.guests[i]; + // One message sent to the player: the window's 21st entry. The ack that + // comes first drops the oldest, so the message is at index 19 when the + // chat names it; taken the other way round, index 19 is still empty and + // the chat fails validation (a kick). + g.last_seen + .add_pending(&[7; horizon_protocol::secure_chat::SIGNATURE_BYTES]); + g.inbox + .push_back((Serverbound::ChatAck(ChatAck { offset: 1 }), 2)); g.inbox.push_back(( Serverbound::Chat(Chat { message: "hello".into(), @@ -1099,19 +1107,18 @@ mod hub_tests { signature: None, last_seen: LastSeenUpdate { offset: 0, - acknowledged: [0; 3], + acknowledged: [0, 0, 0b1000], checksum: 0, }, }), 10, )); - g.inbox - .push_back((Serverbound::ChatAck(ChatAck { offset: 0 }), 2)); g.inbox_bytes = 12; hub.keep_acknowledgements(i, &mut Vec::new()); // Both were handled here, in the order they came; nothing is left for the inbox. assert!(hub.guests[i].inbox.is_empty()); assert_eq!(hub.guests[i].inbox_bytes, 0); assert!(hub.guests[i].left_reason.is_empty(), "not kicked"); + assert_eq!(hub.guests[i].last_seen.tracked_count(), 20); } } From 463f3eff0bcd751b73d06f456c051ffe14acb918 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:34:20 +0100 Subject: [PATCH 17/70] Test: World.sprite_rects (from #9057) in the terrain parts fixture Co-Authored-By: Claude Opus 5.5 --- .../src/gfx/pack_post/terrain_parts_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs index b72bb0c924..7e69f20717 100644 --- a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs +++ b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs @@ -92,6 +92,7 @@ fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> { cull: false, }), block_ids: &[], + sprite_rects: &[], celestial: None, gui_size: SIZE, clouds: None, From 9f2d88fb57788352b0d4e047a6d79453323c4ac0 Mon Sep 17 00:00:00 2001 From: makscoder Date: Wed, 4 Nov 2026 19:31:11 +0300 Subject: [PATCH 18/70] Let peaceful discard hostile mobs through Mob.checkDespawn, not instantly Switching to peaceful removed every hostile mob in the same tick with `mobs.retain(|m| !m.hostile())` in the difficulty command, the LAN/menu difficulty change, and the dedicated-server start. Vanilla only sets the difficulty there; each hostile mob is discarded by its own Mob.checkDespawn on the next tick when the level is peaceful and the type is not allowed in peaceful, so mobs in unloaded chunks persist until loaded and the wither and ender dragon follow their own exceptions. Drop the three retain calls: the per-tick check_despawn already discards the same mobs (difficulty.effective == 0.0 && !allowed_in_peaceful), with vanilla's timing and exceptions. Closes #9283 --- crates/horizon-client/src/game/commands.rs | 4 ---- crates/horizon-client/src/game/dedicated.rs | 3 --- crates/horizon-client/src/game/lan_actions.rs | 3 --- 3 files changed, 10 deletions(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index cdeb62b74e..9d19452450 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -3250,10 +3250,6 @@ impl Game<'_> { // reopen, so it follows the command. self.level.set_difficulty(i); self.sync_rules(); - if i == 0 { - // Peaceful removes hostile mobs. - self.mobs.retain(|m| !m.hostile()); - } // MinecraftServer.setDifficulty: every player, guests included, is // sent the new difficulty. if let Some(host) = self.lan.as_ref() { diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 53f47613bd..93403fc6ce 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -985,9 +985,6 @@ impl Game<'_> { }; self.level.set_difficulty(self.ui.difficulty); self.sync_rules(); - if self.ui.difficulty == 0 { - self.mobs.retain(|m| !m.hostile()); - } // DedicatedServer.initServer: a legacy `announce-player-achievements` // sets the show_advancement_messages rule once, as the file is read. if let Some(announce) = props.announce_player_achievements { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 56a947e9b1..02bd3f2e5b 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2470,9 +2470,6 @@ impl Game<'_> { // survives the reopen, so it follows the change. self.level.set_difficulty(self.ui.difficulty); self.sync_rules(); - if self.ui.difficulty == 0 { - self.mobs.retain(|m| !m.hostile()); - } host.hub .broadcast(&C::ChangeDifficulty(play::ChangeDifficulty { difficulty: i32::from(self.ui.difficulty), From 93afbf2fd81f84cd579c64862f8cf9bce3d18974 Mon Sep 17 00:00:00 2001 From: Arslan Armanuly <72556944+arslanarm@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:19:24 +0500 Subject: [PATCH 19/70] Shader packs: split solid and cutout terrain per quad, by sprite layer The split decided per vertex, and read lava from the high nibble of the vertex colour's alpha. With separateAo=true that byte holds each vertex's own ambient occlusion, so the corners of one leaf quad landed in different parts: each part pushed its other corners off screen, and in our separateAo probe the leaves drew as speckled slivers of both parts. Lava had no flag there and went to the cutout part. The test now reads only the vertex's sprite layer, which all four vertices of a quad share: OPAQUE_LAYER, or one of lava's two sprite layers, which the game hands the renderer. In Iris 1.11.7 our separateAo probe draws leaves whole with gbuffers_terrain_cutout and lava with gbuffers_terrain_solid; the branch now does the same. Part of #9137 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client-render/src/gfx.rs | 11 ++- .../src/gfx/pack_atlas.rs | 18 +++-- .../src/gfx/pack_post/terrain_parts_tests.rs | 75 ++++++++++++++----- .../horizon-client/src/game/shader_packs.rs | 10 +++ 4 files changed, 88 insertions(+), 26 deletions(-) diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 7406a63ca4..f08f606881 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -1375,6 +1375,8 @@ pub struct Renderer { pack_terrain_flat: bool, /// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a. pub separate_ao: bool, + /// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`). + pack_lava_layers: Vec, pub hud: HudRenderer, /// Vanilla's menu background blur, made on first use. menu_blur: Option, @@ -4640,6 +4642,7 @@ impl Renderer { pack_material_atlases: None, pack_terrain_flat: false, separate_ao: false, + pack_lava_layers: Vec::new(), hud, menu_blur: None, post_effects: None, @@ -6333,6 +6336,10 @@ impl Renderer { } } } + /// Lava's sprite layers (still and flowing), whose faces a pack's solid terrain draws. + pub fn set_pack_lava_layers(&mut self, layers: Vec) { + self.pack_lava_layers = layers; + } /// Runs the sky, material and post stages of one of Horizon's own shader packs from now /// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the /// stages' labels; on an error the pack that ran keeps running. @@ -6396,7 +6403,9 @@ impl Renderer { } }); horizon_shaders::GameInputs { - terrain_parts: terrain.as_ref().map(pack_atlas::terrain_parts), + terrain_parts: terrain + .as_ref() + .map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)), terrain, terrain_geometry, entity_geometry: Some(pack_entities::geometry_inputs()), diff --git a/crates/horizon-client-render/src/gfx/pack_atlas.rs b/crates/horizon-client-render/src/gfx/pack_atlas.rs index faf1ce248f..6f9c64ed66 100644 --- a/crates/horizon-client-render/src/gfx/pack_atlas.rs +++ b/crates/horizon-client-render/src/gfx/pack_atlas.rs @@ -199,18 +199,26 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \ /// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain: /// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque /// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID` -/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava (a -/// fluid face, `mesher::fluid_face`, among the solid draws), which Iris 1.11.7 drew with -/// `gbuffers_terrain_solid` in our probe. +/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's +/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with +/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`. +/// +/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a +/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it +/// holds each vertex's own ambient occlusion. pub fn terrain_parts( terrain: &horizon_shaders::GbuffersInputs, + lava: &[u16], ) -> [horizon_shaders::GbuffersInputs; 2] { let opaque = crate::mesher::OPAQUE_LAYER; + let lava: String = lava + .iter() + .map(|l| format!(" || hz_v_layer == {l}u")) + .collect(); let part = |solid: bool| { let mut inputs = terrain.clone(); inputs.after.push_str(&format!( - " uint hz_part = uint(round(hz_v_color.a * 255.0)) >> 4u;\n \ - bool hz_solid = (hz_v_layer & {opaque}u) != 0u || (hz_part >= 8u && (hz_part & 7u) < 6u);\n \ + " bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \ if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n" )); inputs diff --git a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs index 7e69f20717..c50bf6dd99 100644 --- a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs +++ b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs @@ -3,7 +3,9 @@ //! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and //! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`, //! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written -//! with alpha 0.05, cutout terrain loses it. The programs here are the probes', cut down. +//! with alpha 0.05, cutout terrain loses it. Each quad draws whole with one of them, whatever +//! its vertices' alphas (with `separateAo`, their own ambient occlusion). The programs here are +//! the probes', cut down. use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame}; use super::*; use crate::hud::HudBatch; @@ -30,10 +32,22 @@ fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] { ] } +/// Lava's sprite layer in the fixture. +const LAVA: u16 = 5; + /// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left -/// to right: a quad over opaque texels, a cutout quad, a lava quad. Each strip's colour, `None` -/// where nothing drew; `None` overall without a GPU. +/// to right: a quad over opaque texels, a cutout quad, a lava quad, all with opaque vertex +/// colours. Each strip's colour, `None` where nothing drew; `None` overall without a GPU. fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> { + strips_with_alphas(programs, [255; 4]) +} + +/// `strips` with the quads' corners' vertex alphas (bottom left, bottom right, top right, top +/// left). Fails unless each strip is one colour. +fn strips_with_alphas( + programs: &[(&str, [u8; 3], f32)], + alphas: [u8; 4], +) -> Option<[Option<[u8; 3]>; 3]> { let Some(gpu) = Gpu::new() else { eprintln!("TERRAIN_PARTS_NO_ADAPTER"); return None; @@ -63,7 +77,10 @@ fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> { ..Default::default() }; let inputs = horizon_shaders::GameInputs { - terrain_parts: Some(super::super::pack_atlas::terrain_parts(&terrain)), + terrain_parts: Some(super::super::pack_atlas::terrain_parts( + &terrain, + &[LAVA, LAVA + 1], + )), terrain: Some(terrain), ..Default::default() }; @@ -102,24 +119,16 @@ fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> { inputs, ); let opaque = u32::from(crate::mesher::OPAQUE_LAYER); - let lava = crate::mesher::fluid_face(u32::MAX, 1); let mut vertices: Vec = Vec::new(); - for (i, (layer, color)) in [(opaque, u32::MAX), (0, u32::MAX), (0, lava)] - .into_iter() - .enumerate() - { + for (i, layer) in [opaque | 1, 2, u32::from(LAVA)].into_iter().enumerate() { let (x0, x1) = ( -1.0 + i as f32 * 2.0 / 3.0, -1.0 + (i + 1) as f32 * 2.0 / 3.0, ); - for [x, y] in [ - [x0, -1.0], - [x1, -1.0], - [x1, 1.0], - [x0, -1.0], - [x1, 1.0], - [x0, 1.0], - ] { + let corners = [[x0, -1.0], [x1, -1.0], [x1, 1.0], [x0, 1.0]]; + for corner in [0, 1, 2, 0, 2, 3] { + let [x, y] = corners[corner]; + let color = u32::from(alphas[corner]) << 24 | 0x00FF_FFFF; vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]); } } @@ -138,10 +147,19 @@ fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> { rp.draw(0..18, 0..1); } }); - let row = SIZE[1] as usize / 2 * SIZE[0] as usize; - Some([2, 8, 13].map(|x| { - let [r, g, b, _] = pixels[row + x]; + // Every pixel of each strip (columns 0-4, 6-9 and 11-15; 5 and 10 straddle two strips). + let colour = |x: usize, y: usize| { + let [r, g, b, _] = pixels[y * SIZE[0] as usize + x]; Some([r, g, b]).filter(|&c| c != [0, 0, 0]) + }; + Some([0..5, 6..10, 11..16].map(|columns| { + let first = colour(columns.start, 0); + for x in columns.clone() { + for y in 0..SIZE[1] as usize { + assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})"); + } + } + first })) } @@ -194,6 +212,23 @@ fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() { assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]); } +/// With `separateAo` a vertex's alpha is its own ambient occlusion, so the corners of one quad +/// differ; the quad still draws whole, with its part's program. +#[test] +fn a_quad_draws_whole_whatever_its_vertex_alphas() { + let programs = [ + ("gbuffers_terrain", RED, 1.0), + ("gbuffers_terrain_solid", GREEN, 1.0), + ("gbuffers_terrain_cutout", BLUE, 1.0), + ]; + for alphas in [[255, 160, 255, 160], [128, 255, 200, 64], [160; 4]] { + let Some(got) = strips_with_alphas(&programs, alphas) else { + return; + }; + assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)], "{alphas:?}"); + } +} + #[test] fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() { let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else { diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs index 03f5361a86..252957d3ca 100644 --- a/crates/horizon-client/src/game/shader_packs.rs +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -225,6 +225,16 @@ impl Game<'_> { .get("separateAo") .is_some_and(|v| v.trim() == "true"); renderer.set_separate_ao(separate_ao); + // Lava's faces draw with the solid terrain's program. + renderer.set_pack_lava_layers( + ["lava_still", "lava_flow"] + .iter() + .filter_map(|f| { + let name = format!("minecraft:block/{f}"); + self.pack.render.sprite_layers.get(&name).map(|&l| l as u16) + }) + .collect(), + ); let folder = dimension_folder_named(&pack, self.dim.id(), dimension); let inputs = renderer.pack_inputs(); let mut post = load_post(&pack, &folder, &properties, ¯os, &inputs); From d5f4da2382b70ca8c524c54dc68d213f57c52494 Mon Sep 17 00:00:00 2001 From: BPMichon <79258091+BPMichon@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:47:16 +0000 Subject: [PATCH 20/70] Native mods: health HUD state and local attribute helpers A mod answering native_health_hud can now reach Horizon's HealthHud (native_health_hud_state): its left_height/right_height place the selected item name and the action bar (vanilla Hud.leftHeight/rightHeight), and its layout keeps Horizon's own rows. native_local_attribute(name) reads an attribute of this client's player (LivingEntity.getAttributeValue), such as minecraft:armor_toughness. Both through a helper-only API fragment, so mods no longer read Game's private fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYf41fgTMRfUEXn3afqyTG --- .../horizon-client/src/game/native_hooks.rs | 19 +++++++++++++++++++ crates/horizon-client/src/mod_api.rs | 2 +- docs/native-mods.md | 17 +++++++++++++++++ .../health_hud_state.json | 9 +++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tools/native-hook-fragments/health_hud_state.json diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 31ebd695ad..3706e00d19 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -993,6 +993,7 @@ use horizon_client::{ first_person::Hand, health::{HurtSource, Hurting, Knock}, hud::{HudBatch, rgba}, + hud_layout::HealthHud, items::ItemStack, keybinds, lan::menu::Slots, @@ -8976,6 +8977,24 @@ impl Game<'_> { let screen = self.shown_native_screen()?; Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice())) } + /// Horizon's own health HUD state, for a mod answering + /// `native_health_hud`. `left_height`/`right_height` as they stand when + /// the hook returns place the selected item's name and the action bar + /// (vanilla's `Hud.leftHeight`/`rightHeight`): set them when you draw + /// more or fewer rows. `layout` draws Horizon's rows and advances their + /// frame state (the hearts' blink, the bubble pop sound), so call it at + /// most once a frame, and only when you keep some of its rows. + /// API @API:health_hud_state@. + pub(super) fn native_health_hud_state(&mut self) -> &mut HealthHud { + &mut self.health_hud + } + /// The current value, base plus modifiers, of this client's player's + /// attribute `name` (`minecraft:armor_toughness`), as vanilla's + /// `LivingEntity.getAttributeValue`. None with no player, or for an id + /// the player has no attribute for. API @API:health_hud_state@. + pub(super) fn native_local_attribute(&mut self, name: &str) -> Option { + self.player.as_mut()?.attribute(name).ok() + } /// A data file of the game's resources, content mods' included /// (`data/mymod/machines.json`), when there is one: what a mod's content /// says, read at runtime. Only relative paths of plain names. API 20. diff --git a/crates/horizon-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 043e476fe6..7870264664 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -27,7 +27,7 @@ pub use crate::gfx::native_render::{ wgpu, }; pub use crate::hud::HudBatch; -pub use crate::hud_layout::{GuiDraw, HealthInputs}; +pub use crate::hud_layout::{GuiDraw, HealthHud, HealthInputs}; pub use crate::items::ItemStack; pub use crate::merchant::MerchantOffer; pub use crate::mesher::TintSource; diff --git a/docs/native-mods.md b/docs/native-mods.md index dc91d4361b..75d24a54fa 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2723,6 +2723,23 @@ it moves only what the player sees, not where they are or aim. moved-too-quickly check, which can send them back, as vanilla's does. It answers false for a player not on this server, on a client, and inside a player's hook (call it from a tick hook). +### Health HUD rows a mod draws + +A mod that answers `native_health_hud` (API 27) draws the health, armor, food and air rows itself. Two client helpers +give it what Horizon's own rows use, without reading `Game`'s fields: + +- `native_health_hud_state()` is Horizon's `HealthHud`. Its `left_height` and `right_height`, as they stand when the hook + returns, place the selected item's name and the action bar above the rows, as vanilla's `Hud.leftHeight` and + `rightHeight` do: set them when your rows take more or less room than vanilla's. They keep last frame's values until + something sets them. To keep some of Horizon's rows (food and air, say), call its `layout(inputs, wi, hi)` with the + hook's arguments and keep the draws you want; it also sets both heights. `layout` advances the rows' frame state (the + hearts' blink, the air bubbles' pop sound, which the game plays after the hook), so call it at most once a frame. +- `native_local_attribute(name)` is the current value, base plus modifiers, of an attribute of this client's player + (`minecraft:armor_toughness`, which vanilla doesn't draw), as `LivingEntity.getAttributeValue`. None with no player + or for an id the player has no attribute for. + +Bundles need `"hooks": @API:health_hud_state@`. + ### Values for a mod's shader pack `native_shader_params(partial, params)` (client) asks every frame, while a Horizon shader pack is on, for eight diff --git a/tools/native-hook-fragments/health_hud_state.json b/tools/native-hook-fragments/health_hud_state.json new file mode 100644 index 0000000000..59433c5e8e --- /dev/null +++ b/tools/native-hook-fragments/health_hud_state.json @@ -0,0 +1,9 @@ +{ + "hooks": [], + "helpers": [ + {"name": "health_hud_state", "target": "horizon_client::game::native_hooks::Game<'_>::native_health_hud_state"}, + {"name": "local_attribute", "target": "horizon_client::game::native_hooks::Game<'_>::native_local_attribute"} + ], + "rust_summary": "Horizon's health HUD state for a `native_health_hud` mod (`native_health_hud_state`) and this\nclient's player's attribute values (`native_local_attribute`).", + "doc_summary": "`native_health_hud_state()`, Horizon's health HUD state, for a mod answering `native_health_hud`: its `left_height` and `right_height` place the item name and the action bar, and its `layout` draws Horizon's rows; and `native_local_attribute(name)`, the value of an attribute of this client's player (`minecraft:armor_toughness`). Bundles need \"hooks\": @API:health_hud_state@." +} From a152e92cecd788c4a4bceca53f556e49807b798a Mon Sep 17 00:00:00 2001 From: Florian ERNST <30944236+TheMrZZ@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:48:24 +0100 Subject: [PATCH 21/70] Use world datapack tables for advancement loot rewards Co-Authored-By: Codex --- .../horizon-client/src/game/advancements.rs | 5 +- tools/command_exec_corpus.py | 111 ++++++++++++++++++ tools/test_command_exec_parity.py | 30 ++++- 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index fcb352e4f8..03c63a42de 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -776,10 +776,7 @@ impl Game<'_> { .recipe_book .add_recipes(&world_stations!(self).recipe_index, &rewards); for table in &a.rewards.loot { - let items = self - .res - .items() - .loot_table(table, false, None, &mut self.world.random); + let items = world_items!(self).loot_table(table, false, None, &mut self.world.random); for s in items { if let Some(rest) = self.give(s) && let Some(eye) = self.eye_position() diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index df2a147cc3..357072b991 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -4379,6 +4379,115 @@ def mining_efficiency_9021(): +def advancement_loot_fixtures(group_names): + advancements, tables = {}, {} + if 'advancement world loot rewards' in group_names: + criteria = {'never': {'trigger': 'minecraft:impossible'}} + advancements = {'parity:loot_reward': {'criteria': criteria, + 'rewards': {'loot': ['parity:reward']}}, + 'parity:no_loot': {'criteria': criteria}} + tables['parity:reward'] = {'type': 'minecraft:advancement_reward', + 'pools': [{'rolls': 1, 'entries': [ + {'type': 'minecraft:item', 'name': 'minecraft:diamond'}]}]} + if 'advancement empty loot reference' in group_names: + tables['parity:empty_reward'] = {'type': 'minecraft:advancement_reward', 'pools': []} + return advancements, tables + + +def advancement_world_loot_rewards(named_table=False, include_missing_ack=False): + """One world-only, modifier-free reward table; no XP, recipe or function reward.""" + ops = setup('/forceload add 0 0 15 15', '/setblock 8 229 8 stone', + '/tp @s 8.5 230 8.5 0 0', '/clear @s', + '/scoreboard objectives add reward_loot dummy') + ops += [cmd('/execute if loaded 8 230 8', expect_result=1), + cmd('/execute if entity @s[type=player,nbt={Pos:[8.5d,230d,8.5d],Health:20f}]', expect_result=1), + cmd('/data get entity @s Inventory')] + ops += setup('/data modify storage h292:identity value set from entity @s UUID', + '/execute store result score clock reward_loot run time query gametime') + def reference(table): + return ('{type:"minecraft:advancement_reward",pools:[{rolls:1,entries:[' + '{type:"minecraft:loot_table",value:"' + table + '"}]}]}') + target = 'parity:reward' if named_table else reference('parity:reward') + if not named_table: + # Same reference shape with a missing key must leave the inventory empty. + # Only its three codec/ACK rows are excluded from focused97; full100 + # records them unchanged. Both Inventory guards always remain recorded. + ops += setup('/scoreboard players set missing_result reward_loot -99', + '/scoreboard players set missing_success reward_loot -99') + ops += [cmd('/scoreboard players get missing_result reward_loot', expect_result=-99), + cmd('/scoreboard players get missing_success reward_loot', expect_result=-99), + cmd('/execute store result score missing_result reward_loot store success score missing_success reward_loot run loot give @s loot ' + reference('parity:missing_reward'), record=include_missing_ack), + cmd('/scoreboard players get missing_result reward_loot', record=include_missing_ack), + cmd('/scoreboard players get missing_success reward_loot', record=include_missing_ack), + cmd('/data get entity @s Inventory', expect_result=0), + cmd('/execute unless data entity @s Inventory[]', expect_result=1)] + # A reference, never an inline diamond definition, proves the world-only table. + ops += [cmd('/loot give @s loot ' + target, expect_result=1), + cmd('/data get entity @s Inventory'), + cmd('/execute if entity @s[nbt={Inventory:[{Slot:0b,id:"minecraft:diamond",count:1}]}]', expect_result=1), + cmd('/clear @s', expect_result=1), + cmd('/data get entity @s Inventory')] + cases = [('grant', 'parity:loot_reward', 1, 1, True), + ('grant', 'parity:loot_reward', 0, 1, True), + ('revoke', 'parity:loot_reward', 1, 1, False), + ('grant', 'parity:loot_reward', 1, 2, True), + ('grant', 'parity:no_loot', 1, 2, True), + ('grant', 'parity:no_loot', 0, 2, True), + ('revoke', 'parity:no_loot', 1, 2, False), + ('grant', 'parity:no_loot', 1, 2, True)] + for verb, advancement, changed, count, complete in cases: + ops += [cmd('/execute if entity @s[type=player,nbt={Pos:[8.5d,230d,8.5d],Health:20f}]', expect_result=1)] + ops += setup('/scoreboard players set result reward_loot -99', + '/scoreboard players set success reward_loot -99') + ops += [cmd('/scoreboard players get result reward_loot', expect_result=-99), + cmd('/scoreboard players get success reward_loot', expect_result=-99), + cmd(f'/execute store result score result reward_loot store success score success reward_loot run advancement {verb} @s only {advancement}'), + cmd('/scoreboard players get result reward_loot', expect_result=changed), + cmd('/scoreboard players get success reward_loot', expect_result=changed), + cmd('/data get entity @s Inventory'), + cmd(f'/execute if entity @s[nbt={{Inventory:[{{Slot:0b,id:"minecraft:diamond",count:{count}}}]}}]', expect_result=1)] + ops += setup('/execute store result score count reward_loot run clear @s minecraft:diamond 0') + ops += [cmd('/scoreboard players get count reward_loot', expect_result=count), + cmd(f'/execute {"if" if complete else "unless"} entity @s[advancements={{{advancement}=true}}]', expect_result=1)] + ops += [cmd('/loot give @s loot ' + target, expect_result=1), + cmd('/data get entity @s Inventory'), + cmd('/execute if entity @s[nbt={Inventory:[{Slot:0b,id:"minecraft:diamond",count:3}]}]', expect_result=1)] + ops += setup('/execute store success score identity_changed reward_loot run data modify storage h292:identity value set from entity @s UUID', + '/execute store result score elapsed reward_loot run time query gametime', + '/scoreboard players operation elapsed reward_loot -= clock reward_loot') + ops += [cmd('/scoreboard players get identity_changed reward_loot', expect_result=0), + cmd('/scoreboard players get elapsed reward_loot', expect_result=0)] + return ops + setup('/scoreboard objectives remove reward_loot', '/clear @s') + + +def advancement_empty_loot_reference(): + """Separate valid-empty table control: success with zero output, not a missing reference.""" + reference = '{type:"minecraft:advancement_reward",pools:[{rolls:1,entries:[{type:"minecraft:loot_table",value:"parity:empty_reward"}]}]}' + ops = setup('/forceload add 0 0 15 15', '/setblock 8 229 8 stone', + '/tp @s 8.5 230 8.5 0 0', '/clear @s', + '/scoreboard objectives add empty_loot dummy') + ops += [cmd('/execute if loaded 8 230 8', expect_result=1), + cmd('/execute if entity @s[type=player,nbt={Pos:[8.5d,230d,8.5d],Health:20f}]', expect_result=1), + cmd('/data get entity @s Inventory', expect_result=0)] + ops += setup('/data modify storage h292:empty_identity value set from entity @s UUID', + '/execute store result score clock empty_loot run time query gametime', + '/scoreboard players set result empty_loot -99', + '/scoreboard players set success empty_loot -99') + ops += [cmd('/scoreboard players get result empty_loot', expect_result=-99), + cmd('/scoreboard players get success empty_loot', expect_result=-99), + cmd('/execute store result score result empty_loot store success score success empty_loot run loot give @s loot ' + reference, expect_result=0), + cmd('/scoreboard players get result empty_loot', expect_result=0), + cmd('/scoreboard players get success empty_loot', expect_result=1), + cmd('/data get entity @s Inventory', expect_result=0), + cmd('/execute unless data entity @s Inventory[]', expect_result=1)] + ops += setup('/execute store success score identity_changed empty_loot run data modify storage h292:empty_identity value set from entity @s UUID', + '/execute store result score elapsed empty_loot run time query gametime', + '/scoreboard players operation elapsed empty_loot -= clock empty_loot') + ops += [cmd('/scoreboard players get identity_changed empty_loot', expect_result=0), + cmd('/scoreboard players get elapsed empty_loot', expect_result=0)] + return ops + setup('/scoreboard objectives remove empty_loot') + + def groups(): try: from .loaded_cloud_corpus import loaded_clouds @@ -4386,6 +4495,8 @@ def groups(): from loaded_cloud_corpus import loaded_clouds return [ ('setup', group_setup()), + ('advancement world loot rewards', advancement_world_loot_rewards()), + ('advancement empty loot reference', advancement_empty_loot_reference()), ('mining_efficiency_9021', mining_efficiency_9021()), ('structured item slots', structured_item_slots()), ('loaded potion clouds', loaded_clouds(cmd, setup)), diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index 9801da04e3..c425faa852 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -39,8 +39,8 @@ except ImportError: SEED = 1 -def install_datapack_fixtures(world, predicates, functions, jar): - if not predicates and not functions: +def install_datapack_fixtures(world, predicates, functions, jar, advancements=None, loot_tables=None): + if not predicates and not functions and not advancements and not loot_tables: return with zipfile.ZipFile(jar) as archive: version = json.loads(archive.read('version.json'))['pack_version'] @@ -61,10 +61,25 @@ def install_datapack_fixtures(world, predicates, functions, jar): target.parent.mkdir(parents=True, exist_ok=True) target.write_text(contents, encoding='utf-8') + for identifier, advancement in (advancements or {}).items(): + namespace, path = identifier.split(':', 1) + target = pack / 'data' / namespace / 'advancement' / (path + '.json') + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(advancement), encoding='utf-8') + for identifier, table in (loot_tables or {}).items(): + namespace, path = identifier.split(':', 1) + target = pack / 'data' / namespace / 'loot_table' / (path + '.json') + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(table), encoding='utf-8') + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--prepare', action='store_true') + parser.add_argument('--advancement-loot-inline-parser-diagnostic', action='store_true', + help='retain all 100 inline observations, including the three missing-reference ACK differences') + parser.add_argument('--advancement-loot-named-diagnostic', action='store_true', + help='retain the separate named world-loot registry-admission diagnostic') parser.add_argument('--pot-string-sherd-diagnostic', action='store_true', help='use string sherd input in the pot data group (known separate codec gap)') parser.add_argument('--limit', type=int, default=40, help='print at most this many mismatches') @@ -99,6 +114,12 @@ def main(): sys.exit(f'{name} failed ({result.returncode}):\n{tail}') groups = corpus_module.groups() + if args.advancement_loot_named_diagnostic and args.advancement_loot_inline_parser_diagnostic: + parser.error('choose only one advancement-loot diagnostic') + if args.advancement_loot_named_diagnostic or args.advancement_loot_inline_parser_diagnostic: + groups = [(name, corpus_module.advancement_world_loot_rewards( + named_table=args.advancement_loot_named_diagnostic, include_missing_ack=True) + if name == 'advancement world loot rewards' else ops) for name, ops in groups] if args.pot_string_sherd_diagnostic: groups = [(name, corpus_module.decorated_pot_data(string_sherd=True) if name == 'decorated pot data' else ops) for name, ops in groups] @@ -122,12 +143,13 @@ def main(): ensure_ascii=False) + '\n' for op in script), encoding='utf-8') predicates = corpus_module.predicate_selector_fixtures([name for name, _ in groups]) functions = corpus_module.function_return_fixtures([name for name, _ in groups]) + advancements, loot_tables = corpus_module.advancement_loot_fixtures([name for name, _ in groups]) run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-cp', str(jars['server']) + os.pathsep + dependency_path, '-d', classes, oracle], 'compile-java') vanilla_dir = build / 'vanilla' shutil.rmtree(vanilla_dir, ignore_errors=True) vanilla_dir.mkdir() - install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server']) + install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server'], advancements, loot_tables) run(['java', '-Xmx3G', '-Duser.timezone=UTC', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]), 'dev.horizonlab.parity.VanillaCommandExecOracle', vanilla_dir / 'universe', script_path, build / 'vanilla.jsonl', SEED], 'java', cwd=vanilla_dir) @@ -140,7 +162,7 @@ def main(): rust_dir = build / 'rust' shutil.rmtree(rust_dir, ignore_errors=True) rust_dir.mkdir() - install_datapack_fixtures(rust_dir / 'saves/harness', predicates, functions, jars['server']) + install_datapack_fixtures(rust_dir / 'saves/harness', predicates, functions, jars['server'], advancements, loot_tables) # The oracle's Services resolve no profile (/fetchprofile fails on the next tick): the game gets no services # either, a discovery address that refuses at once, rather than Mojang's over the network. env = lab.game_env(HORIZON_PLAYER_NAME="Player", HORIZON_COMMAND_SCRIPT=script_path, From aac36f65dc337befdc38a04595bc08df5af93c49 Mon Sep 17 00:00:00 2001 From: degepie Date: Wed, 4 Nov 2026 21:49:08 +0500 Subject: [PATCH 22/70] Advancement predicates read a world's data pack tags A `#tag` in an advancement predicate never matched a tag a world's data pack defines: `View::in_tag` read the game's own item, block, entity type, biome and damage type tags. The world's tables now also keep the entity type, biome and damage type tags with the packs' overlay (`TagLoader.loadTagsForExistingRegistries` binds every registry's tags from the pack stack), and `in_tag` reads them, and the item and block tags through `Game::items()`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK --- crates/horizon-client/src/game.rs | 17 +++++++++++++++++ crates/horizon-client/src/game/advancements.rs | 12 +++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 37232b0934..c442c73240 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1963,6 +1963,15 @@ struct WorldTables { /// The values of the data map types native mods registered /// (`native_data_map_types`). data_maps: native_data_maps::DataMaps, + /// The entity type, biome and damage type tags with the packs' over the + /// game's own, by registry (`TagLoader.loadTagsForExistingRegistries`): + /// what the predicates' `#tag`s of those registries read. + pack_tags: HashMap<&'static str, HashMap>>, +} + +impl WorldTables { + /// The registries [`WorldTables::pack_tags`] holds. + const PACK_TAG_REGISTRIES: [&'static str; 3] = ["entity_type", "worldgen/biome", "damage_type"]; } impl Resources { @@ -19842,6 +19851,13 @@ impl Game<'_> { return None; } }; + let pack_tags = WorldTables::PACK_TAG_REGISTRIES + .into_iter() + .map(|registry| { + let tags = horizon_client::items::load_tags(root, registry, &overlay); + (registry, tags.unwrap_or_default()) + }) + .collect(); Some(WorldTables { suffocation, files, @@ -19849,6 +19865,7 @@ impl Game<'_> { recipes, stations, data_maps, + pack_tags, }) } /// Reload the content tables with the world's packs, as diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index fcb352e4f8..36b60b0182 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -211,7 +211,17 @@ impl adv::World for View<'_, '_> { }) } fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool { - let items = &self.0.res.items(); + // The world's packs' tags over the game's own, as the server's + // registries bind them (`TagLoader.loadTagsForExistingRegistries`). + if let Some(tags) = self + .0 + .world_tables + .as_ref() + .and_then(|t| t.pack_tags.get(registry)) + { + return tags.get(tag).is_some_and(|t| t.contains(id)); + } + let items = self.0.items(); match registry { "item" => items.item_in_tag(tag, id), "block" => items.block_in_tag(tag, id), From 0e2a6b8700979801ace1029ff189c3876c70d371 Mon Sep 17 00:00:00 2001 From: degepie Date: Wed, 4 Nov 2026 21:49:49 +0500 Subject: [PATCH 23/70] Advancement block predicates test the block entity's nbt `BlockPredicate`'s `nbt` was skipped, so a predicate with it matched any block of its `blocks` (and an `inverted` one never matched). As `BlockPredicate.matchesBlockEntity` does, the pattern now has to be in the block entity's `saveWithFullMetadata` (`NbtUtils.compareNbt`), and a block without a block entity fails. The predicates' `World` asks for the data `/data get block` builds, moved into `Game::block_entity_nbt`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK --- crates/horizon-client/src/advancements.rs | 72 +++++++++++++++++++ .../horizon-client/src/game/advancements.rs | 3 + .../horizon-client/src/game/commands/data.rs | 44 ++++++------ 3 files changed, 96 insertions(+), 23 deletions(-) diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index 97c25daaf3..11814a829f 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -464,6 +464,10 @@ pub trait World { fn light(&self, pos: [i32; 3]) -> i32; fn smokey(&self, pos: [i32; 3]) -> bool; fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool; + /// The block entity's `saveWithFullMetadata` at `pos`, `None` without one. + fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option { + None + } } /// An item stack as predicates see it. #[derive(Clone, Debug, Default)] @@ -670,6 +674,16 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool { } } } + // `matchesBlockEntity`: the block entity's data holds the pattern + // (`NbtPredicate.matches`, `NbtUtils.compareNbt`); no block entity fails. + if let Some(nbt) = v["nbt"].as_str() { + let Ok((pattern, _)) = horizon_world::nbt::parse_snbt(nbt) else { + return false; + }; + if !crate::nbt_path::compare_nbt(&pattern, w.block_entity_nbt(pos).as_ref()) { + return false; + } + } true } /// `LocationPredicate`. @@ -1451,6 +1465,64 @@ mod tests { && matches!(id, "minecraft:fireball" | "minecraft:arrow")) } } + /// `BlockPredicate.matchesBlockEntity`: an `nbt` test needs a block + /// entity whose `saveWithFullMetadata` contains the pattern + /// (`NbtPredicate.matches`), so it never matches a plain block. + #[test] + fn a_block_predicates_nbt_needs_the_block_entitys_data() { + let w = Flat; + let stone = serde_json::json!({"blocks": "minecraft:stone", "nbt": "{}"}); + assert!(!block(&stone, [0, 0, 0], &w), "stone has no block entity"); + assert!(block( + &serde_json::json!({"blocks": "minecraft:stone"}), + [0, 0, 0], + &w + )); + /// An unpowered beacon: its saved data has `Levels:0`. + struct Beacon; + impl World for Beacon { + fn dimension(&self) -> &str { + Flat.dimension() + } + fn biome(&self, p: [i32; 3]) -> String { + Flat.biome(p) + } + fn structures(&self, p: [i32; 3]) -> Vec { + Flat.structures(p) + } + fn block(&self, _: [i32; 3]) -> (String, BTreeMap) { + ("minecraft:beacon".into(), BTreeMap::new()) + } + fn fluid(&self, _: [i32; 3]) -> Option { + None + } + fn can_see_sky(&self, _: [i32; 3]) -> bool { + true + } + fn light(&self, _: [i32; 3]) -> i32 { + 15 + } + fn smokey(&self, _: [i32; 3]) -> bool { + false + } + fn in_tag(&self, _: &str, _: &str, _: &str) -> bool { + false + } + fn block_entity_nbt(&self, _: [i32; 3]) -> Option { + horizon_world::nbt::parse_snbt(r#"{id:"minecraft:beacon",x:0,y:0,z:0,Levels:0}"#) + .ok() + .map(|(t, _)| t) + } + } + let beacon = |nbt: &str| serde_json::json!({"blocks": "minecraft:beacon", "nbt": nbt}); + assert!(block(&beacon("{Levels:0}"), [0, 0, 0], &Beacon)); + assert!(block(&beacon("{}"), [0, 0, 0], &Beacon)); + assert!(!block(&beacon("{Levels:4}"), [0, 0, 0], &Beacon)); + assert!( + !block(&beacon("{Levels:0b}"), [0, 0, 0], &Beacon), + "an int, not a byte" + ); + } /// 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 โ€” diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index fcb352e4f8..10aafc973e 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -210,6 +210,9 @@ impl adv::World for View<'_, '_> { .ends_with("campfire") }) } + fn block_entity_nbt(&self, p: [i32; 3]) -> Option { + self.0.block_entity_nbt(p) + } fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool { let items = &self.0.res.items(); match registry { diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 8eaaee8441..9d9168f654 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -560,32 +560,30 @@ impl Game<'_> { } Ok(tag) } - DataRef::Block(p) => { - if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) { - return Ok(tag); - } - let key = (p[0], p[1], p[2]); - let name = self.data_block_name(*p); - let e = self - .world - .block_entities - .get(&key) - .ok_or_else(|| err("commands.data.block.invalid", &[]))?; - // BlockEntity.saveWithFullMetadata: no chunk-only keepPacked, - // and the lock and custom name the model does not hold. - let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key)); - if let (Some(custom), Tag::Compound(map)) = - (self.world.block_names.get(&key), &mut tag) - { - map.entry("CustomName".into()) - .or_insert_with(|| Tag::String(custom.as_str().into())); - } - self.restore_loot_table(key, &mut tag); - Ok(tag) - } + DataRef::Block(p) => self + .block_entity_nbt(*p) + .ok_or_else(|| err("commands.data.block.invalid", &[])), DataRef::Storage(id) => Ok(self.score.storage.get(id)), } } + /// `BlockEntity.saveWithFullMetadata` of the block entity at `p`: no + /// chunk-only keepPacked, and the lock and custom name the model does + /// not hold. `None` where there is no block entity. + pub(in crate::game) fn block_entity_nbt(&self, p: [i32; 3]) -> Option { + if let Some(tag) = self.comparator_data(p).or_else(|| self.pot_data(p)) { + return Some(tag); + } + let key = (p[0], p[1], p[2]); + let name = self.data_block_name(p); + let e = self.world.block_entities.get(&key)?; + let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key)); + if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) { + map.entry("CustomName".into()) + .or_insert_with(|| Tag::String(custom.as_str().into())); + } + self.restore_loot_table(key, &mut tag); + Some(tag) + } pub(in crate::game) fn set_data_of(&mut self, r: &DataRef, tag: Tag) -> R<()> { match r { DataRef::Entity(t) => self.apply_entity_nbt(*t, tag), From 4986dcaf60eadf0d824be70c5067a552223516b1 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 17:57:13 +0100 Subject: [PATCH 24/70] Play the honey landing slide and show its particles. HoneyBlock.fallOn plays the slide (and the block's fall sound when the landing hurts) and broadcasts entity event 54, ten honey crumbs. The guest turn dropped both, and clients only drew the five crumbs of a slide. Co-authored-by: Cursor Co-Authored-By: Grok --- crates/horizon-client/src/game.rs | 9 ++++- crates/horizon-client/src/game/lan.rs | 11 ++++-- crates/horizon-client/src/game/lan_actions.rs | 19 +++++++-- crates/horizon-client/src/game/remote.rs | 21 ++++++---- crates/horizon-client/src/landing.rs | 39 +++++++++++++++++++ 5 files changed, 83 insertions(+), 16 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index f7cc9f18ed..4ddb978f1f 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -3831,7 +3831,14 @@ impl Game<'_> { /// `HoneyBlock.showSlideParticles` (entity event 53): five honey block /// particles at the entity. fn honey_slide_particles(&mut self, at: [f64; 3]) { + self.honey_entity_particles(at, 53); + } + /// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54. + fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) { use horizon_client::particles::{ParticleOptions, ParticleType}; + let Some(count) = horizon_client::landing::honey_particle_count(event) else { + return; + }; let Some(honey) = self .pack .assets @@ -3846,7 +3853,7 @@ impl Game<'_> { at[2].floor() as i32, ]; if let Some((sprite, tint)) = self.particle_sprite(honey, block) { - for _ in 0..5 { + for _ in 0..count { self.particles.add( &ParticleOptions::block(ParticleType::Block, sprite.clone(), tint), at, diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index e3ed35df48..59af17f8a4 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -2705,10 +2705,13 @@ impl Game<'_> { if event == 21 && self.options.execution_role.is_client() { self.start_guardian_sound(id as u32); } - // Event 53: a mob sliding down a honey block sheds honey particles - // (HoneyBlock.showSlideParticles), drawn by the client that runs the world. - if event == 53 && self.options.execution_role.is_client() { - self.honey_slide_particles(at); + // Events 53 and 54: honey crumbs while sliding, and the landing burst + // (`HoneyBlock.showSlideParticles` / `showJumpParticles`), drawn by the + // client that runs the world. + if self.options.execution_role.is_client() + && horizon_client::landing::honey_particle_count(event).is_some() + { + self.honey_entity_particles(at, event); } // Event 2: a mob's spear hit felt; the same window hears its own mob's hit. if event == 2 && self.options.execution_role.is_client() { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 3bbf377041..bf274c1a31 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -4608,20 +4608,33 @@ impl Game<'_> { host.hub.sound(name, at, volume, pitch, source, None); } } - // LivingEntity.causeFallDamage: Player.playSound skips the - // guest, whose own client plays it; everyone else hears it. + // LivingEntity.causeFallDamage, and HoneyBlock.fallOn's + // `entity.playSound`: Player.playSound skips the guest, whose + // own client plays it; everyone else hears it. MotionEvent::Fall(horizon_client::landing::FallEvent::Sound { sound, volume, pitch, .. }) => { - if let Some(name) = sound.player_fall_sound() { + if let Some(name) = sound + .player_fall_sound() + .or_else(|| sound.honey_landing_sound()) + { let source = horizon_client::settings::sound_source(name) as i32; self.audio.play_unshared(name, Some(at), volume, pitch); host.hub.sound(name, at, volume, pitch, source, Some(conn)); } } + // HoneyBlock.fallOn: entity event 54, including the player who + // landed (`broadcastEntityEvent` has no excluded player). + MotionEvent::Fall(horizon_client::landing::FallEvent::Broadcast(event)) + if horizon_client::landing::honey_particle_count(event as i8).is_some() => + { + if let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) { + host.hub.entity_event(id, event as i8, at); + } + } MotionEvent::Fall(event) => self.landing_block_updates(&event), // PowderSnowBlock.entityInside: every player around sees the // snowflake (`ServerLevel.sendParticles`). diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index ef62a143df..07c78c2b18 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4214,9 +4214,10 @@ impl Game<'_> { if p.event == super::combat::DROWN_EVENT { self.remote_drown(p.id); } - // Entity event 53: the honey slide particles of a player - // sliding down a honey block (HoneyBlock.showSlideParticles). - if p.event == 53 { + // Entity events 53 and 54: honey crumbs while sliding, and the + // ten from a landing (`HoneyBlock.showSlideParticles` / + // `showJumpParticles`). + if horizon_client::landing::honey_particle_count(p.event).is_some() { let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { self.player .as_ref() @@ -4228,7 +4229,7 @@ impl Game<'_> { .map(|m| [m.position.x, m.position.y, m.position.z]) }; if let Some(at) = at { - self.honey_slide_particles(at); + self.honey_entity_particles(at, p.event); } } if p.event == super::consume::USE_ITEM_COMPLETE_EVENT @@ -6232,9 +6233,10 @@ impl Game<'_> { self.audio .play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0); } - // LivingEntity.causeFallDamage on the client's own level: the - // fall sound is heard here (LocalPlayer.playSound); the - // server's copy skips this player (Player.playSound). + // LivingEntity.causeFallDamage and HoneyBlock.fallOn on the + // client's own level: the fall and honey sounds are heard here + // (LocalPlayer.playSound); the server's copy skips this player + // (Player.playSound). horizon_client::motion::MotionEvent::Fall( horizon_client::landing::FallEvent::Sound { sound, @@ -6243,7 +6245,10 @@ impl Game<'_> { .. }, ) => { - if let Some(name) = sound.player_fall_sound() { + if let Some(name) = sound + .player_fall_sound() + .or_else(|| sound.honey_landing_sound()) + { self.audio.play(name, Some(feet), volume, pitch); } } diff --git a/crates/horizon-client/src/landing.rs b/crates/horizon-client/src/landing.rs index bc0235ecdf..97190975e7 100644 --- a/crates/horizon-client/src/landing.rs +++ b/crates/horizon-client/src/landing.rs @@ -104,6 +104,16 @@ impl SceneSound { _ => None, } } + /// `HoneyBlock.fallOn`: `entity.playSound` of the slide, and of the block's + /// own fall sound when the landing still hurts. A player's `playSound` + /// skips that player; their client plays the same event itself. + pub fn honey_landing_sound(self) -> Option<&'static str> { + match self { + Self::Block(LandingSound::HoneySlide) => Some("minecraft:block.honey_block.slide"), + Self::Block(LandingSound::HoneyFall) => Some("minecraft:block.honey_block.fall"), + _ => None, + } + } fn name(self) -> &'static str { match self { Self::SmallFall => "small", @@ -117,6 +127,16 @@ impl SceneSound { } } } +/// `HoneyBlock.showParticles`: a slide (entity event 53) sheds 5 honey-block +/// crumbs, a landing (entity event 54) sheds 10. +pub fn honey_particle_count(event: i8) -> Option { + match event { + 53 => Some(5), + 54 => Some(10), + _ => None, + } +} + #[derive(Clone, Copy, Debug)] pub enum FallEvent { Particles { @@ -703,4 +723,23 @@ mod tests { ); assert_eq!(SceneSound::BigFall.block_sound(), None); } + + /// `HoneyBlock.fallOn` plays the slide, then the block's fall sound when + /// the landing hurts. `showSlideParticles` is entity event 53 (5 crumbs); + /// `showJumpParticles` is entity event 54 (10). + #[test] + fn a_honey_landing_names_its_sounds_and_particle_counts() { + assert_eq!( + SceneSound::Block(LandingSound::HoneySlide).honey_landing_sound(), + Some("minecraft:block.honey_block.slide") + ); + assert_eq!( + SceneSound::Block(LandingSound::HoneyFall).honey_landing_sound(), + Some("minecraft:block.honey_block.fall") + ); + assert_eq!(SceneSound::SmallFall.honey_landing_sound(), None); + assert_eq!(honey_particle_count(53), Some(5)); + assert_eq!(honey_particle_count(54), Some(10)); + assert_eq!(honey_particle_count(46), None); + } } From d733ed6a3430d5c3b2aa21c4b42b313a88260c61 Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:06:10 +0000 Subject: [PATCH 25/70] test: the bed explosion harness watches a stand, an item and a boat `tools/test_bed_explosion_parity.py` compared one victim: Alice's health after the blast and the push her client is sent. What an explosion does to anything else went unobserved, and `game/explosion.rs` is the one place in the game where the entity side of `ServerExplosion` lives. The scene now puts four things that are not the player in front of the bed head, where no placement puts Alice or the obsidian wall, and gives each of them `NoGravity` so the only motion any of them can get is the blast's own: - `stand`: an armour stand two blocks from the head, - `invisible`: an armour stand with `Invisible:1b` three blocks away (`ArmorStand.ignoreExplosion` asks `isInvisible`), - `item`: a stone item entity four blocks away, - `boat`: an oak boat two blocks to the side. After the blast each one is read back with `/execute if entity` (did it survive), and with `Pos` and `Motion` where it did. `compared()` keeps those answers and gives the lists nine decimals the way `test_server_knockback_parity.py` reads its mob: a blast writes arbitrary bits into `Pos` and `Motion`, and NBT prints the shortest form that round-trips. What the first corpus says in vanilla 26.3, in all twelve cases: the visible armour stand, the item and the boat are destroyed, and the invisible armour stand survives untouched at its summon position with zero motion - the `ArmorStand.ignoreExplosion` rule, seen from the outside. --- tools/test_bed_explosion_parity.py | 52 +++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tools/test_bed_explosion_parity.py b/tools/test_bed_explosion_parity.py index 37ab76c224..5bd9d70f54 100644 --- a/tools/test_bed_explosion_parity.py +++ b/tools/test_bed_explosion_parity.py @@ -6,10 +6,14 @@ full netherite) stands in the Nether on an obsidian floor at y=200 and right-cli each facing (north, south, west, east) she stands in three places: behind the bed's foot, beside the head, and beside the foot with an obsidian block hiding her from the head. Her health after the blast and the push her client is sent are compared exactly (AbstractBedBlock.useWithoutItem, BedBlock.destroyOnUse, ServerExplosion.hurtEntities). +What the blast does to something that is not a player is compared too: an armour stand, an invisible one, an item and +a boat stand in front of the bed's head, and each one is asked whether it survived and, if it did, where it is and how +it moves (`Entity.ignoreExplosion` and `ArmorStand.ignoreExplosion`'s `isInvisible`). """ import os from pathlib import Path import platform +import re import shutil import subprocess import sys @@ -30,6 +34,21 @@ NETHERITE = [f'cmd Alice item replace entity @s armor.{s} with minecraft:netheri for s, p in (('head', 'helmet'), ('chest', 'chestplate'), ('legs', 'leggings'), ('feet', 'boots'))] +# The things that are not the player standing in the blast, by the tag their +# facts are read back under: an armour stand, one made invisible +# (`ArmorStand.ignoreExplosion` asks `isInvisible`), an item and a boat. +VICTIMS = ('stand', 'invisible', 'item', 'boat') + + +def victim_facts(tag): + """What the blast did to one victim: whether it is still there, and its + position and motion if it is.""" + at = f'@e[tag={tag},limit=1]' + return [f'cmd Alice execute if entity {at}', + f'cmd Alice data get entity {at} Pos', + f'cmd Alice data get entity {at} Motion'] + + def scene(index, facing, place): dx, dz = FACINGS[facing] lx, lz = -dz, dx @@ -51,10 +70,21 @@ def scene(index, facing, place): f'cmd Alice setblock {hx} 200 {hz} minecraft:red_bed[facing={facing},part=head]'] if wall: cmds.append(f'cmd Alice setblock {wall[0]} 200 {wall[1]} minecraft:obsidian') + # The victims stand in front of the head, where no placement puts Alice or + # the wall, and none of them carries gravity, so the only motion any of + # them can get is the blast's own. + def spot(along, side): + return f'{hx + dx * along + lx * side}.5 200 {hz + dz * along + lz * side}.5' + cmds += [f'cmd Alice summon minecraft:armor_stand {spot(2, 0)} {{Tags:["stand"],NoGravity:1b}}', + f'cmd Alice summon minecraft:armor_stand {spot(3, 0)} {{Tags:["invisible"],Invisible:1b,NoGravity:1b}}', + f'cmd Alice summon minecraft:item {spot(4, 0)} ' + f'{{Item:{{id:"minecraft:stone",count:1}},Tags:["item"],NoGravity:1b,Age:1s}}', + f'cmd Alice summon minecraft:oak_boat {spot(2, -2)} {{Tags:["boat"],NoGravity:1b}}'] pos = f'{at[0]} 200.0 {at[1]}' return [*cmds, f'cmd Alice tp Alice {pos} 0 0', f'pos Alice {pos} 0 0 1', 'ticks 20', 'cmd Alice effect give @s minecraft:instant_health 1 10 true', 'ticks 5', f'useblock Alice {hx} 200 {hz}', 'ticks 10', 'cmd Alice data get entity @s Health', + *[line for tag in VICTIMS for line in victim_facts(tag)], 'cmd Alice kill @e[type=!player]'] @@ -65,6 +95,21 @@ SETUP = ['cmd Alice gamerule spawn_mobs false', 'cmd Alice gamerule advance_time 'ticks 20'] +DATA = re.compile(r'has the following entity data: (\[.*\])$') + + +def nine(text): + """A list of doubles or floats to 9 decimal places. The blast writes + arbitrary bits into a victim's Pos and Motion, and NBT prints the shortest + form that round-trips, so the raw text of those two is not a stable + comparison (`test_server_knockback_parity` reads its mob the same way). + A scalar (`@s Health`) stays as it was printed.""" + found = DATA.search(text) + if not found: + return text + return 'data ' + ' '.join(f'{float(v.rstrip("df")):.9f}' for v in found.group(1)[1:-1].split(', ')) + + def compared(lines): cases = {} for line in lines: @@ -73,8 +118,13 @@ def compared(lines): if step not in CASES: continue kept = cases.setdefault(step, []) - if 'has the following entity data' in text or text.startswith(('explode push', 'error', 'disconnected')): + if 'has the following entity data' in text: + kept.append(nine(text)) + elif text.startswith(('explode push', 'error', 'disconnected')): kept.append(text) + elif text.removeprefix('chat ').startswith(('Test passed', 'Test failed')): + # `/execute if entity`: whether the victim survived the blast. + kept.append(text.removeprefix('chat ')) return cases From f8dc1e773b79bfa3abd4dfe41b7f1c78a277549f Mon Sep 17 00:00:00 2001 From: TheMrZZ <30944236+TheMrZZ@users.noreply.github.com> Date: Wed, 4 Nov 2026 18:12:27 +0100 Subject: [PATCH 26/70] Apply scoreboard team formatting to mob command names Reuse the existing mob team lookup and team formatter when command feedback names a mob. Preserve its custom name or translated type and the player paths. Extend the real command corpus with guarded zero-tick join, custom-name, prefix/suffix clearing, color-only and leave controls against Java 26.3. Co-Authored-By: Codex gpt-6.1-sol --- crates/horizon-client/src/game/commands.rs | 12 ++-- tools/command_exec_corpus.py | 70 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 3212ad3833..88e1ecc625 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -697,11 +697,13 @@ impl Game<'_> { let block = Text::translate(&format!("block.{block}"), &[]); Text::translate("entity.minecraft.falling_block_type", &[block]) } - Target::Mob(id) => self - .mobs - .iter() - .find(|m| m.id == id) - .map_or_else(|| Text::translate("entity.", &[]), |m| m.command_name()), + Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else( + || Text::translate("entity.", &[]), + |m| match self.mob_team_of(m) { + Some(team) => team.formatted_name(m.command_name()), + None => m.command_name(), + }, + ), other => { // Entity.getDisplayName: the CustomName component, else the type. let custom = match other { diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 0710b39fbe..21b3c1067e 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -128,6 +128,75 @@ def allay_pocket_inventory(): return ops +def mob_team_command_names(): + """Live mob command decoration; no damage, entity ticks or rendering claims.""" + uid = '00000125-0000-0000-0000-000000000001' + team = 'command_red' + objective = 'team_name_cmd' + out = [] + + def check(command, result=1): + out.append(cmd(command, expect_result=result)) + + check('/tick freeze') + check('/gamerule minecraft:spawn_mobs', 0) + out.extend(setup('/forceload remove 0 0')) + check('/forceload add 0 0') + out.append({'op': 'settle'}) + check('/execute if loaded 10 200 10') + check('/setblock 10 199 10 stone') + check('/execute if block 10 199 10 stone') + check(f'/scoreboard objectives add {objective} dummy') + out.extend(setup(f'/execute store result score #before {objective} run time query gametime')) + check('/summon zombie 10.5 200 10.5 {UUID:[I;293,0,0,1],Tags:["command_team_subject"],' + 'NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b}') + + def observe(member, host_member, custom): + # Guard the actual actor and values before absence/default assertions. + check(f'/execute as {uid} if entity @s[type=minecraft:zombie,tag=command_team_subject]') + check(f'/execute if data entity {uid} {{UUID:[I;293,0,0,1],Health:20f,' + 'Pos:[10.5d,200d,10.5d],NoAI:1b,NoGravity:1b,Silent:1b,PersistenceRequired:1b}') + check(f'/data get entity {uid} UUID', 4) + check(f'/data get entity {uid} Pos', 3) + check(f'/data get entity {uid} Health', 20) + check(f'/execute as {uid} {"if" if member else "unless"} entity @s[team={team}]') + check(f'/execute {"if" if host_member else "unless"} entity @s[team={team}]') + if custom: + check(f'/data get entity {uid} CustomName', 3) + else: + out.append(cmd(f'/data get entity {uid} CustomName')) + check(f'/tp {uid} 10.5 200 10.5') + check('/tp @s 8.5 230 8.5') + out.extend(setup(f'/execute store result score #clock {objective} run time query gametime')) + check(f'/scoreboard players operation #clock {objective} -= #before {objective}', 0) + + observe(False, False, False) + check(f'/team add {team}') + check(f'/team modify {team} prefix "[P] "') + check(f'/team modify {team} suffix " [S]"') + check(f'/team modify {team} color red', 0) + check(f'/team join {team} @s') + # The host witnesses the same stored formatting before the mob joins. + observe(False, True, False) + check(f'/team join {team} {uid}') + observe(True, True, False) + check(f'/data merge entity {uid} {{CustomName:"Bob"}}') + observe(True, True, True) + check(f'/team modify {team} prefix ""') + observe(True, True, True) + check(f'/team modify {team} suffix ""') + observe(True, True, True) + check(f'/team leave {uid}') + observe(False, True, True) + check('/team leave @s') + check(f'/data remove entity {uid} CustomName') + observe(False, False, False) + check(f'/team remove {team}', 0) + out.extend(setup(f'/kill {uid}', f'/scoreboard objectives remove {objective}', + '/setblock 10 199 10 air', '/forceload remove 0 0', '/tick unfreeze')) + return out + + def client_commands(): return [ cmd('/version'), @@ -4483,6 +4552,7 @@ def groups(): from loaded_cloud_corpus import loaded_clouds return [ ('setup', group_setup()), + ('mob team command names', mob_team_command_names()), ('mining_efficiency_9021', mining_efficiency_9021()), ('structured item slots', structured_item_slots()), ('loaded potion clouds', loaded_clouds(cmd, setup)), From 1c615062f6f101c3dfad5e2a476e43dcad7c146e Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:17:34 +0100 Subject: [PATCH 27/70] Apply worn mob_visibility when mobs measure detection range. A skeleton skull, zombie head, creeper head or piglin head should halve how far that mob notices the player. getVisibilityPercent multiplies by the item's component, and the targeting paths only scaled sneaking and invisibility. Co-Authored-By: Grok Co-authored-by: Cursor --- .../examples/probe/mob_ai_probe.rs | 3 + crates/horizon-client/src/game/creatures.rs | 3 + crates/horizon-client/src/game/lan_mobs.rs | 3 + crates/horizon-client/src/mobs.rs | 140 +++++++++++++++++- .../horizon-client/src/mobs/animal_brain.rs | 2 +- crates/horizon-client/src/mobs/cube.rs | 3 + crates/horizon-client/src/mobs/goals.rs | 22 +-- crates/horizon-client/src/mobs/happy_ghast.rs | 3 +- crates/horizon-client/src/mobs/piglin.rs | 2 +- crates/horizon-client/src/mobs/world.rs | 3 + crates/horizon-items/src/items.rs | 79 ++++++++++ 11 files changed, 241 insertions(+), 22 deletions(-) diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 690b04dbc6..3232985f8c 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -1130,6 +1130,9 @@ pub fn main() -> horizon_client::Result<()> { main_hand: s.hand.as_deref().map(Into::into), off_hand: None, head: None, + chest: None, + legs: None, + feet: None, armor_cover: 0.0, invisible: false, spectator: false, diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index bf144b502e..2a20e0e50f 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -325,6 +325,9 @@ impl Game<'_> { main_hand: self.held().map(|s| s.item.clone()), off_hand: self.offhand.as_ref().map(|s| s.item.clone()), head: self.armor[0].as_ref().map(|s| s.item.clone()), + chest: self.armor[1].as_ref().map(|s| s.item.clone()), + legs: self.armor[2].as_ref().map(|s| s.item.clone()), + feet: self.armor[3].as_ref().map(|s| s.item.clone()), armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0, invisible: self.potions.effects.get("invisibility").is_some(), spectator: self.spectator(), diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index bcab2be97e..c9a49847ff 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -476,6 +476,9 @@ mod tests { main_hand: None, off_hand: None, head: None, + chest: None, + legs: None, + feet: None, armor_cover: 0.0, invisible: false, spectator: false, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 57f3492e6e..951127fee3 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -2662,6 +2662,10 @@ pub struct PlayerView { pub main_hand: Option>, pub off_hand: Option>, pub head: Option>, + /// `getItemBySlot` for chest, legs and feet. + pub chest: Option>, + pub legs: Option>, + pub feet: Option>, pub armor_cover: f32, /// The Invisibility effect (`isInvisible`). pub invisible: bool, @@ -2710,8 +2714,28 @@ pub struct Vehicle { pub velocity: Vec3, } impl PlayerView { - /// `LivingEntity.getVisibilityPercent` without the worn-skull scaling. - pub fn visibility_percent(&self) -> f64 { + /// Worn stacks in `EquipmentSlot.VALUES` order. A player has no body or + /// saddle slot. + fn worn_slots(&self) -> [(&'static str, Option<&str>); 6] { + [ + ("mainhand", self.main_hand.as_deref()), + ("offhand", self.off_hand.as_deref()), + ("feet", self.feet.as_deref()), + ("legs", self.legs.as_deref()), + ("chest", self.chest.as_deref()), + ("head", self.head.as_deref()), + ] + } + /// `LivingEntity.getVisibilityPercent`: 1, ร—0.8 while discrete + /// (`isShiftKeyDown`), ร—0.7ร—armor cover (at least 0.1) while invisible, + /// then each worn stack's `minecraft:mob_visibility` when its equippable + /// slot matches and `targeting` is in `targetingEntityTypes`. Clamped to + /// 0..=10 (`Mth.clamp`). `targeting` is null when nobody is looking. + pub fn visibility_percent( + &self, + targeting: Option<&str>, + items: Option<&crate::items::Items>, + ) -> f64 { let mut visibility = 1.0; if self.sneaking { visibility *= 0.8; @@ -2719,7 +2743,20 @@ impl PlayerView { if self.invisible { visibility *= 0.7 * f64::from(self.armor_cover).max(0.1); } - visibility + if let (Some(targeting), Some(items)) = (targeting, items) { + for (slot, item) in self.worn_slots() { + let Some(item) = item else { continue }; + if let Some(factor) = items.worn_visibility(item, slot, targeting) { + visibility *= f64::from(factor); + } + } + } + // `Mth.clamp`: value < min ? min : Math.min(value, max). + if visibility < 0.0 { + 0.0 + } else { + visibility.min(10.0) + } } /// `LivingEntity.hasEffect`, by id without namespace. pub fn has_effect(&self, effect: &str) -> bool { @@ -5364,3 +5401,100 @@ mod silent_voice_tests { assert_eq!(quiet_next, loud_next, "the pitch is drawn either way"); } } + +#[cfg(test)] +mod visibility_percent_tests { + use super::*; + use serde_json::json; + + /// The four heads `Items` registers, with the component JSON vanilla writes. + fn heads() -> crate::items::Items { + let mut items = crate::items::Items::for_tests(HashMap::new()); + items.insert_mob_visibility_component( + "minecraft:skeleton_skull", + &json!({ + "minecraft:equippable": {"slot": "head", "swappable": false}, + "minecraft:mob_visibility": { + "targeting_entity_types": "minecraft:skeleton", + "visibility": 0.5 + } + }), + ); + items.insert_mob_visibility_component( + "minecraft:zombie_head", + &json!({ + "minecraft:equippable": {"slot": "head"}, + "minecraft:mob_visibility": { + "targeting_entity_types": "minecraft:zombie", + "visibility": 0.5 + } + }), + ); + items.insert_mob_visibility_component( + "minecraft:creeper_head", + &json!({ + "minecraft:equippable": {"slot": "head"}, + "minecraft:mob_visibility": { + "targeting_entity_types": "minecraft:creeper", + "visibility": 0.5 + } + }), + ); + items.insert_mob_visibility_component( + "minecraft:piglin_head", + &json!({ + "minecraft:equippable": {"slot": "head"}, + "minecraft:mob_visibility": { + "targeting_entity_types": ["minecraft:piglin", "minecraft:piglin_brute"], + "visibility": 0.5 + } + }), + ); + items + } + + /// `LivingEntity.getVisibilityPercent` halves the range for the mob named + /// on the worn head, and leaves every other mob at full range. A skull + /// held in the hand does not count: its equippable slot is the head. + #[test] + fn worn_mob_heads_halve_that_mobs_detection_range() { + let items = heads(); + let see = |head: &str, mob: &str| { + let mut player = world::test_player(Vec3::ZERO); + player.head = Some(Arc::from(head)); + player.visibility_percent(Some(mob), Some(&items)) + }; + assert_eq!(see("minecraft:skeleton_skull", "minecraft:skeleton"), 0.5); + assert_eq!(see("minecraft:zombie_head", "minecraft:zombie"), 0.5); + assert_eq!(see("minecraft:creeper_head", "minecraft:creeper"), 0.5); + assert_eq!(see("minecraft:piglin_head", "minecraft:piglin"), 0.5); + assert_eq!(see("minecraft:piglin_head", "minecraft:piglin_brute"), 0.5); + assert_eq!(see("minecraft:skeleton_skull", "minecraft:zombie"), 1.0); + assert_eq!( + see("minecraft:piglin_head", "minecraft:zombified_piglin"), + 1.0 + ); + assert_eq!( + see( + "minecraft:wither_skeleton_skull", + "minecraft:wither_skeleton" + ), + 1.0 + ); + + let mut held = world::test_player(Vec3::ZERO); + held.main_hand = Some(Arc::from("minecraft:skeleton_skull")); + assert_eq!( + held.visibility_percent(Some("minecraft:skeleton"), Some(&items)), + 1.0 + ); + + let mut sneaking = world::test_player(Vec3::ZERO); + sneaking.head = Some(Arc::from("minecraft:skeleton_skull")); + sneaking.sneaking = true; + assert_eq!( + sneaking.visibility_percent(Some("minecraft:skeleton"), Some(&items)), + 0.4 + ); + } +} diff --git a/crates/horizon-client/src/mobs/animal_brain.rs b/crates/horizon-client/src/mobs/animal_brain.rs index 2815e07d44..bb67eeedf9 100644 --- a/crates/horizon-client/src/mobs/animal_brain.rs +++ b/crates/horizon-client/src/mobs/animal_brain.rs @@ -645,7 +645,7 @@ fn targetable(m: &mut Mob, w: &MobWorld, who: Who) -> bool { // TargetingConditions.forNonCombat(): in range (scaled by the // player's visibility, at least 2) and in sight. let Some(p) = &w.player else { return false }; - let r = (range * p.visibility_percent()).max(2.0); + let r = (range * p.visibility_percent(Some(m.kind.id.as_str()), w.items)).max(2.0); p.alive && !p.spectator && m.distance_sqr(p.position) <= r * r && m.sees(w, who) } Who::Mob(id) => { diff --git a/crates/horizon-client/src/mobs/cube.rs b/crates/horizon-client/src/mobs/cube.rs index bcac9c9307..ad9e2b6af3 100644 --- a/crates/horizon-client/src/mobs/cube.rs +++ b/crates/horizon-client/src/mobs/cube.rs @@ -532,6 +532,9 @@ mod golem_touch_tests { main_hand: None, off_hand: None, head: None, + chest: None, + legs: None, + feet: None, armor_cover: 0.0, invisible: false, spectator: false, diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 5b7dcc52dc..ecd3bac6ac 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -753,22 +753,12 @@ impl Mob { if !p.alive || p.spectator { return false; } - // LivingEntity.getVisibilityPercent - let mut visibility = if scale { p.visibility_percent() } else { 1.0 }; - if let Some(head) = p.head.as_ref().filter(|_| scale) { - let skull = match self.species() { - Species::Zombie | Species::Husk | Species::Drowned | Species::ZombieVillager => { - "minecraft:zombie_head" - } - Species::Skeleton | Species::Stray | Species::Bogged => "minecraft:skeleton_head", - Species::Creeper => "minecraft:creeper_head", - Species::Piglin | Species::PiglinBrute => "minecraft:piglin_head", - _ => "", - }; - if &**head == skull { - visibility *= 0.5; - } - } + // LivingEntity.getVisibilityPercent, including worn mob_visibility. + let visibility = if scale { + p.visibility_percent(Some(self.kind.id.as_str()), w.items) + } else { + 1.0 + }; let dist = (range * visibility).max(2.0); if self.distance_sqr(p.position) > dist * dist { return false; diff --git a/crates/horizon-client/src/mobs/happy_ghast.rs b/crates/horizon-client/src/mobs/happy_ghast.rs index eb00144832..e7cff8d728 100644 --- a/crates/horizon-client/src/mobs/happy_ghast.rs +++ b/crates/horizon-client/src/mobs/happy_ghast.rs @@ -380,7 +380,8 @@ impl Goal for GhastTempt { let Some(p) = &cx.w.player else { return false }; // getNearestPlayer(TargetingConditions.forNonCombat() // .ignoreLineOfSight().range(TEMPT_RANGE 16)): creative players too. - let range = (16.0_f64 * p.visibility_percent()).max(2.0); + let range = + (16.0_f64 * p.visibility_percent(Some(m.kind.id.as_str()), cx.w.items)).max(2.0); p.alive && !p.spectator && (Self::wants(m, &p.main_hand) || Self::wants(m, &p.off_hand)) diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index 5110f99ed1..7f453c12c3 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -140,7 +140,7 @@ fn visible(m: &mut Mob, w: &MobWorld, who: Who) -> bool { match who { Who::Player => { let Some(p) = &w.player else { return false }; - let r = (range * p.visibility_percent()).max(2.0); + let r = (range * p.visibility_percent(Some(m.kind.id.as_str()), w.items)).max(2.0); p.alive && !p.spectator && m.distance_sqr(p.position) <= r * r && m.sees(w, who) } Who::Mob(id) => { diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 6db22a14be..032b8d34df 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -520,6 +520,9 @@ pub(crate) fn test_player(position: Vec3) -> super::PlayerView { main_hand: None, off_hand: None, head: None, + chest: None, + legs: None, + feet: None, armor_cover: 0.0, invisible: false, spectator: false, diff --git a/crates/horizon-items/src/items.rs b/crates/horizon-items/src/items.rs index cc2d0cfa17..c8e5babb9b 100644 --- a/crates/horizon-items/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -365,6 +365,9 @@ thread_local! { } pub struct Items { pub items: HashMap, ItemInfo>, + /// Default `minecraft:mob_visibility` by item id, with the `equippable` + /// slot it applies in (`LivingEntity.getVisibilityPercent`). + mob_visibility: HashMap, (String, MobVisibility)>, pub order: Vec>, /// The same items in item registry (network id) order, as vanilla lists /// them; sorted by name when the registry export is missing. @@ -591,6 +594,56 @@ fn entity_spec(tags: &HashMap>, spec: &str) -> HashSet HashSet::from([spec.to_owned()]), } } +/// `MobVisibility`: entity types a worn item hides its wearer from, and the +/// factor `LivingEntity.getVisibilityPercent` multiplies by. +#[derive(Clone, Debug)] +pub struct MobVisibility { + pub targeting: HashSet, + pub visibility: f32, +} +/// `minecraft:mob_visibility` (`targeting_entity_types` is one id, a `#tag`, +/// or a list) when the object is present and names at least one type. +fn parse_mob_visibility( + v: &Value, + tags: &HashMap>, +) -> Option { + if !v.is_object() { + return None; + } + let visibility = v.get("visibility")?.as_f64()? as f32; + let targeting = match v.get("targeting_entity_types")? { + Value::String(spec) => entity_spec(tags, spec), + Value::Array(list) => list + .iter() + .filter_map(Value::as_str) + .flat_map(|spec| entity_spec(tags, spec)) + .collect(), + _ => return None, + }; + if targeting.is_empty() { + return None; + } + Some(MobVisibility { + targeting, + visibility, + }) +} +/// The item's `equippable.slot` and `mob_visibility`, when both are set. +/// `getVisibilityPercent` multiplies only when the stack is in that slot. +fn worn_mob_visibility( + components: &Value, + tags: &HashMap>, +) -> Option<(String, MobVisibility)> { + let slot = components + .get("minecraft:equippable")? + .get("slot")? + .as_str()? + .to_owned(); + Some(( + slot, + parse_mob_visibility(components.get("minecraft:mob_visibility")?, tags)?, + )) +} /// Every item of the last item registry loaded with its `villager_food` /// nutrition (0 without the component): what the mobs' villagers read, since /// a mob does not hold the registry (`None` until one is loaded). @@ -706,6 +759,7 @@ impl Items { }; let mut items = HashMap::new(); let mut order = Vec::new(); + let mut mob_visibility = HashMap::new(); for (name, c) in raw.iter() { let id: Arc = Arc::from(name.as_str()); let tool = c["minecraft:tool"]["rules"] @@ -911,6 +965,9 @@ impl Items { attack_speed: modifier("minecraft:attack_speed"), }, ); + if let Some(parsed) = worn_mob_visibility(c, &entity_tags) { + mob_visibility.insert(id.clone(), parsed); + } order.push(id); } // Every loot table, keyed by its id (blocks/stone, entities/sheep/white, @@ -922,6 +979,7 @@ impl Items { set_villager_food(&items); Ok(Self { items, + mob_visibility, order, registry_order, block_tags: load_tags(root, "block", overlay)?, @@ -936,6 +994,25 @@ impl Items { pub fn get(&self, id: &str) -> Option<&ItemInfo> { self.items.get(id) } + /// `ItemStack.get(DataComponents.MOB_VISIBILITY).visibility()` when the + /// prototype's equippable slot is `slot` and `targeting` is in + /// `targetingEntityTypes`. None otherwise (`getVisibilityPercent` skips it). + pub fn worn_visibility(&self, item: &str, slot: &str, targeting: &str) -> Option { + let (equip, vis) = self.mob_visibility.get(item)?; + if equip == slot && vis.targeting.contains(targeting) { + Some(vis.visibility) + } else { + None + } + } + /// Record one prototype's `equippable` and `mob_visibility` the way `load` + /// reads them from the default-component report. + #[cfg(any(test, feature = "test-support"))] + pub fn insert_mob_visibility_component(&mut self, id: &str, components: &Value) { + if let Some(parsed) = worn_mob_visibility(components, &HashMap::new()) { + self.mob_visibility.insert(Arc::from(id), parsed); + } + } /// The instrument a goat horn plays: its `instrument` component's /// registry entry or inline instrument. pub fn instrument(&self, stack: &ItemStack) -> Option { @@ -952,6 +1029,7 @@ impl Items { pub fn tags_only(root: &Path, overlay: &PackOverlay) -> Result { Ok(Self { items: HashMap::new(), + mob_visibility: HashMap::new(), order: Vec::new(), registry_order: Vec::new(), block_tags: load_tags(root, "block", overlay)?, @@ -968,6 +1046,7 @@ impl Items { pub fn for_tests(item_tags: HashMap>) -> Self { Self { items: HashMap::new(), + mob_visibility: HashMap::new(), order: Vec::new(), registry_order: Vec::new(), block_tags: HashMap::new(), From 553d0d26e9c9b9c5b0f1dd0fcc36988ea9839b8c Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:22:59 +0100 Subject: [PATCH 28/70] Swim frogs with Frog.travelInWater instead of generic fluid travel. A frog in water was using LivingEntity.travelInFluid, so its speed and drag did not match vanilla. Co-Authored-By: Grok Co-authored-by: Cursor --- crates/horizon-client/src/mobs/ai.rs | 3 ++ crates/horizon-client/src/mobs/frog.rs | 47 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 4e8bf57449..7a552c479e 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -2725,6 +2725,9 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec aquatic::travel(m, w, input, events), Family::Guardian => super::guardian::travel(m, w, input, events), diff --git a/crates/horizon-client/src/mobs/frog.rs b/crates/horizon-client/src/mobs/frog.rs index 4b5532aee5..fd3b5ea0d0 100644 --- a/crates/horizon-client/src/mobs/frog.rs +++ b/crates/horizon-client/src/mobs/frog.rs @@ -162,6 +162,18 @@ pub fn tick(m: &mut Mob) { let still = m.fluids.in_water && m.limb_speed <= 1.0e-5; m.anim.states[SWIM_IDLE].animate_when(still, age); } +/// `Frog.travelInWater`: `moveRelative(getSpeed(), input)`, the move, then +/// the motion scaled by 0.9. `getSpeed` is the speed the move control stored. +/// Out of water the usual travel runs. +pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec) -> bool { + if !m.fluids.in_water { + return false; + } + m.move_relative(m.ai.speed, input); + m.do_move(w, events); + m.velocity = m.velocity.scale(0.9); + true +} /// `Frog.canEat`: a slime or magma cube of size 1. /// `Frog.updateWalkAnimation`: the frog's short steps walk the legs 25 times @@ -980,6 +992,41 @@ mod tests { Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0) } + /// `Frog.travelInWater`: the move uses `getSpeed` (the speed the move + /// control stored, here 0.25, not the movement attribute 1.0 and not the + /// turtle's 0.1), then the whole motion is scaled by 0.9. Out of water + /// this path does not run. + #[test] + fn water_travel_uses_get_speed_and_scales_by_nine_tenths() { + let ran = crate::mobs::world::with_test_world(&[], |w, _| { + let mut m = frog(); + m.attrs.speed = 1.0; + m.ai.speed = 0.25; + m.yaw = 0.0; + m.velocity = Vec3::new(1.0, -0.5, 0.125); + let kept = m.velocity; + assert!(!travel( + &mut m, + w, + Vec3::new(0.0, 0.0, 1.0), + &mut Vec::new() + )); + assert_eq!(m.velocity, kept); + + m.fluids.in_water = true; + m.position = Vec3::new(0.5, 80.0, 0.5); + m.travel(w, Vec3::new(0.0, 0.0, 1.0), &mut Vec::new()); + // (1, -0.5, 0.125) + (0, 0, getSpeed 0.25) = (1, -0.5, 0.375), + // then * 0.9 = (0.9, -0.45, 0.3375). + assert!((m.velocity.x - 0.9).abs() < 1.0e-9, "{}", m.velocity.x); + assert!((m.velocity.y - -0.45).abs() < 1.0e-9, "{}", m.velocity.y); + assert!((m.velocity.z - 0.3375).abs() < 1.0e-9, "{}", m.velocity.z); + }); + if !ran { + return; + } + } + /// `Frog.updateWalkAnimation`: target `min(distance * 25, 1)`, 0 while /// the jump animation is started, eased by 0.4 (`WalkAnimationState.update`). #[test] From 34f506d0d1c4dd9665cc20316b1f90e77b145727 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:25:28 +0100 Subject: [PATCH 29/70] A named item frame drops with its custom name. ItemFrame.getFrameItemStackWithData copies the entity name onto the frame item before dropItem spawns it. Glow item frames use that same stack. Co-Authored-By: Grok Co-authored-by: Cursor --- crates/horizon-client/src/entities.rs | 12 ++++++ crates/horizon-client/src/entity_nbt.rs | 42 +++++++++++++++++++ crates/horizon-client/src/game/frames.rs | 28 +++++++------ .../horizon-client/src/game/remote_objects.rs | 1 + tools/port_map/game2-002.tsv | 2 +- 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 36121cdf4f..c0c1ef13e8 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -1550,6 +1550,8 @@ pub struct ItemFrame { pub fixed: bool, pub invisible: bool, pub check_interval: u32, + /// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame. + pub custom_name: Option, } impl ItemFrame { pub fn step(&self) -> [i32; 3] { @@ -1579,6 +1581,16 @@ impl ItemFrame { std::array::from_fn(|i| c[i] + size[i] / 2.0), ) } + /// `ItemFrame.getFrameItemStackWithData`: `getFrameItemStack` (a glow + /// frame's own item), then `DataComponents.CUSTOM_NAME` from + /// `getCustomName`. + pub fn item_stack_with_data(&self) -> ItemStack { + let mut item_stack = ItemStack::new(self.kind(), 1); + if let Some(name) = &self.custom_name { + item_stack.data_mut().custom_name = Some(name.clone()); + } + item_stack + } } /// An armor stand (`ArmorStand`). #[derive(Clone, Debug)] diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 2122d07308..8b6d7aa98b 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -3124,6 +3124,12 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta fall_distance: 0.0, }; let mut tag = base.tag(raw); + // `Entity.saveWithoutId`: `CustomName` before the frame's own fields. + if let Some(name) = &f.custom_name { + put(&mut tag, "CustomName", name.clone()); + } else if let Tag::Compound(map) = &mut tag { + map.remove(&"CustomName".into()); + } put(&mut tag, "Facing", Tag::Byte(f.facing as i8)); put(&mut tag, "block_pos", Tag::IntArray(f.pos.to_vec())); put(&mut tag, "ItemRotation", Tag::Byte(f.rotation as i8)); @@ -3155,6 +3161,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option { fixed: flag(tag, "Fixed"), invisible: flag(tag, "Invisible"), check_interval: 0, + custom_name: tag.get("CustomName").cloned(), }) } /// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`. @@ -6192,6 +6199,41 @@ mod fireball_nbt_tests { assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]); } + #[test] + fn a_named_frame_drops_with_its_custom_name() { + // `ItemFrame.getFrameItemStackWithData`: the frame item, then + // `DataComponents.CUSTOM_NAME` from `getCustomName`. Glow frames + // share it through `GlowItemFrame.getFrameItemStack`. + for id in ["minecraft:item_frame", "minecraft:glow_item_frame"] { + let tag = Tag::compound([ + ("id", string(id)), + ("Pos", doubles(&[0.5, 64.0, 0.5])), + ("Facing", Tag::Byte(2)), + ("CustomName", string("Gallery")), + ]); + let frame = item_frame_from_nbt(&tag, 1).unwrap(); + let stack = frame.item_stack_with_data(); + assert_eq!(&*stack.item, id); + assert_eq!(stack.count, 1); + assert_eq!(stack.custom_name().as_deref(), Some("Gallery")); + let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], None); + assert_eq!(text(saved.get("CustomName")).as_deref(), Some("Gallery")); + } + let bare = Tag::compound([ + ("id", string("minecraft:item_frame")), + ("Pos", doubles(&[0.5, 64.0, 0.5])), + ("Facing", Tag::Byte(3)), + ]); + let frame = item_frame_from_nbt(&bare, 2).unwrap(); + assert!(frame.custom_name.is_none()); + assert!(frame.item_stack_with_data().custom_name().is_none()); + assert!( + item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&bare)) + .get("CustomName") + .is_none() + ); + } + #[test] fn projectile_shot_flag_round_trips_independently_of_age() { for id in [ diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index dc2cf11e22..0f6b6a17c8 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -10,8 +10,6 @@ use horizon_core::{ position::BlockPos, random::RandomSource, }; -use std::sync::Arc; - /// A baked frame model: (corners in block units, unit UVs, sprite) per quad. pub(super) type FrameModel = Vec<([[f32; 3]; 4], [[f32; 2]; 4], String)>; @@ -165,12 +163,21 @@ impl Game<'_> { "minecraft:glow_item_frame" => true, _ => return false, }; - let invisible = stack.extra.as_ref().is_some_and(|extra| { - extra.other.iter().any(|(k, v)| { - (k == "minecraft:entity_data" || k == "entity_data") - && horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0 + let entity_data = stack.extra.as_ref().and_then(|extra| { + extra.other.iter().find_map(|(k, v)| { + (k == "minecraft:entity_data" || k == "entity_data").then_some(v) }) }); + let invisible = entity_data + .and_then(|v| horizon_client::save::number(v.get("Invisible"))) + .unwrap_or(0) + != 0; + // `applyComponentsFromItemStack` sets `CUSTOM_NAME`, then `entity_data` + // loads (`updateCustomEntityTag`): a missing `CustomName` clears it. + let custom_name = match entity_data { + Some(data) => data.get("CustomName").cloned(), + None => stack.data().and_then(|d| d.custom_name.clone()), + }; let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]); // `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not // placed on the top face of the highest block or under the lowest one. @@ -191,6 +198,7 @@ impl Game<'_> { fixed: false, invisible, check_interval: 0, + custom_name, }; if !self.frame_survives(&frame) { return false; @@ -384,12 +392,8 @@ impl Game<'_> { let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15]; let mut drops = Vec::new(); if with_frame { - drops.push(ItemStack { - item: Arc::from(f.kind()), - count: 1, - damage: 0, - extra: None, - }); + // `ItemFrame.getFrameItemStackWithData` (glow frames share it). + drops.push(f.item_stack_with_data()); } if let Some(item) = f.item.clone() && self.world.random.next_float() < f.drop_chance diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index dee282ddc3..3e9ee0fa51 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -112,6 +112,7 @@ impl Game<'_> { fixed: false, invisible: false, check_interval: 0, + custom_name: None, }); } else if kind == "minecraft:cushion" { // BlockAttachedEntity.pos: `BlockPos.containing` of the spawn. diff --git a/tools/port_map/game2-002.tsv b/tools/port_map/game2-002.tsv index c4c4f53e16..f655feff40 100644 --- a/tools/port_map/game2-002.tsv +++ b/tools/port_map/game2-002.tsv @@ -9,7 +9,7 @@ ItemFrame.getSlot inline crates/horizon-client/src/game/commands/items.rs::Game: ItemFrame.onSyncedDataUpdated skip:other network data-sync hook; the item is read from the frame field on demand ItemFrame.setRotation inline crates/horizon-client/src/game/frames.rs::Game::interact_frame the rotate-by-one step; the neighbour update is in frame_output_changed ItemFrame.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::item_frame_from_nbt gap: minor: Facing defaults to 2 where vanilla defaults to DOWN -ItemFrame.getFrameItemStackWithData missing `game/frames.rs::drop_frame_items` builds a plain `item_frame` stack, so a broken frame never carries its custom name +ItemFrame.getFrameItemStackWithData port crates/horizon-client/src/entities.rs::ItemFrame::item_stack_with_data drop_frame_items spawns that stack, so a broken item frame or glow item frame keeps its custom name ItemFrame.getVisualRotationYInDegrees inline crates/horizon-client/src/game/frames.rs::Game::frame_geometry the 45 degree step per rotation step is drawn in frame_geometry LeashFenceKnotEntity.recalculateBoundingBox port crates/horizon-client/src/mobs/leash.rs::knot_state position and box from the block position LeashFenceKnotEntity.getOrCreateKnot inline crates/horizon-client/src/game/leads.rs::Game::interact_knot knots are not entities in game; the knot is used when a lead is tied From 0cfb9167a582791a5b64d85d0a937e60580ab7ac Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:25:42 +0100 Subject: [PATCH 30/70] Shove a rideable minecart that overlaps an armor stand. ArmorStand.pushEntities moves a plain minecart inside the stand when it is within 0.2, and the stand tick never did, so the cart stayed put. Co-Authored-By: Grok Co-authored-by: Cursor --- .../horizon-client/src/game/armor_stands.rs | 169 ++++++++++++++++++ crates/horizon-client/src/game/minecarts.rs | 2 +- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index afab8d1f6c..30c3f30898 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -21,6 +21,48 @@ use horizon_core::{ }; use std::{collections::HashMap, path::PathBuf, sync::Arc}; +/// Velocity `AbstractMinecart.push` adds: the cart, and a quarter on the stand. +#[derive(Clone, Copy, Debug, PartialEq)] +struct CartShove { + cart_x: f64, + cart_z: f64, + stand_x: f64, + stand_z: f64, +} +/// `ArmorStand.pushEntities` for one cart. `None` when the cart is not the +/// plain rideable kind, the boxes miss, `distanceToSqr` is past 0.2, the +/// stand is a passenger of the cart, or the horizontal gap is under +/// `1.0E-4F` (where `AbstractMinecart.push` does nothing). +fn armor_stand_cart_shove( + stand_pos: Vec3, + stand_box: Aabb, + cart_pos: Vec3, + cart_box: Aabb, + kind: super::minecarts::CartKind, + cart_carries_stand: bool, +) -> Option { + if kind != super::minecarts::CartKind::Rideable + || cart_carries_stand + || !stand_box.intersects(cart_box) + { + return None; + } + let (dx, dy, dz) = ( + stand_pos.x - cart_pos.x, + stand_pos.y - cart_pos.y, + stand_pos.z - cart_pos.z, + ); + if dx * dx + dy * dy + dz * dz > 0.2 { + return None; + } + let (xa, za) = super::minecarts::cart_push_from(stand_pos, cart_pos)?; + Some(CartShove { + cart_x: -xa, + cart_z: -za, + stand_x: xa / 4.0, + stand_z: za / 4.0, + }) +} /// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a /// colour when undyed) takes the stack's `minecraft:dyed_color`, else that /// colour; any other layer stays white, dyed or not. The result is in the @@ -879,6 +921,9 @@ impl Game<'_> { true, ); } + // LivingEntity.aiStep calls pushEntities after travel. The stand's + // own doPush is empty; only this shove runs. + self.push_rideable_minecarts(i); let a = &mut self.armor_stands[i]; if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) { dead.push(i); @@ -889,6 +934,50 @@ impl Game<'_> { } Ok(()) } + /// `ArmorStand.pushEntities`: a plain minecart (`RIDABLE_MINECARTS`, + /// `AbstractMinecart.isRideable`) whose box meets the stand's is shoved + /// when `distanceToSqr` is at most 0.2. `AbstractMinecart.push` of a + /// non-cart adds the push to the cart and a quarter of it to the stand + /// (`Entity.push(double)` does not consult `isPushable`). A stand with no + /// physics (`Marker` or `NoGravity`, `ArmorStand.hasPhysics` / `noPhysics`) + /// or one riding that cart does not shove it. + fn push_rideable_minecarts(&mut self, i: usize) { + let stand = &self.armor_stands[i]; + if stand.marker || stand.no_gravity { + return; + } + let stand_pos = stand.position; + let stand_box = stand.bounds(); + let vehicle = stand.vehicle; + let mut applied = Vec::new(); + for (j, cart) in self.carts.iter().enumerate() { + let Some(shove) = armor_stand_cart_shove( + stand_pos, + stand_box, + cart.position, + cart.bounds(), + cart.kind, + vehicle == Some(cart.id), + ) else { + continue; + }; + applied.push((j, shove)); + } + let mut stand_x = 0.0; + let mut stand_z = 0.0; + for (j, shove) in applied { + let cart = &mut self.carts[j]; + cart.velocity.x += shove.cart_x; + cart.velocity.z += shove.cart_z; + stand_x += shove.stand_x; + stand_z += shove.stand_z; + } + if stand_x != 0.0 || stand_z != 0.0 { + let stand = &mut self.armor_stands[i]; + stand.velocity.x += stand_x; + stand.velocity.z += stand_z; + } + } /// An armor stand is a `LivingEntity`, so the egg under it /// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one /// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick @@ -1683,6 +1772,86 @@ mod burn_drop_tests { } } #[cfg(test)] +mod push_tests { + use super::super::minecarts::{CartKind, Minecart}; + use super::*; + + fn boxes(kind: CartKind, cart_at: Vec3) -> (Aabb, Aabb) { + let stand = ArmorStand::new(1, Vec3::ZERO, 0.0); + let cart = Minecart::new(2, kind, cart_at); + (stand.bounds(), cart.bounds()) + } + + #[test] + fn a_rideable_minecart_inside_the_0_2_gate_is_shoved() { + let near = Vec3::new(0.25, 0.0, 0.0); + let far = Vec3::new(0.5, 0.0, 0.0); + // Written here, not taken from the shove: 0.25ยฒ is inside 0.2, 0.5ยฒ is not. + assert!(0.25 * 0.25 <= 0.2); + assert!(0.5 * 0.5 > 0.2); + let (stand_box, cart_box) = boxes(CartKind::Rideable, near); + let shove = armor_stand_cart_shove( + Vec3::ZERO, + stand_box, + near, + cart_box, + CartKind::Rideable, + false, + ) + .expect("a rideable cart 0.25 away is shoved"); + // AbstractMinecart.push: gap 0.25, length 0.25, pow capped at 1, then 0.1F * 0.5. + // The cart moves east, away from the stand; the stand takes a quarter west. + let scale = f64::from(0.1_f32) * 0.5; + assert_eq!(shove.cart_x, scale); + assert_eq!(shove.cart_z, 0.0); + assert_eq!(shove.stand_x, -scale / 4.0); + assert_eq!(shove.stand_z, 0.0); + + let (stand_box, cart_box) = boxes(CartKind::Rideable, far); + assert!( + armor_stand_cart_shove( + Vec3::ZERO, + stand_box, + far, + cart_box, + CartKind::Rideable, + false + ) + .is_none(), + "past 0.2 the cart stays" + ); + for kind in [ + CartKind::Chest, + CartKind::Hopper, + CartKind::Furnace, + CartKind::Tnt, + CartKind::CommandBlock, + CartKind::Spawner, + ] { + let (stand_box, cart_box) = boxes(kind, near); + assert!( + armor_stand_cart_shove(Vec3::ZERO, stand_box, near, cart_box, kind, false) + .is_none(), + "{} is not a rideable minecart", + kind.id() + ); + } + let (stand_box, cart_box) = boxes(CartKind::Rideable, near); + assert!( + armor_stand_cart_shove( + Vec3::ZERO, + stand_box, + near, + cart_box, + CartKind::Rideable, + true + ) + .is_none(), + "a cart does not shove the stand riding it" + ); + } +} +#[cfg(test)] mod layer_color_tests { use super::*; #[test] diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 6e877ecb78..ce542b6f5d 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -218,7 +218,7 @@ fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option { } /// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`: /// the push the cart loses (the entity gets a quarter of it back). -fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> { +pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> { let (xa, za) = (feet.x - cart.x, feet.z - cart.z); let dd = xa * xa + za * za; if dd < f64::from(1.0e-4f32) { From 28778ffc3bd9b85dc3cc0141ba8f5282af3d9a02 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:27:23 +0100 Subject: [PATCH 31/70] client(fireworks): free leads when a glide boost starts A firework used while fall-flying has to drop every lead on the player and play the snap, or the mob stays tied and nobody hears it. Co-authored-by: Cursor Co-Authored-By: Grok --- crates/horizon-client/src/game/fireworks.rs | 111 +++++++++++++++++++- crates/horizon-client/src/game/leads.rs | 32 ++++++ tools/port_map/game-005.tsv | 2 +- 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 9bc01882ce..0eee3cbd47 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -345,13 +345,23 @@ impl Game<'_> { self.particles .add(&ParticleOptions::simple(ParticleType::Firework), at, v); } - /// `FireworkRocketItem.use` while gliding: a rocket attached to the player. + /// `FireworkRocketItem.use` while gliding: drop the player's leads, then a + /// rocket attached to the player. pub(super) fn boost_firework(&mut self, stack: &ItemStack) -> bool { if &*stack.item != "minecraft:firework_rocket" || !self.player.as_ref().is_some_and(|p| p.fall_flying) { return false; } + // Server path of `use`: `dropAllLeashConnections(null)`, and when that + // drops anything `playSound(null, player, LEAD_BREAK, NEUTRAL, 1, 1)`. + // `SoundEvents.LEAD_BREAK` is `minecraft:item.lead.break`. A position + // is what the server thread forwards, so the window hears the snap. + if self.drop_all_leash_connections() + && let Some(p) = self.player.as_ref() + { + self.lead_sound("break", [p.position.x, p.position.y, p.position.z]); + } let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position); let mut rocket = self.new_rocket(at, stack, true); rocket.owner = Some(Who::Player); @@ -759,5 +769,104 @@ mod tests { assert!(super::use_on_passes(true)); assert!(!super::use_on_passes(false)); } + + /// `FireworkRocketItem.use` on the server, while fall-flying: a lead on a + /// mob within the 32-block box drops as an item and one + /// `minecraft:item.lead.break` plays at the player's feet. A lead outside + /// that box, or held for another player, stays. Standing still does neither. + #[test] + #[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"] + fn a_gliding_firework_drops_leads_and_plays_lead_break() { + use crate::game::{Audio, Options, Progress, Settings, load_resources}; + use horizon_client::mobs::{Mob, leash::Holder}; + use horizon_core::geometry::Vec3; + + let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap()); + let settings = Settings::default(); + let loaded = load_resources( + root.clone(), + None, + &root, + &settings, + std::time::Instant::now(), + false, + true, + &Progress::new(), + &mut |_, _| unreachable!(), + ) + .unwrap(); + let mut options = Options::parse(std::iter::empty()).unwrap().unwrap(); + options.resources = root.clone(); + options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer; + let res: &'static _ = Box::leak(Box::new(loaded.resources)); + let mut game = super::Game::new( + res, + loaded.pack, + loaded.gui.unwrap(), + options, + settings, + Audio::silent(&root), + None, + ) + .unwrap(); + game.player = Some( + horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, -3.25)) + .unwrap(), + ); + let feet = { + let p = game.player.as_ref().expect("a player").position; + [p.x, p.y, p.z] + }; + let kind = game + .res + .mob_types() + .get("minecraft:cow") + .expect("cow") + .clone(); + let me = game.acting_uuid(); + let someone_else = [me[0].wrapping_add(1), me[1], me[2], me[3]]; + let mut leashed = |id, at: Vec3, owner: [i32; 4]| { + let mut mob = Mob::new(id, kind.clone(), at, 0.0); + mob.extra.leash = Some(Holder::Player); + mob.extra.leash_player = Some(owner); + game.mobs.push(mob); + }; + let here = Vec3::new(feet[0] + 1.0, feet[1], feet[2]); + leashed(1, here, me); + leashed(2, Vec3::new(feet[0] + 40.0, feet[1], feet[2]), me); + leashed(3, here, someone_else); + game.inventory[game.selected] = Some(super::ItemStack::new("minecraft:firework_rocket", 1)); + let stack = game.inventory[game.selected].clone().unwrap(); + game.audio.capture = Some(Vec::new()); + + assert!(!game.boost_firework(&stack)); + assert_eq!(game.mobs[0].extra.leash, Some(Holder::Player)); + assert!(game.dropped.is_empty()); + assert!(game.audio.capture.as_ref().unwrap().is_empty()); + + game.player.as_mut().unwrap().fall_flying = true; + assert!(game.boost_firework(&stack)); + assert!( + game.mobs[0].extra.leash.is_none(), + "the mob on the lead comes free" + ); + assert_eq!(game.mobs[1].extra.leash, Some(Holder::Player)); + assert_eq!(game.mobs[2].extra.leash, Some(Holder::Player)); + let leads: Vec<_> = game + .dropped + .iter() + .filter(|d| &*d.stack.item == "minecraft:lead") + .collect(); + assert_eq!(leads.len(), 1); + let heard = game.audio.capture.take().unwrap_or_default(); + let snaps: Vec<_> = heard + .into_iter() + .filter(|s| s.0 == "minecraft:item.lead.break") + .collect(); + assert_eq!( + snaps, + vec![("minecraft:item.lead.break".to_owned(), feet, 1.0, 1.0)] + ); + } } horizon_client::impl_joined!(Rocket); diff --git a/crates/horizon-client/src/game/leads.rs b/crates/horizon-client/src/game/leads.rs index 1491054170..99a44a75c7 100644 --- a/crates/horizon-client/src/game/leads.rs +++ b/crates/horizon-client/src/game/leads.rs @@ -197,6 +197,38 @@ impl Game<'_> { pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) { self.drop_leash(id, drop, sound, false); } + /// `Entity.dropAllLeashConnections`: every mob and boat leashed to the + /// acting player, inside the 32-block box around the player's centre, + /// drops its lead. Then `GameEvent.SHEAR`. Whether anything dropped. + /// The player is not `Leashable`, so there is no lead of their own. + pub(super) fn drop_all_leash_connections(&mut self) -> bool { + let center = self.player_center(); + let mobs: Vec = self + .player_leashed(center) + .into_iter() + .map(|i| self.mobs[i].id) + .collect(); + let boats = self.player_led_boats(center); + if mobs.is_empty() && boats.is_empty() { + return false; + } + for id in mobs { + // One snap is played by the caller (`SoundEvents.LEAD_BREAK`), + // not a break sound on each leashed entity. + self.break_leash(id, true, false); + } + self.drop_boat_leashes(&boats); + let at = self + .player + .as_ref() + .map_or([center.x, center.y, center.z], |p| { + [p.position.x, p.position.y, p.position.z] + }); + // `gameEvent(GameEvent.SHEAR, null)`: the player is the position, not the source. + let ctx = self.who_context(None, None); + self.post_game_event("minecraft:shear", at, ctx); + true + } /// `break_leash` by the acting player's use (untying it, shears). fn break_leash_used(&mut self, id: u32, drop: bool) { self.drop_leash(id, drop, false, true); diff --git a/tools/port_map/game-005.tsv b/tools/port_map/game-005.tsv index 9e88692e40..f88dd184ba 100644 --- a/tools/port_map/game-005.tsv +++ b/tools/port_map/game-005.tsv @@ -41,7 +41,7 @@ Entity.awardKillScore inline crates/horizon-client/src/game.rs::Game::tick the p Entity.shouldRender port crates/horizon-client/src/game/entity_culling.rs::Game::entity_in_view range from default_range (64 times the mean box edge times the view scale), then the frustum Entity.saveAsPassenger port crates/horizon-client/src/game/persist.rs::Game::mob_tree_nbt a mob with its riders nested under Passengers; players and unsaved types are skipped Entity.getEncodeId inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt the id written is the mob type's id -Entity.dropAllLeashConnections port crates/horizon-client/src/game/dispenser.rs::Game::shear_off_leashes gap: minor: only shears (dispenser) reach it; a firework used while gliding does not drop the player's leads +Entity.dropAllLeashConnections port crates/horizon-client/src/game/leads.rs::Game::drop_all_leash_connections FireworkRocketItem.use while gliding; dispenser shears still drop through Game::shear_off_leashes Entity.canCollideWith inline crates/horizon-client/src/game/ghasts.rs::Game::player_entity_collisions gap: player: the entities a player cannot walk through are boats and happy ghasts only; a live shulker (canBeCollidedWith) is not solid, so the player walks through it Entity.getDefaultPassengerAttachmentPoint inline crates/horizon-client/src/mobs/riders.rs::passenger_attachment one point per vehicle, so the passenger index is always 0; default is the top of the box Entity.ejectPassengers port crates/horizon-client/src/game/boat_passengers.rs::Game::eject_boat_mobs gap: minor: boats place riders at the dismount spot; when a mob vehicle dies sync_riders only lets go and the rider stays where it was seated From 820b8fe8f54aee7984a09886ce18b72927c2f333 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:28:09 +0100 Subject: [PATCH 32/70] Start a ridden pig or strider's boost when the client receives its total. The rider's client simulates the mount, and ItemBasedSteering.onSynced starts that timer from the synced int. Without it the boost factor stays 1. Co-Authored-By: Grok Co-authored-by: Cursor --- crates/horizon-client/src/game/lan.rs | 7 ++ .../src/game/remote_mob_data.rs | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index e3ed35df48..566f76daff 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -504,6 +504,13 @@ fn mob_meta( } let e = &m.extra; let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}")); + // Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry + // after AgeableMob's two). The total `boost()` stored in `counter2`. + // The default 0 is not registered: vanilla never writes it back, and the + // rider's client finishes the timer `onSynced` started. + if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 { + meta.push((18, serializer::INT, Value::Int(e.counter2))); + } mob_variant_meta(m, hub, meta, defaults); match kind { "minecraft:guardian" | "minecraft:elder_guardian" => { diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 5d7e4dbe5b..1b00e70a3a 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -145,6 +145,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti (Species::Mooshroom, 18, Value::Int(variant)) => { m.extra.variant = (*variant == 1) as u8 } + // Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (the first entry + // after AgeableMob's two; the variant and DATA_SUFFOCATING are 19). + // ItemBasedSteering.onSynced: boosting starts, boostTime is 0, and + // the total is this int (140 to 980 from boost()). + (Species::Pig | Species::Strider, 18, Value::Int(total)) => { + m.extra.counter = 0; + m.extra.counter2 = *total; + } // Piglin.DATA_IS_DANCING: the dance pose. (Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing), // TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4. @@ -1033,4 +1041,66 @@ mod tests { // Last fuse tick: still primed at 0 until the server removes it. assert_eq!(horizon_client::mobs::sulfur_cube::fuse(&m), 0); } + + /// `Pig.onSyncedDataUpdated` and `Strider.onSyncedDataUpdated`: a synced + /// `DATA_BOOST_TIME` (index 18, after AgeableMob's two) calls + /// `ItemBasedSteering.onSynced`. Boosting starts, `boostTime` is 0, and + /// the total is the int (`boost()`'s minimum is 140). The next + /// `tickBoost` then `boostFactor` is `1 + 1.15 * sin(boostTime / total * PI)`. + #[test] + fn a_synced_boost_starts_the_client_timer() { + use horizon_client::mobs::MobTypes; + use horizon_core::geometry::trig::Mth; + use std::{fs, sync::Arc}; + + let root = std::env::temp_dir().join(format!("horizon-boost-sync-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + fs::write( + root.join("entity-types.json"), + r#"{"minecraft:pig": {}, "minecraft:strider": {}}"#, + ) + .unwrap(); + fs::write( + root.join("entity-models.json"), + r#"{"minecraft:pig#main": {}, "minecraft:strider#main": {}}"#, + ) + .unwrap(); + let mut types = MobTypes::load(&root).unwrap(); + fs::remove_dir_all(root).unwrap(); + + // ItemBasedSteering: nextInt(841) + 140, and 1.15 in boostFactor. + const TOTAL: i32 = 140; + const FACTOR: f32 = 1.15; + for (kind, steer) in [ + ("minecraft:pig", 0.225_f64), + ("minecraft:strider", 0.55_f64), + ] { + let t = Arc::new(types.types.remove(kind).unwrap()); + let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0); + m.attrs.speed = 0.25; + m.extra.saddled = true; + m.extra.counter = 5; + m.extra.counter2 = 3; + // The next entry (pig variant, strider suffocating) is not the boost. + apply(&mut m, &[(19, s::INT, Value::Int(TOTAL))]); + assert_eq!((m.extra.counter, m.extra.counter2), (5, 3), "{kind}"); + apply(&mut m, &[(18, s::INT, Value::Int(TOTAL))]); + assert_eq!(m.extra.counter, 0, "{kind}: onSynced sets boostTime to 0"); + assert_eq!(m.extra.counter2, TOTAL, "{kind}: the synced total"); + m.rider = Some(equine::Rider { + steer: true, + ..Default::default() + }); + equine::ridden_step(&mut m); + // tickBoost post-increments, then boostFactor reads boostTime 1. + let boost = 1.0 + FACTOR * (1.0_f32 / TOTAL as f32 * std::f32::consts::PI).msin(); + let expected = (0.25_f64 * steer) as f32 * boost; + assert_eq!(m.extra.counter, 1, "{kind}"); + assert!( + (m.ai.speed - expected).abs() < 1.0e-5, + "{kind}: {} != {expected}", + m.ai.speed + ); + } + } } From 31ef855776c216102e77007d94f434b3784899a9 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:28:34 +0100 Subject: [PATCH 33/70] A flying ender dragon takes LivingEntity knockback from a melee hit. EnderDragon.knockback skips that push while the dragon is sitting, including a hit that makes it take off. Co-Authored-By: Grok Co-authored-by: Cursor --- crates/horizon-client/src/game/dragon.rs | 128 ++++++++++++++++++++++- tools/port_map/game2-001.tsv | 2 +- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index f99eb61161..ed16388284 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -565,6 +565,19 @@ impl Dragon { counts: Option>, kind: DamageKind, direct: Option<&ProjectileKind>, + ) -> Hurt { + self.hurt_part_from(part, damage, counts, kind, direct, None) + } + /// `hurt_part` with the melee attacker's position (`DamageSource.getSourcePosition`, + /// the direct entity's feet). `None` for a hit that is not a player's melee. + fn hurt_part_from( + &mut self, + part: usize, + damage: f32, + counts: Option>, + kind: DamageKind, + direct: Option<&ProjectileKind>, + from: Option, ) -> Hurt { if is_fire(kind) { return Hurt::Refused; @@ -575,7 +588,7 @@ impl Dragon { ProjectileKind::Arrow { .. } | ProjectileKind::Trident | ProjectileKind::WindCharge ) ); - self.hurt(part, damage, counts, arrow) + self.hurt_from(part, damage, counts, arrow, from) } /// `EnderDragon.hurt(part, source, damage)`: whether the hit landed /// (an arrow is spent, else it turns back). `counts` is the source @@ -584,6 +597,17 @@ impl Dragon { /// direct arrow or wind charge, which a perched dragon shrugs off /// (`AbstractDragonSittingPhase.onHurt`). fn hurt(&mut self, part: usize, damage: f32, counts: Option, arrow: bool) -> Hurt { + self.hurt_from(part, damage, counts, arrow, None) + } + /// `hurt` with the position `dealDefaultKnockback` pushes away from. + fn hurt_from( + &mut self, + part: usize, + damage: f32, + counts: Option, + arrow: bool, + from: Option, + ) -> Hurt { if matches!(self.phase, Phase::Dying { .. }) || !self.alive() { return Hurt::Refused; } @@ -640,6 +664,19 @@ impl Dragon { }); } } + // `LivingEntity.hurtServer` calls `dealDefaultKnockback` (strength + // `0.4F`) on a full hit, before `EnderDragon.hurt` adds sitting damage + // and maybe takes off. `from` is the attacker's feet for a player's + // melee hit. Explosions are `#no_knockback`; their push is + // `explosion_knockback`. + if full && let Some(from) = from { + self.knockback( + f64::from(0.4_f32), + from.x - self.position.x, + from.z - self.position.z, + source, + ); + } if self.phase.sitting() { self.sitting_damage += before - self.health.max(0.0); if self.sitting_damage > 0.25 * MAX_HEALTH { @@ -653,6 +690,43 @@ impl Dragon { } Hurt::Landed { full } } + /// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase + /// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`: + /// the source position minus this position). Knockback resistance is the + /// mob default, 0. `noPhysics` never sets `onGround`, so the vertical + /// component stays. + fn knockback(&mut self, power: f64, xd: f64, zd: f64, source: HurtSource<'_>) { + if self.phase.sitting() { + return; + } + let mut knock = horizon_client::health::Knock { + strength: power, + x: xd, + z: zd, + }; + if horizon_client::health::knockback_canceled( + horizon_client::health::Hurting::Dragon(self.id as i32, source), + &mut knock, + ) { + return; + } + let horizon_client::health::Knock { + strength: power, + x: mut xd, + z: mut zd, + } = knock; + if power <= 0.0 { + return; + } + while xd * xd + zd * zd < f64::from(1.0e-5_f32) { + xd = (self.random.next_double() - self.random.next_double()) * 0.01; + zd = (self.random.next_double() - self.random.next_double()) * 0.01; + } + let len = (xd * xd + zd * zd).sqrt(); + let (kx, kz) = (xd / len * power, zd / len * power); + let v = self.velocity; + self.velocity = Vec3::new(v.x / 2.0 - kx, v.y, v.z / 2.0 - kz); + } /// `ServerExplosion.hurtEntities` on the dragon: the dragon itself (its /// body takes it), then each part, in `Level.getEntities` order, each by /// its distance and how much of its box the blast sees (`exposure`). @@ -1275,12 +1349,19 @@ impl Game<'_> { ) -> Hurt { let by_id = by.map(|who| self.fight_entity(who)); let source = counts.then(|| hurt_source(kind, by_id, direct)); + // `dealDefaultKnockback` for a player's melee: away from the attacker's + // feet. The scope names that player to the knockback hook. + let _attacker = (kind == DamageKind::PlayerAttack) + .then(|| self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id))); + let from = (kind == DamageKind::PlayerAttack) + .then(|| self.player.as_ref().map(|p| p.position)) + .flatten(); let Some(d) = self.dragon.as_mut() else { return Hurt::Refused; }; let was_down = d.down(); let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt); - let hurt = d.hurt_part(part, damage, source, kind, direct); + let hurt = d.hurt_part_from(part, damage, source, kind, direct, from); // LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer // (100 ticks); a hit the damage cooldown absorbs returns before that. if counts @@ -4565,6 +4646,49 @@ mod tests { assert_eq!(d.health, MAX_HEALTH - 9.0); } + /// `EnderDragon.knockback` calls `LivingEntity.knockback` only when the + /// phase is not sitting. `dealDefaultKnockback` uses `0.4F` away from the + /// attacker's feet. Resistance is 0 and `onGround` stays false + /// (`noPhysics`), so from velocity `(1, -0.3, 0.5)` and a source 3 by 4 + /// blocks away the push is `(0.5 - 0.6p, -0.3, 0.25 - 0.8p)` with + /// `p = (double) 0.4F`. A perched hit, including one that makes it take + /// off, leaves the velocity alone. A hit inside the damage cooldown is + /// not a full hit, so it does not push either. + #[test] + fn a_melee_hit_knocks_a_flying_dragon_and_not_a_perched_one() { + let from = Vec3::new(3.0, 70.0, 4.0); + let velocity = Vec3::new(1.0, -0.3, 0.5); + let power = f64::from(0.4_f32); + let pushed = Vec3::new(0.5 - 0.6 * power, -0.3, 0.25 - 0.8 * power); + + let mut perched = dragon(Phase::Scanning { time: 0 }); + perched.velocity = velocity; + assert_eq!( + perched.hurt_from(0, 9.0, HIT, false, Some(from)), + Hurt::Landed { full: true } + ); + assert_eq!(perched.velocity, velocity); + + let mut taking_off = dragon(Phase::Flaming { ticks: 0 }); + taking_off.velocity = velocity; + taking_off.hurt_from(0, 60.0, HIT, false, Some(from)); + assert!(matches!(taking_off.phase, Phase::Takeoff { .. })); + assert_eq!(taking_off.velocity, velocity); + + let mut flying = dragon(flying()); + flying.velocity = velocity; + assert_eq!( + flying.hurt_from(0, 9.0, HIT, false, Some(from)), + Hurt::Landed { full: true } + ); + assert_eq!(flying.velocity, pushed); + assert_eq!( + flying.hurt_from(0, 20.0, HIT, false, Some(from)), + Hurt::Landed { full: false } + ); + assert_eq!(flying.velocity, pushed); + } + #[test] fn a_part_refuses_fire() { // EnderDragonPart.hurtServer: the dragon is fire immune, so a diff --git a/tools/port_map/game2-001.tsv b/tools/port_map/game2-001.tsv index 0b9845fad0..5b3717ca2e 100644 --- a/tools/port_map/game2-001.tsv +++ b/tools/port_map/game2-001.tsv @@ -56,7 +56,7 @@ EnderDragon.tickPart port crates/horizon-client/src/game/dragon.rs::Game::place_ EnderDragon.getHeadYOffset inline crates/horizon-client/src/game/dragon.rs::Game::place_dragon_parts sitting gives -1, else flight sample 5 minus sample 0 EnderDragon.knockBack port crates/horizon-client/src/game/dragon.rs::Game::dragon_contacts wing zones push players and living mobs; head/neck contacts hurt living mobs EnderDragon.handleKillingBlow inline crates/horizon-client/src/game/dragon.rs::Dragon::hurt health set to 1 and dying phase unless sitting -EnderDragon.knockback missing minor: a hit does not push the dragon back outside the sitting phase +EnderDragon.knockback port crates/horizon-client/src/game/dragon.rs::Dragon::knockback LivingEntity.knockback only while the phase is not sitting EnderDragon.findClosestNode port crates/horizon-client/src/game/dragon.rs::Game::closest_node EnderDragon.findPath port crates/horizon-client/src/game/dragon.rs::Game::dragon_path EnderDragon.reconstructPath inline crates/horizon-client/src/game/dragon.rs::Game::dragon_path path list built inside the A* search From c131ee3feea8a95294f1a5b451efe208cdbf6c05 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:47:14 +0100 Subject: [PATCH 34/70] Drop constant asserts that clippy rejects in the armor-stand shove test. The 0.2 gate is already checked by whether the cart is shoved. Co-authored-by: Cursor Co-Authored-By: Grok --- crates/horizon-client/src/game/armor_stands.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 30c3f30898..1e22e57970 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -1784,11 +1784,9 @@ mod push_tests { #[test] fn a_rideable_minecart_inside_the_0_2_gate_is_shoved() { + // 0.25ยฒ is inside the 0.2 gate; 0.5ยฒ is not. The function's Some/None is the check. let near = Vec3::new(0.25, 0.0, 0.0); let far = Vec3::new(0.5, 0.0, 0.0); - // Written here, not taken from the shove: 0.25ยฒ is inside 0.2, 0.5ยฒ is not. - assert!(0.25 * 0.25 <= 0.2); - assert!(0.5 * 0.5 > 0.2); let (stand_box, cart_box) = boxes(CartKind::Rideable, near); let shove = armor_stand_cart_shove( Vec3::ZERO, From 0758947658da1d347d7a2e32ea5d3c50127d81e3 Mon Sep 17 00:00:00 2001 From: Arslan Armanuly <72556944+arslanarm@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:34:44 +0500 Subject: [PATCH 35/70] Shader packs: smooth() in an if branch not taken keeps its value, as in Iris Custom uniforms evaluated every argument of if(), so a smooth() in a branch not taken still moved toward its value each frame. Our probe pack in Iris 1.11.7 (if(fmod(frameTimeCounter, 4) < 2, smooth(sq, 10), 0.5) over a 16 s square wave) reads values that fit "frozen while the branch isn't taken" within 0.008, and miss "updated every frame" by up to 0.14. if() now evaluates only the conditions up to the first true one and that branch. The same probes also time smooth() over a square wave from if(), eyeBrightness, worldTime and rainStrength, fades 0.5 to 100 and the default: their Iris values fit a half-life of 0.1 s per fade unit, as custom.rs already has (a fit to our probes, not Iris's code). A test pins worldTime steps and the default fade. Fixes #9371 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/custom.rs | 105 ++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 12 deletions(-) diff --git a/crates/horizon-shaders/src/custom.rs b/crates/horizon-shaders/src/custom.rs index fa3e21b631..2bb704ed7e 100644 --- a/crates/horizon-shaders/src/custom.rs +++ b/crates/horizon-shaders/src/custom.rs @@ -11,6 +11,8 @@ //! half-lives counted in tenths of a second (`FADE_UNIT`), `fadeIn` while rising, `fadeOut` //! while falling. Each call keeps its own state, so the optional id is not needed. As in Iris //! 1.11.7, the first argument is that id when it is written as a number and more follow. +//! A `smooth` in an `if` branch not taken keeps its value until the branch is taken again: +//! `if` evaluates only the conditions up to the first true one and that branch (#9371). //! - Unknown names, as in Iris 1.11.7 (#8995): an expression naming something the custom //! uniforms can't see (an unknown name, or one of [`HIDDEN`]) reads 0 as a whole, even when //! the name is in an `if` branch not taken, and so does every expression using its uniform or @@ -27,8 +29,11 @@ use crate::Properties; /// The seconds one unit of a `smooth` fade time lasts. Fitted to Iris 1.11.7 with a probe pack of /// our own (`smoothprobe`: `smooth(rainStrength, f, f)` for several fades, read across the scene /// suite's weather changes): a half-life of a tenth of the fade time's seconds gives every -/// measured value within 0.016. Horizon's built-in smoothed uniforms use the same 0.1 s unit -/// (`pack_uniforms.rs` `SECONDS_A_TICK`). +/// measured value within 0.016. A second probe pack of ours (#9371: a square wave from `if`, +/// `eyeBrightness`, `worldTime`; fades 0.5 to 100, the default 1, rising and falling) fits the +/// same unit: half-life / fade between 0.085 and 0.100 over 63 readings. These are fits to our +/// probes' Iris values, not Iris's rule. Horizon's built-in smoothed uniforms use the same 0.1 s +/// unit (`pack_uniforms.rs` `SECONDS_A_TICK`). const FADE_UNIT: f64 = 0.1; /// A value: up to 16 components (matrices only as names, for `m.column.row`). @@ -440,6 +445,18 @@ impl Custom { _ => bool_(a.truth() || b.truth()), } } + Expr::Call(f, args, _) if f == "if" => { + // Only the conditions up to the first true one and its branch are evaluated: a + // `smooth` in a branch not taken keeps its value and doesn't advance. + let mut i = 0; + while i + 1 < args.len() { + if ev(&args[i], self).truth() { + return ev(&args[i + 1], self); + } + i += 2; + } + args.last().map_or(Val::scalar(0.0), |e| ev(e, self)) + } Expr::Call(f, args, site) => { let a: Vec = args.iter().map(|e| ev(e, self)).collect(); let arg = |i: usize| a.get(i).copied().unwrap_or(Val::scalar(0.0)); @@ -490,16 +507,6 @@ impl Custom { Val::scalar((x + (arg(1).x() - x) * self.random()).floor()) } "randomInt" => Val::scalar((self.random() * f64::from(i32::MAX)).floor()), - "if" => { - let mut i = 0; - while i + 1 < a.len() { - if a[i].truth() { - return a[i + 1]; - } - i += 2; - } - a.last().copied().unwrap_or(Val::scalar(0.0)) - } "smooth" => { // A first argument written as a number names the call when more // follow (`smooth(1, x, 2)`); any other is the value (`smooth(x, 2)`). @@ -1104,6 +1111,80 @@ mod tests { } } + /// Iris 1.11.7 with a probe pack of our own (#9371): a step in `worldTime / 24000` from 0.25 + /// to 0.75, read 1.355 s later, and a square wave's fall read 0.575 s later. Read there: + /// fades 3, 10 and 30 give 0.7282, 0.5545 and 0.3844; the default fade 0.0186. Our probe's + /// values fit a half-life of 0.1 s ร— the fade for every input tried (a fit, not Iris's code). + #[test] + fn smooth_steps_as_iris_does_for_other_inputs_and_the_default_fade() { + let mut c = custom( + "uniform.float.a = smooth(worldTime / 24000.0, 3.0)\n\ + uniform.float.b = smooth(worldTime / 24000.0, 10.0)\n\ + uniform.float.c = smooth(worldTime / 24000.0, 30.0)\n\ + uniform.float.d = smooth(if(worldTime > 15000, 0.0, 1.0))\n", + ); + let mut hold = |time: f64, seconds: f64| { + let mut read = Vec::new(); + for _ in 0..(seconds * 120.0).round() as usize { + read = c.eval(1.0 / 120.0, &|n: &str| { + (n == "worldTime").then(|| Val::scalar(time)) + }); + } + read.iter().map(|v| v.v[0]).collect::>() + }; + hold(6000.0, 60.0); + let got = hold(18000.0, 1.355); + for (g, iris) in got.iter().zip([0.7282, 0.5545, 0.3844]) { + assert!((g - iris).abs() < 0.01, "{got:?}: Iris read {iris}"); + } + hold(6000.0, 60.0); + let got = hold(18000.0, 0.575); + assert!( + (got[3] - 0.0186).abs() < 0.005, + "default fade: {got:?}, Iris read 0.0186" + ); + } + + /// Iris 1.11.7 with a probe pack of our own (#9371): our probe's Iris readings fit a `smooth` + /// inside an `if` branch keeping its value while the branch isn't taken (within 0.008; + /// updated every frame misses by up to 0.14). A fit, not Iris's code. The probe's + /// `if(fmod(frameTimeCounter, 4) < 2, smooth(sq, 10), 0.5)`, with `sq` a square wave of 16 s + /// (1 for 8 s, then 0), read in Iris at these points of the wave: 1.214 s 0.5962, 5.557 s + /// 0.9205, 12.254 s 0.1965, 13.264 s 0.1041, 13.963 s 0.0603. Updated every frame, the + /// smooth would read 0.571, 0.979, 0.052, 0.026 and 0.016 there. + #[test] + fn smooth_in_a_branch_not_taken_keeps_its_value() { + let mut c = custom( + "variable.float.sq = if(fmod(frameTimeCounter, 16.0) < 8.0, 1.0, 0.0)\n\ + uniform.float.br = if(fmod(frameTimeCounter, 4.0) < 2.0, smooth(sq, 10.0), 0.5)\n", + ); + let dt = 1.0 / 120.0; + let mut iris = vec![ + (1.214, 0.5962), + (5.557, 0.9205), + (12.254, 0.1965), + (13.264, 0.1041), + (13.963, 0.0603), + ]; + for frame in 0..16 * 120 * 20 { + let t = frame as f64 * dt; + let got = c.eval(dt, &|n: &str| { + (n == "frameTimeCounter").then(|| Val::scalar(t)) + })[0] + .v[0]; + // The last of 20 periods: the wave has long forgotten where it started. + let phase = t - 16.0 * 19.0; + if let Some(i) = iris.iter().position(|p| (phase - p.0).abs() < dt / 2.0) { + let (at, want) = iris.remove(i); + assert!( + (got - want).abs() < 0.02, + "{got} at {at} s, Iris read {want}" + ); + } + } + assert!(iris.is_empty(), "not reached: {iris:?}"); + } + /// After a restart `smooth` starts from its next value, as on the first frame, instead of /// fading from the old one (a 1000-second fade from a dry run's zero). #[test] From 4af07cfcf10bc8e0a3271e087e39299096268e4d Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:49:09 +0100 Subject: [PATCH 36/70] Drop the needless return at the end of the frog water-travel test. Clippy rejects a return that only ends a unit test. Co-authored-by: Cursor Co-Authored-By: Grok --- crates/horizon-client/src/mobs/frog.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/mobs/frog.rs b/crates/horizon-client/src/mobs/frog.rs index fd3b5ea0d0..f5ba8c58bf 100644 --- a/crates/horizon-client/src/mobs/frog.rs +++ b/crates/horizon-client/src/mobs/frog.rs @@ -1022,9 +1022,8 @@ mod tests { assert!((m.velocity.y - -0.45).abs() < 1.0e-9, "{}", m.velocity.y); assert!((m.velocity.z - 0.3375).abs() < 1.0e-9, "{}", m.velocity.z); }); - if !ran { - return; - } + // Without a test world the closure does not run; there is nothing else to check. + let _ = ran; } /// `Frog.updateWalkAnimation`: target `min(distance * 25, 1)`, 0 while From f19df01c7c8907f63687086fc1df4e96d75e0755 Mon Sep 17 00:00:00 2001 From: Joansitoh Date: Wed, 4 Nov 2026 18:56:29 +0100 Subject: [PATCH 37/70] Keep the dragon hit helpers that only tests call out of the server build. The game hits through hurt_part_from, so the no-attacker wrappers were dead code without cfg(test). Co-authored-by: Cursor Co-Authored-By: Grok --- crates/horizon-client/src/game/dragon.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index ed16388284..275294e671 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -557,7 +557,9 @@ impl Dragon { /// (the dragon's type is fire immune) before `EnderDragon.hurt`. /// `direct` is the projectile that hit: a direct `AbstractArrow` or a /// `WindCharge` is the arrow a perched dragon shrugs off, a breeze's - /// `BreezeWindCharge` is neither. + /// `BreezeWindCharge` is neither. Gameplay calls `hurt_part_from`; this + /// is the no-attacker form the tests use. + #[cfg(test)] fn hurt_part( &mut self, part: usize, @@ -595,7 +597,8 @@ impl Dragon { /// `reallyHurt` takes when `source.getEntity() instanceof Player` or its /// type always hurts dragons (explosions), and none otherwise; `arrow` a /// direct arrow or wind charge, which a perched dragon shrugs off - /// (`AbstractDragonSittingPhase.onHurt`). + /// (`AbstractDragonSittingPhase.onHurt`). Gameplay calls `hurt_from`. + #[cfg(test)] fn hurt(&mut self, part: usize, damage: f32, counts: Option, arrow: bool) -> Hurt { self.hurt_from(part, damage, counts, arrow, None) } From 41be700c66443b0d2c9d090df41e48cc225204b4 Mon Sep 17 00:00:00 2001 From: DUzzL <98381970+DUzzL@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:00:01 +0100 Subject: [PATCH 38/70] Fail dry Riptide trident use without dipping the hand (TridentItem.use FAIL) --- crates/horizon-client/src/game/remote.rs | 4 ++-- crates/horizon-client/src/game/trident.rs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 27bb82e036..114b2e4d32 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -8471,8 +8471,8 @@ impl Game<'_> { } // TridentItem.use: not about to break, Riptide only in water or rain. "minecraft:trident" => { - self.use_trident(held); - return true; + // FAIL: no dip, and the off hand gets its turn. + return self.use_trident(held); } // SpyglassItem.use (its sound is the server's). "minecraft:spyglass" => true, diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index 5fb4f2d070..ba01049550 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -253,6 +253,8 @@ impl Game<'_> { .unwrap_or_else(|| self.items().get(&stack.item).map_or(250, |i| i.max_damage)) } /// `TridentItem.use`: start the throw unless one more use would break it. + /// Returns false for `InteractionResult.FAIL` (about to break, or Riptide + /// while dry) so callers neither dip the hand nor consume the click. pub(super) fn use_trident(&mut self, stack: &ItemStack) -> bool { if &*stack.item != "minecraft:trident" { return false; @@ -260,7 +262,7 @@ impl Game<'_> { let max = self.trident_max_damage(stack); let riptide = stack.enchantment_level("minecraft:riptide") > 0; if stack.damage >= max.saturating_sub(1) || (riptide && !self.in_water_or_rain()) { - return true; + return false; } self.start_using_item(); true From 22ca656476539a657758652b67c868e241003e38 Mon Sep 17 00:00:00 2001 From: phexuss Date: Wed, 4 Nov 2026 19:01:29 +0100 Subject: [PATCH 39/70] fix(render): check a fetched PNG's size before decoding it --- crates/horizon-client-render/src/hud.rs | 63 +++++++++++++++++++++---- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index ac764d258a..2c7e81c27f 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -256,7 +256,15 @@ pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec)> { match ImageFormat::sniff(data) { Some(ImageFormat::WebP) => decode_webp(data), Some(ImageFormat::Jpeg) => decode_jpeg(data), - Some(ImageFormat::Png) | None => decode_bytes(data), + // A PNG's IHDR states the size before the decoder sizes its pixel + // buffer, so it is checked there like the WebP and JPEG headers: the + // PNG reader alone would trust the header and allocate it. + Some(ImageFormat::Png) | None => { + if let Some((width, height)) = png_bytes_size(data) { + check_fetched_size(u64::from(width), u64::from(height))?; + } + decode_bytes(data) + } } } @@ -308,15 +316,19 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet { .map(|p| (*p).clone()) .collect() } +/// A PNG's width and height from its first 24 bytes (`IHDR`), before any +/// pixel buffer is made. +fn png_bytes_size(data: &[u8]) -> Option<(u32, u32)> { + let header = data.get(..24)?; + if header[..8] != *b"\x89PNG\r\n\x1a\n" || header[12..16] != *b"IHDR" { + return None; + } + let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default()); + Some((field(16), field(20))) +} /// A PNG's width and height, from its header alone. fn png_size(path: &Path) -> Option<(u32, u32)> { - let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24) - .ok()? - .try_into() - .ok()?; - let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default()); - (header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR") - .then(|| (field(16), field(20))) + png_bytes_size(&horizon_core::prepared_files::read_head(path, 24).ok()?) } fn walk(dir: &Path, out: &mut Vec) { // A prepared root's folder: from its data file, without listing the disk. @@ -2905,6 +2917,41 @@ mod tests { assert!(check_fetched_size(16_383, 16_383).is_err()); } + /// A fetched PNG is refused by the size its own header claims, before the + /// reader makes the pixel buffer: a few hundred bytes of payload would + /// otherwise ask for 64 MiB or much more. + #[test] + fn a_fetched_png_is_refused_by_the_size_its_header_claims() { + let mut encoded = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut encoded, 2, 2); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&[7; 16]).unwrap(); + writer.finish().unwrap(); + } + assert!(matches!( + ImageFormat::sniff(&encoded), + Some(ImageFormat::Png) + )); + assert_eq!( + decode_image_bytes(&encoded).expect("a small PNG decodes").0, + 2 + ); + + // The same image, its IHDR claiming one pixel row past the limit: the + // CRC is ignored on purpose, so this stands in for a hostile header. + let ihdr = encoded.windows(4).position(|w| w == b"IHDR").expect("IHDR"); + encoded[ihdr + 4..ihdr + 8].copy_from_slice(&4097u32.to_be_bytes()); + encoded[ihdr + 8..ihdr + 12].copy_from_slice(&4096u32.to_be_bytes()); + let refused = decode_image_bytes(&encoded).expect_err("the size is refused"); + assert!( + refused.to_string().contains("too large to decode"), + "{refused}" + ); + } + /// Only the network path reads WebP and JPEG: a resource pack's texture /// stays PNG-only, as vanilla's `NativeImage.read` is. #[test] From b6763cc941be0af3f88b9a24677f21a07ac27ddc Mon Sep 17 00:00:00 2001 From: gigamans442 <125603364+gigamans442@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:03:21 -0500 Subject: [PATCH 40/70] Honor explicit weather depth settings in shader packs Carry rain.depth through Post into weather pipeline depth writes. Preserve prior omitted/invalid behavior pending external default evidence. Add original property and hardware GPU regression coverage. Co-Authored-By: OpenAI GPT-6.1 Sol --- .../src/gfx/pack_post.rs | 5 +- .../src/gfx/pack_post/weather_depth_tests.rs | 277 ++++++++++++++++++ crates/horizon-shaders/src/post.rs | 51 ++++ 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 crates/horizon-client-render/src/gfx/pack_post/weather_depth_tests.rs diff --git a/crates/horizon-client-render/src/gfx/pack_post.rs b/crates/horizon-client-render/src/gfx/pack_post.rs index 56390cfa92..5d68121451 100644 --- a/crates/horizon-client-render/src/gfx/pack_post.rs +++ b/crates/horizon-client-render/src/gfx/pack_post.rs @@ -43,6 +43,9 @@ mod shadow_faces_tests; #[cfg(test)] mod forced_flip_tests; +#[cfg(test)] +mod weather_depth_tests; + /// The texture behind a pack sampler name. #[derive(Clone, Copy, PartialEq)] enum Source { @@ -2255,7 +2258,7 @@ impl PackPost { let weather_geometry = world.weather.then(|| Geometry { buffers: &entity_buffers, table: None, - depth_write: true, + depth_write: post.rain_depth, atlas: None, material_maps: false, ids: None, diff --git a/crates/horizon-client-render/src/gfx/pack_post/weather_depth_tests.rs b/crates/horizon-client-render/src/gfx/pack_post/weather_depth_tests.rs new file mode 100644 index 0000000000..6a62c1551a --- /dev/null +++ b/crates/horizon-client-render/src/gfx/pack_post/weather_depth_tests.rs @@ -0,0 +1,277 @@ +//! Original synthetic weather oracle: no pack assets or external shader algorithms. +use super::*; +use std::io::{Cursor, Write}; +use wgpu::util::DeviceExt; + +#[test] +#[ignore = "requires a host GPU; missing adapter is a failure"] +fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() { + let instance = crate::gfx::test_gpu::instance(); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + eprintln!("Weather depth GPU: {:?}", adapter.get_info()); + assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu); + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .unwrap(); + let mut depth_failures = Vec::new(); + for program in ["gbuffers_weather", "gbuffers_textured_lit"] { + for (property, writes) in [ + ("rain.depth=true", true), + ("rain.depth=false", false), + // An evidence-limited compatibility choice: preserve accepted main here. + // The permitted packs do not establish the external omitted-value default. + ("", true), + ] { + let mut post = pack(&device, &queue, &adapter, program, property); + // Retain the pipelines for another frame, then resize their targets. + for size in [[6, 1], [6, 1], [12, 2]] { + post.resize(&device, size); + let mut encoder = device.create_command_encoder(&Default::default()); + let targets = post.targets.as_ref().unwrap(); + let color = &targets.color[0].as_ref().unwrap()[0]; + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[attach( + color, + wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLUE), + store: wgpu::StoreOp::Store, + }, + )], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &targets.depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.5), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + post.set_weather(Some(geometry(&device))); + post.draw_weather(&device, &queue, &mut encoder, size); + assert!(post.weather_draw.is_none(), "draw consumes this frame only"); + assert!(post.has_weather(), "pipelines survive the draw"); + // A dry frame must neither redraw old quads nor discard the pipelines. + post.set_weather(None); + post.draw_weather(&device, &queue, &mut encoder, size); + assert!(post.has_weather()); + let targets = post.targets.as_ref().unwrap(); + let color = targets.color[0].as_ref().unwrap()[0].texture(); + let colors = readback(&device, &mut encoder, color); + let depths = readback(&device, &mut encoder, &targets.depth_texture); + queue.submit([encoder.finish()]); + for buffer in [&colors, &depths] { + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + } + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let colors = colors.slice(..).get_mapped_range().unwrap(); + let depths = depths.slice(..).get_mapped_range().unwrap(); + for y in 0..size[1] as usize { + for x in 0..size[0] as usize { + let quad = x / (size[0] as usize / 6); + let visible = quad.is_multiple_of(3); + // First quad is in front; second is behind scene depth; third discards. + let expected_color = if visible && quad < 3 { + [128, 0, 127, 255] + } else if visible { + [0, 128, 127, 255] + } else { + [0, 0, 255, 255] + }; + let offset = y * 256 + x * 4; + let actual_color = &colors[offset..offset + 4]; + for (actual, expected) in actual_color.iter().zip(expected_color) { + assert!( + i16::from(*actual).abs_diff(expected) <= 1, + "{program} {property:?} size={size:?} ({x},{y}): color {actual_color:?}" + ); + } + let depth = + f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap()); + let expected_depth = if visible && writes { 0.25 } else { 0.5 }; + if depth != expected_depth { + depth_failures.push(format!( + "{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}" + )); + } + } + } + eprintln!( + "Weather oracle {program} {property:?} {size:?}: color, occlusion and discard pass; cumulative depth mismatches={}", + depth_failures.len() + ); + } + } + } + assert!(depth_failures.is_empty(), "{}", depth_failures.join("\n")); +} + +fn pack( + device: &wgpu::Device, + queue: &wgpu::Queue, + adapter: &wgpu::Adapter, + program: &str, + property: &str, +) -> PackPost { + let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); + for (extension, source) in [ + ( + "vsh", + "#version 120\nvarying vec4 color; varying float marker; void main(){gl_Position=gl_Vertex;color=gl_Color;marker=gl_MultiTexCoord0.x;}", + ), + ( + "fsh", + "#version 120\nvarying vec4 color; varying float marker; /* DRAWBUFFERS:0 */ void main(){if(marker>1.5) discard;gl_FragData[0]=color;}", + ), + ] { + zip.start_file( + format!("shaders/{program}.{extension}"), + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + zip.write_all(source.as_bytes()).unwrap(); + } + let pack = horizon_shaders::Pack::from_zip_bytes( + "original-weather-depth", + zip.finish().unwrap().into_inner(), + ) + .unwrap(); + let macros = horizon_shaders::Macros::standard(); + let properties = horizon_shaders::Properties::parse(property, ¯os); + let post = horizon_shaders::load_post( + &pack, + "", + &properties, + ¯os, + &horizon_shaders::GameInputs { + weather: Some(super::super::pack_weather::inputs()), + ..Default::default() + }, + ); + assert!(post.notes.is_empty(), "{:?}", post.notes); + assert_eq!(post.weather.as_ref().unwrap().name, program); + let lightmap = device + .create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()); + let mut post = PackPost::new( + device, + queue, + adapter, + post, + wgpu::TextureFormat::Rgba8Unorm, + World { + atlas: None, + material_maps: None, + lightmap, + terrain: None, + block_ids: &[], + sprite_rects: &[], + celestial: None, + gui_size: [1, 1], + clouds: None, + weather: true, + hand: false, + }, + ) + .expect("weather pack runs"); + assert!( + post.has_weather(), + "production weather pipelines must compile: {:?}", + post.notes + ); + post.copied |= 1; // Keep colortex0 readable for the independent color oracle. + post +} + +fn geometry(device: &wgpu::Device) -> Weather { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + for quad in 0..6u32 { + let left = quad as f32 / 3.0 - 1.0; + let right = (quad + 1) as f32 / 3.0 - 1.0; + let z = if quad % 3 == 1 { 0.5 } else { -0.5 }; + for pos in [ + [left, -1.0, z], + [right, -1.0, z], + [right, 1.0, z], + [left, 1.0, z], + ] { + vertices.push(super::super::Vertex { + pos, + uv: [(quad % 3) as f32, 0.0], + layer: 0, + color: u32::from_le_bytes(if quad < 3 { + [255, 0, 0, 128] + } else { + [0, 255, 0, 128] + }), + light: 0, + normal: [0; 4], + glint_uv: [0.0; 2], + }); + } + indices.extend([0, 1, 2, 0, 2, 3].map(|i| quad * 4 + i)); + } + let buffer = |contents: &[u8], usage| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents, + usage, + }) + }; + Weather { + vertices: buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX), + indices: buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX), + quads: [0..3, 3..6], + sprites: [None, None], + generation: 1, + } +} + +fn readback( + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + texture: &wgpu::Texture, +) -> wgpu::Buffer { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: u64::from(256 * texture.height()), + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + aspect: if texture.format().is_depth_stencil_format() { + wgpu::TextureAspect::DepthOnly + } else { + wgpu::TextureAspect::All + }, + ..texture.as_image_copy() + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + texture.size(), + ); + buffer +} diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs index a69c49db5c..1f5010c633 100644 --- a/crates/horizon-shaders/src/post.rs +++ b/crates/horizon-shaders/src/post.rs @@ -422,6 +422,9 @@ pub struct Post { pub lines: Option, /// What draws rain and snow, after deferred. pub weather: Option, + /// `rain.depth`: whether rain and snow write depth. Without an explicit false, retain + /// Horizon's existing writes; the inspected packs do not establish the external default. + pub rain_depth: bool, /// What draws the first-person hand and held items. pub hand: Option, /// `clouds=off`: no clouds at all, neither the game's nor the pack's program for them. @@ -617,6 +620,9 @@ pub fn load_post( clouds: None, lines: None, weather: None, + rain_depth: properties + .get("rain.depth") + .is_none_or(|v| v.trim() != "false"), hand: None, clouds_off: false, shadow_pass: false, @@ -2415,6 +2421,51 @@ blend.composite1.colortex3 = ONE ONE ONE ONE assert!(off.clouds.is_none() && off.clouds_off); } + #[test] + fn rain_depth_follows_live_properties_independently_of_weather_fallback() { + let pack = Pack::from_zip_bytes( + "original-weather-depth", + crate::pack::tests::zip(&[ + ( + "shaders/gbuffers_basic.vsh", + "#version 120\nvoid main(){gl_Position=vec4(0.0,0.0,0.0,1.0);}", + ), + ( + "shaders/gbuffers_basic.fsh", + "#version 120\nvoid main(){gl_FragColor=vec4(1.0);}", + ), + ]), + ) + .unwrap(); + let macros = Macros::standard(); + let inputs = GameInputs { + weather: Some(GbuffersInputs::default()), + ..Default::default() + }; + for (text, writes) in [ + ("rain.depth = true", true), + ("rain.depth = false", false), + ( + "#if 0\nrain.depth=true\n#else\nrain.depth = false\n#endif", + false, + ), + ("rain.depth=true\nrain.depth=false", false), + ("rain.depth=false\nrain.depth=true", true), + // These defaults preserve accepted main as a compatibility choice, not a claim + // about the external default contract, which the permitted packs don't establish. + ("", true), + ("rain.depth=invalid", true), + ] { + let properties = Properties::parse(text, ¯os); + let post = load_post(&pack, "", &properties, ¯os, &inputs); + assert_eq!(post.rain_depth, writes, "{text:?}"); + let weather = post.weather.expect("weather fallback loads"); + assert_eq!(weather.name, "gbuffers_basic"); + assert_eq!(weather.group, Group::Translucent); + assert_eq!(weather.blend, Some(TRANSLUCENT_BLEND)); + } + } + /// Without `gbuffers_clouds` the clouds draw with `gbuffers_textured`, else `gbuffers_basic`, /// blended; a pack with none of them leaves the clouds to the game. #[test] From 487d76b3f2191cdcb4857398404acebcdf7b6f27 Mon Sep 17 00:00:00 2001 From: oxi-does-dev Date: Wed, 4 Nov 2026 21:10:00 +0300 Subject: [PATCH 41/70] Run client mod commands from the control socket The control socket's chat line went straight to the server on a connected world, skipping the client mod commands, the Chat option's restrictions and the recent list that typed chat goes through. It now uses handle_chat_input like the chat screen, so /modcmd opens the mod's screen on a server and the rest of the line is sent only when no mod owns it. The control socket's command line also tries a client mod's command before the server console: the console cannot see a mod that is compiled into the client, so hudplus or another client mod's command failed there. AI agent: oxi-does-dev via OpenCode, GLM 5.3. --- crates/horizon-client/src/game/agent.rs | 5 +++++ crates/horizon-client/src/game/control.rs | 7 +++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index ac1cf328aa..ba4a4d4aa8 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -1658,6 +1658,11 @@ impl Game<'_> { if text.is_empty() { return Err("command TEXT".into()); } + // A native mod's command first, as typed chat does; the + // server's console cannot run a client mod's command. + if self.client_mod_command(text) { + return ok(json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) })); + } let command = format!("/{text}"); // Single player's console is its server thread's. if let Some(server) = &self.integrated { diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 15c758107d..c68eb5eeed 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -917,11 +917,10 @@ impl Game<'_> { } "chat" => { let text = line.trim_start().strip_prefix("chat").unwrap_or("").trim(); - if self.remote.is_some() { - self.send_chat(text); - return ok(); - } self.require_world()?; + // As if typed: a client mod's command runs before the line + // reaches the server, and the Chat option's restrictions and + // the recent list apply to a connected server too. self.handle_chat_input(text); Ok(Some( json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }), From e98ab97b731707177dc3e78a225123fbd683096a Mon Sep 17 00:00:00 2001 From: TheMrZZ <30944236+TheMrZZ@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:17:26 +0100 Subject: [PATCH 42/70] Preserve mob damage protection across NBT edits Keep the internal damage cooldown and previous-hit amount when a data command reconstructs a mob, matching an in-place vanilla entity load. Continue loading explicitly edited Health and HurtTime values from NBT. Add guarded command regression cases for equal hits, stronger hits, unchanged writes, fresh actors and measured cooldown expiry. Co-Authored-By: Codex gpt-6.1-sol --- .../horizon-client/src/game/commands/data.rs | 4 + tools/command_exec_corpus.py | 124 ++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 8eaaee8441..be4a1bd85f 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -347,6 +347,10 @@ impl Game<'_> { slot.extra.tame = old.extra.tame; slot.extra.owner = old.extra.owner; } + // Entity.load mutates the existing living entity; its damage + // cooldown and previous-hit amount are not read from saved NBT. + slot.invulnerable = old.invulnerable; + slot.last_hurt = old.last_hurt; slot.ai = old.ai; slot.extra.armor_attributes = old.extra.armor_attributes; slot.age = old.age; diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 499597afd0..ba0b6e051e 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -3910,6 +3910,129 @@ def sulfur_cube_fuse_ticks(immediate_power=False): +def mob_nbt_damage_cooldown(): + """Unrelated NBT edits retain a live mob's internal damage protection.""" + objective = 'rewrite_hit' + out = setup('/gamerule minecraft:spawn_mobs false', '/forceload remove 0 0 31 15') + + def check(command, result=1): + out.append(cmd(command, expect_result=result)) + + def action(command, accepted=1): + check(f'/scoreboard players set #r {objective} 31', 31) + check(f'/scoreboard players set #s {objective} 17', 17) + out.append(cmd(f'/execute store result score #r {objective} ' + f'store success score #s {objective} run {command.lstrip("/")}')) + check(f'/scoreboard players get #r {objective}', accepted) + check(f'/scoreboard players get #s {objective}', accepted) + + def state(health, hurt, custom='before', silent=False): + # Identity and actual values precede every rejected-hit observation. + check(f'/execute as {uid} if entity @s[type=minecraft:pig,tag=rewrite_hit_{serial}]') + check(f'/execute if data entity {uid} {{UUID:[I;294,0,0,{serial}],' + f'Health:{health}f,HurtTime:{hurt}s,Pos:[{x}d,200d,10.5d],' + f'NoAI:1b,NoGravity:1b,PersistenceRequired:1b,CustomName:"{custom}"}}') + check(f'/data get entity {uid} Health', health) + check(f'/data get entity {uid} HurtTime', hurt) + check(f'/data get entity {uid} CustomName', len(custom)) + check(f'/execute as {uid} {"if" if silent else "unless"} entity @s[nbt={{Silent:1b}}]') + out.extend(setup(f'/execute store result score #clock {objective} run time query gametime')) + check(f'/scoreboard players operation #clock {objective} -= #before {objective}', elapsed) + + check('/tick freeze') + check('/gamerule minecraft:spawn_mobs', 0) + check('/forceload add 0 0 31 15', 2) + out.append({'op': 'settle'}) + check('/execute if loaded 0 200 0') + check('/execute if loaded 31 200 15') + out.extend(setup('/kill @e[type=!player]', '/fill 0 199 0 31 203 15 air')) + check(f'/scoreboard objectives add {objective} dummy') + cases = ['no_write', 'name', 'silent', 'unchanged', 'stronger', 'expiry', + 'fresh_write', 'health_write', 'hurt_write'] + for serial, case in enumerate(cases, 1): + x = 2.5 + 3 * (serial - 1) + uid = f'00000126-0000-0000-0000-{serial:012x}' + elapsed = 0 + check(f'/execute if block {int(x)} 200 10 air') + check(f'/setblock {int(x)} 199 10 stone') + check(f'/execute if block {int(x)} 199 10 stone') + out.extend(setup(f'/execute store result score #before {objective} run time query gametime')) + check(f'/summon pig {x} 200 10.5 {{UUID:[I;294,0,0,{serial}],' + f'Tags:["rewrite_hit","rewrite_hit_{serial}"],NoAI:1b,NoGravity:1b,' + 'Silent:0b,PersistenceRequired:1b,Health:10f,CustomName:"before"}') + check(f'/data get entity {uid} UUID', 4) + check(f'/data get entity {uid} Pos', 3) + state(10, 0) + if case == 'fresh_write': + action(f'/data merge entity {uid} {{CustomName:"after"}}') + state(10, 0, 'after') + action(f'/damage {uid} 1 generic') + state(9, 10, 'after') + action(f'/damage {uid} 1 generic', 0) + state(9, 10, 'after') + else: + action(f'/damage {uid} 1 generic') + state(9, 10) + action(f'/damage {uid} 1 generic', 0) + state(9, 10) + custom, silent, health, hurt = 'before', False, 9, 10 + if case != 'no_write': + changes = {'name': 'CustomName:"after"', 'silent': 'Silent:1b', + 'unchanged': 'CustomName:"before"', 'stronger': 'Silent:1b', + 'expiry': 'CustomName:"after"', 'health_write': 'Health:6f,Silent:1b', + 'hurt_write': 'HurtTime:0s,CustomName:"after"'}[case] + action(f'/data merge entity {uid} {{{changes}}}', int(case != 'unchanged')) + if case in ('name', 'expiry', 'hurt_write'): + custom = 'after' + if case in ('silent', 'stronger', 'health_write'): + silent = True + if case == 'health_write': + health = 6 + if case == 'hurt_write': + hurt = 0 + state(health, hurt, custom, silent) + if case == 'expiry': + check('/tick step 9') + out.append({'op': 'tick', 'n': 9}) + elapsed = 9 + state(9, 1, custom) + action(f'/damage {uid} 1 generic', 0) + state(9, 1, custom) + check('/tick step 1') + out.append({'op': 'tick', 'n': 1}) + elapsed = 10 + state(9, 0, custom) + action(f'/damage {uid} 1 generic') + state(8, 10, custom) + elif case == 'stronger': + action(f'/damage {uid} 2 generic') + state(8, 10, custom, silent) + action(f'/damage {uid} 2 generic', 0) + state(8, 10, custom, silent) + else: + action(f'/damage {uid} 1 generic', 0) + state(health, hurt, custom, silent) + if case == 'no_write': + action(f'/damage {uid} 2 generic') + state(8, 10) + # Prove ordinary expiry independently of any NBT rewrite. + check('/tick step 9') + out.append({'op': 'tick', 'n': 9}) + elapsed = 9 + state(8, 1) + action(f'/damage {uid} 2 generic', 0) + state(8, 1) + check('/tick step 1') + out.append({'op': 'tick', 'n': 1}) + elapsed = 10 + state(8, 0) + action(f'/damage {uid} 2 generic') + state(6, 10) + out.extend(setup(f'/kill {uid}')) + return out + setup(f'/scoreboard objectives remove {objective}', + '/fill 0 199 0 31 203 15 air', '/forceload remove 0 0 31 15', '/tick unfreeze') + + def mob_full_resistance_hits(): lines = [cmd('/forceload add 0 0 31 31'), {'op': 'settle'}] for x, z in [(0, 0), (16, 0), (0, 16), (16, 16)]: @@ -4625,6 +4748,7 @@ def groups(): ('recipe special arguments', recipe_special_arguments()), ('progression', progression()), ('functions and packs', functions_and_packs()), + ('mob NBT damage cooldown', mob_nbt_damage_cooldown()), ('mob full resistance hits', mob_full_resistance_hits()), ('sulfur cube loaded health', sulfur_cube_loaded_health()), ('function return feedback', function_return_feedback()), From 26860a60dfa800e8757777929d8dd1fe975e4a17 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Wed, 4 Nov 2026 17:19:14 +0100 Subject: [PATCH 43/70] Dropped items and end crystals draw their phase from their own random ItemEntity's bobOffs and EndCrystal's time come from the entity's own random in vanilla. The client drew them from the level's random, shared with splashes, eating and other client effects, so the same dropped item could bob and spin from a different phase from run to run. Each now draws from a random seeded from its id and position, as a client mob's is (Mob::new, now mobs::entity_random). Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/remote.rs | 5 ++++- .../src/game/remote_projectiles.rs | 8 +++++--- crates/horizon-client/src/mobs.rs | 18 ++++++++++++------ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 70400eca0d..1e0a1edff2 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5579,7 +5579,10 @@ impl Game<'_> { return; }; let at = Vec3::new(p[0], p[1], p[2]); - let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0; + // ItemEntity's constructor draws bobOffs from the item's own random. + let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float() + * std::f32::consts::PI + * 2.0; self.dropped.push(ItemEntity { rotation: [0.0, 0.0], join: Default::default(), diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index b798ee3fab..d4ecf0c3da 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -360,9 +360,11 @@ impl Game<'_> { // FishingHook.recreateFromPacket: the owner's id is the data. "minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, velocity, data), "minecraft:end_crystal" => { - // EndCrystal's constructor starts `time` random, so each - // placed crystal spins from its own phase. - let time = self.world.random.next_int_bound(100_000).unwrap_or(0); + // EndCrystal's constructor starts `time` from the crystal's own + // random, so each placed crystal spins from its own phase. + let time = horizon_client::mobs::entity_random(id, at) + .next_int_bound(100_000) + .unwrap_or(0); self.crystals.push(super::dragon::Crystal { id, position: at, diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 934d13ebb3..8550b65555 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -2827,14 +2827,20 @@ impl<'a> Teams<'a> { } } } +/// `Entity.random` of a client's copy of entity `id` first seen at `position`: vanilla's is its +/// own (`RandomSource.create()`), drawn on by nothing else; this one is seeded from the two. +pub fn entity_random(id: u32, position: Vec3) -> LegacyRandom { + let mut random = LegacyRandom::new( + i64::from(id).wrapping_mul(0x5DEE_CE66D) + ^ (position.x.to_bits() as i64) + ^ (position.z.to_bits() as i64).rotate_left(17), + ); + let _ = random.next_int(); + random +} impl Mob { pub fn new(id: u32, kind: Arc, position: Vec3, yaw: f32) -> Self { - let mut random = LegacyRandom::new( - i64::from(id).wrapping_mul(0x5DEE_CE66D) - ^ (position.x.to_bits() as i64) - ^ (position.z.to_bits() as i64).rotate_left(17), - ); - let _ = random.next_int(); + let random = entity_random(id, position); let mut mob = Self { join: Default::default(), id, From 05a83660175e63bed17683f0cf6da9f3048bd8d5 Mon Sep 17 00:00:00 2001 From: DUzzL <98381970+DUzzL@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:29:55 +0100 Subject: [PATCH 44/70] Refuse breaking game master blocks without game master permission --- crates/horizon-client/src/game.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 159eb7fb93..c9de4f98f7 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -6160,8 +6160,15 @@ impl Game<'_> { { return Ok(()); } - // ServerPlayerGameMode.destroyBlock. + // ServerPlayerGameMode.destroyBlock (and MultiPlayerGameMode.destroyBlock + // on the client): a GameMasterBlock stays when the player cannot use + // game master blocks (Player.canUseGameMasterBlocks). let state = self.world.block(pos[0], pos[1], pos[2]); + if !self.can_use_game_master_blocks() + && is_game_master_block(&self.res.facts().get(state).name) + { + return Ok(()); + } // Native mods, once the game allowed the break: a canceled one keeps // the block, and a guest's acknowledgement puts it back in its world. let mut canceled = false; @@ -22162,6 +22169,19 @@ fn debug_crosshair_drawn( enabled && first_person && !hide_gui && !panorama } +/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure +/// block, jigsaw and the test blocks. +fn is_game_master_block(name: &str) -> bool { + horizon_client::command_block::Mode::of_block(name).is_some() + || matches!( + name, + "minecraft:structure_block" + | "minecraft:jigsaw" + | "minecraft:test_block" + | "minecraft:test_instance_block" + ) +} + #[cfg(test)] mod tests { #[test] From e2c6c17bd7d8093e72c85c08dc45f53ac8e0f5e3 Mon Sep 17 00:00:00 2001 From: phexuss Date: Wed, 4 Nov 2026 19:30:18 +0100 Subject: [PATCH 45/70] fix(skins): keep the skin when only its cape fails, and retry failed textures --- crates/horizon-client-render/src/skins.rs | 249 +++++++++++++++++++--- 1 file changed, 223 insertions(+), 26 deletions(-) diff --git a/crates/horizon-client-render/src/skins.rs b/crates/horizon-client-render/src/skins.rs index 2c5e928597..90eb6ba5ce 100644 --- a/crates/horizon-client-render/src/skins.rs +++ b/crates/horizon-client-render/src/skins.rs @@ -709,15 +709,24 @@ pub fn lookup( ]; for (kind, url) in urls { let Some(url) = url else { continue }; - // registerTexture computes the hash first; a bad URL fails the lookup. - let hash = texture_hash(url)?; + // registerTexture computes the hash first. A cape or elytra URL the + // hash cannot take is that texture's problem alone: vanilla's lookup + // goes on to the next, and only the skin texture decides the default. + let Some(hash) = texture_hash(url) else { + if kind == Kind::Skin { + return None; + } + continue; + }; if kind == Kind::Skin { skin = Some(skin_location(&hash)); } match download(kind, url) { - Download::Failed => return None, + // A cape or elytra that cannot be fetched is simply not drawn; only + // the skin itself falls back to the default one (`SkinManager.get`). + Download::Failed if kind == Kind::Skin => return None, + Download::Failed | Download::Ready => {} Download::Pending => state = Download::Pending, - Download::Ready => {} } } if state != Download::Ready || require_secure && !textures.signed { @@ -809,6 +818,12 @@ pub struct Downloader { resource_root: std::path::PathBuf, /// How each `patch/` texture of the resource packs loaded, by location. resources: std::collections::HashMap, + /// When each failed texture download was last tried, so it can be retried: + /// a transient network error must not pin a player to the default skin for + /// the rest of the session. + failed: std::collections::HashMap<(Kind, String), std::time::Instant>, + /// How long a failed texture download waits before it is tried again. + retry_after: std::time::Duration, } /// `images` is bounded only so a long session on a busy server does not grow /// without end; the disk cache holds everything it drops. @@ -839,6 +854,8 @@ impl Downloader { drawn: Default::default(), resource_root: Default::default(), resources: Default::default(), + failed: Default::default(), + retry_after: std::time::Duration::from_secs(60), } } /// The resource packs changed (or were first loaded) at `root`: patch @@ -879,7 +896,8 @@ impl Downloader { }; self.resources.insert(location.to_owned(), state); } - /// How the download of `url` stands, starting it the first time. + /// How the download of `url` stands, starting it the first time. A + /// failure is permanent for `retry_after`, then tried again. pub fn state(&mut self, kind: Kind, url: &str) -> Download { while let Ok((key, image)) = self.done.try_recv() { let state = match image { @@ -889,9 +907,13 @@ impl Downloader { Kind::Cape => self.queue(cape_location(&key.1), rgba), Kind::Elytra => {} } + self.failed.remove(&key); Download::Ready } - None => Download::Failed, + None => { + self.failed.insert(key.clone(), std::time::Instant::now()); + Download::Failed + } }; self.states.insert(key, state); } @@ -899,34 +921,57 @@ impl Downloader { return Download::Failed; }; let key = (kind, hash); - if let Some(state) = self.states.get(&key) { - return *state; + if let Some(state) = self.states.get(&key).copied() { + let due = self + .failed + .get(&key) + .is_none_or(|at| at.elapsed() >= self.retry_after); + if state != Download::Failed || !due { + return state; + } + self.states.remove(&key); + self.failed.remove(&key); } self.states.insert(key.clone(), Download::Pending); let digest: String = skin_location(&key.1)["skins/".len()..].to_owned(); - let file = self - .root - .join(if digest.len() > 2 { &digest[..2] } else { "xx" }) - .join(&digest); - let (url, send) = (url.to_owned(), self.send.clone()); + let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone()); std::thread::spawn(move || { - let bytes = std::fs::read(&file).ok().or_else(|| { - let body = http_get(&url)?; - let _ = std::fs::create_dir_all(file.parent()?); - let _ = std::fs::write(&file, &body); - Some(body) - }); - let image = bytes - .and_then(|b| crate::hud::decode_bytes(&b).ok()) - .and_then(|(w, h, rgba)| match kind { - Kind::Skin => process_legacy_skin(w, h, &rgba), - Kind::Cape => process_cape(w, h, &rgba), - Kind::Elytra => Some(rgba), - }); + let image = Self::load_texture(&root, kind, &digest, &url, http_get); let _ = send.send((key, image)); }); Download::Pending } + /// One texture's pixels: the disk cache's body if it decodes, else a fetch + /// (`SkinTextureDownloader.downloadSkin`). Only a body that decodes is + /// written to the cache: an error page or a half image must not be what a + /// later start reads instead of fetching again. + fn load_texture( + root: &std::path::Path, + kind: Kind, + digest: &str, + url: &str, + fetch: impl FnOnce(&str) -> Option>, + ) -> Option> { + let file = root + .join(if digest.len() > 2 { &digest[..2] } else { "xx" }) + .join(digest); + let decode = |bytes: &[u8]| -> Option> { + let (w, h, rgba) = crate::hud::decode_bytes(bytes).ok()?; + match kind { + Kind::Skin => process_legacy_skin(w, h, &rgba), + Kind::Cape => process_cape(w, h, &rgba), + Kind::Elytra => Some(rgba), + } + }; + if let Some(image) = std::fs::read(&file).ok().and_then(|b| decode(&b)) { + return Some(image); + } + let body = fetch(url)?; + let image = decode(&body)?; + let _ = std::fs::create_dir_all(file.parent()?); + let _ = std::fs::write(&file, &body); + Some(image) + } /// Keeps a decoded skin for `again`, dropping the oldest past the bound. fn remember(&mut self, location: String, rgba: Vec) { if !self.images.contains_key(&location) { @@ -1516,4 +1561,156 @@ mod tests { "abc" ); } + + /// A cape or elytra that cannot be fetched takes only itself out of the + /// picture: the player's skin still shows. Only the skin texture's own + /// failure is the default skin. + #[test] + fn a_failed_cape_does_not_hide_the_skin() { + let textures = super::Textures { + skin: Some("https://textures.minecraft.net/texture/skin1".into()), + cape: Some("https://textures.minecraft.net/texture/cape1".into()), + elytra: None, + signed: true, + slim: false, + }; + let skin = super::lookup(&textures, true, |kind, _| match kind { + super::Kind::Skin => super::Download::Ready, + _ => super::Download::Failed, + }); + assert_eq!(skin, Some(super::skin_location("skin1"))); + // A cape URL the hash cannot take is the same: the skin shows. + let bad_url = super::Textures { + cape: Some("no-scheme".into()), + ..textures.clone() + }; + assert_eq!( + super::lookup(&bad_url, true, |_, _| super::Download::Ready), + Some(super::skin_location("skin1")) + ); + // The skin itself failing is still the default skin. + assert_eq!( + super::lookup(&textures, true, |_, _| super::Download::Failed), + None + ); + } + + /// A 1x1 PNG of `rgba`. + fn png(rgba: [u8; 4]) -> Vec { + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, 1, 1); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().unwrap(); + writer.write_image_data(&rgba).unwrap(); + writer.finish().unwrap(); + out + } + + /// A downloaded body reaches the disk cache only if it decodes; a broken + /// cached file is fetched again and replaced. A failed fetch writes nothing. + #[test] + fn the_texture_cache_holds_only_what_decodes() { + let root = + std::env::temp_dir().join(format!("horizon-test-skin-cache-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let body = png([7, 8, 9, 255]); + let file = root.join("ab").join("abcdef"); + let load = |fetched: Option<&[u8]>| { + let fetched = fetched.map(<[u8]>::to_vec); + super::Downloader::load_texture( + &root, + super::Kind::Elytra, + "abcdef", + "https://example/texture/abc.png", + |_| fetched.clone(), + ) + }; + assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255])); + assert_eq!(std::fs::read(&file).unwrap(), body); + // From then on the cache answers; no fetch is needed. + assert_eq!(load(None), Some(vec![7, 8, 9, 255])); + // A broken file is fetched again and replaced, not kept. + std::fs::write(&file, b"not a png").unwrap(); + assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255])); + assert_eq!(std::fs::read(&file).unwrap(), body); + // A body that does not decode is not cached at all. + std::fs::remove_dir_all(&root).unwrap(); + assert_eq!(load(Some(b"not a png")), None); + assert!(!file.exists()); + } + + fn wait_state( + d: &mut super::Downloader, + kind: super::Kind, + url: &str, + want: super::Download, + ) -> super::Download { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let state = d.state(kind, url); + if state == want || std::time::Instant::now() > deadline { + return state; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + /// A texture download that failed is tried again after the wait, instead of + /// pinning the player to the default skin for the rest of the session. + #[test] + fn a_failed_download_is_retried_after_the_wait() { + use std::io::{BufRead, BufReader, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let body = png([1, 2, 3, 4]); + let served = std::sync::Arc::new(AtomicUsize::new(0)); + let count = served.clone(); + std::thread::spawn(move || { + for (i, stream) in listener.incoming().take(2).enumerate() { + let mut stream = stream.unwrap(); + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + while reader.read_line(&mut String::new()).unwrap() > 2 {} + count.fetch_add(1, Ordering::SeqCst); + if i == 0 { + stream + .write_all( + b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .unwrap(); + } else { + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(head.as_bytes()).unwrap(); + stream.write_all(&body).unwrap(); + } + } + }); + let root = + std::env::temp_dir().join(format!("horizon-test-skin-retry-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let mut d = super::Downloader::new(root); + let url = format!("{base}/texture/retry.png"); + assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending); + assert_eq!( + wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Failed), + super::Download::Failed + ); + // Within the wait the failure stands: no request goes out. + assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Failed); + assert_eq!(served.load(Ordering::SeqCst), 1); + // After it, the download is started again and takes the good answer. + d.retry_after = std::time::Duration::ZERO; + assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending); + assert_eq!( + wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Ready), + super::Download::Ready + ); + assert_eq!(served.load(Ordering::SeqCst), 2); + } } From 84ccda46f1a70e6cbbee06c068607349bd5c8030 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Wed, 4 Nov 2026 18:08:31 +0100 Subject: [PATCH 46/70] A lightning flash fades in a frozen level, and its border waits ClientLevel.tick fades the sky flash on every tick, and moves the world border's size change only while the level runs normally. The client did it the other way round: after a /tick freeze, a flash that had just struck lit the sky for as long as the level stayed frozen, and a border change went on shrinking or growing on this side while the server's held. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 3 ++- crates/horizon-client/src/game/levels.rs | 5 ++--- crates/horizon-client/src/game/remote.rs | 11 +++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 4756bf29e8..352a0edd39 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -7669,8 +7669,9 @@ impl Game<'_> { self.world.weather.tick_timers(&mut self.world.random); } self.world.weather.tick_levels(); - self.tick_weather_effects(); } + // ClientLevel.tick: the sky flash fades in a frozen level too. + self.tick_weather_effects(); let _ = self.finish_player_autosaves(false); // MinecraftServer.tickServer's countdown, then autoSave: three // hundred seconds of ticks at the rate the server runs at now. diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index fbcbf55bf5..95defa5ebb 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -486,9 +486,8 @@ impl Game<'_> { self.tick() } else { let running = running.unwrap_or_else(|| self.clock.world_runs()); - if running { - self.tick_weather_effects(); - } + // ClientLevel.tick: the sky flash fades in a frozen level too. + self.tick_weather_effects(); self.tick_world(running) } }, diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 70400eca0d..761deaebc8 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -5935,8 +5935,10 @@ impl Game<'_> { for pop in &mut self.hotbar_pop { *pop = pop.saturating_sub(1); } - // ClientLevel.tick: the border's size change. - self.border.tick(); + // ClientLevel.tick: the border's size change, while the level runs. + if running { + self.border.tick(); + } self.player_border(); self.tick_remote_entities(running); self.tick_random_textures(); @@ -6920,6 +6922,8 @@ impl Game<'_> { for (item, ticks, at, eye, pitch, yaw) in using { self.remote_player_eating(&item, ticks, at, eye, pitch, yaw); } + // ClientLevel.tick: the sky flash fades in a frozen level too. + self.flash = self.flash.saturating_sub(1); if running { self.guardian_bubbles(); // The dragon: LivingEntity's interpolation, inside its aiStep. @@ -6983,8 +6987,7 @@ impl Game<'_> { }, ); self.tick_remote_projectiles(&mut step); - // LightningBolt.tick on a client, and the sky flash fading. - self.flash = self.flash.saturating_sub(1); + // LightningBolt.tick on a client. self.tick_bolts(); } lerps.retain(|_, l| l.steps > 0); From e49d129642f214457565366a3f6678a879931bf8 Mon Sep 17 00:00:00 2001 From: phexuss Date: Wed, 4 Nov 2026 19:44:18 +0100 Subject: [PATCH 47/70] fix(launcher): reuse the Wardrobe session until it expires --- launcher/src/wardrobe.rs | 51 +++++++++++++ launcher/src/wardrobe/tests.rs | 130 +++++++++++++++++++++++++++++++++ launcher/src/wardrobe_page.rs | 28 +++---- 3 files changed, 193 insertions(+), 16 deletions(-) diff --git a/launcher/src/wardrobe.rs b/launcher/src/wardrobe.rs index f0148fafaf..5a555aa601 100644 --- a/launcher/src/wardrobe.rs +++ b/launcher/src/wardrobe.rs @@ -301,6 +301,10 @@ impl Session { pub fn live(&self) -> bool { Instant::now() < self.until } + /// Whether this is the account `uuid`'s session and it has not run out. + pub fn for_account(&self, uuid: &str) -> bool { + self.uuid == uuid && self.live() + } } pub struct Service { @@ -433,6 +437,53 @@ impl Service { Ok(()) } + /// Send the changes waiting and read the account's own answer with `session`, which is kept + /// while it is live: the account is proved with Mojang once per service session, not once + /// per refresh. The service's 401 (its session ended) signs in once more and retries; any + /// other failure stands. + pub fn sync_with_session( + &self, + name: &str, + uuid: &str, + session: &mut Option>, + saved: &mut Saved, + join: impl Fn(&str) -> Result<(), String>, + ) -> Result<(), Error> { + let mut renewed = false; + loop { + let current = match session.clone().filter(|s| s.for_account(uuid)) { + Some(current) => current, + None => { + let signed = std::sync::Arc::new(self.sign_in(name, &join)?); + *session = Some(signed.clone()); + signed + } + }; + if let Err(e) = self.sync(¤t, saved) { + if e == Error::SignedOut && !renewed { + *session = None; + renewed = true; + continue; + } + return Err(e); + } + match self.me(¤t) { + Ok(mut me) => { + for change in &saved.pending { + me.apply(change); + } + saved.me = Some(me); + return Ok(()); + } + Err(Error::SignedOut) if !renewed => { + *session = None; + renewed = true; + } + Err(e) => return Err(e), + } + } + } + /// An item's file, from `cache` (`-.json`, kept by hash: it never changes for one) /// or the service, then kept there. An entry whose id or hash isn't `Entry::plain` is refused /// before the disk or the service is touched. diff --git a/launcher/src/wardrobe/tests.rs b/launcher/src/wardrobe/tests.rs index 70c65847c3..a851736525 100644 --- a/launcher/src/wardrobe/tests.rs +++ b/launcher/src/wardrobe/tests.rs @@ -18,6 +18,8 @@ struct Mock { log: Vec<(String, String, String, String)>, /// The service fails on its side (503) for the account's changes. down: bool, + /// The service refuses every sign-in (401), as when it is misconfigured. + sign_in_fails: bool, /// "Show my cosmetics", as the service keeps it. visible: bool, /// The file every `?hash=` request answers, whatever its path says. @@ -45,6 +47,7 @@ fn route(m: &mut Mock, method: &str, path: &str, auth: &str, body: &str) -> (u16 "claim": {"state": m.claim, "items": ["hat"], "starts_at": null, "ends_at": null}, }), ), + ("POST", "/v1/auth/start") if m.sign_in_fails => (401, json!({"error": "no_session"})), ("POST", "/v1/auth/start") => (200, json!({"server_id": "issued-1", "expires_in": 120})), ("POST", "/v1/auth/finish") => { let v: Value = serde_json::from_str(body).unwrap(); @@ -450,3 +453,130 @@ fn the_history_keeps_its_orders_and_an_old_answer_still_reads() { .unwrap(); assert!(old.purchases.is_empty()); } + +/// `join` for `sync_with_session`: tells Mojang the account joined `id`, as a server would. +fn joined(m: &Arc>) -> impl Fn(&str) -> Result<(), String> { + let m = m.clone(); + move |id: &str| { + m.lock().unwrap().joined.push(id.to_owned()); + Ok(()) + } +} + +/// How often the service was asked to finish a sign-in. +fn finishes(m: &Arc>) -> usize { + m.lock() + .unwrap() + .log + .iter() + .filter(|(_, path, ..)| path == "/v1/auth/finish") + .count() +} + +/// A live session is reused: the account is proved with Mojang once per service session, not +/// once per refresh (the Wardrobe used to sign in again every few minutes). +#[test] +fn a_live_session_is_reused_between_refreshes() { + let (m, service) = serve(|_| {}); + let mut saved = Saved::default(); + let mut session = None; + service + .sync_with_session("Notch", UUID, &mut session, &mut saved, joined(&m)) + .unwrap(); + assert_eq!(finishes(&m), 1); + assert_eq!(m.lock().unwrap().joined.len(), 1); + service + .sync_with_session("Notch", UUID, &mut session, &mut saved, joined(&m)) + .unwrap(); + assert_eq!( + finishes(&m), + 1, + "a live session was proved with Mojang again" + ); + assert_eq!(m.lock().unwrap().joined.len(), 1); +} + +/// A session that ran out, or one for another account, signs in again. +#[test] +fn an_ended_session_signs_in_again() { + let (m, service) = serve(|_| {}); + let mut saved = Saved::default(); + let ended = Arc::new(Session { + token: "svc-session".into(), + until: Instant::now() - Duration::from_secs(1), + uuid: UUID.into(), + name: "Notch".into(), + }); + let mut session = Some(ended); + service + .sync_with_session("Notch", UUID, &mut session, &mut saved, joined(&m)) + .unwrap(); + assert_eq!(finishes(&m), 1); + assert!(session.as_ref().is_some_and(|s| s.live())); + // Another account's live session is not this account's. + let other = Arc::new(Session { + token: "svc-session".into(), + until: Instant::now() + Duration::from_secs(600), + uuid: "00000000000000000000000000000000".into(), + name: "Other".into(), + }); + let mut session = Some(other); + service + .sync_with_session("Notch", UUID, &mut session, &mut saved, joined(&m)) + .unwrap(); + assert_eq!(finishes(&m), 2); + assert_eq!(session.as_ref().unwrap().uuid, UUID); +} + +/// The service answering 401 ends the session: the launcher signs in once more and retries the +/// call. +#[test] +fn a_401_signs_in_again_and_retries_once() { + let (m, service) = serve(|_| {}); + let mut saved = Saved::default(); + let stale = Arc::new(Session { + token: "ended".into(), + until: Instant::now() + Duration::from_secs(600), + uuid: UUID.into(), + name: "Notch".into(), + }); + let mut session = Some(stale); + service + .sync_with_session("Notch", UUID, &mut session, &mut saved, joined(&m)) + .unwrap(); + assert_eq!(finishes(&m), 1); + assert_eq!(session.as_ref().unwrap().token, "svc-session"); + assert!(saved.me.is_some()); +} + +/// A service that refuses every sign-in is not retried in a loop: the 401 stands. +#[test] +fn a_refused_sign_in_is_not_retried_forever() { + let (m, service) = serve(|m| m.sign_in_fails = true); + let stale = Arc::new(Session { + token: "ended".into(), + until: Instant::now() + Duration::from_secs(600), + uuid: UUID.into(), + name: "Notch".into(), + }); + let mut session = Some(stale); + let err = service + .sync_with_session( + "Notch", + UUID, + &mut session, + &mut Saved::default(), + joined(&m), + ) + .unwrap_err(); + assert_eq!(err, Error::SignedOut); + // One refused 401, one refused sign-in: no second round. + let requested: Vec = m + .lock() + .unwrap() + .log + .iter() + .map(|(_, path, ..)| path.clone()) + .collect(); + assert_eq!(requested, ["/v1/me", "/v1/auth/start"]); +} diff --git a/launcher/src/wardrobe_page.rs b/launcher/src/wardrobe_page.rs index f888e13c88..be89690703 100644 --- a/launcher/src/wardrobe_page.rs +++ b/launcher/src/wardrobe_page.rs @@ -160,17 +160,13 @@ fn refresh(service: &Service, account: &account::Account, trip: &mut Trip) -> Re }; trip.items = fetch_items(service, &catalogue); trip.saved.catalogue = Some(catalogue); - let session = Arc::new(service.sign_in(&account.name, |id| { - account::session::join_server(&account.uuid, id) - })?); - trip.session = Some(session.clone()); - service.sync(&session, &mut trip.saved)?; - let mut me = service.me(&session)?; - for change in &trip.saved.pending { - me.apply(change); - } - trip.saved.me = Some(me); - Ok(()) + service.sync_with_session( + &account.name, + &account.uuid, + &mut trip.session, + &mut trip.saved, + |id| account::session::join_server(&account.uuid, id), + ) } /// `founder_visor` โ†’ "Founder Visor", for the preview's made-up names. @@ -253,11 +249,13 @@ impl Launcher { let keep = self.wardrobe.take().filter(|w| w.uuid == account.uuid); let saved = Saved::load(&dir(), &account.uuid); let (slot, turn) = keep.as_ref().map_or(("head", 0), |w| (w.slot, w.turn)); + // A session still live from the last visit is reused by the refresh. + let session = keep.as_ref().and_then(|w| w.session.clone()); self.wardrobe = Some(Wardrobe { uuid: account.uuid.clone(), saved: saved.clone(), items: keep.map(|w| w.items).unwrap_or_default(), - session: None, + session: session.clone(), skin: skin(&account), status: Status::Loading, busy: true, @@ -271,7 +269,7 @@ impl Launcher { let mut trip = Trip { saved, items: HashMap::new(), - session: None, + session, result: Ok(()), }; trip.result = refresh(&service, &account, &mut trip); @@ -362,9 +360,7 @@ impl Launcher { if !trip.items.is_empty() { w.items = trip.items; } - if trip.session.is_some() { - w.session = trip.session; - } + w.session = trip.session; w.saved = trip.saved; if trip.result.is_ok() { tell_the_game(&w.uuid, &w.saved); From ad491f1bf06a9e1a9c74d179e5e30770cc01d08a Mon Sep 17 00:00:00 2001 From: itarqos5 Date: Wed, 4 Nov 2026 18:50:56 +0000 Subject: [PATCH 48/70] perf(far terrain): a previewed column spends one span on a dampening run `shade_sky` records a dampening run's skylight a span per light level, so a sea 15 blocks deep or more becomes 16 spans to carry a gradient down its own side. Over real seed-1234 terrain that is 64% to 78% of every span a far column stores: 18.7, 12.3, 12.0 and 12.6 spans a column at tile levels 0, 2, 4 and 6, where the runs themselves are 4.1 to 4.5. A previewed column now spends one span on the run and drains the same light out of the bottom of it, so every span under the run is lit exactly as before -- which is what the per-level walk was for ("lit seabeds and the ground under canopies"). It reaches no face that is drawn: the spans inside a run have neither a top nor a bottom face (`hides` matches on state, not light), and a run's side is exposed at its top, where its light is brightest, with `add_side` raising anything above the neighbour's top to the neighbour's own sky. Chunk columns keep the per-level walk. They are what `Column::reduced` merges, and a reduction takes the brightest light of each slice, so one bright span for a whole sea would light its bed as brightly as its surface. A previewed column already stands for a whole far cell and is never reduced against its neighbours. Measured on 4448 far tiles at lodDistance 256, Full far detail, two runs each with the heap census: `Far sampling` 302.9 and 309.6 MiB against 264.5 and 268.2, and the resident set 1386 MiB against 1252 and 1242. Four pinned views are identical across the change to within the animation noise floor, while two runs of the same build differ by 7% of their pixels -- far-tile resolution is not reproducible between launches, so the controls are in the pull request. `shade_runs` takes the block-state test as a predicate so the shading can be tested without prepared resources. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011paghWkrnyQRBhJRodZpG2 --- crates/horizon-client-render/src/lod/mesh.rs | 4 +- crates/horizon-client/src/vanillagen/lod.rs | 147 ++++++++++++++++++- 2 files changed, 142 insertions(+), 9 deletions(-) diff --git a/crates/horizon-client-render/src/lod/mesh.rs b/crates/horizon-client-render/src/lod/mesh.rs index 68de4f683c..75efae0ba6 100644 --- a/crates/horizon-client-render/src/lod/mesh.rs +++ b/crates/horizon-client-render/src/lod/mesh.rs @@ -354,7 +354,9 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column { if !fluid(top) { spans.push(ground(top)); } else if let Some(under) = below.iter().rev().find(|&span| !fluid(span)) { - // Shaded water is a span per light level; the bed is under all of it. + // The bed is under all of the water, however many spans the water + // takes: a chunk's own columns shade it a span a light level, a + // previewed far column one span for the run. spans.push(ground(under)); spans.push(Span { min: under.max, diff --git a/crates/horizon-client/src/vanillagen/lod.rs b/crates/horizon-client/src/vanillagen/lod.rs index d0f2527442..92b9d68871 100644 --- a/crates/horizon-client/src/vanillagen/lod.rs +++ b/crates/horizon-client/src/vanillagen/lod.rs @@ -342,11 +342,37 @@ fn columns(chunk: &ProtoChunk, biome_map: &[u16], dimension: &str) -> Arc<[Colum } } if dimension != "minecraft:the_nether" { - shade_sky(&mut column, states); + shade_sky(&mut column, states, Dampening::PerLevel); } column })) } +/// How much of its own dampening a run records, for [`shade_sky`]. Either +/// way the light it passes on to the spans under it is the same, so seabeds +/// and the ground under canopies are lit identically. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Dampening { + /// A span a level, as a real chunk's light has them. A column that + /// `Column::reduced` merges with its neighbours needs this: a reduction + /// takes the brightest light of each slice it merges, so one bright span + /// for a whole sea lit its bed as brightly as its surface. + PerLevel, + /// One span for the run, lit as its top is. A previewed column already + /// stands for a whole far cell and is never reduced against neighbouring + /// columns, so nothing merges a run with the bed under it, and the levels + /// between its top and its bottom reach no face that is drawn: the spans + /// inside a run have neither a top nor a bottom face (`hides` matches on + /// state, not light), and the side of a run is exposed at its top, where + /// the light is the run's brightest -- below that the neighbour hides it, + /// and above it `add_side` raises the light to the neighbour's own sky. + /// + /// Over real seed-1234 terrain, nine tiles each at levels 0, 2, 4 and 6, + /// `PerLevel` leaves 18.7, 12.3, 12.0 and 12.6 spans a column where the + /// runs themselves are 4.1 to 4.5: 64% to 78% of every stored far span + /// was a gradient step, and a sea 15 blocks deep or more spent 16 spans + /// on one. + PerRun, +} /// Skylight as it reaches the top of each span straight down from the sky: /// fluids and leaves take a level per block, as vanilla's dampening does. /// Full skylight everywhere lit seabeds and the ground under canopies as if @@ -355,10 +381,21 @@ fn columns(chunk: &ProtoChunk, biome_map: &[u16], dimension: &str) -> Arc<[Colum // stop it: the faces under them that far terrain shows are cliffs and // overhangs, which vanilla lights sideways from the open air, and they went // black. The lighting engine for exact light. -fn shade_sky(column: &mut Column, states: &GenBlockStates) { - // Dimming blocks get a span a level, as a real chunk's light has them, - // down to the dark: one lit span for a whole sea lit its bed, where - // `Column::reduced` merges water and sand, as brightly as its surface. +fn shade_sky(column: &mut Column, states: &GenBlockStates, dampening: Dampening) { + shade_runs( + column, + |id| { + let state = states.get(id); + // A solid block stops no light here, as the note above says; a + // fluid or leaves take a level a block. + !(state.motion && state.no_leaves) + }, + dampening, + ); +} +/// [`shade_sky`] over a predicate rather than the generator's block states, +/// which have no constructor outside `horizon-world`. +fn shade_runs(column: &mut Column, dampens: impl Fn(BlockStateId) -> bool, dampening: Dampening) { let mut sky = 15u32; let mut shaded = Vec::with_capacity(column.spans.len()); for span in column.spans.iter().rev() { @@ -368,11 +405,18 @@ fn shade_sky(column: &mut Column, states: &GenBlockStates) { light: (span.light & !0xf0) | (sky << 4) as u16, ..*span }; - let state = states.get(span.state); - if state.motion && state.no_leaves { + if !dampens(span.state) { shaded.push(lit(span.min, span.max, sky)); continue; } + if dampening == Dampening::PerRun { + shaded.push(lit(span.min, span.max, sky)); + // What the per-level walk below leaves, so the spans under the + // run are lit the same either way. + let height = i32::from(span.max) - i32::from(span.min); + sky = sky.saturating_sub(height.max(0) as u32); + continue; + } let mut top = span.max; while top > span.min { let bottom = if sky == 0 { span.min } else { top - 1 }; @@ -642,7 +686,11 @@ impl VanillaLodSampler { self.vegetation.apply(&mut column, x, z, width, light); self.islands.apply(&mut column, x, z, width, light); if !nether { - shade_sky(&mut column, self.generator.generator.data().block_states()); + shade_sky( + &mut column, + self.generator.generator.data().block_states(), + Dampening::PerRun, + ); } column } @@ -650,6 +698,89 @@ impl VanillaLodSampler { #[cfg(test)] mod tests { + /// Either dampening lights everything under a run the same, so seabeds + /// and the ground under canopies keep the light they had; `PerRun` spends + /// one span on the run itself where `PerLevel` spends one a level. + #[test] + fn a_run_passes_on_the_same_light_however_much_of_it_it_records() { + use super::*; + let span = |min: i16, max: i16, state: u32| Span { + min, + max, + state: BlockStateId(state), + biome: 0, + light: 240, + }; + // State 1 dampens (water), state 2 does not (sand). + let dampens = |id: BlockStateId| id == BlockStateId(1); + let sky = |span: &Span| (span.light & 0xf0) >> 4; + for depth in [1i16, 2, 5, 14, 15, 16, 40] { + let bed = span(0, 4, 2); + let water = span(4, 4 + depth, 1); + let mut level = Column { + surface: None, + spans: vec![bed, water].into(), + }; + let mut run = level.clone(); + shade_runs(&mut level, dampens, Dampening::PerLevel); + shade_runs(&mut run, dampens, Dampening::PerRun); + + // The bed is one span either way, lit identically: this is what + // the per-level walk was added for. + assert_eq!(level.spans[0], run.spans[0], "depth {depth}: the bed"); + assert_eq!( + sky(&level.spans[0]), + 15u16.saturating_sub(depth.max(0) as u16), + "depth {depth}: the bed takes the light the run let through" + ); + // The run's top face is the topmost span's, and takes the light + // arriving at the top of the run: full sky, nothing above it. + assert_eq!(sky(level.spans.last().unwrap()), 15, "depth {depth}"); + assert_eq!(sky(run.spans.last().unwrap()), 15, "depth {depth}"); + assert_eq!(level.spans.last().unwrap().max, 4 + depth); + assert_eq!(run.spans.last().unwrap().max, 4 + depth); + // One span for the run, against one a level down to the dark. + assert_eq!(run.spans.len(), 2, "depth {depth}"); + // A span a block while the sky lasts (15 levels), then one for + // whatever of the run is left below the dark. + let walked = if depth <= 15 { depth as usize } else { 16 }; + assert_eq!( + level.spans.len(), + 1 + walked, + "depth {depth}: a span a level, then the rest in one" + ); + // Neither invents or loses ground: the spans still cover the + // column exactly, bottom to top, without a gap or an overlap. + for shaded in [&level, &run] { + assert_eq!(shaded.spans.first().unwrap().min, 0); + assert_eq!(shaded.spans.last().unwrap().max, 4 + depth); + for pair in shaded.spans.windows(2) { + assert_eq!(pair[0].max, pair[1].min, "depth {depth}"); + } + } + } + } + /// A solid run takes no light from what passes down it, under either + /// dampening: only fluids and leaves dim the column. + #[test] + fn solid_runs_are_one_span_and_dim_nothing() { + use super::*; + let stone = Span { + min: 0, + max: 60, + state: BlockStateId(2), + biome: 0, + light: 240, + }; + for dampening in [Dampening::PerLevel, Dampening::PerRun] { + let mut column = Column { + surface: None, + spans: vec![stone].into(), + }; + shade_runs(&mut column, |_| false, dampening); + assert_eq!(column.spans, [stone], "{dampening:?}"); + } + } /// The all-cached path must return exactly the per-cell reductions the /// old override produced, including negative chunk coordinates. #[test] From 150a1013d2b3213a03da7258ec1a8a326a8837a5 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Wed, 4 Nov 2026 15:20:19 +0100 Subject: [PATCH 49/70] Worldgen: compile the surface rule once per generator worker Every proto chunk compiled the world's surface rule twice (build_surface and apply_carving_mask): a deep copy of the rule tree, every biome condition's names normalised into a new set, its biome ids looked up. A generator worker now compiles it once, without the chunk's possible biomes, and keeps it with the noises it bound; each chunk's worker takes it and folds the biome conditions its possible biomes decide, as compile folds them, so the tree, its bands and every block are the same. Rules that bind densities, workers that already hold rules, and possible biomes the surface doesn't number compile as before. Fixes #9218 Co-Authored-By: Claude Opus 5.5 --- crates/horizon-world/src/worldgen/carving.rs | 28 +++- .../horizon-world/src/worldgen/generator.rs | 34 ++-- .../src/worldgen/material_condition.rs | 52 +++++- .../src/worldgen/material_condition/rule.rs | 155 +++++++++++++++++- .../src/worldgen/material_stage.rs | 24 ++- 5 files changed, 274 insertions(+), 19 deletions(-) diff --git a/crates/horizon-world/src/worldgen/carving.rs b/crates/horizon-world/src/worldgen/carving.rs index d627050140..ea6e3804a0 100644 --- a/crates/horizon-world/src/worldgen/carving.rs +++ b/crates/horizon-world/src/worldgen/carving.rs @@ -1,7 +1,8 @@ //! Apply ordered carving-mask intervals to pre-lighting terrain. use super::{ material_condition::{ - MaterialConditions, MaterialDensitySources, MaterialRule, MaterialSurfaceQueries, + CompiledSurfaceRule, MaterialConditions, MaterialDensitySources, MaterialRule, + MaterialSurfaceQueries, }, material_stage::{SurfaceError, TopMaterialInputs, top_material_cell}, terrain::{TerrainAquifer, TerrainBlockStates, TerrainChunk, TerrainError}, @@ -68,6 +69,29 @@ pub fn apply_carving_mask, aquifer: &mut (impl TerrainAquifer + ?Sized), blocks: &CarvingBlocks, + filter: impl FnMut([i32; 3]) -> bool, +) -> Result<(), SurfaceError> +where + R::Positional: Clone, +{ + apply_carving_mask_shared( + chunk, mask, &rule, &mut None, inputs, aquifer, blocks, filter, + ) +} +/// `apply_carving_mask` with the rule compiled once for the chunks `shared` +/// serves (`MaterialConditions::add_shared_rule`). +pub fn apply_carving_mask_shared< + R: RandomSource, + S: MaterialSurfaceQueries, + B: TerrainBlockStates, +>( + chunk: &mut TerrainChunk<'_, B>, + mask: &CarvingMask, + rule: &MaterialRule, + shared: &mut Option>, + inputs: TopMaterialInputs<'_, R, S>, + aquifer: &mut (impl TerrainAquifer + ?Sized), + blocks: &CarvingBlocks, mut filter: impl FnMut([i32; 3]) -> bool, ) -> Result<(), SurfaceError> where @@ -96,7 +120,7 @@ where )?; let mut worker = MaterialConditions::::with_possible_biomes(generation, root, sources, None); - let rule = worker.add_rule(rule)?; + let rule = worker.add_shared_rule(rule, shared)?; let cx = chunk.position().x.wrapping_shl(4); let cz = chunk.position().z.wrapping_shl(4); for column in mask.columns() { diff --git a/crates/horizon-world/src/worldgen/generator.rs b/crates/horizon-world/src/worldgen/generator.rs index cfad8d9585..26834951ea 100644 --- a/crates/horizon-world/src/worldgen/generator.rs +++ b/crates/horizon-world/src/worldgen/generator.rs @@ -19,17 +19,17 @@ use super::{ biome_source::{BiomeSourceCatalog, ResolvedBiomeSource}, biome_temperature::{BiomeTemperature, BiomeTemperatureSettings}, carver::{CarverCatalog, WorldCarver, generate_carving_mask}, - carving::{CarvingBlocks, apply_carving_mask}, + carving::{CarvingBlocks, apply_carving_mask_shared}, climate::{ClimateSampler, decode_parameter, quantize}, decode_noise, density::DensityRegistry, generator_structures::Starts, material_condition::{ - MaterialConditionRegistry, MaterialConditions, MaterialDensitySources, MaterialRule, - MaterialRuleError, MaterialRuleLibrary, MaterialSurfaceQueries, + CompiledSurfaceRule, MaterialConditionRegistry, MaterialConditions, MaterialDensitySources, + MaterialRule, MaterialRuleError, MaterialRuleLibrary, MaterialSurfaceQueries, }, material_extensions::{ExtensionBlocks, ExtensionNoises, MaterialExtensions}, - material_stage::{SurfaceInputs, TopMaterialInputs, build_surface}, + material_stage::{SurfaceInputs, TopMaterialInputs, build_surface_shared}, material_surface::{ClayBandBlocks, MaterialSurface}, overrides::{self, WorldgenOverrides}, router::{NoiseRouter, RouterField, RouterSamplers}, @@ -51,6 +51,7 @@ use horizon_core::{ }; use serde_json::Value; use std::{ + any::Any, collections::{BTreeMap, BTreeSet, HashMap}, fs, io, path::Path, @@ -1035,7 +1036,7 @@ fn state(registry: &BlockRegistry, raw: &Value) -> Result Pipeline +impl Pipeline where R::Positional: Clone + Send + Sync, { @@ -1707,10 +1708,17 @@ where .map_err(stage("noise"))?; let generation = self.generation(); worker.timings.noise += lap(&mut clock); + // The surface rule, compiled by this worker's first chunk. + let mut shared = worker + .surface_rule + .take() + .and_then(|cache| cache.downcast::>().ok()) + .map(|cache| *cache); if !self.identity_end_surface(pos) { - build_surface( + build_surface_shared( &mut chunk, - self.rule.clone(), + &self.rule, + &mut shared, SurfaceInputs:: { generation, noise_volume: volume, @@ -1749,10 +1757,11 @@ where self.router.get(RouterField::Erosion).as_ref(), ); let mut biome_context = SamplerContext::default(); - apply_carving_mask( + apply_carving_mask_shared( &mut chunk, &mask, - self.rule.clone(), + &self.rule, + &mut shared, TopMaterialInputs:: { generation, root: self.root.clone(), @@ -1772,6 +1781,7 @@ where ) .map_err(stage("carvers"))?; } + worker.surface_rule = shared.map(|rule| Box::new(rule) as Box); worker.timings.carvers += lap(&mut clock); chunk .prime_heightmaps(&[ @@ -2010,7 +2020,7 @@ where }) } } -impl super::structure::start::StartContext for StructureQueries<'_, R> +impl super::structure::start::StartContext for StructureQueries<'_, R> where R::Positional: Clone + Send + Sync, { @@ -2380,6 +2390,9 @@ pub(super) struct WorkerState { /// Decorate with structure pieces only (for structure parity checks). pub(super) structures_only: bool, pub(super) structure_mobs: Option>, + /// The world's surface rule compiled once for this worker's chunks: a + /// `CompiledSurfaceRule` of the pipeline's random. + surface_rule: Option>, } /// `Mob.finalizeSpawn(level, difficulty, STRUCTURE, null)` of the entities a /// structure places with finalizeEntities, run by the game, which owns mobs. @@ -3637,6 +3650,7 @@ impl OverworldGenerator { structures_only: false, structure_mobs: None, heights: HashMap::new(), + surface_rule: None, }, } } diff --git a/crates/horizon-world/src/worldgen/material_condition.rs b/crates/horizon-world/src/worldgen/material_condition.rs index 59572a042d..db49647577 100644 --- a/crates/horizon-world/src/worldgen/material_condition.rs +++ b/crates/horizon-world/src/worldgen/material_condition.rs @@ -23,7 +23,7 @@ use horizon_core::{ pub use library::MaterialRuleLibrary; pub use noise::MaterialNoiseSources; use rule::CompiledRule; -pub use rule::SurfaceVariants; +pub use rule::{CompiledSurfaceRule, SurfaceVariants}; pub use rule::{MaterialRule, MaterialRuleError}; use std::{ collections::{BTreeMap, BTreeSet}, @@ -370,6 +370,56 @@ where } } } +/// A chunk's possible biomes, by name and, where the surface numbers its +/// biomes, as a bit per index (`Kind::BiomeIds`). +struct PossibleBiomes<'a> { + names: &'a BTreeSet, + ids: Vec, +} +impl Compiled +where + R::Positional: Clone, +{ + /// This condition as `compile` makes it for a chunk with `possible` + /// biomes: a biome condition none of them, or all of them, meet is + /// constant. + fn fold_biomes(&self, possible: &PossibleBiomes) -> Self { + let decided = match &self.kind { + Kind::Biome(names) if names.is_disjoint(possible.names) => Some(false), + Kind::Biome(names) if possible.names.is_subset(names) => Some(true), + Kind::BiomeIds(bits) => { + let bit = |ids: &[u64], i: usize| ids.get(i).copied().unwrap_or(0); + let mut words = 0..possible.ids.len().max(bits.len()); + if words + .clone() + .all(|i| bit(bits, i) & bit(&possible.ids, i) == 0) + { + Some(false) + } else if words.all(|i| bit(&possible.ids, i) & !bit(bits, i) == 0) { + Some(true) + } else { + None + } + } + Kind::Not(child) => { + return Self { + kind: Kind::Not(Box::new(child.fold_biomes(possible))), + revision: self.revision, + result: self.result, + }; + } + _ => None, + }; + match decided { + Some(value) => Self { + kind: Kind::Constant(value), + revision: self.revision, + result: self.result, + }, + None => self.clone(), + } + } +} impl Compiled { /// The value `facts` decide for this condition at every position they /// cover. Conditions read the position and the world, never change them, diff --git a/crates/horizon-world/src/worldgen/material_condition/rule.rs b/crates/horizon-world/src/worldgen/material_condition/rule.rs index c1b4b89899..7e3ae9500d 100644 --- a/crates/horizon-world/src/worldgen/material_condition/rule.rs +++ b/crates/horizon-world/src/worldgen/material_condition/rule.rs @@ -1,11 +1,12 @@ //! Compiled material rule composition, sharing the condition worker's context. use super::{ - Compiled, Context, Facts, MaterialCondition, MaterialConditions, MaterialSurfaceQueries, - density::MaterialDensitySources, + CachedNoise, Compiled, Context, Facts, MaterialCondition, MaterialConditions, + MaterialSurfaceQueries, PossibleBiomes, density::MaterialDensitySources, }; use crate::{registry::BlockStateId, worldgen::DecodeError}; use horizon_core::{ density::DensitySampler, + noise::Noise, position::BlockPos, random::{PositionalRandom, RandomSource}, }; @@ -268,6 +269,63 @@ where } } } + /// This rule as `compile` makes it for a chunk with `possible` biomes + /// (a copy without them): the biome conditions they decide are constant, + /// and folded away as `compile` folds them. + fn fold_biomes(&self, possible: Option<&PossibleBiomes>) -> Self { + match self { + Self::Empty => Self::Empty, + Self::Block(block) => Self::Block(*block), + Self::Bandlands => Self::Bandlands, + Self::OreVein { + blocks, + raw_ore_chance, + densities, + random, + } => Self::OreVein { + blocks: *blocks, + raw_ore_chance: *raw_ore_chance, + densities: *densities, + random: random.clone(), + }, + Self::Condition { condition, rule } => { + let condition = match possible { + Some(possible) => condition.fold_biomes(possible), + None => condition.clone(), + }; + let rule = rule.fold_biomes(possible); + match condition.constant_result() { + Some(false) => Self::Empty, + Some(true) => rule, + None => Self::Condition { + condition, + rule: Box::new(rule), + }, + } + } + Self::Sequence(children) => { + let mut result: Vec<_> = children + .iter() + .map(|child| child.fold_biomes(possible)) + .filter(|child| !matches!(child, Self::Empty)) + .collect(); + match result.len() { + 0 => Self::Empty, + 1 => result.pop().unwrap(), + _ => Self::Sequence(result), + } + } + } + } + /// Whether the rule binds densities, which belong to one chunk's volume. + fn binds_densities(&self) -> bool { + match self { + Self::OreVein { .. } => true, + Self::Condition { rule, .. } => rule.binds_densities(), + Self::Sequence(children) => children.iter().any(Self::binds_densities), + Self::Empty | Self::Block(_) | Self::Bandlands => false, + } + } /// Whether applying this rule never gives `None` (only an error). fn always_produces(&self) -> bool { match self { @@ -367,6 +425,99 @@ where }) } } +/// A world's surface rule compiled once for every chunk of a generator +/// worker: the rule before any chunk's possible biomes fold it, and the +/// noises it bound, in the order it bound them. +pub struct CompiledSurfaceRule { + rule: CompiledRule, + noises: Vec<((String, bool), Arc)>, +} +impl MaterialConditions +where + R::Positional: Clone, +{ + /// `add_rule`, compiling `rule` only the first time `shared` is used: + /// later workers take the compiled rule and its noises, and fold it with + /// their own possible biomes as `compile` does. A worker that already + /// holds rules or noises, a possible biome the surface doesn't number, or + /// a rule that binds densities (to one chunk's volume) compiles as + /// `add_rule` does. + pub fn add_shared_rule( + &mut self, + rule: &MaterialRule, + shared: &mut Option>, + ) -> Result { + let fresh = self.rules.is_empty() && self.context.noises.is_empty(); + if !fresh || self.rules.len() >= 8192 { + return self.add_rule(rule.clone()); + } + let mut ids = Vec::new(); + if let Some(names) = &self.context.possible_biomes { + for name in names { + let Some(Some(id)) = self.context.surface.biome_index(name) else { + return self.add_rule(rule.clone()); + }; + let word = id as usize / 64; + if ids.len() <= word { + ids.resize(word + 1, 0); + } + ids[word] |= 1 << (id % 64); + } + } + match shared { + Some(shared) if shared.rule.binds_densities() => return self.add_rule(rule.clone()), + Some(shared) => { + for (key, sampler) in &shared.noises { + let is_3d = key.1; + let index = self.context.noises.len(); + self.context.noises.push(CachedNoise { + sampler: Arc::clone(sampler), + is_3d, + revision: if is_3d { + self.context.block_revision + } else { + self.context.column_revision + } + .wrapping_sub(1), + value: 0.0, + }); + self.context.noise_indices.insert(key.clone(), index); + } + } + None => { + let possible = self.context.possible_biomes.take(); + let compiled = + CompiledRule::compile(rule.clone(), &self.root, &mut self.context, 0, &mut 0); + self.context.possible_biomes = possible; + let mut noises: Vec<_> = self + .context + .noise_indices + .iter() + .map(|(key, &index)| (index, key.clone())) + .collect(); + noises.sort_unstable_by_key(|(index, _)| *index); + let noises = noises + .into_iter() + .map(|(index, key)| (key, Arc::clone(&self.context.noises[index].sampler))) + .collect(); + *shared = Some(CompiledSurfaceRule { + rule: compiled?, + noises, + }); + } + } + let shared = shared.as_ref().expect("compiled above"); + let possible = self + .context + .possible_biomes + .as_ref() + .map(|names| PossibleBiomes { names, ids }); + let folded = shared.rule.fold_biomes(possible.as_ref()); + let index = self.rules.len(); + self.rules.push(folded); + Ok(index) + } +} impl MaterialConditions> { /// Re-target the per-cell density bindings for a reused worker. pub fn retarget_surface_densities(&mut self, position: [i32; 3]) -> Result<(), DecodeError> { diff --git a/crates/horizon-world/src/worldgen/material_stage.rs b/crates/horizon-world/src/worldgen/material_stage.rs index 121573ba49..b8a198d503 100644 --- a/crates/horizon-world/src/worldgen/material_stage.rs +++ b/crates/horizon-world/src/worldgen/material_stage.rs @@ -3,8 +3,8 @@ use super::{ DecodeError, anchor::WorldGenerationContext, material_condition::{ - MaterialConditions, MaterialDensitySources, MaterialRule, MaterialRuleError, - MaterialSurfaceQueries, + CompiledSurfaceRule, MaterialConditions, MaterialDensitySources, MaterialRule, + MaterialRuleError, MaterialSurfaceQueries, }, material_extensions::{ExtensionError, MaterialColumn, MaterialExtensions}, terrain::{TerrainBlockStates, TerrainChunk, TerrainError}, @@ -265,6 +265,20 @@ pub fn build_surface, extensions: &MaterialExtensions, ) -> Result<(), SurfaceError> +where + R::Positional: Clone, +{ + build_surface_shared(chunk, &rule, &mut None, inputs, extensions) +} +/// `build_surface` with the rule compiled once for the chunks `shared` serves +/// (`MaterialConditions::add_shared_rule`): one world's, one worker's. +pub fn build_surface_shared( + chunk: &mut TerrainChunk<'_, B>, + rule: &MaterialRule, + shared: &mut Option>, + inputs: SurfaceInputs<'_, R, S>, + extensions: &MaterialExtensions, +) -> Result<(), SurfaceError> where R::Positional: Clone, { @@ -295,6 +309,7 @@ where let result = build_columns( chunk, rule, + shared, &mut worker, extensions, inputs.write_min_y, @@ -306,7 +321,8 @@ where } fn build_columns( chunk: &mut TerrainChunk<'_, B>, - rule: MaterialRule, + rule: &MaterialRule, + shared: &mut Option>, worker: &mut MaterialConditions, extensions: &MaterialExtensions, write_min_y: i32, @@ -316,7 +332,7 @@ where R::Positional: Clone, { let min = chunk.min_y(); - let rule = worker.add_rule(rule)?; + let rule = worker.add_shared_rule(rule, shared)?; let variants = worker.surface_variants(rule)?; let cx = chunk.position().x.wrapping_shl(4); let cz = chunk.position().z.wrapping_shl(4); From 609e5306c14e6ce9e7119c8a4469339988946c6d Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Wed, 4 Nov 2026 18:28:07 +0100 Subject: [PATCH 50/70] Worldgen: the shared surface rule binds each chunk's densities The world's surface rule holds the ore veins, whose densities belong to each chunk's volume, so every chunk fell back to compiling the rule. The shared rule now keeps the densities compile binds, in its order, and each chunk's worker binds them to its own volume the same way: the indices, and the samples, are compile's. Co-Authored-By: Claude Opus 5.5 --- .../src/worldgen/material_condition/rule.rs | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/crates/horizon-world/src/worldgen/material_condition/rule.rs b/crates/horizon-world/src/worldgen/material_condition/rule.rs index 7e3ae9500d..cbcf36bdf8 100644 --- a/crates/horizon-world/src/worldgen/material_condition/rule.rs +++ b/crates/horizon-world/src/worldgen/material_condition/rule.rs @@ -317,15 +317,6 @@ where } } } - /// Whether the rule binds densities, which belong to one chunk's volume. - fn binds_densities(&self) -> bool { - match self { - Self::OreVein { .. } => true, - Self::Condition { rule, .. } => rule.binds_densities(), - Self::Sequence(children) => children.iter().any(Self::binds_densities), - Self::Empty | Self::Block(_) | Self::Bandlands => false, - } - } /// Whether applying this rule never gives `None` (only an error). fn always_produces(&self) -> bool { match self { @@ -426,21 +417,47 @@ where } } /// A world's surface rule compiled once for every chunk of a generator -/// worker: the rule before any chunk's possible biomes fold it, and the -/// noises it bound, in the order it bound them. +/// worker: the rule before any chunk's possible biomes fold it, the noises it +/// bound, and the densities (ore veins) it binds to each chunk's volume, in +/// the order it bound them. pub struct CompiledSurfaceRule { rule: CompiledRule, noises: Vec<((String, bool), Arc)>, + densities: Vec<(Arc, bool)>, +} +/// The densities `CompiledRule::compile` binds for `rule`, in its order, +/// with whether it samples each over the volume now. +fn density_bindings(rule: &MaterialRule, out: &mut Vec<(Arc, bool)>) { + match rule { + MaterialRule::Reference(value) => density_bindings(value, out), + MaterialRule::OreVein { + density, + richness, + filler_gap, + .. + } => out.extend([ + (Arc::clone(density), true), + (Arc::clone(richness), true), + (Arc::clone(filler_gap), false), + ]), + MaterialRule::Condition { then_run, .. } => density_bindings(then_run, out), + MaterialRule::Sequence(children) => { + for child in children { + density_bindings(child, out); + } + } + MaterialRule::Block(_) | MaterialRule::Bandlands => {} + } } impl MaterialConditions where R::Positional: Clone, { /// `add_rule`, compiling `rule` only the first time `shared` is used: - /// later workers take the compiled rule and its noises, and fold it with - /// their own possible biomes as `compile` does. A worker that already - /// holds rules or noises, a possible biome the surface doesn't number, or - /// a rule that binds densities (to one chunk's volume) compiles as + /// later workers take the compiled rule and its noises, bind its densities + /// to their own volume in the same order, and fold it with their own + /// possible biomes as `compile` does. A worker that already holds rules or + /// noises, or a possible biome the surface doesn't number, compiles as /// `add_rule` does. pub fn add_shared_rule( &mut self, @@ -465,7 +482,6 @@ where } } match shared { - Some(shared) if shared.rule.binds_densities() => return self.add_rule(rule.clone()), Some(shared) => { for (key, sampler) in &shared.noises { let is_3d = key.1; @@ -483,6 +499,11 @@ where }); self.context.noise_indices.insert(key.clone(), index); } + for (sampler, eager) in &shared.densities { + self.context + .surface + .bind_density(Arc::clone(sampler), *eager)?; + } } None => { let possible = self.context.possible_biomes.take(); @@ -500,9 +521,12 @@ where .into_iter() .map(|(index, key)| (key, Arc::clone(&self.context.noises[index].sampler))) .collect(); + let mut densities = Vec::new(); + density_bindings(rule, &mut densities); *shared = Some(CompiledSurfaceRule { rule: compiled?, noises, + densities, }); } } From c9642c76a05219fb07d49d074fdf09a5be679477 Mon Sep 17 00:00:00 2001 From: JulesB40 Date: Wed, 4 Nov 2026 19:03:08 +0100 Subject: [PATCH 51/70] Worldgen: a shared rule that fails to compile returns before its bindings are walked Co-Authored-By: Claude Opus 5.5 --- crates/horizon-world/src/worldgen/material_condition/rule.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/horizon-world/src/worldgen/material_condition/rule.rs b/crates/horizon-world/src/worldgen/material_condition/rule.rs index cbcf36bdf8..f6a14c49b5 100644 --- a/crates/horizon-world/src/worldgen/material_condition/rule.rs +++ b/crates/horizon-world/src/worldgen/material_condition/rule.rs @@ -510,6 +510,7 @@ where let compiled = CompiledRule::compile(rule.clone(), &self.root, &mut self.context, 0, &mut 0); self.context.possible_biomes = possible; + let compiled = compiled?; let mut noises: Vec<_> = self .context .noise_indices @@ -524,7 +525,7 @@ where let mut densities = Vec::new(); density_bindings(rule, &mut densities); *shared = Some(CompiledSurfaceRule { - rule: compiled?, + rule: compiled, noises, densities, }); From e7e4c7c2d3adc499d1a1aa2c9d85a5b437f459da Mon Sep 17 00:00:00 2001 From: Arslan Armanuly <72556944+arslanarm@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:15:41 +0500 Subject: [PATCH 52/70] Shader packs: terrain parts after a failed part, program.enabled, buffer sizes Review of #9176: - A split part whose program exists but doesn't load, or whose pipeline can't be built, no longer leaves the other part drawing alone: neither draws, and the game draws all terrain, as when an unsplit pack's terrain program fails. Iris 1.11.7 turns the whole pack off for our probe whose gbuffers_terrain_cutout doesn't compile. - A part with no program in its chain still draws nothing while the other draws alone, now with a note: Iris 1.11.7 left the other part undrawn with our probes having only gbuffers_terrain_solid, or only gbuffers_terrain_cutout. - Loader::has honours program..enabled, as load does: Iris drew all terrain with gbuffers_terrain for our probe that turns its gbuffers_terrain_solid off with program.gbuffers_terrain_solid.enabled=false. - Post::every_program lists terrain_cutout, so buffer_minimums counts the storage blocks the cutout program binds. Part of #9137 Co-Authored-By: Claude Opus 5.5 --- .../src/gfx/pack_post.rs | 21 +- .../src/gfx/pack_post/terrain_parts_tests.rs | 67 ++++- crates/horizon-shaders/src/post.rs | 234 ++++++++++++++---- 3 files changed, 264 insertions(+), 58 deletions(-) diff --git a/crates/horizon-client-render/src/gfx/pack_post.rs b/crates/horizon-client-render/src/gfx/pack_post.rs index 2e8537d785..ea1b5307e7 100644 --- a/crates/horizon-client-render/src/gfx/pack_post.rs +++ b/crates/horizon-client-render/src/gfx/pack_post.rs @@ -514,10 +514,12 @@ pub struct PackPost { /// Shadow cache for temporal reprojection. shadow_cache: ShadowCache, /// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`; - /// with `terrain_cutout`, solid terrain only (`gbuffers_terrain_solid` or its fallback). + /// when the pack draws terrain in two parts, solid terrain only (`gbuffers_terrain_solid` or + /// its fallback). terrain: Option, /// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`, - /// when the pack draws terrain in two parts. + /// when the pack draws terrain in two parts. A pack with no program for one part has only + /// the other. terrain_cutout: Option, /// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`. water: Option, @@ -2103,7 +2105,8 @@ impl PackPost { ids: block_ids.as_ref().map(|i| &i.0), cull: g.cull, }); - let terrain = post + let split = post.terrain.is_some() && post.terrain_cutout.is_some(); + let mut terrain = post .terrain .zip(terrain_geometry.as_ref()) .and_then(|(p, g)| build(p, Some(g))); @@ -2288,6 +2291,18 @@ impl PackPost { .filter_map(|p| build(p, None)) .collect(); let stand_ins = pack_images::stand_ins(device, stand_ins.take()); + // Terrain in two parts draws both or neither: one alone would leave the other part's + // terrain undrawn. Without either, the game draws all terrain, as when the terrain + // program of a pack that doesn't split can't draw. + if split + && terrain.is_some() != terrain_cutout.is_some() + && let Some(p) = terrain.take().or(terrain_cutout.take()) + { + notes.push(format!( + "{}: the other part of terrain can't draw; the game draws all terrain", + p.name + )); + } match (&terrain, &mut terrain_cutout) { (Some(t), None) => notes.push(format!("terrain draws with {}", t.name)), (t, Some(c)) => { diff --git a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs index c50bf6dd99..87564b5c07 100644 --- a/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs +++ b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs @@ -48,6 +48,21 @@ fn strips_with_alphas( programs: &[(&str, [u8; 3], f32)], alphas: [u8; 4], ) -> Option<[Option<[u8; 3]>; 3]> { + let files = programs + .iter() + .flat_map(|&(name, rgb, alpha)| program(name, rgb, alpha)) + .collect(); + let (strips, _) = strips_of(files, alphas)?; + Some(strips) +} + +/// `strips_with_alphas` with the pack's terrain programs' `files`, and the built pack's notes +/// when it draws no terrain itself (`None` when it does). +#[allow(clippy::type_complexity)] +fn strips_of( + programs: Vec<(String, String)>, + alphas: [u8; 4], +) -> Option<([Option<[u8; 3]>; 3], Option>)> { let Some(gpu) = Gpu::new() else { eprintln!("TERRAIN_PARTS_NO_ADAPTER"); return None; @@ -62,9 +77,7 @@ fn strips_with_alphas( .to_owned(), ), ]; - for &(name, rgb, alpha) in programs { - files.extend(program(name, rgb, alpha)); - } + files.extend(programs); // The game's terrain inputs as far as the parts read them: the sprite layer and the colour. let terrain = horizon_shaders::GbuffersInputs { declarations: "layout(location=0)in vec3 fixture_position;\n\ @@ -139,6 +152,7 @@ fn strips_with_alphas( contents: bytemuck::cast_slice(&vertices), usage: wgpu::BufferUsages::VERTEX, }); + let undrawn = (!post.has_terrain()).then(|| post.notes.clone()); let hud = HudBatch::new(SIZE); let frame = fixture_frame(&hud); let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| { @@ -152,7 +166,7 @@ fn strips_with_alphas( let [r, g, b, _] = pixels[y * SIZE[0] as usize + x]; Some([r, g, b]).filter(|&c| c != [0, 0, 0]) }; - Some([0..5, 6..10, 11..16].map(|columns| { + let strips = [0..5, 6..10, 11..16].map(|columns| { let first = colour(columns.start, 0); for x in columns.clone() { for y in 0..SIZE[1] as usize { @@ -160,7 +174,8 @@ fn strips_with_alphas( } } first - })) + }); + Some((strips, undrawn)) } #[test] @@ -236,3 +251,45 @@ fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() { }; assert_eq!(got, [Some(RED); 3]); } + +/// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) draws that part, +/// and nothing draws the other: Iris 1.11.7 left it undrawn with our probes `tsc-solidonly` and +/// `tsc-cutoutonly`. +#[test] +fn a_lone_split_program_draws_only_its_own_terrain() { + let solid = strips(&[("gbuffers_terrain_solid", GREEN, 1.0)]); + let Some(solid) = solid else { return }; + assert_eq!(solid, [Some(GREEN), None, Some(GREEN)]); + let cutout = strips(&[("gbuffers_terrain_cutout", BLUE, 1.0)]); + assert_eq!(cutout, Some([None, Some(BLUE), None])); +} + +/// When one part's pipeline can't be built (here a cutout program writing no buffer, which +/// Horizon skips), the other part doesn't draw alone either: the game draws all terrain. +#[test] +fn a_part_that_fails_to_build_takes_the_other_with_it() { + let mut files: Vec<(String, String)> = [ + program("gbuffers_terrain", RED, 1.0), + program("gbuffers_terrain_solid", GREEN, 1.0), + ] + .concat(); + files.push(( + "gbuffers_terrain_cutout.vsh".into(), + "#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(), + )); + files.push(( + "gbuffers_terrain_cutout.fsh".into(), + "#version 120\nvoid main(){}".into(), + )); + let Some((got, undrawn)) = strips_of(files, [255; 4]) else { + return; + }; + assert_eq!(got, [None; 3]); + let notes = undrawn.expect("the game draws terrain"); + for note in [ + "gbuffers_terrain_cutout: writes no buffer; skipped", + "gbuffers_terrain_solid: the other part of terrain can't draw", + ] { + assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}"); + } +} diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs index 9b456985f9..1c26814e60 100644 --- a/crates/horizon-shaders/src/post.rs +++ b/crates/horizon-shaders/src/post.rs @@ -416,12 +416,13 @@ pub struct Post { /// The flips of the virtual `deferred_pre` and `composite_pre` (6.1.6). pub deferred_pre: Vec<(u32, bool)>, pub composite_pre: Vec<(u32, bool)>, - /// What draws solid and cutout terrain, when the game asked for it and one loads; with - /// `terrain_cutout`, solid terrain only. + /// What draws solid and cutout terrain, when the game asked for it and one loads; when + /// terrain draws in two parts, solid terrain only. pub terrain: Option, /// What draws cutout terrain when the pack has `gbuffers_terrain_solid` or /// `gbuffers_terrain_cutout` and the game draws terrain in two parts - /// (`GameInputs::terrain_parts`); `terrain` then draws the solid part. + /// (`GameInputs::terrain_parts`); `terrain` then draws the solid part. A pack with no + /// program for one part has only the other. pub terrain_cutout: Option, /// What draws translucent terrain (water, stained glass) after deferred. pub water: Option, @@ -498,6 +499,7 @@ impl Post { fn every_program(&self) -> impl Iterator { let draws = [ &self.terrain, + &self.terrain_cutout, &self.water, &self.sky, &self.sky_textured, @@ -742,6 +744,9 @@ pub fn load_post( let split_terrain = ["gbuffers_terrain_solid", "gbuffers_terrain_cutout"] .iter() .any(|n| loader.has(n)); + // Whether the pack has a program for each part, loaded or not. + let [has_solid, has_cutout] = + [&TERRAIN_SOLID, &TERRAIN_CUTOUT].map(|chain| chain.iter().any(|n| loader.has(n))); let mut gbuffers = |names: &[&str], group, inputs: &GbuffersInputs| { names.iter().find_map(|name| { // `alphaTest.` replaces the geometry's default test (OQ-23). @@ -873,15 +878,45 @@ pub fn load_post( // program (lower alphas not probed). // Otherwise solid and cutout terrain draw together, with the cutout test. let parts = inputs.terrain_parts.as_ref().filter(|_| split_terrain); + let mut lone = None; let (solid, terrain_cutout) = match parts { - Some([solid, cut]) => ( - gbuffers(&TERRAIN_SOLID, Group::Gbuffers, solid), - gbuffers(&TERRAIN_CUTOUT, Group::Gbuffers, &cutout(cut)), - ), + Some([solid, cut]) => { + let solid = gbuffers(&TERRAIN_SOLID, Group::Gbuffers, solid); + let cut = gbuffers(&TERRAIN_CUTOUT, Group::Gbuffers, &cutout(cut)); + // One part loaded: its name, the other part, and whether the pack has a + // program for that one. + let one = match (&solid, &cut) { + (Some(p), None) => Some((p.name.clone(), "cutout", has_cutout)), + (None, Some(p)) => Some((p.name.clone(), "solid", has_solid)), + _ => None, + }; + match one { + // No program in the other part's chain: that part draws nothing, and this + // one draws alone. Iris 1.11.7 left the other part's terrain undrawn with + // our probes having only gbuffers_terrain_solid, or only _cutout. + Some((name, other, false)) => { + lone = Some(format!( + "{name}: no program for {other} terrain; nothing draws it" + )); + (solid, cut) + } + // The other part's program didn't load: this one doesn't draw alone, which + // would leave that part's terrain undrawn. The game draws all terrain, as + // when an unsplit pack's terrain program doesn't load. + Some((name, other, true)) => { + lone = Some(format!( + "{name}: {other} terrain's program didn't load; the game draws all terrain" + )); + (None, None) + } + None => (solid, cut), + } + } None => (gbuffers(&TERRAIN, Group::Gbuffers, &cutout(terrain)), None), }; // gbuffers_water falls back to gbuffers_terrain's chain (2.3). let water = gbuffers(&WATER, Group::Translucent, terrain); + loader.post.notes.extend(lone); loader.post.terrain = solid; loader.post.water = water.map(|mut p| { p.blend = blend(properties, &p.name).unwrap_or(Some(TRANSLUCENT_BLEND)); @@ -1071,12 +1106,19 @@ struct Loader<'a> { } impl Loader<'_> { - /// The pack has both stages of the program `name` in this folder. + /// The pack has both stages of the program `name` in this folder, and doesn't turn it off + /// (`program..enabled`), as `load` reads them. fn has(&self, name: &str) -> bool { ["vsh", "fsh"].iter().all(|ext| match self.folder { "" => self.pack.exists(&format!("{name}.{ext}")), f => self.pack.exists(&format!("{f}/{name}.{ext}")), - }) + }) && enabled( + self.properties, + self.folder, + name, + &self.options, + &self.switches, + ) } /// The program `name` translated by `translate`, or `None` when it is missing, disabled or @@ -2519,61 +2561,153 @@ blend.composite1.colortex3 = ONE ONE ONE ONE assert_eq!(clouds(&["gbuffers_terrain", "composite"]), None); } + const TERRAIN_VSH: &str = "#version 120\nvoid main() { gl_Position = ftransform(); }\n"; + const TERRAIN_FSH: &str = "#version 120\nvoid main() { gl_FragData[0] = vec4(1.0); }\n"; + + /// The pack of `files` (under `shaders/`), with `properties`, loaded with the game's terrain + /// inputs and, with `parts`, its two parts. + fn terrain_files(files: &[(String, &str)], properties: &str, parts: bool) -> Post { + let files: Vec<_> = (files.iter()) + .map(|(name, source)| (format!("shaders/{name}"), *source)) + .collect(); + let files: Vec<_> = files.iter().map(|(n, s)| (n.as_str(), *s)).collect(); + let pack = Pack::from_zip_bytes("terrain-parts", crate::pack::tests::zip(&files)).unwrap(); + let inputs = crate::GbuffersInputs { + declarations: "layout(location = 0) in vec3 hz_v_pos;".into(), + body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n hz_Color = vec4(1.0);\n".into(), + ..Default::default() + }; + let inputs = GameInputs { + terrain_parts: parts.then(|| [inputs.clone(), inputs.clone()]), + terrain: Some(inputs), + ..Default::default() + }; + let macros = Macros::standard(); + let properties = Properties::parse(properties, ¯os); + load_post(&pack, "", &properties, ¯os, &inputs) + } + + /// `terrain_files` with `programs`, each writing white. + fn terrain_pack(programs: &[&str], properties: &str, parts: bool) -> Post { + let files: Vec<_> = (programs.iter()) + .flat_map(|p| { + [ + (format!("{p}.vsh"), TERRAIN_VSH), + (format!("{p}.fsh"), TERRAIN_FSH), + ] + }) + .collect(); + terrain_files(&files, properties, parts) + } + + /// What draws solid (or all) terrain and what draws cutout terrain. + fn terrain_names(programs: &[&str], properties: &str, parts: bool) -> [Option; 2] { + let post = terrain_pack(programs, properties, parts); + [post.terrain, post.terrain_cutout].map(|p| p.map(|p| p.name)) + } + + const TERRAIN_PROGRAMS: [&str; 3] = [ + "gbuffers_terrain", + "gbuffers_terrain_solid", + "gbuffers_terrain_cutout", + ]; + /// Iris 1.11.7 with our probe packs (#9137): `gbuffers_terrain_solid` draws solid terrain /// and `gbuffers_terrain_cutout` cutout terrain, each falling back to `gbuffers_terrain`. #[test] fn terrain_solid_and_cutout_fall_back_to_terrain() { - let names = |programs: &[&str], parts: bool| { - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); - for name in programs { - for ext in ["vsh", "fsh"] { - let options = zip::write::SimpleFileOptions::default(); - zip.start_file(format!("shaders/{name}.{ext}"), options) - .unwrap(); - let source = "#version 120\nvoid main() { gl_Position = ftransform(); }\n"; - let source = match ext { - "vsh" => source, - _ => "#version 120\nvoid main() { gl_FragData[0] = vec4(1.0); }\n", - }; - std::io::Write::write_all(&mut zip, source.as_bytes()).unwrap(); - } - } - let pack = Pack::from_zip_bytes("terrain-parts", zip.finish().unwrap().into_inner()); - let inputs = crate::GbuffersInputs { - declarations: "layout(location = 0) in vec3 hz_v_pos;".into(), - body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n hz_Color = vec4(1.0);\n".into(), - ..Default::default() - }; - let inputs = GameInputs { - terrain_parts: parts.then(|| [inputs.clone(), inputs.clone()]), - terrain: Some(inputs), - ..Default::default() - }; - let macros = Macros::standard(); - let post = load_post(&pack.unwrap(), "", &Properties::default(), ¯os, &inputs); - let name = |p: Option| p.map(|p| p.name); - (name(post.terrain), name(post.terrain_cutout)) - }; + let names = |programs: &[&str], parts: bool| terrain_names(programs, "", parts); let some = |name: &str| Some(name.to_owned()); - let (terrain, solid, cutout) = ( - "gbuffers_terrain", - "gbuffers_terrain_solid", - "gbuffers_terrain_cutout", - ); + let [terrain, solid, cutout] = TERRAIN_PROGRAMS; assert_eq!( names(&[terrain, solid, cutout], true), - (some(solid), some(cutout)) + [some(solid), some(cutout)] ); - assert_eq!(names(&[terrain, solid], true), (some(solid), some(terrain))); + assert_eq!(names(&[terrain, solid], true), [some(solid), some(terrain)]); assert_eq!( names(&[terrain, cutout], true), - (some(terrain), some(cutout)) + [some(terrain), some(cutout)] ); - assert_eq!(names(&[terrain], true), (some(terrain), None)); + assert_eq!(names(&[terrain], true), [some(terrain), None]); // Without the game's two parts, terrain draws whole. assert_eq!( names(&[terrain, solid, cutout], false), - (some(terrain), None) + [some(terrain), None] + ); + } + + /// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) has nothing + /// for the other part: that part draws alone, and nothing draws the other, as Iris 1.11.7 + /// did with our probes `tsc-solidonly` and `tsc-cutoutonly`. + #[test] + fn a_lone_split_program_draws_only_its_part() { + let [_, solid, cutout] = TERRAIN_PROGRAMS; + let some = |name: &str| Some(name.to_owned()); + assert_eq!(terrain_names(&[solid], "", true), [some(solid), None]); + assert_eq!(terrain_names(&[cutout], "", true), [None, some(cutout)]); + let post = terrain_pack(&[solid], "", true); + let note = "gbuffers_terrain_solid: no program for cutout terrain; nothing draws it"; + assert!(post.notes.iter().any(|n| n == note), "{:?}", post.notes); + } + + /// When the other part has a program that fails to load, the part that loaded doesn't draw + /// alone: the game draws all terrain (#9176's review). + #[test] + fn a_split_part_that_fails_to_load_takes_the_other_with_it() { + let [_, solid, cutout] = TERRAIN_PROGRAMS; + // `cutout` with a stage that doesn't translate. + let files = [ + (format!("{solid}.vsh"), TERRAIN_VSH), + (format!("{solid}.fsh"), TERRAIN_FSH), + (format!("{cutout}.vsh"), TERRAIN_VSH), + ( + format!("{cutout}.fsh"), + "void main() { gl_FragData[0] = vec4(1.0) }\n", + ), + ]; + let post = terrain_files(&files, "", true); + assert!(post.terrain.is_none() && post.terrain_cutout.is_none()); + let note = "gbuffers_terrain_solid: cutout terrain's program didn't load; the game draws all terrain"; + assert!(post.notes.iter().any(|n| n == note), "{:?}", post.notes); + } + + /// `program..enabled` turning off the only split program leaves terrain whole, through + /// `gbuffers_terrain`, as for a pack without it: Iris 1.11.7 drew all terrain with + /// `gbuffers_terrain` for our probe `tsc-disabled-false`. + #[test] + fn a_disabled_split_program_leaves_terrain_whole() { + let [terrain, solid, _] = TERRAIN_PROGRAMS; + let off = "program.gbuffers_terrain_solid.enabled = false\n"; + let some = |name: &str| Some(name.to_owned()); + assert_eq!( + terrain_names(&[terrain, solid], off, true), + [some(terrain), None] + ); + let on = "program.gbuffers_terrain_solid.enabled = true\n"; + assert_eq!( + terrain_names(&[terrain, solid], on, true), + [some(solid), some(terrain)] + ); + } + + /// The cutout part's storage blocks count towards the buffers' sizes, like every other + /// program's. + #[test] + fn the_cutout_parts_storage_blocks_size_their_buffers() { + let fsh = "#version 430\nlayout(std430, binding = 0) buffer Sums { vec4 a[4]; } sums;\n\ + void main() {\n/* DRAWBUFFERS:0 */\ngl_FragData[0] = sums.a[3];\n}\n"; + let files = [ + ("gbuffers_terrain.vsh".into(), TERRAIN_VSH), + ("gbuffers_terrain.fsh".into(), TERRAIN_FSH), + ("gbuffers_terrain_cutout.vsh".into(), TERRAIN_VSH), + ("gbuffers_terrain_cutout.fsh".into(), fsh), + ]; + let post = terrain_files(&files, "bufferObject.0 = 16\n", true); + let cutout = post.terrain_cutout.as_ref().map(|p| p.name.as_str()); + assert_eq!(cutout, Some("gbuffers_terrain_cutout"), "{:?}", post.notes); + assert_eq!( + post.buffer_minimums(), + std::collections::BTreeMap::from([(0, 64)]) ); } From 5cea1b3475e99229dede4342b09f03f7effabf8f Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Wed, 4 Nov 2026 20:14:53 +0100 Subject: [PATCH 53/70] Math.sin and Math.cos on Windows round like vanilla's jmath::sin and jmath::cos used the platform's libm on x86-64, which on Linux (glibc) rounds like HotSpot's x86-64 intrinsics for all but about 0.2% of arguments. Microsoft's rounds differently for about 3%, so on Windows item drops, mob goals and worldgen features came out a last bit off vanilla's, and drops_match_vanillas_motion_bit_for_bit failed there. On Windows they are now correctly rounded (jmath::exact, double-double arithmetic with a fast path), which HotSpot's are for all but about 0.1%. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-core/src/jmath.rs | 289 ++++++++++++++++++++++++++++++- 1 file changed, 287 insertions(+), 2 deletions(-) diff --git a/crates/horizon-core/src/jmath.rs b/crates/horizon-core/src/jmath.rs index 3c05e96d75..742091df58 100644 --- a/crates/horizon-core/src/jmath.rs +++ b/crates/horizon-core/src/jmath.rs @@ -9,7 +9,10 @@ //! everywhere. //! - On x86-64, `sin`, `cos`, `tan`, `exp`, `log`, `log10` and `pow` are //! Intel's libm stubs, which round like glibc for nearly every argument; -//! there they use the platform libm, as before. +//! there they use the platform libm, as before. Windows' libm (Microsoft's) +//! rounds `sin` and `cos` differently for about 3% of arguments, so there +//! they are correctly rounded instead ([`exact`]), as Intel's stubs are for +//! all but about 0.1%. //! - On AArch64 only `sin` and `cos` are intrinsics: fdlibm's kernels //! evaluated with fused multiply-adds (`macroAssembler_aarch64_trig.cpp`). //! Everything else is `StrictMath`. @@ -38,6 +41,8 @@ pub fn round_f32(a: f32) -> i32 { pub fn sin(x: f64) -> f64 { if cfg!(target_arch = "aarch64") { aarch64::sin(x) + } else if cfg!(windows) { + exact::sin(x) } else { x.sin() } @@ -47,6 +52,8 @@ pub fn sin(x: f64) -> f64 { pub fn cos(x: f64) -> f64 { if cfg!(target_arch = "aarch64") { aarch64::cos(x) + } else if cfg!(windows) { + exact::cos(x) } else { x.cos() } @@ -548,9 +555,246 @@ pub mod aarch64 { } } +/// `sin` and `cos` correctly rounded, for Windows, whose libm rounds them +/// unlike Java's x86-64 intrinsics. Double-double arithmetic (two `f64`s per +/// value): `x` reduced by a four-part pi/2, a short series checked against an +/// error bound, and where the bound leaves the rounding open, the full series +/// with its terms summed exactly. Checked against 400-bit results on 1.3 million +/// arguments, small ones and ones next to multiples of pi/2 among them; not a +/// proof. +pub mod exact { + /// A value `hi + lo` with |lo| <= ulp(hi) / 2. + #[derive(Clone, Copy)] + struct Dd(f64, f64); + + /// `a + b` exactly, as a sum and its rounding error (Knuth). + fn two_sum(a: f64, b: f64) -> Dd { + let s = a + b; + let v = s - a; + Dd(s, (a - (s - v)) + (b - v)) + } + + /// `a + b` exactly when |a| >= |b| (Dekker). + fn fast_two_sum(a: f64, b: f64) -> Dd { + let s = a + b; + Dd(s, b - (s - a)) + } + + /// `a * b` exactly, as a product and its rounding error (Dekker's split, no fused multiply-add). + fn two_prod(a: f64, b: f64) -> Dd { + fn split(a: f64) -> (f64, f64) { + let t = 134_217_729.0 * a; // 2^27 + 1 + let hi = t - (t - a); + (hi, a - hi) + } + let p = a * b; + let (ah, al) = split(a); + let (bh, bl) = split(b); + Dd(p, ((ah * bh - p) + ah * bl + al * bh) + al * bl) + } + + impl Dd { + fn add(self, b: Dd) -> Dd { + let s = two_sum(self.0, b.0); + let t = two_sum(self.1, b.1); + let s = fast_two_sum(s.0, s.1 + t.0); + fast_two_sum(s.0, s.1 + t.1) + } + fn mul(self, b: Dd) -> Dd { + let p = two_prod(self.0, b.0); + fast_two_sum(p.0, p.1 + (self.0 * b.1 + self.1 * b.0)) + } + /// Divided by a small whole number (exact in an `f64`). + fn div(self, d: f64) -> Dd { + let q = self.0 / d; + let p = two_prod(q, d); + let r = ((self.0 - p.0) - p.1 + self.1) / d; + fast_two_sum(q, r) + } + fn neg(self) -> Dd { + Dd(-self.0, -self.1) + } + } + + /// pi/2 = P1 + P2 + P3 + P4 to 2^-160: the first three have 33 significant bits, so `k * Pn` is + /// exact for |k| < 2^20. + const P1: f64 = f64::from_bits(0x3FF9_21FB_5440_0000); + const P2: f64 = f64::from_bits(0x3DD0_B461_1A60_0000); + const P3: f64 = f64::from_bits(0x3BA3_198A_2E00_0000); + const P4: f64 = f64::from_bits(0x397B_839A_2520_49C1); + + /// `x - k * pi/2` and the quadrant `k mod 4`, for |x| <= 2^20. + fn reduce(x: f64) -> (Dd, u32) { + let k = (x * std::f64::consts::FRAC_2_PI).round(); + let r = Dd(x - k * P1, 0.0) + .add(Dd(-(k * P2), 0.0)) + .add(Dd(-(k * P3), 0.0)) + .add(two_prod(k, P4).neg()); + (r, (k as i64 & 3) as u32) + } + + /// `sum(xs)` rounded once, exactly (Shewchuk's partials, with the half-way fix-up of a final + /// rounding that a running sum can't see). + fn exact_sum(xs: &[f64]) -> f64 { + let mut partials = [0.0f64; 64]; + let mut len = 0; + for &x0 in xs { + let mut x = x0; + let mut i = 0; + for j in 0..len { + let mut y = partials[j]; + if x.abs() < y.abs() { + std::mem::swap(&mut x, &mut y); + } + let hi = x + y; + let lo = y - (hi - x); + if lo != 0.0 { + partials[i] = lo; + i += 1; + } + x = hi; + } + partials[i] = x; + len = i + 1; + } + if len == 0 { + return 0.0; + } + let mut n = len - 1; + let mut hi = partials[n]; + let mut lo = 0.0; + while n > 0 { + let x = hi; + n -= 1; + let y = partials[n]; + hi = x + y; + lo = y - (hi - x); + if lo != 0.0 { + break; + } + } + if n > 0 && ((lo < 0.0 && partials[n - 1] < 0.0) || (lo > 0.0 && partials[n - 1] > 0.0)) { + let y = lo * 2.0; + let x = hi + y; + if y == x - hi { + hi = x; + } + } + hi + } + + /// sin(r) or cos(r) for |r| <= pi/4 + a little: the Taylor series to below 2^-110, each term + /// kept as a double-double and the terms summed exactly. The leading terms are exact for a small + /// `x` (r = x), where cos x and sin x come closest to half-way between two doubles. + fn series(r: Dd, cos: bool) -> f64 { + let r2 = r.mul(r); + let mut parts = [0.0f64; 32]; + let mut len = 0; + let (mut term, first) = if cos { (Dd(1.0, 0.0), 1.0) } else { (r, 2.0) }; + parts[0] = term.0; + parts[1] = term.1; + len += 2; + for n in 1..=14 { + let k = 2.0 * f64::from(n) + first - 1.0; + term = term.mul(r2).div(k * (k - 1.0)).neg(); + parts[len] = term.0; + parts[len + 1] = term.1; + len += 2; + } + exact_sum(&parts[..len]) + } + + /// Taylor coefficients 1/n! with alternating sign, for the f64 tails: sin from r^7, cos from r^8. + const SIN_TAIL: [f64; 9] = [ + -1.0 / 5040.0, + 1.0 / 362880.0, + -1.0 / 39916800.0, + 1.0 / 6227020800.0, + -1.0 / 1307674368000.0, + 1.0 / 355687428096000.0, + -1.0 / 121645100408832000.0, + 1.0 / 51090942171709440000.0, + -1.0 / 25852016738884976640000.0, + ]; + const COS_TAIL: [f64; 8] = [ + 1.0 / 40320.0, + -1.0 / 3628800.0, + 1.0 / 479001600.0, + -1.0 / 87178291200.0, + 1.0 / 20922789888000.0, + -1.0 / 6402373705728000.0, + 1.0 / 2432902008176640000.0, + -1.0 / 1124000727777607680000.0, + ]; + + fn horner(c: &[f64], x: f64) -> f64 { + c.iter().rev().fold(0.0, |acc, &k| acc * x + k) + } + + /// sin(r) or cos(r) to about 2^-64: the first terms in double-double, the small rest in f64. + fn fast(r: Dd, cos: bool) -> Dd { + let r2 = r.mul(r); + let x2 = r2.0; + if cos { + let t2 = r2.div(2.0).neg(); + let t4 = r2.mul(r2).div(24.0); + let t6 = t4.mul(r2).div(30.0).neg(); + let tail = x2 * x2 * x2 * x2 * horner(&COS_TAIL, x2); + Dd(1.0, 0.0).add(t2).add(t4).add(t6).add(Dd(tail, 0.0)) + } else { + let t3 = r2.mul(r).div(6.0).neg(); + let t5 = t3.mul(r2).div(20.0).neg(); + let tail = r.0 * x2 * x2 * x2 * horner(&SIN_TAIL, x2); + r.add(t3).add(t5).add(Dd(tail, 0.0)) + } + } + + /// Rounds `v` when its error bound can't change the rounding, else `None`. + fn rounded(v: Dd) -> Option { + let e = v.0.abs() * f64::from_bits(0x3C30_0000_0000_0000); // 2^-60 + let (a, b) = (v.0 + (v.1 - e), v.0 + (v.1 + e)); + (a == b).then_some(a) + } + + /// sin (`cos == false`) or cos of a reduced angle in quadrant `q`. + fn eval(r: Dd, q: u32, cos: bool) -> f64 { + // sin(x) = sin r, cos r, -sin r, -cos r in quadrants 0..3; cos(x) is a quadrant later. + let q = (q + u32::from(cos)) & 3; + let want_cos = q & 1 == 1; + let v = rounded(fast(r, want_cos)).unwrap_or_else(|| series(r, want_cos)); + if q >= 2 { -v } else { v } + } + + /// `sin(x)` rounded to nearest; beyond |x| = 2^20 the platform's. + pub fn sin(x: f64) -> f64 { + if x.abs() < f64::from_bits(0x3E40_0000_0000_0000) { + // |x| < 2^-27: sin x rounds to x (0 included). + return x; + } + if x.is_nan() || x.abs() > 1_048_576.0 { + return x.sin(); + } + let (r, q) = reduce(x); + eval(r, q, false) + } + + /// `cos(x)` rounded to nearest; beyond |x| = 2^20 the platform's. + pub fn cos(x: f64) -> f64 { + if x.abs() < f64::from_bits(0x3E40_0000_0000_0000) { + // |x| < 2^-27: cos x rounds to 1. + return 1.0; + } + if x.is_nan() || x.abs() > 1_048_576.0 { + return x.cos(); + } + let (r, q) = reduce(x); + eval(r, q, true) + } +} + #[cfg(test)] mod tests { - use super::{aarch64, fdlibm, round_f32}; + use super::{aarch64, exact, fdlibm, round_f32}; #[test] fn round_f32_is_exact_like_java() { @@ -587,6 +831,47 @@ mod tests { } } + /// Java 24's `Math.sin` and `Math.cos` on x86-64 HotSpot, for arguments + /// where Windows' libm rounds the other way. + #[test] + fn exact_sin_and_cos_round_like_javas_x86_intrinsics() { + for case in [ + (0x400cfed4c52a225f, 0xbfddb6fb8dfc34d9), + (0x401562e96dc95e79, 0xbfe9c702a472eb20), + (0x3fe83defdda344b2, 0x3fe5fd24ae6866f7), + (0x4015f2973d92bdce, 0xbfe6df4f6eb17f8b), + (0x400ccab8c6bca404, 0xbfdc43578964fcd9), + (0x4081275db9d375bb, 0x3fe835467b8bb2a8), + (0x408545bc77918c1a, 0x3feb130e731423b6), + (0xc08a86d7f86386d0, 0xbfe2bc064eff6f2b), + ] { + check1(exact::sin, case); + } + for case in [ + (0x3fff44a3b3c18058, 0xbfd7f1ca30696aa9), + (0x401824074317a30f, 0x3fef055d8c6cc46e), + (0x4001b86d21e7b163, 0xbfe33824a109fcc3), + (0xc08b37219efa0d36, 0xbfe913c9a4e569fe), + (0xc084ce175c9dc180, 0x3feef489ec429ba7), + (0x400ed26ab1d4ada5, 0xbfe83e5b263a94b1), + (0x40156db4b780dcab, 0x3fe33b5a52983c7c), + (0x408e8b5accbb6a88, 0xbfedab57ae231703), + // About 3e-28 below a half-way point: a double-double running sum loses that. + (0x3e937b1311fb4936, 0x3feffffffffffe85), + ] { + check1(exact::cos, case); + } + // Small arguments, the quadrant edges and beyond the reduced range. + assert_eq!(exact::sin(1e-300), 1e-300); + assert_eq!(exact::cos(-1e-10), 1.0); + assert_eq!(exact::sin(0.0).to_bits(), 0); + assert_eq!(exact::sin(-0.0).to_bits(), (-0.0f64).to_bits()); + assert!(exact::sin(f64::NAN).is_nan() && exact::cos(f64::INFINITY).is_nan()); + assert_eq!(exact::sin(std::f64::consts::FRAC_PI_2), 1.0); + assert_eq!(exact::cos(std::f64::consts::PI), -1.0); + assert_eq!(exact::sin(3.0e6), 3.0e6f64.sin()); + } + fn check1(f: fn(f64) -> f64, (x, expected): (u64, u64)) { assert_eq!(f(f64::from_bits(x)).to_bits(), expected, "{x:x}"); } From abe568d4460e088d4a38fe5022a4058ee98f20f6 Mon Sep 17 00:00:00 2001 From: DUzzL <98381970+DUzzL@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:48:05 +0100 Subject: [PATCH 54/70] Don't swing the hand when a mob interaction is CONSUME on the client --- crates/horizon-client/src/game/remote.rs | 6 +++- .../horizon-client/src/game/remote_mob_use.rs | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index d986e0ca22..def82cdf8a 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -7640,7 +7640,11 @@ impl Game<'_> { .is_some_and(|r| r.level_players.contains_key(&id)); if !player && self.remote_mob_takes(i, hand) { self.remote_feed_particles(i, hand); - self.with_hand(hand, Game::swing_arm); + // As for the objects above, only SUCCESS swings the hand: a + // CONSUME ends the click without one. + if self.remote_mob_swings(i, hand) { + self.with_hand(hand, Game::swing_arm); + } return true; } } diff --git a/crates/horizon-client/src/game/remote_mob_use.rs b/crates/horizon-client/src/game/remote_mob_use.rs index 5c2bd785c4..233ca8d480 100644 --- a/crates/horizon-client/src/game/remote_mob_use.rs +++ b/crates/horizon-client/src/game/remote_mob_use.rs @@ -132,6 +132,39 @@ impl Game<'_> { _ => false, } } + /// Whether that answer is `InteractionResult.SUCCESS`, the only result that + /// swings the hand: `CONSUME` ends the click without one. An adult animal's + /// food (`Animal.mobInteract`), a tame tamable's sit or stand, a zombie + /// villager's golden apple and a sheep's shears (the server alone checks + /// `readyForShearing`) all answer CONSUME on a client (#9437). + pub(super) fn remote_mob_swings(&self, i: usize, hand: Hand) -> bool { + let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else { + return false; + }; + let stack = match hand { + Hand::Main => self.stack_in(self.selected), + Hand::Off => self.offhand.as_ref(), + }; + let item = stack.map_or("", |s| &*s.item); + // A baby's food is SUCCESS, since it ages up; an adult's is CONSUME, + // as only the server knows whether it can breed. + if m.kind.flags.animal + && !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus) + && m.kind.food.iter().any(|f| f.as_str() == item) + { + return m.is_baby() && !m.extra.age_locked; + } + let consumes = match m.species() { + // TamableAnimal.mobInteract: a tame one sits or stands. + Species::Wolf | Species::Cat | Species::Parrot => m.extra.tame, + // ZombieVillager.mobInteract: a golden apple cures it on the server. + Species::ZombieVillager => item == "minecraft:golden_apple", + // Sheep.mobInteract: shears, sheared or not. + Species::Sheep => item == "minecraft:shears", + _ => false, + }; + !consumes + } /// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this /// client's copy: a baby fed something that ages it draws one happy /// villager particle. The server draws it on its own level only, so no From 99b0ab1f18cac888cbd1688bad030cf890ea940a Mon Sep 17 00:00:00 2001 From: GabrielNichols Date: Wed, 4 Nov 2026 16:49:44 -0300 Subject: [PATCH 55/70] fix(port-pack): invalidate stale harness verdicts --- crates/horizon-port-pack/src/carry.rs | 113 +++++++++++++++++++++++ tests/test_mod_port_harness.py | 125 ++++++++++++++++++++++++++ tools/mod_port_harness.py | 5 +- 3 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 tests/test_mod_port_harness.py diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index 4811b3263d..f09d41f748 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -855,6 +855,13 @@ pub fn carry( } let file = fs::File::open(jar).map_err(|e| format!("{}: {e}", jar.display()))?; let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?; + // The previous harness verdict describes the content we are about to replace. + let verdict = out.join("harness/result.json"); + if let Err(e) = fs::remove_file(&verdict) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(format!("{}: {e}", verdict.display())); + } let (inside, outside) = (out.join("carry"), out.join("outside")); let _ = fs::remove_dir_all(&inside); let _ = fs::remove_dir_all(&outside); @@ -1438,6 +1445,112 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn recarry_invalidates_the_verdict_before_replacing_content() { + use crate::{ + inventory::Record, + report::{self, Status}, + }; + + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = + std::env::temp_dir().join(format!("horizon-recarry-{}-{nonce}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let jar = dir.join("example.jar"); + let payload = br#"{"variants":{"":{"model":"example:block/stone"}}}"#; + let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap()); + zip.start_file( + "assets/example/blockstates/stone.json", + zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(payload).unwrap(); + zip.finish().unwrap(); + let work = dir.join("work"); + let out = work.join("mods/example"); + let verdict = out.join("harness/result.json"); + let info = crate::jar::JarInfo { + mod_id: "example".into(), + ..Default::default() + }; + let run = |source: &Path| { + carry( + source, + &info, + &out, + None, + &|_| true, + &Pack { + loaded: &|_| true, + mods: &|_| false, + }, + &BTreeMap::new(), + ) + }; + let records = [Record { + key: "example".into(), + class: "content".into(), + tier: "data".into(), + ..Default::default() + }]; + let row = || report::build(&records, &work).pop().unwrap(); + run(&jar).unwrap(); + fs::create_dir_all(verdict.parent().unwrap()).unwrap(); + let green = br#"{"checks":{"blocks":{"passed":1,"failed":0}}}"#; + let untouched = work.join("mods/untouched/harness/result.json"); + fs::create_dir_all(untouched.parent().unwrap()).unwrap(); + fs::write(&untouched, green).unwrap(); + for aggregate in [false, true] { + let result = if aggregate { + br#"{"passed":true,"checks":{"blocks":{"passed":1,"failed":0}}}"#.as_slice() + } else { + green.as_slice() + }; + fs::write(&verdict, result).unwrap(); + assert_eq!(row().status, Status::Ported); + run(&jar).unwrap(); + assert_eq!(row().status, Status::Partial); + assert!(row().reason.contains("harness not run")); + assert!(!verdict.exists()); + assert_eq!(fs::read(&untouched).unwrap(), green); + } + fs::write(&verdict, green).unwrap(); + // An invalid archive has not changed the old content, so its evidence stays valid. + let bad = dir.join("invalid.jar"); + fs::write(&bad, b"not a zip").unwrap(); + assert!(run(&bad).is_err()); + assert_eq!(row().status, Status::Ported); + assert_eq!(fs::read(&verdict).unwrap(), green); + + // Opening this ZIP succeeds, but reading its payload fails the CRC after mutation starts. + let mut corrupt = fs::read(&jar).unwrap(); + let offset = corrupt + .windows(payload.len()) + .position(|w| w == payload) + .unwrap(); + corrupt[offset] ^= 1; + fs::write(&bad, corrupt).unwrap(); + assert!(run(&bad).is_err()); + assert_eq!(row().status, Status::Partial); + assert!(!verdict.exists()); + run(&jar).unwrap(); + fs::write(&verdict, green).unwrap(); + assert_eq!(row().status, Status::Ported); + + // An invalidation error must leave the carried content alone. + fs::remove_file(&verdict).unwrap(); + fs::create_dir(&verdict).unwrap(); + let sentinel = out.join("carry/keep.txt"); + fs::write(&sentinel, b"keep").unwrap(); + assert!(run(&jar).is_err()); + assert_eq!(fs::read(&sentinel).unwrap(), b"keep"); + fs::remove_dir_all(&dir).unwrap(); + } + /// YUNG's pools carry comments, which Minecraft's lenient reader skips; /// the carry holds them as plain JSON. A comment marker inside a string /// stays, and text that is not JSON without its comments is refused. diff --git a/tests/test_mod_port_harness.py b/tests/test_mod_port_harness.py new file mode 100644 index 0000000000..23c101097f --- /dev/null +++ b/tests/test_mod_port_harness.py @@ -0,0 +1,125 @@ +import contextlib +import io +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from tools import mod_port_harness as harness + + +class VerdictLifecycleTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.work = Path(self.directory.name) + self.mods = ("first", "second", "untouched") + self.old = json.dumps({"checks": {"items": {"passed": 7, "failed": 0}}}) + for mod in self.mods: + folder = self.work / "mods" / mod + (folder / "carry").mkdir(parents=True) + (folder / "carry" / "keep.txt").write_text("content") + (folder / "harness").mkdir() + self.verdict(mod).write_text(self.old) + (folder / "carry-report.json").write_text('{"items": 1}') + facts = self.work / "oracle" / "facts.json" + facts.parent.mkdir() + facts.write_text(json.dumps({ + "blocks": {}, "tags": {"block": {}}, "recipes": {}, + "items": {f"{mod}:item": {"max_stack_size": 64} for mod in self.mods}, + })) + + def verdict(self, mod): + return self.work / "mods" / mod / "harness" / "result.json" + + def attempt(self, *mods, failure=None, jars=False, missing_item=False): + def run(command, **kwargs): + if command[0] == "cargo": + stage = "build" + elif command[0] == sys.executable: + stage = "export" + else: + stage = command[-1] + if failure == stage: + raise subprocess.CalledProcessError(1, command) + if failure == "interrupt" and stage == "first": + raise KeyboardInterrupt() + if stage in self.mods: + if failure == "parse": + Path(command[3]).write_text("not JSON") + return + Path(command[3]).write_text(json.dumps({ + "blocks": {}, "block_tags": {}, "recipes": {}, + "items": {} if missing_item else {f"{stage}:item": {"max_stack_size": 64}}, + })) + + args = ["mod_port_harness.py", "--work", str(self.work)] + if jars: + args += ["--jars", str(self.work / "jars")] + args += mods + metadata = json.dumps({"target_directory": str(self.work / "target")}) + with patch.object(sys, "argv", args), \ + patch.object(harness.subprocess, "run", side_effect=run), \ + patch.object(harness.subprocess, "check_output", return_value=metadata), \ + contextlib.redirect_stdout(io.StringIO()): + harness.main() + + def test_failed_or_interrupted_attempt_cannot_reuse_selected_verdicts(self): + for failure in ("export", "build", "first", "second", "interrupt", "parse"): + with self.subTest(failure=failure): + for mod in ("first", "second"): + self.verdict(mod).write_text(self.old) + with self.assertRaises((subprocess.CalledProcessError, KeyboardInterrupt, json.JSONDecodeError)): + self.attempt("first", "second", failure=failure, jars=failure == "export") + self.assertFalse(self.verdict("first").exists()) + self.assertFalse(self.verdict("second").exists()) + self.assertEqual(self.verdict("untouched").read_text(), self.old) + self.assertEqual((self.work / "mods/first/carry/keep.txt").read_text(), "content") + + def test_missing_facts_cannot_leave_a_selected_green_verdict(self): + (self.work / "oracle/facts.json").unlink() + with self.assertRaises(SystemExit): + self.attempt("first") + self.assertFalse(self.verdict("first").exists()) + self.assertEqual(self.verdict("second").read_text(), self.old) + + def test_complete_attempt_publishes_real_comparison_and_preserves_unselected_legacy(self): + with self.assertRaises(subprocess.CalledProcessError): + self.attempt("first", failure="first") + self.assertFalse(self.verdict("first").exists()) + for missing_item in (False, True): + with self.subTest(missing_item=missing_item): + self.attempt("first", missing_item=missing_item) + result = json.loads(self.verdict("first").read_text()) + self.assertEqual(result["passed"], not missing_item) + self.assertEqual(result["checks"]["items"], { + "passed": 0 if missing_item else 1, + "failed": 1 if missing_item else 0, + "examples": ["first:item: missing"] if missing_item else [], + }) + self.assertEqual(self.verdict("second").read_text(), self.old) + self.assertEqual(self.verdict("untouched").read_text(), self.old) + + def test_default_selection_invalidates_all_carried_mods_before_build(self): + with self.assertRaises(subprocess.CalledProcessError): + self.attempt(failure="build") + self.assertTrue(all(not self.verdict(mod).exists() for mod in self.mods)) + + def test_failed_invalidation_stops_before_replacing_evidence(self): + # A directory cannot be unlinked as a verdict on either Windows or Unix. + self.verdict("first").unlink() + self.verdict("first").mkdir() + sentinel = self.verdict("first") / "keep.txt" + sentinel.write_text("keep") + with self.assertRaises(OSError): + self.attempt("first") + self.assertEqual(sentinel.read_text(), "keep") + self.assertFalse((self.work / "mods/first/harness/horizon.json").exists()) + self.assertEqual(self.verdict("untouched").read_text(), self.old) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/mod_port_harness.py b/tools/mod_port_harness.py index 1f6b6b5214..de7944e87c 100644 --- a/tools/mod_port_harness.py +++ b/tools/mod_port_harness.py @@ -228,6 +228,10 @@ def main(): parser.add_argument('mods', nargs='*') args = parser.parse_args() work = Path(args.work).resolve() + mods = args.mods or sorted(p.name for p in (work / 'mods').iterdir() if (p / 'carry').is_dir()) + # A failed or interrupted attempt must not leave a previous green verdict available to report. + for mod in mods: + (work / 'mods' / mod / 'harness' / 'result.json').unlink(missing_ok=True) facts = work / 'oracle' / 'facts.json' if args.jars: facts.parent.mkdir(parents=True, exist_ok=True) @@ -244,7 +248,6 @@ def main(): probe = Path(metadata['target_directory']) / 'release/examples/mod_port_probe' oracle = json.loads(facts.read_text()) old_items = set(oracle.get('vanilla_items', [])) - mods = args.mods or sorted(p.name for p in (work / 'mods').iterdir() if (p / 'carry').is_dir()) rows = [] for mod in mods: # One mod at a time: each against vanilla, as a player adding it would load it (and a pack's From 60244bf97efea07cbeb0edd49f39ddd33a0ccfac Mon Sep 17 00:00:00 2001 From: voidsip Date: Wed, 4 Nov 2026 13:59:12 -0600 Subject: [PATCH 56/70] Update entity-bound sound port ledger after #9382 Co-Authored-By: Codex --- tools/port_map/client-002.tsv | 2 +- tools/port_map/client2-008.tsv | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/port_map/client-002.tsv b/tools/port_map/client-002.tsv index 0cc2545795..8d5cc3705b 100644 --- a/tools/port_map/client-002.tsv +++ b/tools/port_map/client-002.tsv @@ -77,7 +77,7 @@ ClientPacketListener.applyPlayerInfoUpdate inline crates/horizon-client/src/game ClientPacketListener.initializeChatSession port crates/horizon-client/src/game/remote.rs::Remote::new_chat_validator the session each INITIALIZE_CHAT entry carries is kept and checked against the services' profile-key keys when the player's chat is first read ClientPacketListener.enforcesSecureChat inline crates/horizon-client/src/game/remote.rs::Game::handle_packet the Login flag is kept as serverEnforcesSecureChat and the fallback message validator enforces it ClientPacketListener.handleGameRuleValues port crates/horizon-client/src/game/lan_screen.rs::Game::remote_world_options_rules -ClientPacketListener.handleSoundEntityEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet SoundEntity arm: the sound plays where the tracked entity is +ClientPacketListener.handleSoundEntityEvent port crates/horizon-client/src/game/remote.rs::Game::handle_packet SoundEntity arm binds the sound to the current entity object, preserving packet category and seed; unknown entities are ignored ClientPacketListener.handleAddObjective port crates/horizon-client/src/game/remote.rs::Game::remote_objective add, remove and update of an objective ClientPacketListener.handleSetScore port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetScore arm ClientPacketListener.handleResetScore port crates/horizon-client/src/game/remote.rs::Game::handle_packet ResetScore arm diff --git a/tools/port_map/client2-008.tsv b/tools/port_map/client2-008.tsv index f4cfeab53b..4ed37afdd9 100644 --- a/tools/port_map/client2-008.tsv +++ b/tools/port_map/client2-008.tsv @@ -140,7 +140,7 @@ AbstractSoundInstance.getOrResolve port crates/horizon-client-render/src/sound_e BeeSoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick Ticking::Bee follows the bee; pitch and volume from speed DirectionalSoundInstance.setPosition port crates/horizon-client/src/game/end.rs::Game::tick_end gap: minor: the end flash plays without a position, so the 10-block offset toward the view is not kept ElytraOnPlayerSoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick the `Elytra` arm: `item.elytra.flying` follows the player each tick, stops 20 ticks after gliding ends, volume `speed / 4` clamped, silent for a second then fading in over the next -EntityBoundSoundInstance.tick missing minor: a sound tied to an entity does not follow it; it stays at its start point +EntityBoundSoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick EntityBound arm follows the emitter's float-narrowed position and stops when the original entity is removed RidingEntitySoundInstance.tick port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick the `RidingEntity` arm for `entity.happy_ghast.riding` and `entity.nautilus.riding`: stops when the ridden mob is gone or silent, volume 0 unless the underwater test matches and the speed is at least 0.01, else the clamped lerp RidingMinecartSoundInstance.shoudlPlaySound port crates/horizon-client-render/src/sound_engine.rs::SoundInstance::tick RidingMinecart arm; the predicate is always true for OldMinecartBehavior, Horizon's only minecart SnifferSoundInstance.tick port crates/horizon-client/src/game/sniffers.rs::Game::tick_sniffer_effects gap: player: the digging sound plays once when digging starts; vanilla loops it while digging From 0d8d49d825c521489513c660d5f2ca91397fe9b4 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 03:46:03 +0100 Subject: [PATCH 57/70] Swing for a tame wolf, cat or parrot sitting down, and for food that heals or tames Wolf.mobInteract and Cat.mobInteract answer SUCCESS for sitting down, for food that heals a hurt tame one and for food that tames a cat, before Animal.mobInteract is reached. AbstractHorse.fedFood and Panda answer SUCCESS_SERVER, which swings on the server only. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/remote_mob_use.rs | 112 ++++++++++++++---- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/crates/horizon-client/src/game/remote_mob_use.rs b/crates/horizon-client/src/game/remote_mob_use.rs index 233ca8d480..693a90ec66 100644 --- a/crates/horizon-client/src/game/remote_mob_use.rs +++ b/crates/horizon-client/src/game/remote_mob_use.rs @@ -134,9 +134,10 @@ impl Game<'_> { } /// Whether that answer is `InteractionResult.SUCCESS`, the only result that /// swings the hand: `CONSUME` ends the click without one. An adult animal's - /// food (`Animal.mobInteract`), a tame tamable's sit or stand, a zombie - /// villager's golden apple and a sheep's shears (the server alone checks - /// `readyForShearing`) all answer CONSUME on a client (#9437). + /// food (`Animal.mobInteract`), a zombie villager's golden apple and a + /// sheep's shears (the server alone checks `readyForShearing`) answer + /// CONSUME on a client, and a horse's or panda's food SUCCESS_SERVER + /// (#9437). A tame wolf, cat or parrot sitting down is SUCCESS. pub(super) fn remote_mob_swings(&self, i: usize, hand: Hand) -> bool { let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else { return false; @@ -146,24 +147,7 @@ impl Game<'_> { Hand::Off => self.offhand.as_ref(), }; let item = stack.map_or("", |s| &*s.item); - // A baby's food is SUCCESS, since it ages up; an adult's is CONSUME, - // as only the server knows whether it can breed. - if m.kind.flags.animal - && !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus) - && m.kind.food.iter().any(|f| f.as_str() == item) - { - return m.is_baby() && !m.extra.age_locked; - } - let consumes = match m.species() { - // TamableAnimal.mobInteract: a tame one sits or stands. - Species::Wolf | Species::Cat | Species::Parrot => m.extra.tame, - // ZombieVillager.mobInteract: a golden apple cures it on the server. - Species::ZombieVillager => item == "minecraft:golden_apple", - // Sheep.mobInteract: shears, sheared or not. - Species::Sheep => item == "minecraft:shears", - _ => false, - }; - !consumes + mob_swings(m, item) } /// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this /// client's copy: a baby fed something that ages it draws one happy @@ -268,6 +252,48 @@ pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool { } } +/// [`Game::remote_mob_swings`] for mob `m` and the held `item`. +fn mob_swings(m: &Mob, item: &str) -> bool { + let food = m.kind.food.iter().any(|f| f.as_str() == item); + let hurt = m.health < m.attrs.max_health; + match m.species() { + // AbstractHorse.fedFood (Camel's, Llama's too): SUCCESS_SERVER, + // which swings on the server's side only. + s if food + && (equine::is_equine(s) + || llama::is_llama(s) + || matches!(s, Species::Camel | Species::CamelHusk)) => + { + return false; + } + // Panda.mobInteract: its bamboo answers SUCCESS_SERVER. + Species::Panda if food => return false, + // Cat.mobInteract: food tames an untamed cat, and heals a hurt + // tame one: SUCCESS before Animal.mobInteract is reached. + Species::Cat if food && (!m.extra.tame || hurt) => return true, + // Wolf.mobInteract: food heals a hurt tame wolf (SUCCESS). + Species::Wolf if food && m.extra.tame && hurt => return true, + _ => {} + } + // Animal.mobInteract: a baby's food is SUCCESS, since it ages up; an + // adult's is CONSUME, as only the server knows whether it can breed. + if m.kind.flags.animal + && !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus) + && food + { + return m.is_baby() && !m.extra.age_locked; + } + // A tame wolf, cat or parrot sits or stands up: SUCCESS + // (TamableAnimal's subclasses), so it swings. + let consumes = match m.species() { + // ZombieVillager.mobInteract: a golden apple cures it on the server. + Species::ZombieVillager => item == "minecraft:golden_apple", + // Sheep.mobInteract: shears, sheared or not. + Species::Sheep => item == "minecraft:shears", + _ => false, + }; + !consumes +} #[cfg(test)] mod tests { use super::*; @@ -290,6 +316,50 @@ mod tests { )) } + /// Tamables' and horses' own `mobInteract` answer before `Animal.mobInteract`'s + /// CONSUME (#9450): sitting down and healing are SUCCESS, a horse's food + /// SUCCESS_SERVER. + #[test] + fn only_a_client_success_swings_the_hand() { + let Some(mut wolf) = mob("minecraft:wolf") else { + return; + }; + wolf.extra.tame = true; + assert!( + mob_swings(&wolf, ""), + "a tame wolf sits: SUCCESS.withoutItem" + ); + assert!( + !mob_swings(&wolf, "minecraft:beef"), + "full health: Animal's CONSUME" + ); + wolf.health = wolf.attrs.max_health - 1.0; + assert!( + mob_swings(&wolf, "minecraft:beef"), + "hurt: Wolf.feed is SUCCESS" + ); + let mut cat = mob("minecraft:cat").unwrap(); + assert!(mob_swings(&cat, "minecraft:cod"), "taming: SUCCESS"); + cat.extra.tame = true; + assert!( + !mob_swings(&cat, "minecraft:cod"), + "tame, full health: CONSUME" + ); + assert!(mob_swings(&cat, ""), "a tame cat sits: SUCCESS"); + let mut parrot = mob("minecraft:parrot").unwrap(); + parrot.extra.tame = true; + assert!(mob_swings(&parrot, ""), "a tame parrot sits: SUCCESS"); + let horse = mob("minecraft:horse").unwrap(); + assert!( + !mob_swings(&horse, "minecraft:apple"), + "AbstractHorse.fedFood: SUCCESS_SERVER" + ); + let cow = mob("minecraft:cow").unwrap(); + assert!(!mob_swings(&cow, "minecraft:wheat"), "adult: CONSUME"); + let sheep = mob("minecraft:sheep").unwrap(); + assert!(!mob_swings(&sheep, "minecraft:shears"), "Sheep: CONSUME"); + } + /// `Sheep.mobInteract` and `DyeItem.interactLivingEntity` on a client (#9121). #[test] fn a_sheep_takes_shears_always_and_dye_only_to_change_its_colour() { From 3bbcda7ad5a911325e4096ad99404e1dbb9816e2 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:05:37 +0100 Subject: [PATCH 58/70] Carry an ore vein's positive_y through the shared rule's biome fold Main gave CompiledRule::OreVein the y bounds of its positive density; the fold copies them, as they depend on the rule and not on the chunk. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-world/src/worldgen/material_condition/rule.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/horizon-world/src/worldgen/material_condition/rule.rs b/crates/horizon-world/src/worldgen/material_condition/rule.rs index 5d4cf1b055..143b374f2b 100644 --- a/crates/horizon-world/src/worldgen/material_condition/rule.rs +++ b/crates/horizon-world/src/worldgen/material_condition/rule.rs @@ -299,11 +299,13 @@ where raw_ore_chance, densities, random, + positive_y, } => Self::OreVein { blocks: *blocks, raw_ore_chance: *raw_ore_chance, densities: *densities, random: random.clone(), + positive_y: *positive_y, }, Self::Condition { condition, rule } => { let condition = match possible { From 86d09bfcb76f8247d2fe40574b5a26ed5aae417d Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:08:08 +0100 Subject: [PATCH 59/70] Read the captured lead snap through CapturedSound's fields Main made the audio capture a struct; the ignored resource test still compared tuples and no longer compiled. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/fireworks.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 0eee3cbd47..e02cf5b461 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -861,12 +861,10 @@ mod tests { let heard = game.audio.capture.take().unwrap_or_default(); let snaps: Vec<_> = heard .into_iter() - .filter(|s| s.0 == "minecraft:item.lead.break") + .filter(|s| s.event == "minecraft:item.lead.break") + .map(|s| (s.position, s.volume, s.pitch)) .collect(); - assert_eq!( - snaps, - vec![("minecraft:item.lead.break".to_owned(), feet, 1.0, 1.0)] - ); + assert_eq!(snaps, vec![(feet, 1.0, 1.0)]); } } horizon_client::impl_joined!(Rocket); From 2ec49e8bc24d5b39931aa44c1c5ec7f6349dd50c Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:17:14 +0100 Subject: [PATCH 60/70] mod API: Host::equipment(MainHand) is the selected slot in an off-hand pass held() is the acting hand: during the off-hand pass of a use it is the off hand, so equipment(MainHand) answered the off-hand stack. LivingEntity.getItemBySlot doesn't depend on the hand acting. From martipops's review of #9222. Co-Authored-By: Claude Opus 5.5 --- .../src/game/native_host_bridge.rs | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/native_host_bridge.rs b/crates/horizon-client/src/game/native_host_bridge.rs index 8a4c68bcbf..db34b91c83 100644 --- a/crates/horizon-client/src/game/native_host_bridge.rs +++ b/crates/horizon-client/src/game/native_host_bridge.rs @@ -97,7 +97,9 @@ impl Game<'_> { /// which a player has none of. API @API:host_bridge@. pub(super) fn native_equipment(&self, slot: EquipmentSlot) -> Option { match slot { - EquipmentSlot::MainHand => self.held().cloned(), + // The selected slot, not `held`: that is the acting hand, the off hand's stack during + // the off-hand pass of a use (`with_hand(Hand::Off, ..)`). + EquipmentSlot::MainHand => self.inventory.get(self.selected).cloned().flatten(), EquipmentSlot::OffHand => self.offhand.clone(), EquipmentSlot::Head => self.armor[0].clone(), EquipmentSlot::Chest => self.armor[1].clone(), @@ -118,3 +120,64 @@ impl Game<'_> { self.keys.contains(&KeyCode::ShiftLeft) } } + +#[cfg(test)] +mod tests { + use crate::game::{Audio, Game, Options, Progress, Settings, load_resources}; + use horizon_client::{first_person::Hand, items::ItemStack}; + use horizon_world::player_inventory::EquipmentSlot; + + /// `LivingEntity.getItemBySlot` doesn't depend on the hand acting: in the + /// off-hand pass of a use, the main hand is still the selected slot's. + #[test] + #[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"] + fn equipment_reads_the_main_hand_in_an_off_hand_pass() { + let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap()); + let settings = Settings::default(); + let loaded = load_resources( + root.clone(), + None, + &root, + &settings, + std::time::Instant::now(), + false, + true, + &Progress::new(), + &mut |_, _| unreachable!(), + ) + .unwrap(); + let mut options = Options::parse(std::iter::empty()).unwrap().unwrap(); + options.resources = root.clone(); + let mut game = Game::new( + &loaded.resources, + loaded.pack, + loaded.gui.unwrap(), + options, + settings, + Audio::silent(&root), + None, + ) + .unwrap(); + let name = |stack: Option| stack.map(|s| s.item.to_string()); + game.inventory[game.selected] = Some(ItemStack::new("minecraft:stick", 1)); + game.offhand = Some(ItemStack::new("minecraft:torch", 1)); + for hand in [Hand::Main, Hand::Off] { + game.with_hand(hand, |g| { + assert_eq!( + name(g.native_equipment(EquipmentSlot::MainHand)).as_deref(), + Some("minecraft:stick"), + "{hand:?}" + ); + assert_eq!( + name(g.native_equipment(EquipmentSlot::OffHand)).as_deref(), + Some("minecraft:torch"), + "{hand:?}" + ); + }); + } + game.inventory[game.selected] = None; + game.with_hand(Hand::Off, |g| { + assert!(g.native_equipment(EquipmentSlot::MainHand).is_none()); + }); + } +} From 00922e5c8dfe13868da91196a19add001d1c129d Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:18:05 +0100 Subject: [PATCH 61/70] mod API: a machine's tool rolls only its own block; no ore experience with silk touch drop_tool and drop_into applied to every dropResources inside the machine's with_level, so blocks popping off with the break (grass on the dirt) dropped with the machine's tool and went into its list. They now drop as with no tool and pop, as Level.destroyBlock does for neighbours. A silk touch tool no longer collects ore experience (EnchantmentHelper.processBlockExperience, after the xpRange draw), which made an unlimited experience farm. From martipops's review of #9223. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 8 ++++--- .../src/game/native_machines.rs | 6 +++-- crates/horizon-client/src/game/spill.rs | 23 +++++++++++++++---- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a562d79df9..b37c91755c 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1005,9 +1005,11 @@ struct GameLevel<'w> { debug: bool, native_sounds: &'w mut Vec, native_particles: &'w mut Vec, - /// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`): - /// `Block.dropResources` rolls the loot with it instead of with none. - drop_tool: Option, + /// The block a mod's machine breaks and the tool it breaks it with + /// (`native_machine_destroy_block`): `Block.dropResources` rolls that + /// block's loot with the tool instead of with none. Neighbours that pop + /// off because of the break drop as with no tool, as in vanilla. + drop_tool: Option<((i32, i32, i32), Option)>, /// Where `Block.dropResources` puts the drops instead of popping them, /// while a mod's machine breaks a block (`native_machine_destroy_block`). drop_into: Option>, diff --git a/crates/horizon-client/src/game/native_machines.rs b/crates/horizon-client/src/game/native_machines.rs index 02a0398abc..ac5bc4ca86 100644 --- a/crates/horizon-client/src/game/native_machines.rs +++ b/crates/horizon-client/src/game/native_machines.rs @@ -23,7 +23,9 @@ impl Game<'_> { /// (`Block.dropResources(state, level, pos, blockEntity, null, tool)`): /// the break particles and sound, the block's drops with what its block /// entity copies into them (a shulker box's contents), experience for an - /// ore as with no tool, then the block's fluid in its place. `tool` picks + /// ore unless `tool` has silk touch (`EnchantmentHelper.processBlockExperience`), + /// then the block's fluid in its place. Blocks that pop off because of + /// the break drop as with no tool, and pop whatever `collect` says. `tool` picks /// the drops as a player's held item does (silk touch, fortune, shears); /// `None` is an empty hand. The mods' say on drops (`native_loot_drops`) /// applies. With `collect`, the drops are returned and nothing is popped @@ -47,7 +49,7 @@ impl Game<'_> { let at = BlockPos::new(pos[0], pos[1], pos[2]); let tool_kept = tool.cloned(); let mut drops = self.with_level(|b, level, ticks| { - level.drop_tool = tool_kept; + level.drop_tool = Some(((pos[0], pos[1], pos[2]), tool_kept)); level.drop_into = Some(Vec::new()); b.destroy_block(level, ticks, at, true); level.drop_tool = None; diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 7f3ee9620f..47e2d5dcdf 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -413,7 +413,15 @@ impl GameLevel<'_> { self.pending.record_saved(at, &tag); } let dynamic = self.pending.dynamic(at); - let tool = self.drop_tool.as_ref(); + // Only the block a machine breaks: a neighbour that pops off with it + // drops as with no tool, and pops (`Level.destroyBlock` for it). + let machine = self + .drop_tool + .as_ref() + .filter(|(target, _)| *target == key) + .map(|(_, tool)| tool.as_ref()); + let tool = machine.flatten(); + let silk = tool.is_some_and(|t| t.enchantment_level("minecraft:silk_touch") > 0); let drops = match self.items.block_loot_sequence(self.registry, state) { Some(sequence) => { let random = self.rand_sequences.get(&sequence, self.seed); @@ -432,7 +440,7 @@ impl GameLevel<'_> { if &*stack.item == "minecraft:air" || stack.count == 0 { continue; } - match self.drop_into.as_mut() { + match self.drop_into.as_mut().filter(|_| machine.is_some()) { Some(kept) => { self.pending.attach(at, &mut stack); kept.push(stack); @@ -441,10 +449,15 @@ impl GameLevel<'_> { } } self.pending.clear(at); - // BlockBehaviour.spawnAfterBreak with no tool: ores, sculk and spawners - // award experience at the block's centre. + // BlockBehaviour.spawnAfterBreak: ores, sculk and spawners award + // experience at the block's centre, except to a machine's silk touch + // tool (`processBlockExperience`; a spawner keeps its own), as the + // player's break does. let name = self.facts.get(state).name.clone(); - if let Some(mut xp) = removal::break_experience(&mut self.world.random, &name) + // `xpRange.sample` draws before silk touch zeroes it. + let xp = removal::break_experience(&mut self.world.random, &name) + .filter(|_| !silk || &*name == "minecraft:spawner"); + if let Some(mut xp) = xp && self.rules.block_drops { if self.native_authoritative { From 9f83f20c74d45d74c9cfa5b97a8b25d240581dc9 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:18:41 +0100 Subject: [PATCH 62/70] mod API: chunk tickets refuse chunks past the world's edge; held chunks generate between ticks A ticket past /forceload's 30,000,000-block edge is refused (the ring's arithmetic overflowed near i32::MAX). pump_chunks kept the workers fed between ticks for /forceload only; a mod's held chunks get the same. The docs say where tickets differ from NeoForge's and what they can't do yet (lazy Nether/End, pausing servers, free owners, cost). From martipops's review of #9226. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/commands/pregen.rs | 7 +++++-- .../horizon-client/src/game/native_chunk_tickets.rs | 10 +++++++--- docs/native-mods.md | 13 +++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/commands/pregen.rs b/crates/horizon-client/src/game/commands/pregen.rs index dfe66238e8..649166f869 100644 --- a/crates/horizon-client/src/game/commands/pregen.rs +++ b/crates/horizon-client/src/game/commands/pregen.rs @@ -244,7 +244,7 @@ impl Game<'_> { /// hand them the next jobs as soon as each one is done, rather than once /// a tick. pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) { - // Force-loaded chunks too: decorating in vanilla's order, a chunk + // Force-loaded chunks too, a mod's held ones as `/forceload`'s: decorating in vanilla's order, a chunk // waits for its neighbours far more often than once a tick allows. // And the players' chunks while they load, as vanilla's server runs // its chunk tasks while it waits for the next tick @@ -255,7 +255,10 @@ impl Game<'_> { // only asked for by the tick's own `update_world`. let players = !paused && self.spawn_wait.is_empty(); while self.pregen_runs_here() - || ((players || !self.streaming.forced.is_empty()) && self.streaming.busy()) + || ((players + || !self.streaming.forced.is_empty() + || !self.streaming.mod_forced.is_empty()) + && self.streaming.busy()) { let now = Instant::now(); if now >= until { diff --git a/crates/horizon-client/src/game/native_chunk_tickets.rs b/crates/horizon-client/src/game/native_chunk_tickets.rs index 3a884deef4..2641c5a547 100644 --- a/crates/horizon-client/src/game/native_chunk_tickets.rs +++ b/crates/horizon-client/src/game/native_chunk_tickets.rs @@ -18,13 +18,17 @@ impl Game<'_> { /// stays open with nobody there. Tickets are kept until the owner lets go /// or the server stops, not saved: keep what you hold (a level /// attachment) and hold it again in `native_level_load`. True when the - /// set of owners changed; false for an owner without a namespace and off - /// a server. API @API:chunk_tickets@. + /// set of owners changed; false for an owner without a namespace, a chunk + /// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@. pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool { let namespaced = owner .split_once(':') .is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty()); - if !namespaced || !self.native_authoritative() { + // `/forceload`'s world: blocks -30,000,000 to 29,999,999, so a mod's + // corrupt saved list asks for nothing past the edge (where the ring's + // arithmetic would overflow). + let in_world = chunk.iter().all(|c| (-1_875_000..1_875_000).contains(c)); + if !namespaced || !self.native_authoritative() || (hold && !in_world) { return false; } let key = (chunk[0], chunk[1]); diff --git a/docs/native-mods.md b/docs/native-mods.md index 0484cd0c22..7963e7d46e 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2923,6 +2923,19 @@ held while any owner holds it. It is kept as a `/forceload`ed chunk is: it is lo and its level stays open. `/forceload` neither lists nor releases it. `native_forced_chunks(owner)` lists what an owner holds. Tickets are not saved: keep what you hold (a level attachment) and hold it again in `native_level_load`. +Where this differs from NeoForge, and its limits today: + +- The ticket goes to the level in place, with no level argument, and every ticket ticks. Hold and release from a hook + tied to that level (`native_level_load`, a block entity's tick), not from a server-wide one such as + `native_lifecycle_server_tick_start`, where the level in place is whichever ticked last. +- The Nether and the End open only when something goes there, so their `native_level_load` doesn't run at start: a + ticket held there before a restart stays released until the level opens again. `/forceload` has the same limit. +- A dedicated server that pauses when empty (`pause-when-empty-seconds`) stops ticking held chunks too. +- `owner` is any namespaced string, not tied to the calling mod, and `native_forced_chunks` needs it exactly: keep the + owners you hold for, or a chunk stays held until the server stops. +- A chunk past the world's edge (beyond 30,000,000 blocks, as `/forceload` refuses) is refused. Each ticket keeps a + 5x5 ring loaded and the forced set is scanned per column: keep to what a machine needs (dozens, not thousands). + ### Mod messages API 40, `crates/horizon-client/src/game/native_net.rs`: bytes between a mod's server half and its client half, on the From af82f65855f14f77ff86c24f4f9304a5ef7154f6 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:19:25 +0100 Subject: [PATCH 63/70] mod API: scenes shade and cull block entity quads as the world does; a stale state id can't crash Mod quads, cubes, items and text come with their colour baked; the scene shaded them a second time (an east face of a cube at 0.30 against the block model's 0.6, glowing text dimmed). It now shades only what entity.wgsl shades (|normal.w| >= 0.5) and culls only what it culls (CULL_BACK, SHADED_CULL_W), so a flat quad or text seen from behind and a mirrored part draw as in the world. A degenerate quad takes its vertex normal. A state the registry doesn't know no longer panics in the face culling. The docs list the scene's limits. From martipops's review of #9229. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/native_scene.rs | 34 ++++++++++++++++--- docs/native-mods.md | 6 ++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/native_scene.rs b/crates/horizon-client/src/game/native_scene.rs index 02860aa998..8906595955 100644 --- a/crates/horizon-client/src/game/native_scene.rs +++ b/crates/horizon-client/src/game/native_scene.rs @@ -21,6 +21,10 @@ struct SceneQuad { uv: [[f32; 2]; 4], color: u32, normal: Vec3, + /// Dropped when it faces away: block model faces, and the quads a block + /// entity marks for culling as `entity.wgsl` does (`CULL_BACK`, + /// `SHADED_CULL_W`). Other quads draw both sides, as in the world. + cull: bool, } /// The GUI transform of `view` in the box `rect` (x0, y0, x1, y1): the view's @@ -82,8 +86,14 @@ impl Game<'_> { if !self.native_window() || !view.scale.is_finite() || view.scale <= 0.0 { return; } - let occupied: std::collections::HashMap<[i32; 3], _> = - blocks.iter().map(|b| (b.pos, b.state)).collect(); + // A state the registry doesn't know (a mod's stale id) culls nothing + // instead of panicking in the block facts. + let registry = &self.pack.assets.registry; + let occupied: std::collections::HashMap<[i32; 3], _> = blocks + .iter() + .filter(|b| registry.state(b.state).is_some()) + .map(|b| (b.pos, b.state)) + .collect(); let solid = |pos: [i32; 3]| { occupied.get(&pos).is_some_and(|&state| { self.res.behaviours().facts(state).flags & state_flags::SOLID_RENDER != 0 @@ -140,6 +150,7 @@ impl Game<'_> { }), color, normal, + cull: true, }); } if let Some(entity) = block.entity { @@ -166,14 +177,27 @@ impl Game<'_> { ); for q in placed.as_chunks::<4>().0 { let corners = q.each_ref().map(|v| Vec3::from_array(v.pos)); - let normal = (corners[1] - corners[0]) + let [nx, ny, nz, w] = q[0].normal; + let mut normal = (corners[1] - corners[0]) .cross(corners[2] - corners[0]) .normalize_or_zero(); + if normal == Vec3::ZERO { + normal = Vec3::new(f32::from(nx), f32::from(ny), f32::from(nz)) + .normalize_or_zero(); + } + // `entity.wgsl`: |w| >= 0.5 is shaded by the normal; smaller + // keeps the colour baked in (quads, cubes, items, text). + let unbaked = w.unsigned_abs() >= 64; quads.push(SceneQuad { corners, uv: q.each_ref().map(|v| v.uv), - color: shaded(q[0].color, face_shade(normal)), + color: if unbaked { + shaded(q[0].color, face_shade(normal)) + } else { + q[0].color + }, normal, + cull: (1..64).contains(&w) || (-126..0).contains(&w), }); } } @@ -181,7 +205,7 @@ impl Game<'_> { let m = scene_matrix(rect, view); let mut seen: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = quads .into_iter() - .filter(|q| m.transform_vector3(q.normal).z > 0.0) + .filter(|q| !q.cull || m.transform_vector3(q.normal).z > 0.0) .map(|q| { let p = q.corners.map(|c| m.transform_point3(c)); let depth = p.iter().map(|v| v.z).sum::() / 4.0; diff --git a/docs/native-mods.md b/docs/native-mods.md index 7963e7d46e..2d065b5b3f 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2960,6 +2960,12 @@ sets the point at the box's middle, the turn (`yaw`, counterclockwise seen from north), the tilt of its top towards the viewer (`pitch`) and the pixels per block (`scale`). The scene has a depth of its own and is cut to the box. Draw it from the screen's drawing hook; Create's Ponder steps through its scenes this way. +Limits: each call collects every block's models again and calls `native_block_entity_submit` for every block entity +(`keep` has no effect here), so keep scenes small. The hook gets the scene's position as `pos`, with no world around it: +what it reads of the world at that position is unrelated. Only a mod's block entity draws; a chest or a sign shows its +block model only. `spin` parts follow the world's game time (and stop under `/tick freeze`): a scene with its own +timeline turns parts through the pose instead. Faces are shaded and culled as in the world. + ### Chat and commands a client mod sends API 404, `crates/horizon-client/src/game/native_net.rs`: `native_send_chat(message)` sends what the player From 051322cd222dea94881c65d8fefe70f47758abc5 Mon Sep 17 00:00:00 2001 From: HrafnaValkyrja Date: Wed, 4 Nov 2026 21:32:53 -0500 Subject: [PATCH 64/70] Entity events: the particle table decides which ones draw The client forwarded an entity event to the particle code only if its id was in a hand-written list in remote.rs. The table that says what each event draws (entity_event_burst) already had rows for villager 42 (raid sweat) and dolphin 38, but the list did not, so neither showed. The list repeated the table and had drifted from it. It is removed: entity_event_particles already looks the event up in the table and draws nothing when there is no row. Fixes #8589 Co-Authored-By: Claude Sonnet 5.5 --- .../src/game/native_entity_refs.rs | 90 +++++++++++++++++++ crates/horizon-client/src/game/remote.rs | 10 +-- crates/horizon-client/src/game/villagers.rs | 15 ++++ 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/native_entity_refs.rs b/crates/horizon-client/src/game/native_entity_refs.rs index e81da2c271..4886250557 100644 --- a/crates/horizon-client/src/game/native_entity_refs.rs +++ b/crates/horizon-client/src/game/native_entity_refs.rs @@ -392,4 +392,94 @@ mod client_tests { assert_eq!(game.native_entity_position(stand), None); assert_eq!(game.native_entity_kind(display), None); } + + /// #8589: the server sends entity event 42 (the raid sweat) for a villager; + /// the client must draw the particles, as it does for the events it let + /// through before. An event with no particle table row (2) draws nothing. + #[test] + #[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"] + fn a_villager_raid_sweat_event_draws_particles() { + let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap()); + let settings = Settings::default(); + let loaded = load_resources( + root.clone(), + None, + &root, + &settings, + std::time::Instant::now(), + false, + true, + &Progress::new(), + &mut |_, _| unreachable!(), + ) + .unwrap(); + let mut options = Options::parse(std::iter::empty()).unwrap().unwrap(); + options.resources = root.clone(); + let mut game = Game::new( + &loaded.resources, + loaded.pack, + loaded.gui.unwrap(), + options, + settings, + Audio::silent(&root), + None, + ) + .unwrap(); + let (pipe, _server) = horizon_protocol::connection::Pipe::pair(); + game.connect_local(pipe, "Steve").unwrap(); + let builtin = game.remote.as_ref().unwrap().builtin.clone(); + let kind = builtin + .get("minecraft:entity_type") + .unwrap() + .id("minecraft:villager") + .unwrap(); + game.handle_packet(C::Login(play::Login { + player_id: 7, + hardcore: false, + levels: vec!["minecraft:overworld".into()], + max_players: 20, + chunk_radius: 8, + simulation_distance: 8, + reduced_debug_info: false, + show_death_screen: true, + limited_crafting: false, + spawn: spawn("minecraft:overworld"), + online_mode: false, + enforces_secure_chat: false, + })) + .unwrap(); + game.handle_packet(C::PlayerPosition(play::PlayerPosition { + teleport: 1, + position: [0.5, 65.0, 0.5], + delta: [0.0; 3], + yaw: 45.0, + pitch: 10.0, + relatives: 0, + })) + .unwrap(); + game.handle_packet(C::AddEntity(play::AddEntity { + id: 21, + uuid: 0x0011_2233_4455_6677_8899_aabb_ccdd_eeff, + kind, + // Within 32 blocks of the camera, which starts at the origin: the + // particle engine drops a particle farther away than that. + position: [1.5, 1.0, 2.5], + velocity: [0.0; 3], + pitch: 0, + yaw: 64, + head_yaw: 64, + data: 0, + })) + .unwrap(); + // `len` counts only particles already ticked in; `made` counts every + // particle created, which is what the event handler does at once. + let made = |g: &Game| g.particles.made().values().sum::(); + let before = made(&game); + game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 2 })) + .unwrap(); + assert_eq!(made(&game), before, "event 2 has no particle row"); + game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 42 })) + .unwrap(); + assert!(made(&game) > before, "event 42 draws the sweat"); + } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index c5247d6d5e..9fa676d0ca 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -4241,11 +4241,11 @@ impl Game<'_> { if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) { self.poof_particles(p.id as u32); } - // Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and - // 7, Animal and Allay 18: the particles about the mob. - if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18 | 40 | 41) { - self.entity_event_particles(p.id as u32, p.event); - } + // The particle bursts of Villager 12 to 14 and 42, TamableAnimal + // and AbstractHorse 6 and 7, Animal and Allay 18, Ocelot 40 and + // 41, Dolphin 38: the table in `entity_event_burst` is the one + // list of them; any other event draws nothing here. + self.entity_event_particles(p.id as u32, p.event); // Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where // the projectile is (the server removes it right after). if p.event == 3 { diff --git a/crates/horizon-client/src/game/villagers.rs b/crates/horizon-client/src/game/villagers.rs index a4067083c8..e10ce0ebf4 100644 --- a/crates/horizon-client/src/game/villagers.rs +++ b/crates/horizon-client/src/game/villagers.rs @@ -519,3 +519,18 @@ impl Game<'_> { } } } + +#[cfg(test)] +mod entity_event_particle_tests { + /// The raid sweat (42) and the dolphin's treasure hearts (38) are in the + /// table that now decides which entity events draw particles. + #[test] + fn the_table_draws_the_raid_sweat_and_the_dolphin_event() { + let kind = |entity, event| { + horizon_client::particles::entity_event_burst(entity, event).map(|(k, _)| k) + }; + assert_eq!(kind("villager", 42), Some("splash")); + assert_eq!(kind("dolphin", 38), Some("happy_villager")); + assert_eq!(kind("villager", 2), None); + } +} From 5890a75541247c700d3cd98899e168b0d2f00411 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:21:00 +0100 Subject: [PATCH 65/70] docs: Host carries play_sound and send_particles; change_mod_block_entity on a client is local Also fixes the borrow in drop_resources_now from the machine-tool commit (the target check is a bool now, so popping can borrow the level). From martipops's review of #9222. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/spill.rs | 10 +++++----- docs/native-mods.md | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 47e2d5dcdf..41c0a08c67 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -415,12 +415,12 @@ impl GameLevel<'_> { let dynamic = self.pending.dynamic(at); // Only the block a machine breaks: a neighbour that pops off with it // drops as with no tool, and pops (`Level.destroyBlock` for it). - let machine = self + let machine = self.drop_tool.as_ref().is_some_and(|(target, _)| *target == key); + let tool = self .drop_tool .as_ref() - .filter(|(target, _)| *target == key) - .map(|(_, tool)| tool.as_ref()); - let tool = machine.flatten(); + .filter(|_| machine) + .and_then(|(_, tool)| tool.as_ref()); let silk = tool.is_some_and(|t| t.enchantment_level("minecraft:silk_touch") > 0); let drops = match self.items.block_loot_sequence(self.registry, state) { Some(sequence) => { @@ -440,7 +440,7 @@ impl GameLevel<'_> { if &*stack.item == "minecraft:air" || stack.count == 0 { continue; } - match self.drop_into.as_mut().filter(|_| machine.is_some()) { + match self.drop_into.as_mut().filter(|_| machine) { Some(kept) => { self.pending.attach(at, &mut stack); kept.push(stack); diff --git a/docs/native-mods.md b/docs/native-mods.md index 2d065b5b3f..324acd2971 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -3549,7 +3549,7 @@ custom copy handler, conditional serialization, the `sendToPlayer` predicate, `Host` exposes the documented `Game` helpers listed under `helpers` in `native-hooks.json`, without their `native_` prefix. Each keeps the helper's receiver and documentation; a helper without `self` (`config_dir`) takes -`&self` in `Host`. Level services stay on the level hosts above. +`&self` in `Host`. Level services stay on the level hosts above, except `play_sound` and `send_particles`, which the table below carries for a block entity's own tick. A helper's `receiver` also exposes it on that handle. Receivers come from public `*Ref` structs in `mod_api.rs`: for example, a public `BlockEntityRef` struct registers `block_entity`. Adding a handle requires no separate generator registration. @@ -3559,7 +3559,7 @@ go through `dyn Host` (`native_host_bridge.rs`): | `Host` method | For | What it does | |---|---|---| -| `mod_block_entity(pos)`, `change_mod_block_entity(pos, &mut change)` | `with_mod_block_entity` | Reads or changes a neighbouring mod block entity. On a client, `mod_block_entity` returns what the server sent (`native_block_entity_update_tag`), so a client mod can read another block's data. | +| `mod_block_entity(pos)`, `change_mod_block_entity(pos, &mut change)` | `with_mod_block_entity` | Reads or changes a neighbouring mod block entity. On a client, `mod_block_entity` returns what the server sent (`native_block_entity_update_tag`), so a client mod can read another block's data. On a client, `change_mod_block_entity` changes only the local copy, and the next update tag from the server replaces what it changed: change it on the server. | | `trigger_criterion(uuid, trigger, &test)` | `trigger_criterion` | The same call, with the test as `&dyn Fn`. | | `play_sound`, `send_particles` | `LevelWriteHost`'s methods | Work in the level in place, from any server hook, such as a block entity's tick. | | `block_name_in_tag(block, tag)` | `server_block_in_tag` | Takes a block name instead of a state. | From ba1e658704dd4907a1f7de3e8ddd012799d696f4 Mon Sep 17 00:00:00 2001 From: voidsip Date: Wed, 4 Nov 2026 21:31:59 -0600 Subject: [PATCH 66/70] Resolve bound sounds from live indexed entity state --- .../horizon-client/src/game/entity_sounds.rs | 2 +- crates/horizon-client/src/game/music.rs | 92 +++++++++++++++--- crates/horizon-client/src/game/remote.rs | 94 ++++++++++++++----- 3 files changed, 151 insertions(+), 37 deletions(-) diff --git a/crates/horizon-client/src/game/entity_sounds.rs b/crates/horizon-client/src/game/entity_sounds.rs index fde50113cf..3036fa250c 100644 --- a/crates/horizon-client/src/game/entity_sounds.rs +++ b/crates/horizon-client/src/game/entity_sounds.rs @@ -62,7 +62,7 @@ impl Game<'_> { ); } - pub(super) fn sound_entity_state(&self, id: u32) -> Option { + pub(super) fn sound_entity_state(&mut self, id: u32) -> Option { if self.remote.is_some() { return self.remote_sound_entity_state(id as i32); } diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs index 713c69d7d5..3e5f5b1ffa 100644 --- a/crates/horizon-client/src/game/music.rs +++ b/crates/horizon-client/src/game/music.rs @@ -148,19 +148,9 @@ impl Game<'_> { /// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it /// carries on. pub(super) fn tick_sound_engine(&mut self) { - for entity in self.audio.engine.bound_entities() { - let previous = entity.state(); - if previous.removed { - continue; - } - let state = self.sound_entity_state(entity.id); - entity.update( - state.unwrap_or(horizon_client::sound_engine::SoundEntityState { - removed: true, - ..previous - }), - ); - } + update_bound_entities(self.audio.engine.bound_entities(), |id| { + self.sound_entity_state(id) + }); if self.pause != self.sound_paused { if self.pause { @@ -340,3 +330,79 @@ impl Game<'_> { self.ambience.tick(&ctx, &mut self.audio); } } + +/// Resolve each live entity once per sound tick, even when several sounds name it. +fn update_bound_entities( + entities: Vec, + mut resolve: impl FnMut(u32) -> Option, +) { + use horizon_client::sound_engine::SoundEntityState; + let mut states = std::collections::HashMap::new(); + for entity in entities { + let previous = entity.state(); + if previous.removed { + continue; + } + let state = *states + .entry(entity.id) + .or_insert_with(|| resolve(entity.id)); + entity.update(state.unwrap_or(SoundEntityState { + removed: true, + ..previous + })); + } +} + +#[cfg(test)] +mod bound_lookup_tests { + use super::update_bound_entities; + use horizon_client::sound_engine::{SoundEntity, SoundEntityState}; + + #[test] + fn simultaneous_sounds_share_a_lookup_but_follow_each_new_tick() { + let a = SoundEntity::new(7, [0.0; 3], false); + let b = SoundEntity::new(7, [1.0; 3], false); + let mut calls = Vec::new(); + for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] { + update_bound_entities(vec![a.clone(), b.clone()], |id| { + calls.push(id); + Some(SoundEntityState { + position, + silent: true, + removed: false, + }) + }); + assert_eq!(a.state().position, position); + assert_eq!(b.state().position, position); + assert!(a.state().silent && b.state().silent); + } + assert_eq!(calls, vec![7, 7]); + } + + #[test] + fn missing_entities_stop_all_their_sounds_without_reviving_reused_ids() { + let a = SoundEntity::new(7, [1.0; 3], false); + let b = SoundEntity::new(7, [2.0; 3], false); + let mut calls = 0; + update_bound_entities(vec![a.clone(), b.clone()], |_| { + calls += 1; + None + }); + assert_eq!(calls, 1); + assert!(a.state().removed && b.state().removed); + let replacement = SoundEntity::new(7, [3.0; 3], false); + update_bound_entities(vec![a.clone(), replacement.clone(), b.clone()], |_| { + calls += 1; + Some(SoundEntityState { + position: [4.0; 3], + silent: false, + removed: false, + }) + }); + assert_eq!(calls, 2); + assert!(a.state().removed && b.state().removed); + assert_eq!(a.state().position, [1.0; 3]); + assert_eq!(replacement.state().position, [4.0; 3]); + assert!(!replacement.state().removed); + } +} diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5bac76278e..3bb975a9ae 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -171,8 +171,9 @@ fn hurt_sound_for( fn entity_sound_state( id: i32, player: Option<(i32, [f64; 3], bool)>, - mobs: &[Mob], + mob: Option, tracked: &HashMap, + dragon: Option<(i32, [f64; 3])>, ) -> Option { use horizon_client::sound_engine::SoundEntityState; if let Some((player_id, position, silent)) = player @@ -184,18 +185,20 @@ fn entity_sound_state( removed: false, }); } - if let Some(m) = mobs.iter().find(|m| m.id as i32 == id) { - return Some(SoundEntityState { - position: [m.position.x, m.position.y, m.position.z], - silent: m.silent, - removed: m.removed, - }); + let mut state = mob.or_else(|| { + tracked.get(&id).map(|t| SoundEntityState { + position: t.position, + silent: t.silent, + removed: false, + }) + })?; + // The dragon's Mob is metadata storage; only Dragon follows its movement. + if let Some((dragon_id, position)) = dragon + && dragon_id == id + { + state.position = position; } - tracked.get(&id).map(|t| SoundEntityState { - position: t.position, - silent: t.silent, - removed: false, - }) + Some(state) } /// `ClientboundOpenScreenPacket.title` as a container screen draws it: the @@ -776,7 +779,7 @@ impl Game<'_> { } } pub(super) fn remote_sound_entity_state( - &self, + &mut self, id: i32, ) -> Option { let r = self.remote.as_ref()?; @@ -787,9 +790,29 @@ impl Game<'_> { r.local_entity_silent, ) }); - let mut state = entity_sound_state(id, player, &self.mobs, &r.entities)?; - // Vehicles, projectiles and other simulated objects may have moved since - // the last tracking packet, just as the mob interpolation has. + // The local player is not in mobs, so avoid a guaranteed index miss. + if player.is_some_and(|p| p.0 == id) { + return entity_sound_state(id, player, None, &r.entities, None); + } + let mob = self.mob_index.get(&self.mobs, id as u32).map(|m| { + horizon_client::sound_engine::SoundEntityState { + position: [m.position.x, m.position.y, m.position.z], + silent: m.silent, + removed: m.removed, + } + }); + let dragon = self + .dragon + .as_ref() + .map(|d| (d.id as i32, [d.position.x, d.position.y, d.position.z])); + let mut state = entity_sound_state(id, player, mob, &r.entities, dragon)?; + // Mob states already use the live position. In particular, + // level_entity would find the dragon's stale Mob before its Dragon. + if mob.is_some() || dragon.is_some_and(|d| d.0 == id) { + return Some(state); + } + // Vehicles, projectiles and other simulated objects may have moved + // since the last tracking packet. if let Some(position) = self .level_entity(id) .and_then(|target| self.target_position(target)) @@ -11393,28 +11416,28 @@ mod tests { ); let player = Some((2, [0.5, 70.0, 0.5], false)); assert_eq!( - super::entity_sound_state(2, player, &[], &tracked) + super::entity_sound_state(2, player, None, &tracked, None) .filter(|s| !s.silent) .map(|s| s.position), Some([0.5, 70.0, 0.5]), "the local player is named by Remote.entity_id" ); assert_eq!( - super::entity_sound_state(7, player, &[], &tracked) + super::entity_sound_state(7, player, None, &tracked, None) .filter(|s| !s.silent) .map(|s| s.position), Some([8.5, 64.0, -3.5]), "another entity the server spawned" ); assert_eq!( - super::entity_sound_state(9, player, &[], &tracked) + super::entity_sound_state(9, player, None, &tracked, None) .filter(|s| !s.silent) .map(|s| s.position), None, "getEntity is null: handleSoundEntityEvent returns" ); assert_eq!( - super::entity_sound_state(2, None, &[], &tracked) + super::entity_sound_state(2, None, None, &tracked, None) .filter(|s| !s.silent) .map(|s| s.position), None, @@ -11433,15 +11456,40 @@ mod tests { silent: true, }, ); - let state = super::entity_sound_state(7, None, &[], &tracked).unwrap(); + let state = super::entity_sound_state(7, None, None, &tracked, None).unwrap(); assert!(state.silent); assert!(!state.removed); assert_eq!(state.position, [8.5, 64.0, -3.5]); let player = - super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), &[], &tracked).unwrap(); + super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), None, &tracked, None) + .unwrap(); assert!(player.silent); - assert!(super::entity_sound_state(9, None, &[], &tracked).is_none()); + assert!(super::entity_sound_state(9, None, None, &tracked, None).is_none()); } + #[test] + fn a_dragon_sound_uses_live_movement_without_losing_mob_metadata() { + use horizon_client::sound_engine::SoundEntityState; + let stale = SoundEntityState { + position: [0.0, 64.0, 0.0], + silent: true, + removed: false, + }; + let tracked = std::collections::HashMap::new(); + for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] { + let state = + super::entity_sound_state(7, None, Some(stale), &tracked, Some((7, position))) + .unwrap(); + assert_eq!(state.position, position); + assert!(state.silent); + assert!(!state.removed); + } + let unrelated = + super::entity_sound_state(7, None, Some(stale), &tracked, Some((8, [99.0; 3]))) + .unwrap(); + assert_eq!(unrelated.position, stale.position); + assert!(super::entity_sound_state(7, None, None, &tracked, Some((7, [99.0; 3]))).is_none()); + } + /// Q over a slot of the inventory on a server (#3357): the predicted THROW /// drops the stack, so the player's hand swings, as `LivingEntity.drop` /// does for `Prediction.PREDICTED`; a plain pick up drops nothing. From 37477f99ec9616cc0147921aae12eb50e6626162 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:26:37 +0100 Subject: [PATCH 67/70] Sleeping block entities: tests run the real tick on every idle case; chunk unloads keep sleepers asleep The idle tests restated the predicate. The furnace, brewing stand and campfire tests now run the tick the block runs on each idle case and check nothing changed, and that an awake one changes, so an idle() that drifts from its tick fails. Unloading chunks used retain, which wakes every sleeping block entity of the level, so with moving players most idle machines ticked anyway: unload removes only the gone ones. From martipops's review of #9239. Co-Authored-By: Claude Opus 5.5 --- .../src/block_entities.rs | 28 ++++++++ crates/horizon-client-render/src/brewing.rs | 57 ++++++++++++++++ .../horizon-client-render/src/containers.rs | 37 ++++++++++ crates/horizon-client/src/furnace.rs | 67 ++++++++++++++++--- crates/horizon-client/src/game.rs | 2 +- 5 files changed, 180 insertions(+), 11 deletions(-) diff --git a/crates/horizon-client-render/src/block_entities.rs b/crates/horizon-client-render/src/block_entities.rs index 40ff8f7721..9547493583 100644 --- a/crates/horizon-client-render/src/block_entities.rs +++ b/crates/horizon-client-render/src/block_entities.rs @@ -407,6 +407,16 @@ impl BlockEntities { self.add(pos); } } + /// [`Self::retain`] for unloading chunks: removes the block entities at + /// the positions `gone` names and changes none it keeps, so those that + /// sleep stay asleep (a moving player unloads chunks most ticks). + pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) { + let asleep = std::mem::take(&mut self.asleep); + self.retain(|p, _| !gone(p)); + self.asleep = asleep; + let map = &self.map; + self.asleep.retain(|p| map.contains_key(p)); + } /// Removes the block entities `keep` refuses, and their slots /// (`clearAllBlockEntities` of unloaded chunks). pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) { @@ -558,6 +568,24 @@ mod tests { .collect() } + /// Unloading a chunk removes its block entities and leaves the others' + /// sleep alone; `retain`, which may change what it keeps, wakes them all. + #[test] + fn unloading_a_chunk_keeps_the_others_asleep() { + let mut b = level(); + let (kept, gone) = ((1, 64, 1), (17, 64, 1)); + b.insert(kept, hopper()); + b.insert(gone, hopper()); + b.sleep(kept); + b.sleep(gone); + b.unload(|p| p.0 >> 4 == 1); + assert!(b.get(&gone).is_none()); + assert!(!b.is_asleep(&gone)); + assert!(b.is_asleep(&kept)); + b.retain(|_, _| true); + assert!(!b.is_asleep(&kept)); + } + fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec { let mut found: Vec = b.mods_near(x, z, radius).copied().collect(); found.sort(); diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs index 7ff12b2cb4..1b83490c53 100644 --- a/crates/horizon-client-render/src/brewing.rs +++ b/crates/horizon-client-render/src/brewing.rs @@ -487,6 +487,63 @@ mod tests { assert!(!stand.idle()); } + /// The stand sleeps while `idle`, so its tick must change nothing then: run the real + /// tick on idle stands and compare, and check a stand with fuel to take wakes. + #[test] + fn an_idle_brewing_stand_tick_changes_nothing() { + let items = Items::for_tests(std::collections::HashMap::new()); + let brewing = Brewing { + reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(), + inputs: HashSet::new(), + recipes: vec![BrewingRecipe { + id: "awkward".into(), + input: PotionIngredient { + items: ["minecraft:potion".to_owned()].into_iter().collect(), + potions: Some(["minecraft:water".to_owned()].into_iter().collect()), + }, + reagent: PotionIngredient { + items: ["minecraft:nether_wart".to_owned()].into_iter().collect(), + potions: None, + }, + output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"), + }], + }; + let providers = Providers::empty(); + let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")); + let stand = |bottle: bool, fuel: i32, powder: bool| { + let mut s = BrewingStand::new(); + s.fuel = fuel; + if bottle { + s.items[1] = water(); + } + if powder { + s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1)); + } + s.last_bottles = Some([false, bottle, false]); + s + }; + for before in [ + stand(false, 0, false), + stand(true, 0, false), + stand(true, 5, false), + stand(true, 5, true), + ] { + assert!(before.idle(), "{before:?}"); + let mut after = before.clone(); + assert_eq!( + after.tick(&brewing, &items, &providers), + BrewTick::default() + ); + assert_eq!(format!("{after:?}"), format!("{before:?}")); + } + // A brewable ingredient starts the brew: not idle, and the tick changes it. + let mut brewing_now = stand(true, 5, false); + brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1)); + assert!(!brewing_now.idle()); + brewing_now.tick(&brewing, &items, &providers); + assert_eq!(brewing_now.brew_time, 400); + } + /// #3312: `PotionContents.getPotionDescription` names the effects with /// translatable components, so a Russian game reads them from ru_ru.json /// (the prepared one; skipped without it), as the tooltip's other lines. diff --git a/crates/horizon-client-render/src/containers.rs b/crates/horizon-client-render/src/containers.rs index e866ad7f7f..a50a36948c 100644 --- a/crates/horizon-client-render/src/containers.rs +++ b/crates/horizon-client-render/src/containers.rs @@ -749,6 +749,43 @@ mod tests { assert!(c.idle(false)); } + /// The campfire sleeps while `idle`, so the tick its block runs (`cook_tick` lit, + /// `cooldown_tick` unlit) must change nothing then, and must change an awake one. + #[test] + fn an_idle_campfire_tick_changes_nothing() { + let tick = |c: &mut Campfire, lit: bool| { + if lit { + !c.cook_tick().is_empty() + } else { + c.cooldown_tick() + } + }; + let empty = Campfire::default(); + let mut food = Campfire::default(); + assert!(food.place_food(&mut ItemStack::new("minecraft:beef", 1), 600)); + let mut warm = food.clone(); + warm.cook_tick(); + let mut cooled = warm.clone(); + while cooled.cooldown_tick() {} + for (before, lit) in [ + (&empty, true), + (&empty, false), + (&food, false), + (&cooled, false), + ] { + assert!(before.idle(lit), "{before:?} lit {lit}"); + let mut after = before.clone(); + assert!(!tick(&mut after, lit)); + assert_eq!(&after, before, "lit {lit}"); + } + for (before, lit) in [(&food, true), (&warm, false)] { + assert!(!before.idle(lit), "{before:?} lit {lit}"); + let mut after = before.clone(); + tick(&mut after, lit); + assert_ne!(&after, before, "lit {lit}"); + } + } + #[test] fn campfire_slots_take_food_cook_it_and_cool_down() { let mut c = Campfire::default(); diff --git a/crates/horizon-client/src/furnace.rs b/crates/horizon-client/src/furnace.rs index 138eb25263..cc9a8f7704 100644 --- a/crates/horizon-client/src/furnace.rs +++ b/crates/horizon-client/src/furnace.rs @@ -214,15 +214,12 @@ mod tests { assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle()); } - /// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a - /// data root holding that one recipe and an item table with two fuels: `test:coal` - /// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`). - fn burn(fuel: &str, count: u32) -> (Furnace, Vec) { - let root = std::env::temp_dir().join(format!( - "horizon-furnace-remainder-{}-{}-{count}", - std::process::id(), - fuel.replace(':', "-") - )); + /// A data root holding one recipe, `test:ore` into `test:ingot`, and an item table + /// with two fuels: `test:coal` (no remainder) and `test:cell` (a content mod's item + /// that leaves `test:husk`). The folder is gone when this returns. + fn data(name: &str) -> (Items, Recipes, Providers) { + let root = + std::env::temp_dir().join(format!("horizon-furnace-{name}-{}", std::process::id(),)); let _ = fs::remove_dir_all(&root); fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap(); fs::write( @@ -248,6 +245,57 @@ mod tests { let items = Items::catalog(&root, &PackOverlay::NONE).unwrap(); let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap(); let providers = Providers::load(&root); + let _ = fs::remove_dir_all(&root); + (items, recipes, providers) + } + + /// The furnace sleeps while `idle`, so its tick must change nothing then: run the + /// real tick on every idle case and compare, and check a furnace that can smelt + /// isn't idle and does change. + #[test] + fn an_idle_furnace_tick_changes_nothing() { + let (items, recipes, providers) = data("idle"); + let stack = |id: &str| Some(ItemStack::new(id, 1)); + let furnace = |input, fuel, output| Furnace { + kind: FurnaceKind::Furnace, + items: [input, fuel, output], + lit_remaining: 0, + lit_total: 0, + cooking_timer: 0, + cooking_total: 200, + speed: 1.0, + recipes_used: HashMap::new(), + }; + let idle = [ + furnace(None, None, None), + furnace(stack("test:ore"), None, None), + furnace(None, stack("test:coal"), None), + furnace(None, None, stack("test:ingot")), + furnace(stack("test:ingot"), None, stack("test:ingot")), + ]; + for before in idle { + assert!(before.idle(), "{before:?}"); + let mut after = before.clone(); + let mut drops = Vec::new(); + let lit = after.tick(&recipes, &items, &providers, &mut drops); + assert_eq!(lit, None, "{before:?}"); + assert!(drops.is_empty(), "{before:?}"); + assert_eq!(format!("{after:?}"), format!("{before:?}")); + } + let mut busy = furnace(stack("test:ore"), stack("test:coal"), None); + assert!(!busy.idle()); + let before = format!("{busy:?}"); + assert_eq!( + busy.tick(&recipes, &items, &providers, &mut Vec::new()), + Some(true) + ); + assert_ne!(format!("{busy:?}"), before); + } + + /// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot` ([`data`]). + fn burn(fuel: &str, count: u32) -> (Furnace, Vec) { + let (items, recipes, providers) = + data(&format!("remainder-{}-{count}", fuel.replace(':', "-"))); let mut furnace = Furnace { kind: FurnaceKind::Furnace, items: [ @@ -264,7 +312,6 @@ mod tests { }; let mut drops = Vec::new(); let lit = furnace.tick(&recipes, &items, &providers, &mut drops); - let _ = fs::remove_dir_all(&root); assert_eq!(lit, Some(true), "the fuel lit the furnace"); (furnace, drops) } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index a562d79df9..234c8934c3 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -17195,7 +17195,7 @@ impl Game<'_> { unloaded.iter().map(|c| (c.x, c.z)).collect(); self.world .block_entities - .retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4))); + .unload(|p| gone.contains(&(p.0 >> 4, p.2 >> 4))); self.world .block_names .retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4))); From 7218bd068001c4378c976a3848bc3f970cee2a93 Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:33:32 +0100 Subject: [PATCH 68/70] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/spill.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 41c0a08c67..baf24bc4dd 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -415,7 +415,10 @@ impl GameLevel<'_> { let dynamic = self.pending.dynamic(at); // Only the block a machine breaks: a neighbour that pops off with it // drops as with no tool, and pops (`Level.destroyBlock` for it). - let machine = self.drop_tool.as_ref().is_some_and(|(target, _)| *target == key); + let machine = self + .drop_tool + .as_ref() + .is_some_and(|(target, _)| *target == key); let tool = self .drop_tool .as_ref() From 266d52a6cdf98224eeebe58dd58201203b6c78de Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 04:24:24 +0100 Subject: [PATCH 69/70] Mobs: dolphins hold their breath, copper golems breathe under water Dolphins ran WaterAnimal's air rule, which set their air to 300 every tick in water: air never fell under 140, so they never surfaced for air nor drowned, and #9271 now sent that 300 to every viewer. Dolphin's handleAirSupply is empty and it isn't in #can_breathe_under_water, so it now takes LivingEntity.baseTick's air from its 4800 (and stops taking drowning damage on land on top of drying out). The breathing list follows the tag: copper golems in, ghasts, blazes and snow golems out (no override keeps their air; the iron golem's decreaseAirSupply does). A saved mob without an Air tag loads with its species' maximum (Entity.load), not 300. From martipops's review of #9271. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity_nbt.rs | 3 +- crates/horizon-client/src/mobs/ai.rs | 56 +++++++++++++++++++++++ crates/horizon-client/src/mobs/species.rs | 15 +++--- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 0dc6bc776b..ba26401a79 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1952,7 +1952,8 @@ pub fn mob_from_nbt( } read_variant(&mut m, tag); m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32; - m.air = number(tag.get("Air")).unwrap_or(300) as i32; + // Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin. + m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32); if m.species() == crate::mobs::Species::Allay { if let Some(Tag::List(inv)) = tag.get("Inventory") { for stack in inv.iter().filter_map(item_from_nbt) { diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 05b2ff22d3..08d06e8529 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -4822,3 +4822,59 @@ mod ravager_noai_tests { }); } } + +#[cfg(test)] +mod air_tests { + use super::*; + + /// A mob of `kind` in a pool, its eyes under, after `ticks` base ticks. + fn air_after(kind: &str, air: i32, ticks: u32) -> Option { + let build = |world: &mut crate::world::World, + registry: &horizon_world::registry::BlockRegistry| { + world.chunks.insert( + (0, 0), + std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)), + ); + let water = registry.default_state("minecraft:water").unwrap(); + for x in 0..=4 { + for z in 0..=4 { + for y in 0..=8 { + world.set(x, y, z, water).unwrap(); + } + } + } + }; + let mut out = None; + crate::mobs::world::with_built_test_world(&[], build, |w, types| { + let kind = std::sync::Arc::new(types.types[kind].clone()); + let mut m = Mob::new(1, kind, Vec3::new(2.5, 3.0, 2.5), 0.0); + m.air = air; + let mut events = Vec::new(); + for _ in 0..ticks { + base_tick(&mut m, w, &mut events); + } + out = Some(m.air); + }); + out + } + + /// `Dolphin.handleAirSupply` is empty and the dolphin isn't in + /// `#can_breathe_under_water`: under water it loses a breath a tick from + /// its 4800, as `LivingEntity.baseTick` takes it, so it must surface. + #[test] + fn a_dolphin_holds_its_breath_under_water() { + let Some(air) = air_after("minecraft:dolphin", 4800, 10) else { + return; + }; + assert_eq!(air, 4790); + } + + /// A copper golem is in `#can_breathe_under_water`: its air stays full. + #[test] + fn a_copper_golem_breathes_under_water() { + let Some(air) = air_after("minecraft:copper_golem", 300, 10) else { + return; + }; + assert_eq!(air, 300); + } +} diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index cc2379c901..03a77c8453 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -1485,7 +1485,10 @@ impl Def { | S::ZombieNautilus ), undead, - breathes_water: aquatic + // `#can_breathe_under_water`, and the iron golem, whose + // `decreaseAirSupply` keeps its air. Not the dolphin: it holds its + // breath (4800 ticks) and surfaces for air. + breathes_water: (aquatic && s != S::Dolphin) || undead || matches!( s, @@ -1495,13 +1498,13 @@ impl Def { | S::Guardian | S::ElderGuardian | S::IronGolem - | S::SnowGolem - | S::Ghast - | S::Blaze + | S::CopperGolem | S::Nautilus ), - // Nautilus.handleAirSupply: it dries out on land. - needs_water: aquatic || s == S::Nautilus, + // WaterAnimal.handleAirSupply: it dries out on land; Nautilus's + // does the same. Dolphin.handleAirSupply is empty, so a dolphin + // takes LivingEntity.baseTick's air like a land mob. + needs_water: (aquatic && s != S::Dolphin) || s == S::Nautilus, water_sensitive: matches!(s, S::Enderman | S::Blaze | S::SnowGolem | S::Strider), animal: !matches!(s, S::Bat | S::SulfurCube | S::Mannequin) && !self.hostile_class() From 8ea4669674d0dc361e92865785ca453e36d81daf Mon Sep 17 00:00:00 2001 From: Horizon agent Date: Thu, 5 Nov 2026 05:02:07 +0100 Subject: [PATCH 70/70] Config golden: add the [transfer] section #9062 pinned the config reply before this PR added [transfer]; the GET reply now lists it with its defaults. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-server/testdata/api-v1-config.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/horizon-server/testdata/api-v1-config.json b/crates/horizon-server/testdata/api-v1-config.json index 802f8fa2cb..8a9a035fad 100644 --- a/crates/horizon-server/testdata/api-v1-config.json +++ b/crates/horizon-server/testdata/api-v1-config.json @@ -33,6 +33,13 @@ "view_distance": 10, "whitelist": true }, + "transfer": { + "enabled": false, + "expect_seconds": 10, + "max_expect_seconds": 60, + "max_expected": 64, + "max_freeze_seconds": 30 + }, "updates": {"keep": 1}, "world": { "difficulty": "easy",