diff --git a/crates/pumpkin-inventory/src/screen_handler.rs b/crates/pumpkin-inventory/src/screen_handler.rs index f15e15192..392e8bc5b 100644 --- a/crates/pumpkin-inventory/src/screen_handler.rs +++ b/crates/pumpkin-inventory/src/screen_handler.rs @@ -154,6 +154,7 @@ pub trait InventoryPlayer: Send + Sync { fn enqueue_inventory_packet<'a>( &'a self, packet: &'a CSetContainerContent, + window_type: Option, ) -> PlayerFuture<'a, ()>; /// Sends a single slot update packet. diff --git a/crates/pumpkin-inventory/src/sync_handler.rs b/crates/pumpkin-inventory/src/sync_handler.rs index 694958753..57596adb1 100644 --- a/crates/pumpkin-inventory/src/sync_handler.rs +++ b/crates/pumpkin-inventory/src/sync_handler.rs @@ -92,16 +92,19 @@ impl SyncHandler { ) { if let Some(player) = self.player.lock().await.as_ref() { player - .enqueue_inventory_packet(&CSetContainerContent::new( - VarInt(screen_handler.sync_id.into()), - VarInt(next_revision as i32), - stacks - .iter() - .map(|stack| ItemStackSerializer::from(stack.clone())) - .collect::>() - .as_slice(), - &ItemStackSerializer::from(cursor_stack.clone()), - )) + .enqueue_inventory_packet( + &CSetContainerContent::new( + VarInt(screen_handler.sync_id.into()), + VarInt(next_revision as i32), + stacks + .iter() + .map(|stack| ItemStackSerializer::from(stack.clone())) + .collect::>() + .as_slice(), + &ItemStackSerializer::from(cursor_stack.clone()), + ), + screen_handler.window_type, + ) .await; for (i, property) in properties.iter().enumerate() { diff --git a/crates/pumpkin-protocol/src/bedrock/client/block_event.rs b/crates/pumpkin-protocol/src/bedrock/client/block_event.rs new file mode 100644 index 000000000..e9be29743 --- /dev/null +++ b/crates/pumpkin-protocol/src/bedrock/client/block_event.rs @@ -0,0 +1,44 @@ +use pumpkin_macros::packet; +use pumpkin_util::math::position::BlockPos; + +use crate::{codec::var_int::VarInt, serial::PacketWrite}; + +/// Updates a client-side block animation, such as a chest lid opening or closing. +#[derive(PacketWrite)] +#[packet(26)] +pub struct CBlockEvent { + pub position: BlockPos, + pub event_type: VarInt, + pub event_data: VarInt, +} + +impl CBlockEvent { + #[must_use] + pub const fn new(position: BlockPos, event_type: i32, event_data: i32) -> Self { + Self { + position, + event_type: VarInt(event_type), + event_data: VarInt(event_data), + } + } +} + +#[cfg(test)] +mod tests { + use pumpkin_util::math::position::BlockPos; + + use super::*; + use crate::{Packet, serial::PacketWrite}; + + #[test] + fn chest_lid_event_uses_bedrock_wire_format() { + assert_eq!(::PACKET_ID, 26); + + let mut encoded = Vec::new(); + CBlockEvent::new(BlockPos::new(1, 64, -2), 1, 3) + .write(&mut encoded) + .unwrap(); + + assert_eq!(encoded, [2, 128, 1, 3, 2, 6]); + } +} diff --git a/crates/pumpkin-protocol/src/bedrock/client/level_chunk.rs b/crates/pumpkin-protocol/src/bedrock/client/level_chunk.rs index 6ebabbf8e..4d23d9cb6 100644 --- a/crates/pumpkin-protocol/src/bedrock/client/level_chunk.rs +++ b/crates/pumpkin-protocol/src/bedrock/client/level_chunk.rs @@ -2,6 +2,7 @@ use std::io::{Error, Write}; use xxhash_rust::xxh64::xxh64; use pumpkin_macros::packet; +use pumpkin_nbt::{Nbt, compound::NbtCompound}; use pumpkin_world::chunk::{ChunkData, palette::NetworkPalette}; use crate::{ @@ -20,16 +21,26 @@ pub struct CLevelChunk<'a> { // https://gist.github.com/Tomcc/a96af509e275b1af483b25c543cfbf37 // https://github.com/Mojang/bedrock-protocol-docs/blob/main/additional_docs/SubChunk%20Request%20System%20v1.18.10.md pub chunk: &'a ChunkData, + pub block_actors: &'a [NbtCompound], } pub type ChunkBlob = (u64, Vec); pub type EncodedChunk = (Vec, Vec); +fn encode_block_actors(block_actors: &[NbtCompound]) -> Result, Error> { + let mut encoded = Vec::new(); + for block_actor in block_actors { + encoded.write_all(&Nbt::from(block_actor.clone()).write_bedrock())?; + } + Ok(encoded) +} + impl CLevelChunk<'_> { pub fn encode_chunk( chunk: &ChunkData, dimension: i32, cache_enabled: bool, + block_actors: &[NbtCompound], ) -> Result { let mut writer = Vec::new(); @@ -115,6 +126,8 @@ impl CLevelChunk<'_> { } } + let block_actor_bytes = encode_block_actors(block_actors)?; + if cache_enabled { for subchunk_buf in subchunk_bytes_list { let hash = xxh64(&subchunk_buf, 0); @@ -128,9 +141,16 @@ impl CLevelChunk<'_> { writer.write_all(&hash.to_le_bytes())?; } - // Chunk data payload when cache_enabled: only border block count byte (0). - VarUInt(1).write(&mut writer)?; + // Palette data is cached, but the per-chunk border and block actor data is not. + VarUInt(u32::try_from(1 + block_actor_bytes.len()).map_err(|_| { + Error::new( + std::io::ErrorKind::InvalidData, + "Bedrock block actor payload exceeds the packet size limit", + ) + })?) + .write(&mut writer)?; writer.write_all(&[0])?; + writer.write_all(&block_actor_bytes)?; } else { VarUInt(0).write(&mut writer)?; @@ -140,6 +160,7 @@ impl CLevelChunk<'_> { } chunk_data.write_all(&biome_buf)?; chunk_data.write_all(&[0])?; + chunk_data.write_all(&block_actor_bytes)?; VarUInt(chunk_data.len() as u32).write(&mut writer)?; writer.write_all(&chunk_data)?; @@ -151,19 +172,26 @@ impl CLevelChunk<'_> { impl PacketWrite for CLevelChunk<'_> { fn write(&self, writer: &mut W) -> Result<(), Error> { - let (encoded, _) = Self::encode_chunk(self.chunk, self.dimension, self.cache_enabled)?; + let (encoded, _) = Self::encode_chunk( + self.chunk, + self.dimension, + self.cache_enabled, + self.block_actors, + )?; writer.write_all(&encoded) } } #[cfg(test)] mod tests { + use std::io::Cursor; use std::sync::{ Mutex, atomic::{AtomicBool, AtomicU64}, }; use pumpkin_data::chunk::ChunkStatus; + use pumpkin_nbt::{Nbt, compound::NbtCompound, deserializer::NbtReadHelperBedrock}; use pumpkin_world::{ chunk::{ChunkData, ChunkHeightmaps, ChunkLight, ChunkSections}, tick::scheduler::ChunkTickScheduler, @@ -185,9 +213,8 @@ mod tests { panic!("VarUInt is too long"); } - #[test] - fn biomes_follow_subchunks_without_subchunk_headers() { - let chunk = ChunkData { + fn empty_chunk() -> ChunkData { + ChunkData { section: ChunkSections::new(24, -64), heightmap: Mutex::new(ChunkHeightmaps::default()), x: 0, @@ -201,12 +228,18 @@ mod tests { blending_data: None, dirty: AtomicBool::new(false), inhabited_time: AtomicU64::new(0), - }; + } + } + + #[test] + fn biomes_follow_subchunks_without_subchunk_headers() { + let chunk = empty_chunk(); let mut encoded = Vec::new(); CLevelChunk { dimension: 0, cache_enabled: false, chunk: &chunk, + block_actors: &[], } .write(&mut encoded) .unwrap(); @@ -240,4 +273,32 @@ mod tests { assert_eq!(raw[raw_offset], 0); // Border block count. assert_eq!(raw_offset + 1, raw.len()); } + + #[test] + fn block_actor_nbt_follows_the_chunk_border_data() { + let chunk = empty_chunk(); + let mut block_actor = NbtCompound::new(); + block_actor.put_string("id", "Chest".to_string()); + block_actor.put_int("x", 1); + block_actor.put_int("y", 64); + block_actor.put_int("z", 2); + + let (encoded, _) = CLevelChunk::encode_chunk(&chunk, 0, true, &[block_actor]).unwrap(); + let mut offset = 0; + for _ in 0..3 { + read_var_uint(&encoded, &mut offset); + } + read_var_uint(&encoded, &mut offset); + offset += 2; + let blob_count = read_var_uint(&encoded, &mut offset) as usize; + offset += blob_count * size_of::(); + let raw_len = read_var_uint(&encoded, &mut offset) as usize; + let raw = &encoded[offset..offset + raw_len]; + + assert_eq!(raw[0], 0); + let mut reader = NbtReadHelperBedrock::new(Cursor::new(&raw[1..])); + let parsed = Nbt::read(&mut reader).unwrap(); + assert_eq!(parsed.get_string("id"), Some("Chest")); + assert_eq!(parsed.get_int("x"), Some(1)); + } } diff --git a/crates/pumpkin-protocol/src/bedrock/client/mod.rs b/crates/pumpkin-protocol/src/bedrock/client/mod.rs index 5dfe8f0fe..8c6cad50e 100644 --- a/crates/pumpkin-protocol/src/bedrock/client/mod.rs +++ b/crates/pumpkin-protocol/src/bedrock/client/mod.rs @@ -3,6 +3,7 @@ pub mod add_item_actor; pub mod add_player; pub mod available_commands; pub mod biome_definition_list; +pub mod block_event; pub mod boss_event; pub mod change_dimension; pub mod chunk_radius_update; @@ -60,6 +61,7 @@ pub use add_item_actor::*; pub use add_player::*; pub use available_commands::*; pub use biome_definition_list::*; +pub use block_event::*; pub use boss_event::*; pub use change_dimension::*; pub use chunk_radius_update::*; diff --git a/crates/pumpkin/src/block/blocks/chests.rs b/crates/pumpkin/src/block/blocks/chests.rs index 6cdebaad1..282d8c39b 100644 --- a/crates/pumpkin/src/block/blocks/chests.rs +++ b/crates/pumpkin/src/block/blocks/chests.rs @@ -25,7 +25,8 @@ use tokio::sync::Mutex; use crate::block::{ BlockFuture, BrokenArgs, EmitsRedstonePowerArgs, GetComparatorOutputArgs, GetRedstonePowerArgs, - NormalUseArgs, OnPlaceArgs, OnSyncedBlockEventArgs, PlacedArgs, RandomTickArgs, + NormalUseArgs, OnPlaceArgs, OnSyncedBlockEventArgs, PlacedArgs, PlayerPlacedArgs, + RandomTickArgs, }; use crate::entity::EntityBase; use crate::world::World; @@ -126,6 +127,19 @@ async fn placed_chest_impl( } } +fn player_placed_chest_impl(args: &PlayerPlacedArgs<'_>) { + let position = pumpkin_util::math::vector3::Vector3::new( + args.position.0.x as f64 + 0.5, + args.position.0.y as f64 + 0.5, + args.position.0.z as f64 + 0.5, + ); + args.world.play_bedrock_level_sound( + "place", + &position, + i32::from(pumpkin_data::BlockState::to_be_network_id(args.state_id)), + ); +} + async fn get_chest_comparator_output(args: GetComparatorOutputArgs<'_>) -> Option { let state = args.world.get_block_state_id(args.position); let first_chest = args.world.get_block_entity(args.position); @@ -279,6 +293,10 @@ impl BlockBehaviour for ChestBlock { Box::pin(placed_chest_impl(args, ChestBlockEntity::new)) } + fn player_placed<'a>(&'a self, args: PlayerPlacedArgs<'a>) -> BlockFuture<'a, ()> { + Box::pin(async move { player_placed_chest_impl(&args) }) + } + fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(normal_use_chest_impl(args)) } @@ -315,6 +333,10 @@ impl BlockBehaviour for CopperChestBlock { Box::pin(placed_chest_impl(args, ChestBlockEntity::new)) } + fn player_placed<'a>(&'a self, args: PlayerPlacedArgs<'a>) -> BlockFuture<'a, ()> { + Box::pin(async move { player_placed_chest_impl(&args) }) + } + fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(normal_use_chest_impl(args)) } @@ -511,6 +533,10 @@ impl BlockBehaviour for TrappedChestBlock { Box::pin(placed_chest_impl(args, TrappedChestBlockEntity::new)) } + fn player_placed<'a>(&'a self, args: PlayerPlacedArgs<'a>) -> BlockFuture<'a, ()> { + Box::pin(async move { player_placed_chest_impl(&args) }) + } + fn normal_use<'a>(&'a self, args: NormalUseArgs<'a>) -> BlockFuture<'a, BlockActionResult> { Box::pin(normal_use_chest_impl(args)) } diff --git a/crates/pumpkin/src/block/entities/chest_like_block_entity.rs b/crates/pumpkin/src/block/entities/chest_like_block_entity.rs index 912e7c9b1..a46ff6b6f 100644 --- a/crates/pumpkin/src/block/entities/chest_like_block_entity.rs +++ b/crates/pumpkin/src/block/entities/chest_like_block_entity.rs @@ -372,6 +372,12 @@ macro_rules! impl_chest_helper_methods { 0.5, pumpkin_util::random::RandomImpl::next_f32(&mut rng) * 0.1 + 0.9, ); + let bedrock_sound = match sound { + pumpkin_data::sound::Sound::BlockChestOpen => "chest.open", + pumpkin_data::sound::Sound::BlockChestClose => "chest.closed", + _ => return, + }; + world.play_bedrock_level_sound(bedrock_sound, &position, 0); } } }; diff --git a/crates/pumpkin/src/entity/player.rs b/crates/pumpkin/src/entity/player.rs index c9b9ce3d9..e5f60329a 100644 --- a/crates/pumpkin/src/entity/player.rs +++ b/crates/pumpkin/src/entity/player.rs @@ -5661,6 +5661,7 @@ impl InventoryPlayer for Player { fn enqueue_inventory_packet<'a>( &'a self, packet: &'a CSetContainerContent, + window_type: Option, ) -> PlayerFuture<'a, ()> { Box::pin(async move { match self.client.as_ref() { @@ -5699,6 +5700,39 @@ impl InventoryPlayer for Player { storage_item: NetworkItemStackDescriptor::default(), }; bedrock.enqueue_packet(&bedrock_packet).await; + } else if matches!( + window_type, + Some( + WindowType::Generic9x1 + | WindowType::Generic9x2 + | WindowType::Generic9x3 + | WindowType::Generic9x4 + | WindowType::Generic9x5 + | WindowType::Generic9x6 + | WindowType::Generic3x3 + ) + ) { + // Java container screens append the player's 36 inventory slots to + // the container slots. Bedrock synchronizes those two inventories + // separately and addresses generic block containers as LevelEntity. + let container_slot_count = packet + .slot_data + .len() + .saturating_sub(PlayerInventory::MAIN_SIZE); + let slots = packet.slot_data[..container_slot_count] + .iter() + .map(|stack| NetworkItemStackDescriptor::from(&*stack.0)) + .collect(); + let bedrock_packet = CInventoryContent { + container_id: VarUInt(window_id), + slots, + full_container_name: FullContainerName { + container_name: ContainerName::LevelEntity, + dynamic_id: None, + }, + storage_item: NetworkItemStackDescriptor::default(), + }; + bedrock.enqueue_packet(&bedrock_packet).await; } } } diff --git a/crates/pumpkin/src/net/bedrock/mod.rs b/crates/pumpkin/src/net/bedrock/mod.rs index 22fa88c18..7987e0ba9 100644 --- a/crates/pumpkin/src/net/bedrock/mod.rs +++ b/crates/pumpkin/src/net/bedrock/mod.rs @@ -311,8 +311,9 @@ impl BedrockClient { let mut serialize_tasks = Vec::with_capacity(valid_chunks.len()); for chunk in valid_chunks { + let block_actors = player.world().bedrock_chunk_block_actors(&chunk); serialize_tasks.push(tokio::task::spawn_blocking(move || { - CLevelChunk::encode_chunk(&chunk, bedrock_dimension, cache_enabled) + CLevelChunk::encode_chunk(&chunk, bedrock_dimension, cache_enabled, &block_actors) })); } diff --git a/crates/pumpkin/src/world/mod.rs b/crates/pumpkin/src/world/mod.rs index 7b5402597..04ab3bdd8 100644 --- a/crates/pumpkin/src/world/mod.rs +++ b/crates/pumpkin/src/world/mod.rs @@ -71,7 +71,12 @@ use pumpkin_data::{ sound_id_remap::remap_sound_id_for_version, world::{RAW, WorldEvent}, }; -use pumpkin_data::{BlockDirection, BlockState, HorizontalFacingExt, translation}; +use pumpkin_data::{ + BlockDirection, BlockState, HorizontalFacingExt, + block_properties::{BlockProperties, ChestLikeProperties, ChestType}, + tag::Taggable, + translation, +}; use pumpkin_inventory::crafting::recipe_provider::RecipeProvider; use pumpkin_inventory::screen_handler::InventoryPlayer; use pumpkin_nbt::compound::NbtCompound; @@ -90,9 +95,11 @@ use pumpkin_protocol::{ bedrock::{ client::{ add_player::CAddPlayer, + block_event::CBlockEvent as CBedrockBlockEvent, common::BuildPlatform, creative_content::{CCreativeContent, CreativeCategory, Entry, Group}, gamerules_changed::GameRules, + level_sound_event::CLevelSoundEvent, player_list::{CPlayerList, PlayerListEntry, Skin}, remove_actor::CRemoveActor, start_game::{Experiments, GamePublishSetting, LevelSettings}, @@ -166,6 +173,41 @@ type FlowingFluidProperties = pumpkin_data::fluid::FlowingWaterLikeFluidProperti const MAX_LIGHT_LEVEL: u8 = 15; +fn bedrock_chest_block_actor(state_id: BlockStateId, position: BlockPos) -> Option { + let (block, _) = BlockState::from_id_with_block(state_id); + if !block.has_tag(&pumpkin_data::tag::Block::C_CHESTS_WOODEN) + && !block.has_tag(&pumpkin_data::tag::Block::MINECRAFT_COPPER_CHESTS) + { + return None; + } + + // Block actor tags describe the chest itself. Container contents are synchronized + // through inventory packets and must not be exposed in chunk data. + let mut nbt = NbtCompound::new(); + nbt.put_string("id", "Chest".to_string()); + nbt.put_int("x", position.0.x); + nbt.put_int("y", position.0.y); + nbt.put_int("z", position.0.z); + nbt.put_bool("isMovable", true); + + let properties = ChestLikeProperties::from_state_id(state_id, block); + if properties.r#type != ChestType::Single { + let direction = if properties.r#type == ChestType::Left { + properties.facing.rotate_clockwise() + } else { + properties.facing.rotate_counter_clockwise() + }; + let pair = position.offset(direction.to_offset()); + nbt.put_int("pairx", pair.0.x); + nbt.put_int("pairz", pair.0.z); + if properties.r#type == ChestType::Right { + nbt.put_bool("pairlead", true); + } + } + + Some(nbt) +} + use rustc_hash::{FxHashMap, FxHashSet}; impl PumpkinError for GetBlockError { @@ -545,7 +587,7 @@ impl World { continue; } let chunk_pos = event.pos.chunk_position(); - self.broadcast_to_chunk( + self.broadcast_to_chunk_editioned_sync( chunk_pos, &CBlockEvent::new( event.pos, @@ -553,6 +595,7 @@ impl World { event.data, VarInt(block.id.as_u16() as i32), ), + &CBedrockBlockEvent::new(event.pos, i32::from(event.r#type), i32::from(event.data)), ); } } @@ -901,6 +944,34 @@ impl World { self.play_sound_raw(sound as u16, category, position, volume, pitch); } + /// Plays a Bedrock level sound for players close enough to hear it. + pub fn play_bedrock_level_sound( + &self, + sound_id: &str, + position: &Vector3, + extra_data: i32, + ) { + let packet = CLevelSoundEvent { + sound_id: sound_id.to_string(), + position: Vector3::new(position.x as f32, position.y as f32, position.z as f32), + extra_data: VarInt(extra_data), + entity_type: String::new(), + is_baby_mob: false, + is_global: false, + actor_unique_id: 0, + fire_at_position: None, + }; + let chunk_pos = BlockPos::floored_v(*position).chunk_position(); + + for player in self.players.load().iter() { + if is_within_view_distance(chunk_pos, player.get_entity().chunk_pos.load(), 1) + && let ClientPlatform::Bedrock(client) = player.client.as_ref() + { + client.try_enqueue_packet(&packet); + } + } + } + pub fn play_sound_expect( &self, player: &Player, @@ -5337,6 +5408,36 @@ impl World { Some(entity) } + /// Builds Bedrock block actor tags that are not represented by Java block states alone. + pub fn bedrock_chunk_block_actors(&self, chunk: &ChunkData) -> Vec { + let chunk_pos = Vector2::new(chunk.x, chunk.z); + let live_positions: FxHashSet<_> = self + .block_entities + .get(&chunk_pos) + .map(|entities| entities.keys().copied().collect()) + .unwrap_or_default(); + + let pending = chunk + .pending_block_entities + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + live_positions + .iter() + .chain( + pending + .keys() + .filter(|position| !live_positions.contains(position)), + ) + .filter_map(|position| { + let relative = position.chunk_relative_position(); + chunk + .section + .get_block_absolute_y(relative.x as usize, relative.y, relative.z as usize) + .and_then(|state_id| bedrock_chest_block_actor(state_id, *position)) + }) + .collect() + } + pub fn add_block_entity(&self, block_entity: Arc) { let block_pos = block_entity.get_position(); let chunk_pos = block_pos.chunk_position(); @@ -5864,7 +5965,13 @@ impl WorldPortalExt for WorldPortal { #[cfg(test)] mod tests { - use super::bedrock_block_breaking_rate; + use pumpkin_data::{ + Block, + block_properties::{BlockProperties, ChestLikeProperties, ChestType, HorizontalFacing}, + }; + use pumpkin_util::math::position::BlockPos; + + use super::{bedrock_block_breaking_rate, bedrock_chest_block_actor}; #[test] fn bedrock_block_breaking_rate_uses_progress_per_tick() { @@ -5872,4 +5979,20 @@ mod tests { assert_eq!(bedrock_block_breaking_rate(1.0 / 30.0), 2_184); assert_eq!(bedrock_block_breaking_rate(1.0), 65_535); } + + #[test] + fn bedrock_double_chest_block_actor_identifies_pair_and_lead() { + let position = BlockPos::new(5, 64, 7); + let properties = ChestLikeProperties { + facing: HorizontalFacing::North, + r#type: ChestType::Right, + waterlogged: false, + }; + let actor = + bedrock_chest_block_actor(properties.to_state_id(&Block::CHEST), position).unwrap(); + + assert_eq!(actor.get_int("pairx"), Some(4)); + assert_eq!(actor.get_int("pairz"), Some(7)); + assert_eq!(actor.get_bool("pairlead"), Some(true)); + } }