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-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 698fcbdeb2..9d5ca0914f 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}; @@ -1167,6 +1169,7 @@ pub struct Renderer { /// Native mods' world primitives (`Frame::world_primitives`). world_primitives: world_primitives::WorldPrimitives, text_polygon_offset_pipeline: wgpu::RenderPipeline, + world_text_pipeline: wgpu::RenderPipeline, celestial_pipeline: wgpu::RenderPipeline, celestial_buffer: wgpu::Buffer, end_sky: end_sky::EndSky, @@ -1391,6 +1394,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, @@ -3731,6 +3736,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 @@ -4553,6 +4564,7 @@ impl Renderer { see_through_pipeline, world_primitives, text_polygon_offset_pipeline, + world_text_pipeline, celestial_pipeline, celestial_buffer, end_sky: end_sky::EndSky::default(), @@ -4682,6 +4694,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, @@ -6395,6 +6408,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. @@ -6458,6 +6475,9 @@ impl Renderer { } }); horizon_shaders::GameInputs { + 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()), @@ -8242,6 +8262,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, @@ -8267,11 +8289,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) = @@ -10007,6 +10032,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 { @@ -11718,7 +11766,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/pack_atlas.rs b/crates/horizon-client-render/src/gfx/pack_atlas.rs index 8785a34ef3..098179ecae 100644 --- a/crates/horizon-client-render/src/gfx/pack_atlas.rs +++ b/crates/horizon-client-render/src/gfx/pack_atlas.rs @@ -519,6 +519,36 @@ 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'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!( + " 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 + }; + [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 6265addd85..f9720e0675 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 { @@ -516,8 +519,14 @@ 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`; + /// 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. 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, /// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices. @@ -2163,10 +2172,15 @@ 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))); + 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()) @@ -2295,7 +2309,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, @@ -2371,8 +2385,31 @@ 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)); + // 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)) => { + 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), @@ -2456,6 +2493,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))) @@ -2513,6 +2551,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) @@ -2528,6 +2567,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) @@ -2665,6 +2705,7 @@ impl PackPost { setup_pending: true, passes, terrain, + terrain_cutout, water, sky, entities, @@ -3153,7 +3194,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, @@ -3163,13 +3205,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(); @@ -3207,12 +3271,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 @@ -3851,7 +3910,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`). @@ -3902,6 +3961,7 @@ impl PackPost { .passes .iter_mut() .chain(&mut self.terrain) + .chain(&mut self.terrain_cutout) .chain(&mut self.water) .chain(sky) .chain(entities) @@ -4561,6 +4621,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() @@ -4735,6 +4796,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 218a56f72f..a24203096d 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<'_>, @@ -122,7 +122,7 @@ impl Gpu { }); self.queue.submit([encoder.finish()]); } - fn render( + pub(super) fn render( &self, post: &mut PackPost, color: &wgpu::TextureView, @@ -213,7 +213,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)> { @@ -561,7 +561,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..87564b5c07 --- /dev/null +++ b/crates/horizon-client-render/src/gfx/pack_post/terrain_parts_tests.rs @@ -0,0 +1,295 @@ +//! 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. 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; +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:?});}}" + ), + ), + ] +} + +/// 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, 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 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; + }; + 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(), + ), + ]; + 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\ + 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, + &[LAVA, LAVA + 1], + )), + 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: &[], + sprite_rects: &[], + celestial: None, + gui_size: SIZE, + clouds: None, + weather: false, + hand: false, + }, + inputs, + ); + let opaque = u32::from(crate::mesher::OPAQUE_LAYER); + let mut vertices: Vec = Vec::new(); + 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, + ); + 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]); + } + } + 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 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, _, _| { + if list == TerrainList::Solid { + rp.set_vertex_buffer(0, buffer.slice(..)); + rp.draw(0..18, 0..1); + } + }); + // 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]) + }; + 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 { + assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})"); + } + } + first + }); + Some((strips, undrawn)) +} + +#[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)]); +} + +/// 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 { + return; + }; + 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-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-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 045af549ce..a29d5a6176 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. @@ -1928,7 +1940,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 @@ -1944,7 +1956,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( @@ -1956,6 +2003,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. @@ -2023,10 +2071,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| { @@ -2061,8 +2110,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; @@ -2072,7 +2122,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. @@ -2916,6 +2970,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] @@ -3819,6 +3908,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-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-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); + } } diff --git a/crates/horizon-client-render/src/sound_engine.rs b/crates/horizon-client-render/src/sound_engine.rs index b03564a669..1c691ba211 100644 --- a/crates/horizon-client-render/src/sound_engine.rs +++ b/crates/horizon-client-render/src/sound_engine.rs @@ -81,6 +81,10 @@ impl SoundEntity { 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, /// `EntityBoundSoundInstance`. EntityBound, /// `BiomeAmbientSoundsHandler.LoopSoundInstance`. @@ -611,7 +615,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::EntityBound => { let entity = self .bound_entity @@ -1552,6 +1556,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 { @@ -1562,13 +1568,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) { @@ -1586,6 +1596,76 @@ 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" + ); + assert_eq!(backend.pitch[&channel], 0.5); + assert_eq!(backend.position[&channel], [0.0; 3]); + 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])); + let replacement = owned.clone(); + assert!(engine.is_active(id)); + engine.stop(id, &mut backend); + assert_eq!(backend.stopped.get(&channel), Some(&true)); + engine.tick(false, &TickContext::default(), &mut backend); + engine.release(id); + for _ in 0..20 { + engine.tick(false, &TickContext::default(), &mut backend); + } + assert!(engine.instance(id).is_none()); + assert!(!engine.is_active(id)); + assert!(!backend.stopped.contains_key(&channel)); + + let (id, result) = engine.play_new(replacement, true, &mut backend); + assert_eq!(result, PlayResult::Started); + engine.stop_all(&mut backend); + assert!(!engine.is_active(id)); + assert!(backend.stopped.is_empty()); + assert!(engine.instance(id).is_some(), "the owner still retains it"); + engine.release(id); + assert!(engine.instance(id).is_none()); + assert_eq!(engine.census()["playing"], 0); + assert_eq!(engine.census()["ticking"], 0); + assert_eq!(engine.census()["handles"], 0); + } + #[test] fn happy_ghast_riding_sound_follows_the_ghast_speed() { let ghast = RiddenSound { diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index 08e808cad2..42d9b8cc09 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/advancements.rs b/crates/horizon-client/src/advancements.rs index 3a4638cdb5..950d42759a 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -605,6 +605,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)] @@ -853,6 +857,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`. @@ -1810,6 +1824,67 @@ 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 weather(&self) -> (bool, bool) { + Flat.weather() + } + 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" + ); + } #[test] fn weather_conditions_leave_absent_fields_unconstrained_and_compose() { let ctx = Entities { diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index a16085194c..96c3ba5dd6 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -1591,6 +1591,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] { @@ -1620,6 +1622,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 0dc6bc776b..6261284b71 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) { @@ -3186,6 +3187,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)); @@ -3217,6 +3224,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`. @@ -6290,6 +6298,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/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..7d0a0f52fb 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -247,6 +247,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; @@ -441,6 +442,7 @@ pub use native_mob_draw::{NativeMobRender, NativePlayerRender}; pub use native_reload_listeners::NativeReloadListener; 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}; pub use native_world_read::NativeBlockReadError; use std::{ @@ -527,6 +529,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. @@ -662,6 +667,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, @@ -675,7 +681,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); @@ -778,6 +784,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" => { @@ -1005,9 +1015,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>, @@ -1991,6 +2003,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 { @@ -3869,7 +3890,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 @@ -3884,7 +3912,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, @@ -6218,8 +6246,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; @@ -7719,8 +7754,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. @@ -11641,6 +11677,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, @@ -17195,7 +17232,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))); @@ -19947,6 +19984,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> { @@ -20026,6 +20071,20 @@ impl Game<'_> { self.res.core.collision.clone(), &causes_suffocation, )?; + // A registry whose pack tags don't load keeps the game's own tags + // (`in_tag` falls back to them), rather than matching no tag at all. + let pack_tags = WorldTables::PACK_TAG_REGISTRIES + .into_iter() + .filter_map(|registry| { + match horizon_client::items::load_tags(root, registry, &overlay) { + Ok(tags) => Some((registry, tags)), + Err(e) => { + log::warn!("Horizon: the world's {registry} tags are not applied: {e}"); + None + } + } + }) + .collect(); Ok(( Some(WorldTables { suffocation, @@ -20034,6 +20093,7 @@ impl Game<'_> { recipes, stations, data_maps, + pack_tags, }), health, )) @@ -22355,6 +22415,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] diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 4c73039cc4..6cb31686f4 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -209,8 +209,21 @@ impl adv::World for View<'_, '_> { fn smokey(&self, p: [i32; 3]) -> bool { horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p) } + 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(); + // 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), @@ -871,10 +884,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/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 36026031ad..2b377614dd 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -1659,6 +1659,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/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index afab8d1f6c..1e22e57970 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,84 @@ 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() { + // 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); + 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/commands.rs b/crates/horizon-client/src/game/commands.rs index cca52e00f6..76301497eb 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 { @@ -3342,10 +3344,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/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 61e8cf64b1..d47908aecc 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -354,6 +354,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; // Mob.readAdditionalSaveData updates persistent home fields, // while retaining the rest of the live AI/runtime state. @@ -625,35 +629,34 @@ impl Game<'_> { } Ok(tag) } - DataRef::Block(p) => { - if let Some(tag) = self.moving_piston_data(*p) { - return Ok(tag); - } - 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 { + // A live moving piston is the level's block entity there (#9617). + if let Some(tag) = self.moving_piston_data(p) { + return Some(tag); + } + 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), 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/control.rs b/crates/horizon-client/src/game/control.rs index f344f0ba5f..42606a1cde 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) }), diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 65fd63b51a..86f0acbfc0 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -327,6 +327,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/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index 6ed8ebcf6c..49ef236112 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. @@ -985,9 +986,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 { @@ -1025,6 +1023,18 @@ 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()) @@ -1053,6 +1063,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)), @@ -1114,6 +1125,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/status.rs b/crates/horizon-client/src/game/dedicated/status.rs index 10739bebc8..3ba4ca61a4 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..d51bb2e90b --- /dev/null +++ b/crates/horizon-client/src/game/dedicated/transfer.rs @@ -0,0 +1,117 @@ +//! 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. 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}; +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 { + 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()), + 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`, 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 { + 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 }), + }; + // A repeat while the hand-over lasts: the same record, and nothing touched. + if let Some(h) = &host.hub.guests[i].handover { + 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); + // 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); + // 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()); + } + 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 { + host.hub + .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 { + host.hub.guests[i] + .advancements + .to_json(&self.adv.tree, horizon_world::DATA_VERSION) + }; + 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) => arrivals::export_reply(&host.hub, i, record), + Err(e) => json!({ "error": e }), + } + } +} diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index ad0cfe0157..d417a66f20 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -569,7 +569,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, @@ -577,6 +579,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; @@ -587,15 +602,27 @@ 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 /// `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) + } + /// `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; } @@ -652,6 +679,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 { @@ -665,6 +705,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`). @@ -1307,12 +1384,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 @@ -2476,12 +2560,31 @@ 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; if fight { 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(); @@ -3594,7 +3697,10 @@ impl Game<'_> { if self.dragon.as_ref().is_some_and(|d| d.id == id) { // Only the fight's own dragon (`dragonFight`) ends it. let fight = self.is_fight_dragon(); - self.dragon = None; + if let Some(d) = self.dragon.take() { + // remove, then gameEvent(ENTITY_DIE), before the fight hears of it. + self.dragon_died_event(d.id, d.position, d.alive()); + } if fight { self.dragon_killed(); } @@ -4755,6 +4861,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/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/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 9bc01882ce..e02cf5b461 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,102 @@ 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.event == "minecraft:item.lead.break") + .map(|s| (s.position, s.volume, s.pitch)) + .collect(); + assert_eq!(snaps, vec![(feet, 1.0, 1.0)]); + } } horizon_client::impl_joined!(Rocket); 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/lan.rs b/crates/horizon-client/src/game/lan.rs index 3a995f5574..9380cd18b0 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -579,6 +579,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); mob_raider_meta(m, meta, defaults); match kind { @@ -2829,10 +2836,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 b14d06d796..b1d347b5ff 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -2493,9 +2493,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), @@ -4669,20 +4666,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/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/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index 0c514e7dba..12eb5a5b05 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -960,6 +960,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], @@ -981,6 +982,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/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/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 210d1bda34..06fe744552 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -492,9 +492,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/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index a0b2952bdf..206ad51187 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -227,7 +227,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) { 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/native_block_entity_draw.rs b/crates/horizon-client/src/game/native_block_entity_draw.rs index ae554d5d75..3ae24818dc 100644 --- a/crates/horizon-client/src/game/native_block_entity_draw.rs +++ b/crates/horizon-client/src/game/native_block_entity_draw.rs @@ -248,9 +248,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_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/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/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index ab2d3c53a1..deb0cd8a80 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -988,6 +988,7 @@ use super::{ native_mob_draw::{NativeMobRender, NativePlayerRender}, native_panel_recipes::RecipePage, native_reload_listeners::NativeReloadListener, + native_world_draw::NativeWorldDraw, }; use horizon_assets::item_model::StackView; use horizon_client::mod_api::{ @@ -1015,6 +1016,7 @@ use horizon_client::{ first_person::Hand, health::{HurtSource, Hurting, Knock}, hud::{HudBatch, rgba}, + hud_layout::HealthHud, items::ItemStack, keybinds, lan::menu::Slots, @@ -3304,6 +3306,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 @@ -9100,6 +9110,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/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()); + }); + } +} 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/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/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/game/remote.rs b/crates/horizon-client/src/game/remote.rs index a29fc7671a..7886d9d857 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 @@ -489,6 +492,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, @@ -776,7 +782,7 @@ impl Game<'_> { } } pub(super) fn remote_sound_entity_state( - &self, + &mut self, id: i32, ) -> Option { let r = self.remote.as_ref()?; @@ -787,9 +793,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)) @@ -1044,6 +1070,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, @@ -2151,7 +2178,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 = @@ -4237,11 +4282,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 { @@ -4268,9 +4313,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() @@ -4282,7 +4328,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 @@ -5629,7 +5675,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(), @@ -5985,8 +6034,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(); @@ -6287,9 +6338,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, @@ -6298,7 +6350,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); } } @@ -6972,6 +7027,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. @@ -7035,8 +7092,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); @@ -7652,7 +7708,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; } } @@ -8527,8 +8587,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, @@ -11413,28 +11473,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, @@ -11453,15 +11513,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. diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 4f949cd811..e85344139c 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -148,6 +148,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. @@ -1430,4 +1438,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 + ); + } + } } diff --git a/crates/horizon-client/src/game/remote_mob_use.rs b/crates/horizon-client/src/game/remote_mob_use.rs index 2768626e11..dee804d01c 100644 --- a/crates/horizon-client/src/game/remote_mob_use.rs +++ b/crates/horizon-client/src/game/remote_mob_use.rs @@ -132,6 +132,23 @@ 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 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; + }; + 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); + 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 /// villager particle. The server draws it on its own level only, so no @@ -208,6 +225,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::*; @@ -230,6 +289,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"); + } + /// `AbstractNautilus.mobInteract` (#8595): an untamed adult takes its taming /// items only, and a tame adult is mounted by any click. #[test] diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index 5280c020ac..68a209ca1b 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/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/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index c32291e326..b72de65f58 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -975,6 +975,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/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs index f89385f6f5..5dfb407a38 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); diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 7f3ee9620f..baf24bc4dd 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -413,7 +413,18 @@ 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() + .is_some_and(|(target, _)| *target == key); + let tool = self + .drop_tool + .as_ref() + .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) => { let random = self.rand_sequences.get(&sequence, self.seed); @@ -432,7 +443,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) { Some(kept) => { self.pending.attach(at, &mut stack); kept.push(stack); @@ -441,10 +452,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 { diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index dc24c3e647..44863bab33 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-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/game/trident.rs b/crates/horizon-client/src/game/trident.rs index b8587a9f63..98ad4d945e 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 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); + } +} diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index d9f7184119..2ada383577 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -163,6 +163,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/lan/arrivals.rs b/crates/horizon-client/src/lan/arrivals.rs new file mode 100644 index 0000000000..ca278a9017 --- /dev/null +++ b/crates/horizon-client/src/lan/arrivals.rs @@ -0,0 +1,1124 @@ +//! 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. +//! +//! 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}; + +/// `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)] +pub enum ExpectError { + /// The table is full of live expectations. + Full, +} + +/// The players a server was told to expect, by unique id. +#[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 { + 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() >= self.max { + return Err(ExpectError::Full); + } + let deadline = now + ttl.min(self.max_ttl); + Ok(self.until.insert(uuid, deadline).is_some()) + } + + /// 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 { + 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); + } + } + + /// 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 + } + + /// 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 { + self.until.len() + } + + pub fn is_empty(&self) -> bool { + self.until.is_empty() + } +} + +/// The version of the record `player-export` answers with. +pub const RECORD_VERSION: i64 = 1; + +/// 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 +/// 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.left_reason.is_empty() { + Err("that player is leaving") + } 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) + } +} + +/// 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 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, + 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( + &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, + "handover": id.map(handover_text), + "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": records.advancements, + "stats": records.stats, + }); + if let Some(id) = id { + let since = hub.ticks; + hub.guests[i].handover = Some(Handover { + id, + since, + record: record.clone(), + }); + } + 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(); + 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 }) +} + +/// 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 +/// (`[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 => 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"), + }, + }; + 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"); + } + let mut table = transfers.table(); + match table.expect(uuid, ttl, now) { + Ok(renewed) => serde_json::json!({ + "ok": true, + "renewed": renewed, + "ttl_seconds": ttl.min(table.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 + Settings::default().max_expect)); + } + + #[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..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)); + // 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); + } + + #[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)); + } + + /// 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; + 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. +#[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(), + }; + 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. + 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(), + expected: false, + }, + 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. + table(&hub) + .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!(table(&hub).is_empty()); + } + + /// 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 now = Instant::now(); + 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!(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"; + 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!(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] + 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!(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::Settings::default().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 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); + 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!( + 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 { + 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(); + 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; + 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. + 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].handover.is_some()); + assert!(hub.poll(&mut ctx).iter().any(|e| is_warn(e, "frozen"))); + assert_eq!(hub.guests[i].handover, None, "the hand-over is over"); + } + + #[test] + 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); + // 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(); + 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, 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}; + 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(), + timestamp: 0, + salt: 0, + signature: None, + last_seen: LastSeenUpdate { + offset: 0, + acknowledged: [0, 0, 0b1000], + checksum: 0, + }, + }), + 10, + )); + 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); + } +} diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index 129865c2b8..9b9e8fcb2e 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -771,17 +771,40 @@ 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().and_then(|table| { + table + .lock() + .unwrap_or_else(|e| e.into_inner()) + .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); + 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)); @@ -1227,6 +1250,7 @@ fn play( parts: Parts, address: std::net::SocketAddr, counted: Option, + expected: bool, ) { shared.entered_play(); let conn = shared.next_id(); @@ -1277,6 +1301,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 a2fcd34137..d02467bd11 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -338,6 +338,14 @@ pub struct Guest { pub death_time: u32, /// The join packets were sent (PlayerList.placeNewPlayer ran). pub placed: bool, + /// 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, @@ -512,6 +520,8 @@ impl Guest { dead: false, death_time: 0, placed: false, + handover: None, + expected: j.expected, hidden_commands: Vec::new(), flying_speed: 0.05, dimension: "minecraft:overworld".into(), @@ -1534,6 +1544,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 48930d3e34..71aabc0743 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -269,6 +269,9 @@ pub struct Hub { pub native_menu_events: Vec, /// Kicks waiting for the game thread to notify native mods. pending_kicks: Vec, + /// `[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,6 +673,7 @@ impl Hub { mod_commands: (serde_json::Value::Null, None), native_menu_events: Vec::new(), pending_kicks: Vec::new(), + transfers: None, native_tracking: Vec::new(), attachment_snapshots: Vec::new(), sync_attachments: false, @@ -999,6 +1003,35 @@ 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) { + // 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.handover + .as_ref() + .is_some_and(|h| super::arrivals::frozen_too_long(h.since, self.ticks, max)) + { + g.handover = None; + events.push(HostEvent::Warn(format!( + "{} was frozen for a hand-over for {max} ticks and nothing finished it: let go", + g.name, + ))); + } + } + } + /// 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 { @@ -1713,6 +1746,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) => { @@ -1787,6 +1821,41 @@ 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. 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; + 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::ChatAck(c) => self.chat_ack(i, c.offset, events), + S::AcceptTeleportation(_) + | S::PlayerLoaded(_) + | S::ChunkBatchReceived(_) + | S::ClientInformation(_) + | S::ClientTickEnd(_) + | S::KeepAlive(_) + | S::Pong(_) => { + bytes += size; + self.guests[i].inbox.push_back((packet, size)); + } + _ => {} + } + } + self.guests[i].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 @@ -1797,10 +1866,19 @@ impl Hub { let timed = crate::spans::recording_here(); for i in 0..self.guests.len() { if self.here(&self.guests[i]) { + 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, &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]; @@ -4537,6 +4615,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 1b14424939..398f56059c 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -904,6 +904,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 64dcc679d9..73be0cb794 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; @@ -80,6 +81,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 +186,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 +242,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 6b9aadd32f..8360170ef1 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() @@ -538,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-client/src/landing.rs b/crates/horizon-client/src/landing.rs index 874d3f4c93..6a43ed9e82 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 { @@ -783,4 +803,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); + } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 2e733beab7..cf266e46cb 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -2714,6 +2714,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, @@ -2762,8 +2766,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; @@ -2771,7 +2795,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 { @@ -2871,14 +2908,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, @@ -5424,3 +5467,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/ai.rs b/crates/horizon-client/src/mobs/ai.rs index aea7890948..4cc5cebecc 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -2749,6 +2749,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), @@ -4826,3 +4829,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/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/frog.rs b/crates/horizon-client/src/mobs/frog.rs index 27aab43c18..7a5c2501fb 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,40 @@ 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); + }); + // 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 /// the jump animation is started, eased by 0.4 (`WalkAnimationState.update`). #[test] diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index ef5aec7a6f..7e45ab7ca6 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -757,22 +757,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 96d5f1fb9a..fef929e5d8 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/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() 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/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 962dc04756..e61a588273 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -542,6 +542,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-client/src/mod_api.rs b/crates/horizon-client/src/mod_api.rs index 02515ab177..b92ffe0a62 100644 --- a/crates/horizon-client/src/mod_api.rs +++ b/crates/horizon-client/src/mod_api.rs @@ -26,14 +26,14 @@ pub use crate::game::{ NativeCriterion, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap, NativeHeldItem, NativeHit, NativeItemEntity, NativeMapDecoration, NativeMapInfo, NativeMobRender, NativeModEvent, NativePlayer, NativePlayerRender, NativeReloadListener, - NativeShake, + NativeShake, NativeWorldDraw, }; pub use crate::gfx::native_render::{ BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, 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/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] 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}"); } diff --git a/crates/horizon-items/src/enchant.rs b/crates/horizon-items/src/enchant.rs index 4b5b25f74a..1d58a74a7a 100644 --- a/crates/horizon-items/src/enchant.rs +++ b/crates/horizon-items/src/enchant.rs @@ -140,6 +140,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 { /// Use the same frozen registry and resolved tags as player damage. pub fn bind_damage_catalog(&mut self, catalog: &horizon_world::damage_source::Catalog) { @@ -253,37 +293,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() @@ -326,6 +339,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). @@ -1249,6 +1282,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] 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(), 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 3d381e1836..d70c154ec1 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)] @@ -148,6 +149,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"); @@ -229,6 +233,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())); 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/crates/horizon-server/src/admin.rs b/crates/horizon-server/src/admin.rs index c47f897f15..500617329c 100644 --- a/crates/horizon-server/src/admin.rs +++ b/crates/horizon-server/src/admin.rs @@ -546,6 +546,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/src/api.rs b/crates/horizon-server/src/api.rs index 9a180a2470..ed160be201 100644 --- a/crates/horizon-server/src/api.rs +++ b/crates/horizon-server/src/api.rs @@ -1006,6 +1006,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(); + } + const LIFECYCLE_GOLDENS: &str = include_str!("../testdata/api-v1-lifecycle.json"); fn golden_phase(name: &str) -> Phase { @@ -1516,4 +1638,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/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/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/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/crates/horizon-server/testdata/api-v1-config.json b/crates/horizon-server/testdata/api-v1-config.json new file mode 100644 index 0000000000..8a9a035fad --- /dev/null +++ b/crates/horizon-server/testdata/api-v1-config.json @@ -0,0 +1,106 @@ +{ + "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 + }, + "transfer": { + "enabled": false, + "expect_seconds": 10, + "max_expect_seconds": 60, + "max_expected": 64, + "max_freeze_seconds": 30 + }, + "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 + } + } +} 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" + } +} 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"} + } +} 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" + } +} 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] diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs index a69c49db5c..dca712f2dc 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,14 @@ 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; 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. 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, /// What draws the sky (disc, sunset, stars, void) before terrain. @@ -422,6 +447,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. @@ -474,6 +502,7 @@ impl Post { fn every_program(&self) -> impl Iterator { let draws = [ &self.terrain, + &self.terrain_cutout, &self.water, &self.sky, &self.sky_textured, @@ -584,6 +613,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 +637,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, @@ -617,6 +650,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, @@ -711,6 +747,12 @@ 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). @@ -825,23 +867,70 @@ 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 mut lone = None; + let (solid, terrain_cutout) = match parts { + 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, inputs); - loader.post.terrain = terrain; + 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)); 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 +979,7 @@ fn load_shadow( let post = &*loader.post; let gbuffers = [ &post.terrain, + &post.terrain_cutout, &post.water, &post.sky, &post.sky_textured, @@ -1022,6 +1112,21 @@ struct Loader<'a> { } impl Loader<'_> { + /// 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 /// fails (with a note). fn load( @@ -2415,6 +2520,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] @@ -2462,6 +2612,156 @@ 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| terrain_names(programs, "", parts); + let some = |name: &str| Some(name.to_owned()); + let [terrain, solid, cutout] = TERRAIN_PROGRAMS; + 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] + ); + } + + /// 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)]) + ); + } + #[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 +2781,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, 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 0f89ff710d..76ba1560c4 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 3db3d1c7c7..143b374f2b 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}, }; @@ -285,6 +286,56 @@ 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, + 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 { + 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 applying this rule never gives `None` (only an error). fn always_produces(&self) -> bool { match self { @@ -460,6 +511,133 @@ where rule } } +/// A world's surface rule compiled once for every chunk of a generator +/// 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, 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, + 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) => { + 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); + } + for (sampler, eager) in &shared.densities { + self.context + .surface + .bind_density(Arc::clone(sampler), *eager)?; + } + } + 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 compiled = compiled?; + 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(); + let mut densities = Vec::new(); + density_bindings(rule, &mut densities); + *shared = Some(CompiledSurfaceRule { + rule: compiled, + noises, + densities, + }); + } + } + 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 007a009a65..236aa73fd0 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::{ColumnBounds, TerrainBlockStates, TerrainChunk, TerrainError}, @@ -323,6 +323,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, { @@ -359,6 +373,7 @@ where let result = build_columns( chunk, rule, + shared, &mut worker, extensions, inputs.write_min_y, @@ -370,7 +385,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, @@ -380,7 +396,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 mut variants = worker.surface_variants(rule)?; let cx = chunk.position().x.wrapping_shl(4); let cz = chunk.position().z.wrapping_shl(4); diff --git a/docs/api/server-reference.md b/docs/api/server-reference.md index 85158c73ed..ce17c7940a 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/native-mods.md b/docs/native-mods.md index 0484cd0c22..423d2bebf5 100644 --- a/docs/native-mods.md +++ b/docs/native-mods.md @@ -2792,6 +2792,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 @@ -2923,6 +2940,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 @@ -2947,6 +2977,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 @@ -3530,7 +3566,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. @@ -3540,7 +3576,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. | diff --git a/docs/server.md b/docs/server.md index a4777a6e2f..ce20747233 100644 --- a/docs/server.md +++ b/docs/server.md @@ -144,6 +144,98 @@ 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 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`: + +```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. 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, 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": { + "version": 1, + "data_version": 5023, + "handover": "9f2c4be0a1d34e6b8c0f1e2d3c4b5a69", + "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": { }, + "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, 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. 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. +- 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, 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. +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: @@ -300,7 +392,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). diff --git a/launcher/src/wardrobe.rs b/launcher/src/wardrobe.rs index 8e15df0f2a..5650443ccc 100644 --- a/launcher/src/wardrobe.rs +++ b/launcher/src/wardrobe.rs @@ -315,6 +315,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 { @@ -456,6 +460,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 e6d3aaa47b..a2e51ecbe5 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. @@ -63,6 +65,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(); @@ -475,6 +478,133 @@ fn the_history_keeps_its_orders_and_an_old_answer_still_reads() { 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"]); +} + #[test] fn the_free_claim_is_offered_only_where_the_service_says_and_grants_once() { // Where Paddle sells: nothing offered, and the service refuses a claim anyway. diff --git a/launcher/src/wardrobe_page.rs b/launcher/src/wardrobe_page.rs index 5917bc9b82..e48c1dc6d4 100644 --- a/launcher/src/wardrobe_page.rs +++ b/launcher/src/wardrobe_page.rs @@ -161,17 +161,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. @@ -254,11 +250,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, @@ -272,7 +270,7 @@ impl Launcher { let mut trip = Trip { saved, items: HashMap::new(), - session: None, + session, result: Ok(()), }; trip.result = refresh(&service, &account, &mut trip); @@ -363,9 +361,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); 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/command_exec_corpus.py b/tools/command_exec_corpus.py index da48baaf6d..54704fc5bb 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -4088,6 +4088,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)]: @@ -4658,6 +4781,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 bell_server_glow(): """Actual world ticks: near raiders trigger resonance that reaches farther raiders.""" # Centre the harness owner's loading area so settle explicitly awaits entity ticking @@ -4852,6 +5084,73 @@ def loaded_ticket_removal(blocks=False): ops += [cmd('/scoreboard players get changed loaded_audit', expect_result=0), cmd('/data get entity @s UUID', expect_result=4)] 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 groups(): @@ -4871,8 +5170,13 @@ def groups(): from item_merge_corpus import stack_limit_merges return [ ('setup', group_setup()), + ('advancement world loot rewards', advancement_world_loot_rewards()), + ('advancement empty loot reference', advancement_empty_loot_reference()), + + ('item stack-size override merges', stack_limit_merges(cmd, setup)), ('moving piston command data', moving_piston_data(cmd, setup)), + ('mob team command names', mob_team_command_names()), ('bell server glow', bell_server_glow()), ('advancement reward functions', advancement_reward_functions()), ('named weather predicates', named_weather_predicates()), @@ -4961,6 +5265,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()), 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 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@." +} 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"] +} 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 diff --git a/tools/port_map/game-005.tsv b/tools/port_map/game-005.tsv index 1b1fc6430e..46bf107c0a 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 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 diff --git a/tools/port_map/game2-002.tsv b/tools/port_map/game2-002.tsv index 96b64d8961..a76b327de3 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 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 diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index d75ce4c030..069b6a8de9 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, advancements=None): - if not predicates and not functions and not advancements: +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'] @@ -66,11 +66,20 @@ def install_datapack_fixtures(world, predicates, functions, jar, advancements=No 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') @@ -134,6 +143,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.loaded_fixture: groups = [('setup', corpus_module.group_setup()), ('loaded ticket removal', corpus_module.loaded_ticket_removal(args.loaded_block_diagnostic))] @@ -213,6 +228,8 @@ def main(): predicates = corpus_module.predicate_selector_fixtures([name for name, _ in groups]) functions = corpus_module.function_return_fixtures([name for name, _ in groups]) advancements = corpus_module.advancement_reward_fixtures([name for name, _ in groups]) + loot_advancements, loot_tables = corpus_module.advancement_loot_fixtures([name for name, _ in groups]) + advancements.update(loot_advancements) if args.pufferfish_contact_fixture: from pufferfish_contact_corpus import advancements as contact_advancements @@ -222,7 +239,7 @@ def main(): 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'], advancements) + install_datapack_fixtures(vanilla_dir / 'universe/harness', predicates, functions, jars['server'], advancements, loot_tables) java_options = [] if args.orb_water_fixture: (build / 'orb-water-reference.json').unlink(missing_ok=True) @@ -260,7 +277,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'], advancements) + 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, 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}')