fix(bedrock): synchronize Bedrock chest behavior (#2915)

* Fix Bedrock chest synchronization

* Add Bedrock chest lid animations
This commit is contained in:
ZlordHUN
2026-08-13 10:24:02 +02:00
committed by GitHub
parent 820840b8bf
commit 9d0a7b5ff1
10 changed files with 323 additions and 22 deletions

View File

@@ -154,6 +154,7 @@ pub trait InventoryPlayer: Send + Sync {
fn enqueue_inventory_packet<'a>(
&'a self,
packet: &'a CSetContainerContent,
window_type: Option<WindowType>,
) -> PlayerFuture<'a, ()>;
/// Sends a single slot update packet.

View File

@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.as_slice(),
&ItemStackSerializer::from(cursor_stack.clone()),
),
screen_handler.window_type,
)
.await;
for (i, property) in properties.iter().enumerate() {

View File

@@ -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!(<CBlockEvent as Packet>::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]);
}
}

View File

@@ -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<u8>);
pub type EncodedChunk = (Vec<u8>, Vec<ChunkBlob>);
fn encode_block_actors(block_actors: &[NbtCompound]) -> Result<Vec<u8>, 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<EncodedChunk, Error> {
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<W: 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::<u64>();
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));
}
}

View File

@@ -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::*;

View File

@@ -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<E: BlockEntity + 'static>(
}
}
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<u8> {
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))
}

View File

@@ -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);
}
}
};

View File

@@ -5661,6 +5661,7 @@ impl InventoryPlayer for Player {
fn enqueue_inventory_packet<'a>(
&'a self,
packet: &'a CSetContainerContent,
window_type: Option<WindowType>,
) -> 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;
}
}
}

View File

@@ -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)
}));
}

View File

@@ -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<NbtCompound> {
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<f64>,
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<NbtCompound> {
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<dyn BlockEntity>) {
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));
}
}