diff --git a/Cargo.lock b/Cargo.lock index b4081e7af..aa41f656f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1435,6 +1435,7 @@ dependencies = [ "naga", "png", "pollster", + "rustix 1.1.5", "serde", "serde_json", "sha2", diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index ffccb88e8..8713bc1b7 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -51,6 +51,10 @@ horizon-dualsense = { path = "../horizon-dualsense" } # HTTPS to the Minecraft services (online-mode session join). ureq = { version = "2", default-features = false, features = ["tls"] } +[target.'cfg(unix)'.dependencies] +# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs). +rustix = { version = "1", default-features = false, features = ["std", "time"] } + [target.'cfg(windows)'.build-dependencies] # The icon resource in the .exe (build.rs). winresource = "0.1" diff --git a/crates/horizon-client/examples/save_batch_bench.rs b/crates/horizon-client/examples/save_batch_bench.rs index 4ee5103de..d35ca8bbb 100644 --- a/crates/horizon-client/examples/save_batch_bench.rs +++ b/crates/horizon-client/examples/save_batch_bench.rs @@ -66,7 +66,6 @@ fn fixture(per_chunk: usize) -> (Vec>, Entities, Names) { for s in &mut template.sections[..8] { Arc::make_mut(s).blocks = PalettedContainer::new(block_strategy(), 1); } - template.heights = Arc::new([64; 256]); for i in 0..256 { let x = i % 16 - 8; let z = i / 16 - 8; diff --git a/crates/horizon-client/src/bin/horizon-ctl/main.rs b/crates/horizon-client/src/bin/horizon-ctl/main.rs index 2e1b1bb56..27d9d5ba1 100644 --- a/crates/horizon-client/src/bin/horizon-ctl/main.rs +++ b/crates/horizon-client/src/bin/horizon-ctl/main.rs @@ -215,7 +215,12 @@ impl Session { let deadline = Instant::now() + Duration::from_secs_f64(limit.max(0.0)); loop { let status = self.game("status")?; - if !status["player"].is_null() && status["pending_chunks"] == 0 { + // On a server (single player's server thread too), also the + // level around the player. + if !status["player"].is_null() + && status["pending_chunks"] == 0 + && status["server"]["loaded"] != false + { return Ok(status); } if Instant::now() > deadline { diff --git a/crates/horizon-client/src/chunk.rs b/crates/horizon-client/src/chunk.rs index ce2c4ea71..857372045 100644 --- a/crates/horizon-client/src/chunk.rs +++ b/crates/horizon-client/src/chunk.rs @@ -3,7 +3,7 @@ //! (copy-on-write through `Arc::make_mut`). use horizon_core::paletted::{PalettedContainer, Strategy}; use horizon_world::registry::BlockStateId; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; pub const MIN_Y: i32 = -64; pub const HEIGHT: i32 = 384; @@ -45,6 +45,10 @@ impl Light { } } #[inline] +fn column(x: i32, z: i32) -> usize { + ((z & 15) << 4 | (x & 15)) as usize +} +#[inline] pub fn index(x: i32, y: i32, z: i32) -> usize { ((y & 15) << 8 | (z & 15) << 4 | (x & 15)) as usize } @@ -118,8 +122,10 @@ pub struct Chunk { pub x: i32, pub z: i32, pub sections: Vec>, - /// Highest non-air, light-blocking-or-fluid Y per column (MOTION_BLOCKING-like), for rain/sky. - pub heights: Arc<[i16; 256]>, + /// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above + /// the highest non-air block, or `MIN_Y`. Built on the first read; a + /// `set` that can move a column's surface drops it. + surface: OnceLock>, pub lit: bool, /// Light read from a save with `isLightOn`: no light job is needed. pub light_loaded: bool, @@ -148,7 +154,7 @@ impl Chunk { sections: (0..SECTIONS) .map(|_| Arc::new(Section::default())) .collect(), - heights: Arc::new([MIN_Y as i16; 256]), + surface: OnceLock::new(), lit: false, light_loaded: false, fresh: false, @@ -190,7 +196,52 @@ impl Chunk { if !(0..SECTIONS as i32).contains(&s) { return v; } - Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v) + let previous = Arc::make_mut(&mut self.sections[s as usize]).set(index(x, y, z), v); + // `Heightmap.update`: a write two or more below the surface can't + // move it. Any other change rebuilds the map on the next read. + if previous != v + && let Some(surface) = self.surface.get() + && y >= i32::from(surface[column(x, z)]) - 1 + { + self.surface = OnceLock::new(); + } + previous + } + /// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the + /// chunk): one above the highest block that isn't `air`, or `MIN_Y`. + /// Sections must change only through `set` once this has been read. + pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 { + let surface = self.surface.get_or_init(|| self.prime_surface(air)); + i32::from(surface[column(x, z)]) + } + /// `Heightmap.primeHeightmaps` for WORLD_SURFACE: each section from the + /// top, skipping those of one air state, until every column is found. + fn prime_surface(&self, air: impl Fn(u32) -> bool) -> Box<[i16; 256]> { + let mut surface = Box::new([MIN_Y as i16; 256]); + let mut found = [false; 256]; + let mut left = 256; + let mut values = [0u32; 4096]; + for (s, section) in self.sections.iter().enumerate().rev() { + if left == 0 { + break; + } + if section.blocks.uniform().is_some_and(&air) { + continue; + } + section.unpack(&mut values); + let base = MIN_Y + s as i32 * 16; + for i in 0..256 { + if found[i] { + continue; + } + if let Some(dy) = (0..16).rev().find(|&dy| !air(values[dy << 8 | i])) { + surface[i] = (base + dy as i32 + 1) as i16; + found[i] = true; + left -= 1; + } + } + } + surface } #[inline] pub fn light(&self, x: i32, y: i32, z: i32) -> u8 { @@ -261,4 +312,50 @@ mod tests { ); assert_eq!(Chunk::empty(0, 0).highest(air, |_| unreachable!()), None); } + /// The kept WORLD_SURFACE heights match a scan of every column after + /// each of a few thousand writes (air, cave air, stone, whole sections). + #[test] + fn world_surface_follows_writes() { + let (air, cave_air, stone) = (0, 7, 1); + let is_air = |v: u32| v == air || v == cave_air; + let scan = |c: &Chunk, x: i32, z: i32| { + (MIN_Y..MIN_Y + HEIGHT) + .rev() + .find(|&y| !is_air(c.get(x, y, z))) + .map_or(MIN_Y, |y| y + 1) + }; + let mut chunk = Chunk::empty(0, 0); + assert_eq!(chunk.world_surface(5, 5, is_air), MIN_Y); + for y in MIN_Y..MIN_Y + 70 { + for i in 0..256 { + chunk.set(i & 15, y, i >> 4, stone); + } + } + for i in 0..4096 { + Arc::make_mut(&mut chunk.sections[8]).set(i, cave_air); + } + let mut random = horizon_core::random::LegacyRandom::new(9); + use horizon_core::random::RandomSource; + for step in 0..4000 { + let x = random.next_int_bound(16).unwrap(); + let z = random.next_int_bound(16).unwrap(); + let y = MIN_Y + 40 + random.next_int_bound(120).unwrap(); + let v = [air, cave_air, stone][random.next_int_bound(3).unwrap() as usize]; + chunk.set(x, y, z, v); + if step % 7 == 0 { + for i in 0..256 { + assert_eq!( + chunk.world_surface(i & 15, i >> 4, is_air), + scan(&chunk, i & 15, i >> 4) + ); + } + } + } + // Out-of-range writes and unchanged states keep the map. + let before = chunk.world_surface(3, 3, is_air); + chunk.set(3, MIN_Y + HEIGHT, 3, stone); + chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3)); + assert!(chunk.surface.get().is_some()); + assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before); + } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 6f4b91a77..84a90bba9 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -25,6 +25,7 @@ mod command_blocks; mod command_harness; mod commands; mod composter; +mod consume; mod control; mod copper_golems; mod creaking; @@ -1400,6 +1401,21 @@ impl Dim { _ => Self::Overworld, } } + /// `Level.getMinY`: the dimension type's minimum y. + fn min_y(self) -> i32 { + match self { + Self::Overworld => MIN_Y, + Self::Nether | Self::End => 0, + } + } + /// `DimensionType.logicalHeight`. + fn logical_height(self) -> i32 { + match self { + Self::Overworld => HEIGHT, + Self::Nether => 128, + Self::End => 256, + } + } /// `Level.getMaxY`: the dimension type's minimum y plus height, less one. fn max_y(self) -> i32 { match self { @@ -2197,6 +2213,11 @@ impl Game<'_> { pos, data, } => self.dragon_egg_particles([pos.x, pos.y, pos.z], data), + LevelEvent::LevelEvent { + id: 2017, + pos, + data, + } => self.consume_teleport_particles([pos.x, pos.y, pos.z], data), LevelEvent::LevelEvent { id: 2013, pos, @@ -3059,15 +3080,15 @@ impl Game<'_> { } Ok(true) } - /// `Consumable.startConsuming` for held food the player can eat - /// (`Player.canEat`): the right click is held for `consume_seconds`. + /// `Consumable.startConsuming` for a held consumable: food needs + /// `Player.canEat` (`Consumable.canConsume`), anything else is always + /// consumed. The right click is held for `consume_seconds`. fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool { - if !info.consumable - || info.food.is_none() - || !self - .player - .as_ref() - .is_some_and(|p| info.can_always_eat || p.health.food.food < 20 || self.creative) + if info.consumable.is_none() + || (info.food.is_some() + && !self.player.as_ref().is_some_and(|p| { + info.can_always_eat || p.health.food.food < 20 || self.creative + })) { return false; } @@ -3076,18 +3097,17 @@ impl Game<'_> { } true } - fn apply_consume_effect( - &mut self, - effect: &horizon_client::items::ConsumeEffect, - eye: [f64; 3], - ) { + fn apply_consume_effect(&mut self, effect: &horizon_client::items::ConsumeEffect) { use horizon_client::items::ConsumeEffect; match effect { ConsumeEffect::ApplyEffects { effects, probability, } => { - if *probability >= 1.0 || self.world.random.next_float() <= *probability { + // ApplyStatusEffectsConsumeEffect.apply: the user's random + // (the level's stands in, as for every player draw) is drawn + // even at probability 1. + if self.world.random.next_float() < *probability { for eff in effects { if let Some(def) = horizon_client::brewing::effect(&eff.id) { if def.instant { @@ -3120,9 +3140,14 @@ impl Game<'_> { } self.apply_effect_status(); } - ConsumeEffect::TeleportRandomly => {} + ConsumeEffect::TeleportRandomly(teleport) => self.teleport_randomly(teleport), ConsumeEffect::PlaySound(sound) => { - self.audio.play(sound, Some(eye), 1.0, 1.0); + // PlaySoundConsumeEffect: at the user's block position. + let at = self.player.as_ref().map(|p| { + let b = horizon_core::position::BlockPos::containing(p.position); + [b.x, b.y, b.z].map(|v| f64::from(v) + 0.5) + }); + self.audio.play(sound, at, 1.0, 1.0); } } } @@ -3204,49 +3229,21 @@ impl Game<'_> { } return; } - let duration = 32; - // Consumable.shouldEmitParticlesAndSounds(useItemRemainingTicks), with - // the remaining ticks before this tick counts down: every 4 ticks once - // more than 21.875% of the time has passed (none on the last tick). + let Some(consumable) = info.consumable.as_ref() else { + self.using = None; + return; + }; + // ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with + // the remaining ticks before this tick counts them down. + let duration = consumable.ticks().max(0) as u32; let remaining = duration - ticks.min(duration); let ticks = ticks + 1; - let eye = self.eye_position().unwrap_or_default(); - if duration - remaining > (duration as f32 * 0.21875) as u32 && remaining % 4 == 0 { - let pitch = - (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0; - self.audio.play( - "minecraft:entity.generic.eat", - Some(eye), - 0.5 + 0.5 * self.world.random.next_int_bound(2).unwrap_or(0) as f32, - pitch, - ); - self.eat_particles(&stack, 5); + if consumable.should_emit(remaining as i32) { + self.emit_consume(consumable, &stack, 5); } if ticks >= duration { self.using = None; - // Consumable.onConsume: emitParticlesAndSounds(..., 16). - self.eat_particles(&stack, 16); - // Consumable.onConsume. - self.post_entity_event("minecraft:eat", horizon_client::mobs::Who::Player); - if let Some((nutrition, saturation)) = info.food { - if let Some(p) = self.player.as_mut() { - // FoodData.eat(nutrition, saturation) - let food = &mut p.health.food; - food.food = (food.food + nutrition as i32).clamp(0, 20); - food.saturation = (food.saturation + saturation).clamp(0.0, food.food as f32); - } - self.audio.play( - "minecraft:entity.player.burp", - Some(eye), - 0.5, - self.world.random.next_float() * 0.1 + 0.9, - ); - for effect in &info.consume_effects { - self.apply_consume_effect(effect, eye); - } - self.trigger_item("minecraft:consume_item", &stack); - self.consume_held(); - } + self.finish_consuming(&info, consumable, &stack); } else { self.using = Some((slot, ticks)); } diff --git a/crates/horizon-client/src/game/beacon.rs b/crates/horizon-client/src/game/beacon.rs index 7f422dec2..79cf14541 100644 --- a/crates/horizon-client/src/game/beacon.rs +++ b/crates/horizon-client/src/game/beacon.rs @@ -99,11 +99,8 @@ impl Game<'_> { } /// `getHeight(WORLD_SURFACE)`: above the highest non-air block. pub(super) fn world_surface(&self, x: i32, z: i32) -> i32 { - let (min, max) = (super::MIN_Y, super::MIN_Y + super::HEIGHT); - (min..max) - .rev() - .find(|&y| !self.res.facts().is_air(self.world.block(x, y, z))) - .map_or(min, |y| y + 1) + self.world + .world_surface(x, z, |id| self.res.facts().is_air(id)) } /// `BeaconBlockEntity.tick` of the beacon at `key`. pub(super) fn tick_beacon_at(&mut self, key: (i32, i32, i32)) { diff --git a/crates/horizon-client/src/game/consume.rs b/crates/horizon-client/src/game/consume.rs new file mode 100644 index 000000000..50b1fa814 --- /dev/null +++ b/crates/horizon-client/src/game/consume.rs @@ -0,0 +1,330 @@ +//! Finishing a consumable (`Consumable.onConsume` and the use remainder), +//! its particles and sounds, and `TeleportRandomlyConsumeEffect` (chorus +//! fruit) for the local player. The level random stands in for the player's +//! own, as for every other player draw. +use super::{Dim, Game}; +use horizon_client::{ + items::{ConsumeEffect, ItemInfo, ItemStack}, + mobs::Who, + particles::{ParticleOptions, ParticleType}, +}; +use horizon_core::{ + geometry::Vec3, position::BlockPos, random::LegacyRandom, random::RandomSource, +}; +use horizon_world::{ + consumable::{Consumable, EmissionWorld, UseAnimation}, + teleport_consume::{TeleportConsume, TeleportConsumeWorld}, +}; +use std::convert::Infallible; + +type ItemConsumable = Consumable; + +impl Game<'_> { + /// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`. + pub(super) fn emit_consume( + &mut self, + consumable: &ItemConsumable, + stack: &ItemStack, + count: i32, + ) { + let Ok(()) = consumable.emit(&mut Emitter(self), (), stack.clone(), count); + } + /// `LivingEntity.completeUsingItem` for a consumable: `Consumable.onConsume`, + /// then `ItemStack.applyAfterUseComponentSideEffects`' use remainder. + pub(super) fn finish_consuming( + &mut self, + info: &ItemInfo, + consumable: &ItemConsumable, + stack: &ItemStack, + ) { + self.emit_consume(consumable, stack, 16); + // ServerPlayer: the used statistic and the consume_item trigger. + self.stats.add("used", &stack.item, 1); + self.trigger_item("minecraft:consume_item", stack); + // FoodProperties.onConsume, the player's one ConsumableListener. + if let Some((nutrition, saturation)) = info.food { + let at = self.feet(); + let pitch = self.world.random.triangle_float(1.0, 0.4); + self.audio.play(&consumable.sound, at, 1.0, pitch); + if let Some(p) = self.player.as_mut() { + // FoodData.eat(nutrition, saturation) + let food = &mut p.health.food; + food.food = (food.food + nutrition as i32).clamp(0, 20); + food.saturation = (food.saturation + saturation).clamp(0.0, food.food as f32); + } + // Mth.randomBetween(random, 0.9, 1.0) + let pitch = self.world.random.next_float() * (1.0f32 - 0.9f32) + 0.9; + self.audio + .play("minecraft:entity.player.burp", self.feet(), 0.5, pitch); + } + for effect in &consumable.effects { + self.apply_consume_effect(effect); + } + let event = if consumable.animation == UseAnimation::Drink { + "minecraft:drink" + } else { + "minecraft:eat" + }; + self.post_entity_event(event, Who::Player); + // ItemStack.consume, then UseRemainder.convertIntoRemainder: nothing + // with infinite materials, the hand when emptied, else the inventory + // (ServerPlayer.handleExtraItemsCreatedOnUse) or a drop. + self.consume_held(); + if let Some(remainder) = &info.use_remainder + && !self.creative + { + let remainder = ItemStack::new(remainder, 1); + if self.held().is_none() { + *self.held_mut() = Some(remainder); + } else if let Some(rest) = self.give(remainder) { + self.player_drop(rest); + } + } + } + fn feet(&self) -> Option<[f64; 3]> { + self.player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]) + } + /// `TeleportRandomlyConsumeEffect.apply` for the local player. + pub(super) fn teleport_randomly(&mut self, teleport: &TeleportConsume) { + if self.player.is_none() { + return; + } + let dim = self.dim; + let Ok(_) = teleport.apply(&mut Teleporter(self), dim); + } + /// `LivingEntity.randomTeleport(x, y, z, true, avoid)`: down to the first + /// block entities can teleport onto, inside the border, in a loaded chunk. + fn random_teleport(&mut self, target: Vec3, avoid: &str) -> bool { + let [min_x, min_z, max_x, max_z] = self.border.bounds(); + // WorldBorder.clampVec3ToBound (Mth.clamp, 1.0E-5F inside the far edge). + let clamp = |v: f64, low: f64, high: f64| if v < low { low } else { v.min(high) }; + let x = clamp(target.x, min_x, max_x - f64::from(1.0e-5_f32)); + let z = clamp(target.z, min_z, max_z - f64::from(1.0e-5_f32)); + let mut y = target.y; + let mut pos = BlockPos::containing(Vec3::new(x, y, z)); + if !self.world.loaded(pos.x, pos.z) { + return false; + } + while pos.y > self.dim.min_y() { + pos.y -= 1; + let state = self.world.block(pos.x, pos.y, pos.z); + if self.state_in_tag("minecraft:entities_can_teleport_to", state) { + return self.check_position_and_teleport(Vec3::new(x, y, z), state, avoid); + } + y -= 1.0; + } + false + } + /// `LivingEntity.checkPositionAndTeleport`, then `ServerPlayer.teleportTo` + /// (the motion and rotation kept) and entity event 46. + fn check_position_and_teleport( + &mut self, + at: Vec3, + ground: horizon_world::registry::BlockStateId, + avoid: &str, + ) -> bool { + if self.state_in_tag(avoid, ground) { + return false; + } + let entities = self.happy_ghast_player_collisions(); + let Some(player) = self.player.as_mut() else { + return false; + }; + let bounds = player.dimensions.bounds(at); + let free = horizon_client::motion::MotionWorld { + entities: &entities, + player, + scene: &mut self.world, + assets: &self.pack.assets, + collision_binding: self.collision, + resources: self.motion, + } + .no_player_collision(bounds) + .unwrap_or(false); + // LevelReader.containsAnyLiquid and findBlocksIn: the blocks from the + // box's minimum corner to the one holding its maximum corner. + let (lo, hi) = ( + BlockPos::containing(bounds.min_position()), + BlockPos::containing(bounds.max_position()), + ); + let blocks = (lo.x..=hi.x) + .flat_map(|x| (lo.y..=hi.y).flat_map(move |y| (lo.z..=hi.z).map(move |z| (x, y, z)))); + let states: Vec<_> = blocks.map(|(x, y, z)| self.world.block(x, y, z)).collect(); + let liquid = states + .iter() + .any(|&s| self.res.facts().get(s).fluid != horizon_client::blocks::Fluid::None); + if !free || liquid || states.iter().any(|&s| self.state_in_tag(avoid, s)) { + return false; + } + if let Some(p) = self.player.as_mut() { + p.position = at; + } + // The position packet moves the old position along (absolute xyz). + self.previous_position = at; + self.portal_particles(); + true + } + fn state_in_tag(&self, tag: &str, state: horizon_world::registry::BlockStateId) -> bool { + let name = &self.res.facts().get(state).name; + self.res + .items() + .block_tags + .get(tag) + .is_some_and(|s| s.contains(&**name)) + } + /// `LivingEntity.handleEntityEvent(46)`: 128 portal particles from the old + /// to the new position, from the client player's random (the particle + /// engine's here). + fn portal_particles(&mut self) { + let Some(p) = self.player.as_ref() else { + return; + }; + let (from, to) = (self.previous_position, p.position); + let (width, height) = ( + f64::from(p.dimensions.width), + f64::from(p.dimensions.height), + ); + let options = ParticleOptions::simple(ParticleType::Portal); + for i in 0..128 { + let d = f64::from(i) / 127.0; + let r = self.particles.random(); + let v = [0; 3].map(|_| f64::from((r.next_float() - 0.5) * 0.2)); + let x = from.x + d * (to.x - from.x) + (r.next_double() - 0.5) * width * 2.0; + let y = from.y + d * (to.y - from.y) + r.next_double() * height; + let z = from.z + d * (to.z - from.z) + (r.next_double() - 0.5) * width * 2.0; + self.particles.add(&options, [x, y, z], v); + } + } + /// `LivingEntity.stopRiding`. + pub(super) fn stop_riding(&mut self) { + let Some(id) = self.riding else { + return; + }; + if let Some(i) = self.boats.iter().position(|b| b.id == id) { + self.dismount(i); + } else if let Some(i) = self.carts.iter().position(|c| c.id == id) { + self.dismount_cart(i); + } else { + self.dismount_mount(); + } + } +} + +/// `Consumable.emitParticlesAndSounds` against the local player. +struct Emitter<'a, 'g>(&'a mut Game<'g>); +impl EmissionWorld for Emitter<'_, '_> { + type Error = Infallible; + type Stack = ItemStack; + type Sound = String; + type Random = (); + type Source = LegacyRandom; + fn random_source(&mut self, (): ()) -> &mut LegacyRandom { + &mut self.0.world.random + } + /// `LivingEntity.spawnItemParticles`. + fn spawn_item_particles(&mut self, stack: ItemStack, count: i32) -> Result<(), Infallible> { + self.0.eat_particles(&stack, count.max(0) as u32); + Ok(()) + } + fn overrides_consume_sound(&self) -> bool { + false + } + fn consume_sound(&mut self, _: ItemStack) -> Result { + unreachable!("players play the consumable's own sound") + } + /// `Entity.playSound`: at the player's position. + fn play_sound(&mut self, sound: String, volume: f32, pitch: f32) -> Result<(), Infallible> { + let at = self.0.feet(); + self.0.audio.play(&sound, at, volume, pitch); + Ok(()) + } +} + +/// The local player as `TeleportRandomlyConsumeEffect`'s user. +struct Teleporter<'a, 'g>(&'a mut Game<'g>); +impl Teleporter<'_, '_> { + fn at(&self) -> Vec3 { + self.0.player.as_ref().map_or(Vec3::ZERO, |p| p.position) + } +} +impl TeleportConsumeWorld for Teleporter<'_, '_> { + type Error = Infallible; + type Level = Dim; + type Random = (); + type Source = LegacyRandom; + fn position_field(&self) -> Vec3 { + self.at() + } + fn get_random(&mut self) -> Result<(), Infallible> { + Ok(()) + } + fn random_source(&mut self, (): ()) -> &mut LegacyRandom { + &mut self.0.world.random + } + fn min_y(&mut self, level: Dim) -> Result { + Ok(level.min_y()) + } + fn logical_height(&mut self, level: Dim) -> Result { + Ok(level.logical_height()) + } + fn is_passenger(&mut self) -> Result { + Ok(self.0.riding.is_some()) + } + fn stop_riding(&mut self) -> Result<(), Infallible> { + self.0.stop_riding(); + Ok(()) + } + fn position(&mut self) -> Result { + Ok(self.at()) + } + fn random_teleport(&mut self, target: Vec3) -> Result { + Ok(self + .0 + .random_teleport(target, "minecraft:consumable_does_not_teleport_to")) + } + fn teleport_event(&mut self, _: Dim, old: Vec3) -> Result<(), Infallible> { + let ctx = self.0.who_context(Some(Who::Player), None); + self.0 + .post_game_event("minecraft:teleport", [old.x, old.y, old.z], ctx); + Ok(()) + } + fn is_fox(&self) -> bool { + false + } + fn teleport_sound(&mut self, _: Dim, at: Vec3, _: bool) -> Result<(), Infallible> { + self.0.audio.play( + "minecraft:item.chorus_fruit.teleport", + Some([at.x, at.y, at.z]), + 1.0, + 1.0, + ); + Ok(()) + } + fn block_position(&mut self) -> Result { + Ok(BlockPos::containing(self.at())) + } + fn directional_particles( + &mut self, + _: Dim, + origin: BlockPos, + offset: i32, + ) -> Result<(), Infallible> { + self.0 + .level_event_at(2017, [origin.x, origin.y, origin.z], offset); + Ok(()) + } + fn reset_fall_distance(&mut self) -> Result<(), Infallible> { + if let Some(p) = self.0.player.as_mut() { + p.fall_distance = 0.0; + } + Ok(()) + } + fn reset_impulse(&mut self) -> Result<(), Infallible> { + if let Some(p) = self.0.player.as_mut() { + p.impulse.reset(); + } + Ok(()) + } +} diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index e829636e3..f9c3464dd 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -684,7 +684,7 @@ impl Game<'_> { self.ticks = self.ticks.max(1); } // MinecraftServer.tickServer: while paused only the connections run. - self.tick_server(paused)?; + self.tick_server_timed(paused)?; if idle_timeout > 0 { self.kick_idle(idle_timeout); } diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index 18d833488..e4899e4f0 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -317,6 +317,7 @@ impl Game<'_> { // The owner is an operator when the world allows commands // (`IntegratedPlayerList`, `MinecraftServer.getProfilePermissions`). host.hub.ops.insert(owner, if commands { 4 } else { 0 }); + host.hub.singleplayer_owner = Some(owner); host.owner = Some(Owner { uuid: owner, saved: Some(saved), @@ -336,11 +337,11 @@ impl Game<'_> { self.run_tasks(); if flags.paused.load(Ordering::Relaxed) { // Only the connections run. - self.tick_server(true)?; + self.tick_server_timed(true)?; } else { self.follow_distances(&flags); let started = Instant::now(); - self.tick_server(false)?; + self.tick_server_timed(false)?; self.debug_charts.log_tick(started, started.elapsed()); flags.smoothed_tick_ms.store( self.debug_charts.smoothed_tick_ms.to_bits(), diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index 4516ce7dd..47d06254d 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -1,6 +1,7 @@ //! Level events whose client handler (`LevelEventHandler`) spawns particles -//! on a block: dragon egg teleportation (2015), electric sparks (3002), -//! waxing (3003), wax off (3004) and scraping (3005). +//! on a block: dragon egg teleportation (2015), a consumable's teleport +//! (2017), electric sparks (3002), waxing (3003), wax off (3004) and +//! scraping (3005). use super::Game; use horizon_client::particles::{self, ParticleOptions, ParticleType}; use horizon_core::position::BlockPos; @@ -9,7 +10,12 @@ use horizon_world::level::LevelEvent; impl Game<'_> { pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) { self.particles - .block_teleportation(pos, data, self.settings.particles); + .block_teleportation(pos, data, [16, 8, 16], self.settings.particles); + } + /// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles. + pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) { + self.particles + .block_teleportation(pos, data, [127, 127, 127], self.settings.particles); } /// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client /// level's random. diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 2b565090f..bd2f84efb 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -23,7 +23,7 @@ use horizon_client::{ save::SaveDir, streaming::Streaming, tick_chunk_membership::TickChunkMembership, world::World, }; use horizon_world::level::{LevelEvent, Ticks}; -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Instant}; /// Server ticks a level other than the Overworld stays loaded with no player /// in it, before it is saved and closed (vanilla unloads a level's chunks @@ -287,6 +287,37 @@ impl Game<'_> { self.close_idle_levels() } + /// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a + /// tick longer than MS is logged with its thread CPU time and spans. + pub(super) fn tick_server_timed(&mut self, paused: bool) -> Result<()> { + let Some(threshold) = self.options.spikes else { + return self.tick_server(paused); + }; + let (started, cpu) = (Instant::now(), horizon_client::spans::thread_cpu()); + let result = self.tick_server(paused); + let wall = started.elapsed().as_secs_f32() * 1000.0; + // Taken every tick: nothing else takes this thread's spans. + let spans = horizon_client::spans::take(); + if wall > threshold { + let cpu = horizon_client::spans::thread_cpu() + .zip(cpu) + .map_or_else(String::new, |(end, start)| { + format!(", cpu {:.1} ms", (end - start).as_secs_f32() * 1000.0) + }); + let list: Vec = spans + .iter() + .filter(|(_, ms)| *ms >= 0.5) + .map(|(n, ms)| format!("{n} {ms:.1}")) + .collect(); + log::info!( + "Horizon: slow tick {wall:.1} ms{cpu} ({} mobs): {}", + self.mobs.len(), + list.join(", ") + ); + } + result + } + /// The levels this server tick runs, in `MinecraftServer.levels` order /// (the Overworld, the Nether, the End): every level with a player in /// it, or the Overworld while nobody is online. diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 6f2f5e6af..059437d7b 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -198,10 +198,14 @@ impl NaturalLevel for GameLevel<'_, '_> { fn spawn_mobs(&self) -> bool { self.game.ui.rules.bool("spawn_mobs") } - /// Scanned down the column: sections of air alone are skipped (air counts - /// for no heightmap), and an unloaded column reads as air. + /// WORLD_SURFACE (every spawn attempt's) is the chunk's kept heightmap. + /// The others are scanned down the column: sections of air alone are + /// skipped (air counts for no heightmap). An unloaded column reads as air. fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 { let world = self.w.world; + if kind == Heightmap::WorldSurface { + return self.w.world_surface(x, z); + } world .chunks .get(&(x >> 4, z >> 4)) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index c4c832301..b0da6b078 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -1336,6 +1336,9 @@ impl Game<'_> { C::LevelEvent(p) if p.kind == 2015 => { self.dragon_egg_particles(p.pos, p.data); } + C::LevelEvent(p) if p.kind == 2017 => { + self.consume_teleport_particles(p.pos, p.data); + } C::LevelEvent(p) if p.kind == 2001 => { // LevelEvent.PARTICLES_DESTROY_BLOCK: someone else broke a block. let state = BlockStateId(p.data.max(0) as u32); @@ -2426,6 +2429,17 @@ impl Game<'_> { (player.position.z.floor() as i32) >> 4, ); if !self.streaming.is_lit(cx, cz) { + // Moved where nothing is loaded by horizon-ctl (`tp`, `fly`): + // the server hears of it, and sends the chunks there. A player + // who walked there has already sent that move. + let pos = [player.position.x, player.position.y, player.position.z]; + if let Some(r) = self.remote.as_ref() + && r.loaded_sent + && r.last_pos != pos + { + let input = r.last_input; + self.remote_send_state(input); + } return Ok(()); } if self.remote.as_ref().is_some_and(|r| !r.loaded_sent) { @@ -3086,7 +3100,7 @@ impl Game<'_> { } } } - /// `LivingEntity.updatingUsingItem` for food on a server client: the + /// `LivingEntity.updatingUsingItem` for a consumable on a server client: the /// eating animation and its crumbs. The server eats it and sends the /// stack, the hunger and the sounds. fn remote_tick_using(&mut self) { @@ -3094,11 +3108,11 @@ impl Game<'_> { return; }; let stack = self.stack_in(slot).cloned(); - let food = stack + let consumable = stack .as_ref() .and_then(|s| self.res.items().get(&s.item)) - .is_some_and(|i| i.consumable && i.food.is_some()); - let (Some(stack), true) = (stack, food) else { + .and_then(|i| i.consumable.clone()); + let (Some(stack), Some(consumable)) = (stack, consumable) else { self.using = None; return; }; @@ -3107,14 +3121,16 @@ impl Game<'_> { return; } // Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. - let duration = 32; + let duration = consumable.ticks().max(0) as u32; let remaining = duration - ticks.min(duration); - if duration - remaining > (duration as f32 * 0.21875) as u32 && remaining % 4 == 0 { + if consumable.particles && consumable.should_emit(remaining as i32) { self.eat_particles(&stack, 5); } if ticks + 1 >= duration { self.using = None; - self.eat_particles(&stack, 16); + if consumable.particles { + self.eat_particles(&stack, 16); + } } else { self.using = Some((slot, ticks + 1)); } @@ -3718,6 +3734,9 @@ impl Game<'_> { "address": horizon_client::net::shown_address(&r.session.address), "name": r.session.name, "joined": r.joined, + // ServerboundPlayerLoadedPacket sent: the level around the + // player is ready, and the client ticks and sends its moves. + "loaded": r.loaded_sent, "status": r.status, "entity_id": r.entity_id, "dimension": r.dimension.name, diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index 5783eb698..7f2782c72 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -54,7 +54,8 @@ pub enum ConsumeEffect { }, ClearAllEffects, RemoveEffects(Vec), - TeleportRandomly, + /// `TeleportRandomlyConsumeEffect`. + TeleportRandomly(horizon_world::teleport_consume::TeleportConsume), PlaySound(String), } @@ -72,8 +73,10 @@ pub struct ItemInfo { pub tool_damage_per_block: Option, pub food: Option<(u32, f32)>, pub can_always_eat: bool, - pub consumable: bool, - pub consume_effects: Vec, + /// `consumable`: its timing, sound, particles and `on_consume_effects`. + pub consumable: Option>, + /// `use_remainder`: what a finished use leaves (`UseRemainder`). + pub use_remainder: Option, pub model: String, /// Main-hand `attack_damage` / `attack_speed` modifier amounts. pub attack_damage: f32, @@ -507,7 +510,18 @@ impl Items { consume_effects.push(ConsumeEffect::RemoveEffects(targets)); } Some("minecraft:teleport_randomly") => { - consume_effects.push(ConsumeEffect::TeleportRandomly); + consume_effects.push(ConsumeEffect::TeleportRandomly( + horizon_world::teleport_consume::TeleportConsume { + diameter: effect + .get("diameter") + .and_then(Value::as_f64) + .map_or(16.0, |d| d as f32), + directional_particles: effect + .get("directional_particles") + .and_then(Value::as_bool) + .unwrap_or(true), + }, + )); } Some("minecraft:play_sound") => { if let Some(sound) = effect.get("sound").and_then(|s| s.as_str()) { @@ -536,8 +550,22 @@ impl Items { .map(|t| t["damage_per_block"].as_u64().unwrap_or(1) as u32), food, can_always_eat, - consumable: c.get("minecraft:consumable").is_some(), - consume_effects, + // Consumable.CODEC's defaults. + consumable: c.get("minecraft:consumable").map(|cons| { + horizon_world::consumable::Consumable { + seconds: cons["consume_seconds"].as_f64().map_or(1.6, |s| s as f32), + animation: crate::first_person::consumable_animation(cons), + sound: cons["sound"] + .as_str() + .unwrap_or("minecraft:entity.generic.eat") + .to_owned(), + particles: cons["has_consume_particles"].as_bool().unwrap_or(true), + effects: std::mem::take(&mut consume_effects), + } + }), + use_remainder: c["minecraft:use_remainder"]["id"] + .as_str() + .map(str::to_owned), model: c["minecraft:item_model"] .as_str() .unwrap_or(name) @@ -1617,18 +1645,63 @@ mod tests { let apple = items.get("minecraft:golden_apple").expect("golden apple"); assert!(apple.can_always_eat); assert!(apple.food.is_some()); - assert!(!apple.consume_effects.is_empty()); + assert!(!apple.consumable.as_ref().unwrap().effects.is_empty()); let enchanted = items .get("minecraft:enchanted_golden_apple") .expect("enchanted golden apple"); assert!(enchanted.can_always_eat); assert!(enchanted.food.is_some()); - assert!(!enchanted.consume_effects.is_empty()); + assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty()); let beef = items.get("minecraft:cooked_beef").expect("cooked beef"); assert!(!beef.can_always_eat); assert!(beef.food.is_some()); + + // Consumables without food, their style and their remainders. + use horizon_world::consumable::UseAnimation; + let milk = items.get("minecraft:milk_bucket").expect("milk bucket"); + let drink = milk.consumable.as_ref().unwrap(); + assert!(milk.food.is_none()); + assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]); + assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink)); + assert_eq!(drink.sound, "minecraft:entity.generic.drink"); + assert!(!drink.particles); + assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket")); + let honey = items.get("minecraft:honey_bottle").expect("honey bottle"); + let sip = honey.consumable.as_ref().unwrap(); + assert_eq!(sip.ticks(), 40); + assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink"); + assert_eq!( + sip.effects, + [ConsumeEffect::RemoveEffects(vec![ + "minecraft:poison".into() + ])] + ); + assert_eq!( + honey.use_remainder.as_deref(), + Some("minecraft:glass_bottle") + ); + let kelp = items.get("minecraft:dried_kelp").unwrap(); + let bite = kelp.consumable.as_ref().unwrap(); + assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat)); + assert_eq!(bite.sound, "minecraft:entity.generic.eat"); + assert!(bite.particles && kelp.use_remainder.is_none()); + let chorus = items.get("minecraft:chorus_fruit").unwrap(); + assert_eq!( + chorus.consumable.as_ref().unwrap().effects, + [ConsumeEffect::TeleportRandomly( + horizon_world::teleport_consume::TeleportConsume { + diameter: 16.0, + directional_particles: true, + } + )] + ); + let chicken = items.get("minecraft:chicken").unwrap(); + assert!(matches!( + chicken.consumable.as_ref().unwrap().effects[..], + [ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3 + )); } } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 5d593be2d..ca198b7b6 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -132,6 +132,9 @@ pub struct Hub { /// The `player_movement_check` and `elytra_movement_check` game rules, /// set by the game before each tick. pub movement_checks: (bool, bool), + /// An integrated server's owner (`MinecraftServer.isSingleplayerOwner`), + /// whose moves are never checked. + pub singleplayer_owner: Option, /// Who rides what, set by the game before each tick. pub riding: super::entity::Riding, last_clock: i64, @@ -265,6 +268,7 @@ impl Hub { sync, ticks: 0, movement_checks: (true, true), + singleplayer_owner: None, riding: Default::default(), last_clock: i64::MIN, last_rate: 0.0, @@ -1893,6 +1897,7 @@ impl Hub { ) { let (frozen, (check, elytra_check)) = (self.ticking.1, self.movement_checks); let g = &mut self.guests[i]; + let owner = self.singleplayer_owner == Some(g.uuid); let to = [ to[0].clamp(-3.0E7, 3.0E7), to[1].clamp(-2.0E7, 2.0E7), @@ -1916,9 +1921,10 @@ impl Hub { if packets > 5 { packets = 1; } - // shouldCheckPlayerMovement. The server holds no velocity for a - // guest (getDeltaMovement), so nothing is expected. - if check && (!g.fall_flying || elytra_check) { + // shouldCheckPlayerMovement (never the single-player owner). + // The server holds no velocity for a guest (getDeltaMovement), + // so nothing is expected. + if !owner && check && (!g.fall_flying || elytra_check) { let limit = if g.fall_flying { 300.0 } else { 100.0 }; if moved > limit * f64::from(packets) { events.push(HostEvent::Warn(format!( diff --git a/crates/horizon-client/src/mobs/spawn_rules.rs b/crates/horizon-client/src/mobs/spawn_rules.rs index 22f9206af..1cdca41b9 100644 --- a/crates/horizon-client/src/mobs/spawn_rules.rs +++ b/crates/horizon-client/src/mobs/spawn_rules.rs @@ -589,11 +589,7 @@ impl SpawnLevel for super::MobWorld<'_> { self.sea_level } fn world_surface(&self, x: i32, z: i32) -> i32 { - let top = crate::chunk::MIN_Y + crate::chunk::HEIGHT; - (crate::chunk::MIN_Y..top) - .rev() - .find(|&y| !self.facts.is_air(self.world.block(x, y, z))) - .map_or(crate::chunk::MIN_Y, |y| y + 1) + self.world.world_surface(x, z, |id| self.facts.is_air(id)) } fn peaceful(&self) -> bool { self.difficulty.effective == 0.0 diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index b4ec0202a..293aa61a2 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -649,7 +649,8 @@ impl MotionWorld<'_, '_, '_> { entities: self.entities, } } - pub(crate) fn no_player_collision(&mut self, bounds: Aabb) -> Result { + /// `Level.noCollision(player, bounds)`: blocks and the host's entity boxes. + pub fn no_player_collision(&mut self, bounds: Aabb) -> Result { let context = self.context(); Ok(world_movement::no_world_collision( &mut self.movement_collisions(), diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index 57d9e60d7..938e8d72d 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -1264,12 +1264,21 @@ impl ParticleEngine { } self.create(options, p, v); } - /// `LevelEventHandler.singleBlockTeleportationParticles` (event 2015). - pub fn block_teleportation(&mut self, from: [i32; 3], data: i32, particle_level: u8) { + /// `LevelEventHandler.singleBlockTeleportationParticles` towards + /// `BlockUtil.unpackDifferenceInPosition(from, data, radius)`: the dragon + /// egg's event 2015 packs offsets within (16, 8, 16), a consumable's + /// teleport (2017) within (127, 127, 127). + pub fn block_teleportation( + &mut self, + from: [i32; 3], + data: i32, + radius: [i32; 3], + particle_level: u8, + ) { let to = [ - from[0].wrapping_add(((data >> 16) & 255) - 16), - from[1].wrapping_add(((data >> 8) & 255) - 8), - from[2].wrapping_add((data & 255) - 16), + from[0].wrapping_add(((data >> 16) & 255) - radius[0]), + from[1].wrapping_add(((data >> 8) & 255) - radius[1]), + from[2].wrapping_add((data & 255) - radius[2]), ] .map(f64::from); let from = from.map(f64::from); @@ -3798,7 +3807,7 @@ mod tests { let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new()); engine.seed_randoms(0, 0, 0); // Destination offset (-15, 7, 15); the unused top byte is ignored. - engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, 0); + engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0); assert_eq!(engine.pending.len(), 128); assert!(engine.pending.iter().all(|p| p.ty == ParticleType::Portal)); let first = &engine.pending[0]; @@ -3818,6 +3827,25 @@ mod tests { assert!(engine.sounds.is_empty()); } + #[test] + fn consume_teleport_unpacks_offsets_within_127() { + let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new()); + engine.seed_randoms(0, 0, 0); + // TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition + // of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4). + let data = (-7 + 127) << 16 | (-3 + 127) << 8 | (9 + 127); + engine.block_teleportation([4, 12, -5], data, [127, 127, 127], 0); + assert_eq!(engine.pending.len(), 128); + for p in &engine.pending { + let [x, y, z] = p.position; + assert!((-3.0..5.0).contains(&x), "{x}"); + assert!((8.5..12.5).contains(&y), "{y}"); + assert!((-5.0..5.0).contains(&z), "{z}"); + } + // The same draws as the egg's: only the unpacking differs. + assert_eq!(engine.level_random.state(), 99_413_408_083_437); + } + #[test] fn egg_teleport_options_roll_before_distance_rejection() { for (option, count, state) in [ @@ -3831,7 +3859,7 @@ mod tests { if distant { engine.camera = [1000.0; 3]; } - engine.block_teleportation([4, 12, -5], 0x010f1f, option); + engine.block_teleportation([4, 12, -5], 0x010f1f, [16, 8, 16], option); assert_eq!(engine.pending.len(), if distant { 0 } else { count }); assert_eq!(engine.level_random.state(), state); } diff --git a/crates/horizon-client/src/spans.rs b/crates/horizon-client/src/spans.rs index 9fd91b2df..5c37f2bfc 100644 --- a/crates/horizon-client/src/spans.rs +++ b/crates/horizon-client/src/spans.rs @@ -1,11 +1,12 @@ //! Named timings within a frame, for finding what makes a frame slow: //! `HORIZON_SPIKES=MS` prints the spans of every frame whose CPU time -//! exceeds MS milliseconds. Costs one clock read per span when enabled and -//! a relaxed load otherwise. +//! exceeds MS milliseconds, and of every server tick (dedicated or +//! integrated) longer than MS. Costs one clock read per span when enabled +//! and a relaxed load otherwise. use std::{ cell::RefCell, sync::atomic::{AtomicBool, Ordering}, - time::Instant, + time::{Duration, Instant}, }; static ENABLED: AtomicBool = AtomicBool::new(false); @@ -28,6 +29,18 @@ pub fn time(name: &'static str, f: impl FnOnce() -> T) -> T { SPANS.with(|s| s.borrow_mut().push((name, ms))); out } +/// The CPU time this thread has used, where the platform tells it: a tick +/// with more wall time than CPU time waited (for a lock, or for a core on a +/// busy machine). +pub fn thread_cpu() -> Option { + #[cfg(unix)] + { + let t = rustix::time::clock_gettime(rustix::time::ClockId::ThreadCPUTime); + Some(Duration::new(t.tv_sec as u64, t.tv_nsec as u32)) + } + #[cfg(not(unix))] + None +} /// The frame's spans, summed by name, slowest first; clears them. pub fn take() -> Vec<(&'static str, f32)> { let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut())); @@ -41,3 +54,29 @@ pub fn take() -> Vec<(&'static str, f32)> { sums.sort_by(|a, b| b.1.total_cmp(&a.1)); sums } + +#[cfg(test)] +mod tests { + /// The thread's CPU clock moves with work done on it, not with time + /// spent asleep. + #[cfg(unix)] + #[test] + fn thread_cpu_counts_work_not_sleep() { + let start = super::thread_cpu().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + let slept = super::thread_cpu().unwrap() - start; + // Busy until the clock shows 10 ms of work (a loaded machine gives + // the thread less than its wall time). + let mut x = 0u64; + let busy = std::time::Instant::now(); + let worked = loop { + x = std::hint::black_box(x.wrapping_mul(31).wrapping_add(7)); + let worked = super::thread_cpu().unwrap() - start - slept; + if worked.as_millis() >= 10 || busy.elapsed().as_secs() >= 10 { + break worked; + } + }; + assert!(slept.as_millis() < 25, "{slept:?} while asleep"); + assert!(worked.as_millis() >= 10, "{worked:?} while busy"); + } +} diff --git a/crates/horizon-client/src/world.rs b/crates/horizon-client/src/world.rs index 8aa66c8dc..af7c1671b 100644 --- a/crates/horizon-client/src/world.rs +++ b/crates/horizon-client/src/world.rs @@ -287,6 +287,13 @@ impl World { } Ok(()) } + /// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the highest + /// block that isn't `air`, or `MIN_Y` (also where the chunk isn't loaded). + pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(BlockStateId) -> bool) -> i32 { + self.chunks.get(&(x >> 4, z >> 4)).map_or(MIN_Y, |chunk| { + chunk.world_surface(x, z, |v| air(BlockStateId(v))) + }) + } pub fn block(&self, x: i32, y: i32, z: i32) -> BlockStateId { self.chunks .get(&(x >> 4, z >> 4)) diff --git a/crates/horizon-world/src/worldgen/generator_structures.rs b/crates/horizon-world/src/worldgen/generator_structures.rs index e78ce0772..3b6d058ae 100644 --- a/crates/horizon-world/src/worldgen/generator_structures.rs +++ b/crates/horizon-world/src/worldgen/generator_structures.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use std::{ collections::{BTreeSet, HashMap}, sync::{ - Arc, Condvar, Mutex, OnceLock, + Arc, Condvar, Mutex, OnceLock, RwLock, atomic::{AtomicUsize, Ordering}, }, }; @@ -30,7 +30,7 @@ pub(super) struct StructureState { possible_sets: Vec, /// Structure indices of each decoration step, in registry order. pub(super) by_step: Vec>, - starts: Mutex>, + starts: StartCache, /// Concentric ring positions, computed on first use and shared with /// later generators of the same seed and dimension. rings: Arc>, @@ -108,6 +108,45 @@ impl RingWork { } } const START_CACHE_LIMIT: usize = 65536; +const START_SHARDS: usize = 64; + +/// The built starts by chunk, in shards: a lookup locks one shard for +/// reading. Each generated chunk reads the 289 start chunks around it +/// (`references`) and the server thread reads the starts of the chunks its +/// mobs and players are in; with one lock for all, the server thread waited +/// on the workers. +struct StartCache { + shards: Box<[RwLock>]>, +} +impl StartCache { + fn new() -> Self { + Self { + shards: (0..START_SHARDS).map(|_| RwLock::default()).collect(), + } + } + fn shard(&self, key: (i32, i32)) -> &RwLock> { + let hash = + (key.0 as u32).wrapping_mul(0x9E37_79B9) ^ (key.1 as u32).wrapping_mul(0x85EB_CA6B); + &self.shards[(hash >> (32 - START_SHARDS.trailing_zeros())) as usize] + } + fn get(&self, key: (i32, i32)) -> Option { + self.shard(key).read().unwrap().get(&key).cloned() + } + /// Keeps `starts` unless another thread built the same chunk's first: + /// everyone keeps the first, so placement state on its pieces is shared. + fn insert_first(&self, key: (i32, i32), starts: Starts) -> Starts { + let mut shard = self.shard(key).write().unwrap(); + if shard.len() >= START_CACHE_LIMIT / START_SHARDS { + shard.clear(); + } + shard.entry(key).or_insert(starts).clone() + } + fn clear(&self) { + for shard in &self.shards { + shard.write().unwrap().clear(); + } + } +} fn biome_set(data: &GeneratorData, value: &Value) -> Result, super::DecodeError> { let names: Vec<&str> = match value { @@ -167,7 +206,7 @@ impl StructureState { bindings, possible_sets, by_step, - starts: Mutex::new(HashMap::new()), + starts: StartCache::new(), rings, ring_work: OnceLock::new(), })) @@ -199,7 +238,7 @@ impl StructureState { /// Drop the cached starts, so that later chunks build fresh pieces (the /// pieces keep per-start placement state such as a shipwreck's height). pub(super) fn forget_starts(&self) { - self.starts.lock().unwrap().clear(); + self.starts.clear(); } /// The (cached) structure starts owned by a chunk. pub(super) fn starts_at( @@ -207,8 +246,8 @@ impl StructureState { context: &mut impl super::structure::start::StartContext, chunk: ChunkPos, ) -> Starts { - if let Some(found) = self.starts.lock().unwrap().get(&(chunk.x, chunk.z)) { - return found.clone(); + if let Some(found) = self.starts.get((chunk.x, chunk.z)) { + return found; } let rings = self.rings(context); let starts: Starts = Arc::new( @@ -225,13 +264,7 @@ impl StructureState { .map(Arc::new) .collect(), ); - let mut cache = self.starts.lock().unwrap(); - if cache.len() >= START_CACHE_LIMIT { - cache.clear(); - } - // Another thread may have built the same starts meanwhile: everyone - // keeps the first, so placement state on its pieces is shared. - cache.entry((chunk.x, chunk.z)).or_insert(starts).clone() + self.starts.insert_first((chunk.x, chunk.z), starts) } /// ChunkGenerator.findNearestMapStructure: the nearest start of one of /// `names` from block `from` (rings: every ring chunk; random spreads: @@ -385,6 +418,39 @@ mod tests { use super::*; use crate::worldgen::structure::stronghold::{ring_positions, ring_searches}; + /// The start cache keeps the first starts built for a chunk, spreads a + /// chunk's 289 reference lookups over the shards, and forgets on clear. + #[test] + fn start_cache_keeps_first_and_spreads_neighbours() { + let cache = StartCache::new(); + let first: Starts = Arc::new(Vec::new()); + let second: Starts = Arc::new(Vec::new()); + assert!(cache.get((3, -4)).is_none()); + assert!(Arc::ptr_eq( + &cache.insert_first((3, -4), first.clone()), + &first + )); + assert!(Arc::ptr_eq(&cache.insert_first((3, -4), second), &first)); + assert!(Arc::ptr_eq(&cache.get((3, -4)).unwrap(), &first)); + for (cx, cz) in [(0, 0), (-1000, 77), (i32::MAX - 8, i32::MIN + 8)] { + let mut used = vec![0; START_SHARDS]; + for x in cx - 8..=cx + 8 { + for z in cz - 8..=cz + 8 { + let shard = cache.shard((x, z)) as *const _; + let i = cache + .shards + .iter() + .position(|s| std::ptr::eq(s, shard)) + .unwrap(); + used[i] += 1; + } + } + assert!(used.iter().all(|&n| n > 0 && n < 20), "{used:?}"); + } + cache.clear(); + assert!(cache.get((3, -4)).is_none()); + } + /// Searches shared between threads give the positions of one thread /// running them in order, set by set. #[test] diff --git a/tools/agent_control_e2e.py b/tools/agent_control_e2e.py index ab408b2b2..53296669a 100644 --- a/tools/agent_control_e2e.py +++ b/tools/agent_control_e2e.py @@ -7,7 +7,8 @@ runs through the CLI (one horizon-ctl process per command) and through `horizon- small JSON-RPC client, and saves a screenshot of each run. The ui scenario drives the menus by widget id instead: the title screen, the options (a slider set by id) and the inventory's slots; the options scenario reads and sets options.txt values by key; the events scenario waits for -states (wait-for) and reads the event log. +states (wait-for) and reads the event log; the consume scenario eats and drinks consumables (a +golden apple, milk, honey, chorus fruit) and checks their effects, remainders and teleport. python3 tools/agent_control_e2e.py # both modes python3 tools/agent_control_e2e.py --mode mcp # one of them @@ -378,7 +379,52 @@ def events_scenario(d, out): return {'events': len(events)} -SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario} +def consume_scenario(d, out): + # Consumables finish as vanilla's Consumable.onConsume: always-edible food at full hunger with its + # effects, milk and honey (no food or drink sounds) with their effects and use remainders, and + # chorus fruit's random teleport. + d('click', id='singleplayer') + if 'game' not in widgets(d): + d('click', id='create-new-world') + d('type', field='name', replace=True, text='Consume') + d('click', id='create-new-world') + d('wait_for', condition='loaded', timeout=400) + + def consume(item, seconds): + d('command', command='clear @s') + d('give', item=item, count=1) + d('use_item', hold=seconds) + d('wait', ticks=5) + state = d('state') + return state, {e['id'] for e in state['player']['effects']} + + def held(state): + return state['held'] and state['held']['item'] + + state, effects = consume('golden_apple', 2.2) + assert state['player']['food'] == 20 and {'regeneration', 'absorption'} <= effects, effects + assert held(state) is None, state['held'] + d('command', command='effect give @s minecraft:speed 60') + state, effects = consume('milk_bucket', 2.2) + assert effects == set(), effects + assert held(state) == 'minecraft:bucket', state['held'] + d('command', command='effect give @s minecraft:poison 60') + d('command', command='effect give @s minecraft:speed 60') + state, effects = consume('honey_bottle', 2.6) + assert effects == {'speed'}, effects + assert held(state) == 'minecraft:glass_bottle', state['held'] + before = d('state')['player']['position'] + state, _ = consume('chorus_fruit', 2.2) + after = state['player']['position'] + moved = [b - a for a, b in zip(before, after)] + assert held(state) is None, state['held'] + assert moved != [0, 0, 0] and abs(moved[0]) <= 8 and abs(moved[2]) <= 8, moved + d('screenshot', path=str(out / f'consume-{d.name}.png')) + return {'teleported': [round(m, 2) for m in moved]} + + +SCENARIOS = {'survival': scenario, 'ui': ui_scenario, 'options': options_scenario, 'events': events_scenario, + 'consume': consume_scenario} def run(mode, ctl, out, resources, which='survival'):