Merge latest main and preserve surface-height and consumable changes

This commit is contained in:
rdbtCVS committed 2026-10-18 15:53:28 +01:00
commit de6f15e664
23 files changed
+877 -124

No files matched your search

Generated
+1
View File
@@ -1435,6 +1435,7 @@ dependencies = [
"naga",
"png",
"pollster",
"rustix 1.1.5",
"serde",
"serde_json",
"sha2",
+4
View File
@@ -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"
@@ -66,7 +66,6 @@ fn fixture(per_chunk: usize) -> (Vec<Arc<Chunk>>, 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;
@@ -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 {
+102 -5
View File
@@ -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<Arc<Section>>,
/// 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<Box<[i16; 256]>>,
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);
}
}
+51 -54
View File
@@ -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));
}
+2 -5
View File
@@ -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)) {
+330
View File
@@ -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<String, ConsumeEffect>;
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<String, Infallible> {
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<i32, Infallible> {
Ok(level.min_y())
}
fn logical_height(&mut self, level: Dim) -> Result<i32, Infallible> {
Ok(level.logical_height())
}
fn is_passenger(&mut self) -> Result<bool, Infallible> {
Ok(self.0.riding.is_some())
}
fn stop_riding(&mut self) -> Result<(), Infallible> {
self.0.stop_riding();
Ok(())
}
fn position(&mut self) -> Result<Vec3, Infallible> {
Ok(self.at())
}
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
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<BlockPos, Infallible> {
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(())
}
}
+1 -1
View File
@@ -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);
}
+3 -2
View File
@@ -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(),
@@ -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.
+32 -1
View File
@@ -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<String> = 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.
@@ -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))
+26 -7
View File
@@ -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,
+81 -8
View File
@@ -54,7 +54,8 @@ pub enum ConsumeEffect {
},
ClearAllEffects,
RemoveEffects(Vec<String>),
TeleportRandomly,
/// `TeleportRandomlyConsumeEffect`.
TeleportRandomly(horizon_world::teleport_consume::TeleportConsume),
PlaySound(String),
}
@@ -72,8 +73,10 @@ pub struct ItemInfo {
pub tool_damage_per_block: Option<u32>,
pub food: Option<(u32, f32)>,
pub can_always_eat: bool,
pub consumable: bool,
pub consume_effects: Vec<ConsumeEffect>,
/// `consumable`: its timing, sound, particles and `on_consume_effects`.
pub consumable: Option<horizon_world::consumable::Consumable<String, ConsumeEffect>>,
/// `use_remainder`: what a finished use leaves (`UseRemainder`).
pub use_remainder: Option<String>,
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
));
}
}
+9 -3
View File
@@ -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<u128>,
/// 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!(
@@ -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
+2 -1
View File
@@ -649,7 +649,8 @@ impl MotionWorld<'_, '_, '_> {
entities: self.entities,
}
}
pub(crate) fn no_player_collision(&mut self, bounds: Aabb) -> Result<bool> {
/// `Level.noCollision(player, bounds)`: blocks and the host's entity boxes.
pub fn no_player_collision(&mut self, bounds: Aabb) -> Result<bool> {
let context = self.context();
Ok(world_movement::no_world_collision(
&mut self.movement_collisions(),
+35 -7
View File
@@ -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);
}
+42 -3
View File
@@ -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<T>(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<Duration> {
#[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");
}
}
+7
View File
@@ -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))
@@ -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<usize>,
/// Structure indices of each decoration step, in registry order.
pub(super) by_step: Vec<Vec<usize>>,
starts: Mutex<HashMap<(i32, i32), Starts>>,
starts: StartCache,
/// Concentric ring positions, computed on first use and shared with
/// later generators of the same seed and dimension.
rings: Arc<OnceLock<Rings>>,
@@ -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<HashMap<(i32, i32), Starts>>]>,
}
impl StartCache {
fn new() -> Self {
Self {
shards: (0..START_SHARDS).map(|_| RwLock::default()).collect(),
}
}
fn shard(&self, key: (i32, i32)) -> &RwLock<HashMap<(i32, i32), Starts>> {
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<Starts> {
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<BTreeSet<BiomeId>, 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]
+48 -2
View File
@@ -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'):