From 98c66ada4f2516310544046bd193592d7f8c4246 Mon Sep 17 00:00:00 2001 From: Spencer0187 Date: Sat, 3 Oct 2026 16:24:48 +0100 Subject: [PATCH 1/5] sync server block tags --- pomme-client/src/app/core.rs | 3 + pomme-client/src/net/connection.rs | 31 ++++++---- pomme-client/src/net/mod.rs | 19 ++++++ pomme-client/src/world/block/mod.rs | 91 ++++++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 11 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 80a27cb9f..f3d2fcce3 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1578,6 +1578,9 @@ impl AppCore { NetworkEvent::DialogRegistry(registry) => { game.dialog_registry = registry; } + NetworkEvent::BlockTags { tags } => { + crate::world::block::replace_block_tags(tags); + } NetworkEvent::ContainerSlot { container_id, index, diff --git a/pomme-client/src/net/connection.rs b/pomme-client/src/net/connection.rs index 510f950d3..15cab9cf0 100644 --- a/pomme-client/src/net/connection.rs +++ b/pomme-client/src/net/connection.rs @@ -95,6 +95,7 @@ impl Drop for ConnectionHandle { // The session is over: restore the launched version's wire protocol // and block table so nothing stale leaks into the next one. crate::version::clear_session_protocol(); + crate::world::block::clear_block_tags(); crate::world::block::set_active_protocol(crate::version::selected_protocol()); } } @@ -327,9 +328,10 @@ async fn negotiate_wire_version( Ok(()) } -/// Speaks `wire` for the rest of the session. The translation layer and the -/// block-state tables both key off it, so they always move together. +/// Speaks `wire` for the rest of the session. The translation layer, block +/// tags and block-state tables all key off it, so they always move together. fn adopt_wire_protocol(wire: i32) { + crate::world::block::clear_block_tags(); crate::version::set_session_protocol(wire); crate::world::block::set_active_protocol(wire); } @@ -587,6 +589,9 @@ async fn config_sequence( if let Some(tags) = dialog_tags(&p.tags) { received_dialog_tags = Some(tags); } + if let Some(tags) = super::block_tags_from_packet(&p.tags) { + let _ = event_tx.try_send(NetworkEvent::BlockTags { tags }); + } } ClientboundConfigPacket::SelectKnownPacks(p) => { // Vanilla `handleSelectKnownPacks`: claim the offered packs we @@ -951,10 +956,12 @@ async fn game_loop( if matches!(packet, ClientboundGamePacket::StartConfiguration(_)) { // Vanilla clears the client level before acknowledging // (ClientPacketListener.handleConfigurationStart); chat - // survives the transition. Whatever the game queued goes - // first, then the pending chat acknowledgement. + // survives the transition, block tags don't. Whatever the + // game queued goes first, then the pending chat + // acknowledgement. // TODO: chat events still in flight to the game thread // miss this ack; the next login resets the tracker anyway. + crate::world::block::clear_block_tags(); let _ = event_tx.try_send(NetworkEvent::Reconfiguring); while let Ok(out) = outbound_rx.try_recv() { if let Some(frame) = @@ -999,12 +1006,16 @@ async fn game_loop( { continue; } - if let ClientboundGamePacket::UpdateTags(p) = &packet - && let Some(tags) = dialog_tags(&p.tags) - { - configured.dialogs = std::sync::Arc::new(configured.dialogs.with_tags(tags)); - let _ = - event_tx.try_send(NetworkEvent::DialogRegistry(configured.dialogs.clone())); + if let ClientboundGamePacket::UpdateTags(p) = &packet { + if let Some(tags) = dialog_tags(&p.tags) { + configured.dialogs = + std::sync::Arc::new(configured.dialogs.with_tags(tags)); + let _ = event_tx + .try_send(NetworkEvent::DialogRegistry(configured.dialogs.clone())); + } + if let Some(tags) = super::block_tags_from_packet(&p.tags) { + let _ = event_tx.try_send(NetworkEvent::BlockTags { tags }); + } } if let ClientboundGamePacket::Login(login) = &mut packet { inbound_chat.reset(); diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index dff4de11f..8eb6a3712 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -41,6 +41,22 @@ pub struct PacketLightData { pub empty_block_y_mask: azalea_core::bitset::BitSet, } +/// The `minecraft:block` entries of an UpdateTags map, as (tag, registry ids). +pub(crate) fn block_tags_from_packet( + tags: &azalea_protocol::common::tags::TagMap, +) -> Option)>> { + let (_, block_tags) = tags + .0 + .iter() + .find(|(registry, _)| registry.namespace() == "minecraft" && registry.path() == "block")?; + Some( + block_tags + .iter() + .map(|tag| (tag.name.to_string(), tag.elements.clone())) + .collect(), + ) +} + impl From<&azalea_protocol::packets::game::c_light_update::ClientboundLightUpdatePacketData> for PacketLightData { @@ -64,6 +80,9 @@ pub enum NetworkEvent { /// The `minecraft:dialog` registry with its tags, sent with `Registries` /// and again whenever a tag update replaces the dialog tags. DialogRegistry(Arc), + BlockTags { + tags: Vec<(String, Vec)>, + }, BiomeColors { colors: std::collections::HashMap, }, diff --git a/pomme-client/src/world/block/mod.rs b/pomme-client/src/world/block/mod.rs index ca5b86891..4db5c3d1f 100644 --- a/pomme-client/src/world/block/mod.rs +++ b/pomme-client/src/world/block/mod.rs @@ -3,7 +3,7 @@ pub mod registry; pub mod sound; use std::collections::{HashMap, HashSet}; -use std::sync::OnceLock; +use std::sync::{LazyLock, OnceLock, RwLock, RwLockReadGuard}; use azalea_block::BlockState; use azalea_core::position::BlockPos; @@ -411,6 +411,27 @@ static BLOCK_TABLES: [OnceLock>; BLOCK_DATA.len()] = [const { OnceLock::new() }; BLOCK_DATA.len()]; /// The [`BLOCK_DATA`] slot holding the native version's tables: the initial /// active table and the fallback for protocols without their own data. +/// Server-provided block tags, resolved into Pomme's native block resource +/// names. Sent during configuration and replaced after a datapack reload. +// TODO: the allows go once a gameplay check reads the tags (#620, #647). +#[allow(dead_code)] +#[derive(Debug, Default)] +pub struct BlockTags { + tags: HashMap>, +} + +impl BlockTags { + #[allow(dead_code)] + pub fn contains(&self, tag: &str, block: &str) -> bool { + self.tags + .get(tag) + .is_some_and(|blocks| blocks.contains(block)) + } +} + +static BLOCK_TAGS: LazyLock> = + LazyLock::new(|| RwLock::new(BlockTags::default())); + const NATIVE_SLOT: usize = 0; static ACTIVE_TABLE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(NATIVE_SLOT); @@ -785,6 +806,57 @@ fn table() -> &'static Vec { .expect("world::block::init must be called before use") } +/// Built-in block registry order for the active protocol. The generated state +/// table is grouped by block in registry order, so collapsing adjacent states +/// yields the registry id -> resource-name mapping UpdateTags uses. +fn block_registry_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = Vec::new(); + for data in table() { + if names.last() != Some(&data.id) { + names.push(data.id); + } + } + names +} + +fn resolve_block_tags( + raw_tags: Vec<(String, Vec)>, + registry_names: &[&'static str], +) -> BlockTags { + let mut tags = HashMap::with_capacity(raw_tags.len()); + for (name, elements) in raw_tags { + let mut blocks = HashSet::with_capacity(elements.len()); + for id in elements { + let Some(&block) = usize::try_from(id) + .ok() + .and_then(|index| registry_names.get(index)) + else { + tracing::warn!("ignoring unknown block registry id {id} in tag {name}"); + continue; + }; + blocks.insert(block); + } + tags.insert(name, blocks); + } + BlockTags { tags } +} + +/// Replaces the session's block tags from `ClientboundUpdateTags`. Element ids +/// are built-in block registry ids, not block-state ids. +pub fn replace_block_tags(raw_tags: Vec<(String, Vec)>) { + *BLOCK_TAGS.write().expect("block tag lock poisoned") = + resolve_block_tags(raw_tags, &block_registry_names()); +} + +#[allow(dead_code)] +pub fn block_tags() -> RwLockReadGuard<'static, BlockTags> { + BLOCK_TAGS.read().expect("block tag lock poisoned") +} + +pub fn clear_block_tags() { + *BLOCK_TAGS.write().expect("block tag lock poisoned") = BlockTags::default(); +} + fn block_data(state: BlockState) -> &'static BlockData { static UNKNOWN: std::sync::LazyLock = std::sync::LazyLock::new(|| BlockData { id: "unknown", @@ -1186,6 +1258,23 @@ mod tests { ); } + #[test] + fn block_tag_registry_ids_resolve_to_native_block_names() { + setup(); + let names = block_registry_names(); + let id = |block| names.iter().position(|name| *name == block).unwrap() as i32; + let tags = resolve_block_tags( + vec![( + "minecraft:mineable/pickaxe".to_owned(), + vec![id("stone"), -1, i32::MAX], + )], + &names, + ); + + assert!(tags.contains("minecraft:mineable/pickaxe", "stone")); + assert!(!tags.contains("minecraft:mineable/pickaxe", "dirt")); + } + #[test] fn redstone_wire_colors() { setup(); From cec506118d123ceae6c5fec6bff418848e9a5d8a Mon Sep 17 00:00:00 2001 From: Spencer0187 Date: Sat, 3 Oct 2026 16:26:27 +0100 Subject: [PATCH 2/5] add vanilla climbable movement parity --- pomme-client/src/physics/movement.rs | 240 ++++++++++++++++++++++++++- pomme-client/src/world/block/mod.rs | 25 ++- 2 files changed, 259 insertions(+), 6 deletions(-) diff --git a/pomme-client/src/physics/movement.rs b/pomme-client/src/physics/movement.rs index 4627ccf6d..a7b48346a 100644 --- a/pomme-client/src/physics/movement.rs +++ b/pomme-client/src/physics/movement.rs @@ -10,9 +10,10 @@ use super::block_shape::CollisionContext; use super::collision::{CollisionWorld, find_supporting_block, no_collision, resolve_collision}; use crate::app::input::{self, InputState}; use crate::entity::components::Velocity; -use crate::player::{CROUCH_HEIGHT, LocalPlayer, PLAYER_HALF_WIDTH, STANDING_HEIGHT}; +use crate::player::{CROUCH_HEIGHT, LocalPlayer, PLAYER_HALF_WIDTH, STANDING_HEIGHT, is_spectator}; use crate::world::block::{ - FluidKind, fluid, movement_friction, movement_jump_factor, movement_speed_factor, + BlockTags, FluidKind, block_id, block_properties, block_tags, fluid, movement_friction, + movement_jump_factor, movement_speed_factor, }; use crate::world::block_entity_anim::BlockEntityAnimStore; use crate::world::chunk::ChunkStore; @@ -61,6 +62,8 @@ const FLY_TOGGLE_WINDOW: u32 = 7; // Mth.equal(double, double) widens the float EPSILON constant to double. const MTH_EQUAL_EPSILON: f64 = 1.0e-5_f32 as f64; const MINOR_COLLISION_ANGLE: f64 = 0.139_626_339_077_949_52; +const CLIMB_UP_VELOCITY: f64 = 0.2; +const CLIMB_MAX_FALL_SPEED: f32 = 0.15; const DEG_TO_RAD: f32 = std::f32::consts::PI / 180.0_f32; const SIN_SCALE: f64 = 10_430.378_350_470_453; @@ -316,6 +319,70 @@ fn jump_from_ground( } } +fn is_climbable_state(state: azalea_block::BlockState, tags: &BlockTags) -> bool { + tags.contains("minecraft:climbable", block_id(state)) +} + +fn trapdoor_usable_as_ladder( + trapdoor: azalea_block::BlockState, + below: azalea_block::BlockState, +) -> bool { + if !block_id(trapdoor).ends_with("_trapdoor") + || block_properties(trapdoor).get("open") != Some("true") + || block_id(below) != "ladder" + { + return false; + } + + block_properties(trapdoor).get("facing") == block_properties(below).get("facing") +} + +fn on_climbable_state( + player: &LocalPlayer, + chunk_store: &ChunkStore, +) -> Option { + // Vanilla `LivingEntity.onClimbable` also rejects fall-flying entities in + // `BlockTags.CAN_GLIDE_THROUGH`. TODO: add that branch when fall-flying + // state is modeled by local movement. + if player.flying || is_spectator(player.game_mode) { + return None; + } + + let x = player.position.x.floor() as i32; + let y = player.position.y.floor() as i32; + let z = player.position.z.floor() as i32; + let state = chunk_store.get_block_state(x, y, z); + if is_climbable_state(state, &block_tags()) { + return Some(state); + } + + let below = chunk_store.get_block_state(x, y - 1, z); + trapdoor_usable_as_ladder(state, below).then_some(state) +} + +fn apply_climbable_velocity( + player: &mut LocalPlayer, + state: azalea_block::BlockState, + suppress_slide: bool, +) { + player.fall_distance = 0.0; + let max = f64::from(CLIMB_MAX_FALL_SPEED); + player.velocity.x = player.velocity.x.clamp(-max, max); + player.velocity.z = player.velocity.z.clamp(-max, max); + player.velocity.y = player.velocity.y.max(-max); + + if player.velocity.y < 0.0 && block_id(state) != "scaffolding" && suppress_slide { + player.velocity.y = 0.0; + } +} + +fn handle_on_climbable(player: &mut LocalPlayer, chunk_store: &ChunkStore, suppress_slide: bool) { + let Some(state) = on_climbable_state(player, chunk_store) else { + return; + }; + apply_climbable_velocity(player, state, suppress_slide); +} + fn tick_land( player: &mut LocalPlayer, input: &InputState, @@ -339,8 +406,20 @@ fn tick_land( let accel = friction_influenced_speed(speed, player, block_friction); move_relative(player, forward, strafe, accel, sin_y_rot, cos_y_rot); + handle_on_climbable( + player, + world.chunks, + input.performing_action(input::Action::Sneak), + ); + apply_collision(player, input, world, forward, strafe, sin_y_rot, cos_y_rot); + if (player.horizontal_collision || input.performing_action(input::Action::Jump)) + && on_climbable_state(player, world.chunks).is_some() + { + player.velocity.y = CLIMB_UP_VELOCITY; + } + // `Entity.move`, before gravity and drag. let speed_factor = block_speed_factor(player, world.chunks); player.velocity.x *= f64::from(speed_factor); @@ -424,6 +503,10 @@ fn tick_water( apply_collision(player, input, world, forward, strafe, sin_y_rot, cos_y_rot); // TODO: `Entity.move`'s block speed factor also applies in water. + if player.horizontal_collision && on_climbable_state(player, world.chunks).is_some() { + player.velocity.y = CLIMB_UP_VELOCITY; + } + let slow_down = if player.sprinting { WATER_HORIZONTAL_DRAG_SPRINT } else { @@ -1151,6 +1234,159 @@ mod tests { assert_eq!(MINOR_COLLISION_ANGLE.to_bits(), 0x3fc1df46a0000000); } + fn set_open_trapdoor_ladder(chunks: &ChunkStore, waterlogged: bool) { + chunks.set_block_state( + 8, + 64, + 8, + crate::world::block::find_state( + "oak_trapdoor", + &[ + ("open", "true"), + ("facing", "north"), + ("waterlogged", if waterlogged { "true" } else { "false" }), + ], + ), + ); + chunks.set_block_state( + 8, + 63, + 8, + crate::world::block::find_state("ladder", &[("facing", "north")]), + ); + } + + fn trapdoor_ladder_store(waterlogged: bool) -> ChunkStore { + crate::world::block::init("26.2"); + let chunks = ChunkStore::with_origin_chunk(); + set_open_trapdoor_ladder(&chunks, waterlogged); + chunks + } + + #[test] + fn climbable_membership_comes_from_synchronized_tags() { + crate::world::block::init("26.2"); + let tags = crate::world::block::block_tags_for_test(&[("climbable", &["stone"])]); + let stone = crate::world::block::find_state("stone", &[]); + let ladder = crate::world::block::find_state("ladder", &[("facing", "north")]); + + assert!(is_climbable_state(stone, &tags)); + assert!(!is_climbable_state(ladder, &tags)); + } + + #[test] + fn open_matching_trapdoor_continues_ladder() { + crate::world::block::init("26.2"); + let north_ladder = crate::world::block::find_state("ladder", &[("facing", "north")]); + let south_ladder = crate::world::block::find_state("ladder", &[("facing", "south")]); + let open_north = crate::world::block::find_state( + "oak_trapdoor", + &[("open", "true"), ("facing", "north")], + ); + let open_south = crate::world::block::find_state( + "oak_trapdoor", + &[("open", "true"), ("facing", "south")], + ); + let closed_north = crate::world::block::find_state( + "oak_trapdoor", + &[("open", "false"), ("facing", "north")], + ); + + assert!(trapdoor_usable_as_ladder(open_north, north_ladder)); + assert!(!trapdoor_usable_as_ladder(open_north, south_ladder)); + assert!(!trapdoor_usable_as_ladder(open_south, north_ladder)); + assert!(!trapdoor_usable_as_ladder(closed_north, north_ladder)); + assert!(!trapdoor_usable_as_ladder( + open_north, + crate::world::block::find_state("stone", &[]) + )); + } + + #[test] + fn climbable_handling_clamps_velocity_and_resets_fall_distance() { + let chunks = trapdoor_ladder_store(false); + + let mut player = LocalPlayer::new(); + player.position = dvec3(8.5, 64.5, 8.5).into(); + player.velocity = Velocity::new(0.5, -0.7, -0.5); + player.fall_distance = 12.0; + + handle_on_climbable(&mut player, &chunks, false); + + let max = f64::from(CLIMB_MAX_FALL_SPEED); + assert_eq!(player.velocity, Velocity::new(max, -max, -max)); + assert_eq!(player.fall_distance, 0.0); + + player.velocity.y = -0.1; + handle_on_climbable(&mut player, &chunks, true); + assert_eq!(player.velocity.y, 0.0); + } + + #[test] + fn scaffolding_does_not_suppress_downward_climbable_motion() { + crate::world::block::init("26.2"); + let scaffolding = crate::world::block::find_state("scaffolding", &[("bottom", "false")]); + let mut player = LocalPlayer::new(); + player.velocity.y = -0.4; + + apply_climbable_velocity(&mut player, scaffolding, true); + + assert_eq!(player.velocity.y, -f64::from(CLIMB_MAX_FALL_SPEED)); + } + + #[test] + fn flying_and_spectator_players_do_not_use_trapdoor_ladder_continuation() { + let chunks = trapdoor_ladder_store(false); + let mut player = LocalPlayer::new(); + player.position = dvec3(8.5, 64.5, 8.5).into(); + + player.flying = true; + assert!(on_climbable_state(&player, &chunks).is_none()); + + player.flying = false; + player.game_mode = 3; + assert!(on_climbable_state(&player, &chunks).is_none()); + + player.game_mode = 0; + assert!(on_climbable_state(&player, &chunks).is_some()); + } + + #[test] + fn land_travel_clamps_before_collision_then_applies_climb_impulse() { + let chunks = trapdoor_ladder_store(false); + chunks.set_block_state(9, 64, 8, crate::world::block::find_state("stone", &[])); + + let mut player = LocalPlayer::new(); + player.position = dvec3(8.6, 64.5, 8.5).into(); + player.velocity = Velocity::new(0.5, -0.7, -0.5); + player.fall_distance = 12.0; + let neutral = InputState::released(); + + tick_land(&mut player, &neutral, (&chunks).into(), 0.0, 0.0, 0.0, 1.0); + + assert!(player.horizontal_collision); + assert!((player.position.z - (8.5 - f64::from(CLIMB_MAX_FALL_SPEED))).abs() < 1.0e-12); + let expected_y = (CLIMB_UP_VELOCITY - GRAVITY) * f64::from(VERTICAL_DRAG); + assert!((player.velocity.y - expected_y).abs() < 1.0e-12); + } + + #[test] + fn water_travel_applies_post_collision_climb_impulse() { + let chunks = trapdoor_ladder_store(true); + chunks.set_block_state(9, 64, 8, crate::world::block::find_state("stone", &[])); + + let mut player = LocalPlayer::new(); + player.position = dvec3(8.6, 64.0, 8.5).into(); + player.velocity = Velocity::new(0.5, 0.0, 0.0); + let neutral = InputState::released(); + + tick_water(&mut player, &neutral, (&chunks).into(), 0.0, 0.0, 0.0, 1.0); + + assert!(player.horizontal_collision); + let expected_y = CLIMB_UP_VELOCITY * f64::from(WATER_VERTICAL_DRAG) - GRAVITY / 16.0; + assert!((player.velocity.y - expected_y).abs() < 1.0e-12); + } + #[test] fn tiny_velocity_cleanup_matches_living_entity_thresholds() { let mut below = Velocity::new(0.002, 0.002999, 0.002); diff --git a/pomme-client/src/world/block/mod.rs b/pomme-client/src/world/block/mod.rs index 4db5c3d1f..e9067aec7 100644 --- a/pomme-client/src/world/block/mod.rs +++ b/pomme-client/src/world/block/mod.rs @@ -413,15 +413,12 @@ static BLOCK_TABLES: [OnceLock>; BLOCK_DATA.len()] = /// active table and the fallback for protocols without their own data. /// Server-provided block tags, resolved into Pomme's native block resource /// names. Sent during configuration and replaced after a datapack reload. -// TODO: the allows go once a gameplay check reads the tags (#620, #647). -#[allow(dead_code)] #[derive(Debug, Default)] pub struct BlockTags { tags: HashMap>, } impl BlockTags { - #[allow(dead_code)] pub fn contains(&self, tag: &str, block: &str) -> bool { self.tags .get(tag) @@ -848,7 +845,6 @@ pub fn replace_block_tags(raw_tags: Vec<(String, Vec)>) { resolve_block_tags(raw_tags, &block_registry_names()); } -#[allow(dead_code)] pub fn block_tags() -> RwLockReadGuard<'static, BlockTags> { BLOCK_TAGS.read().expect("block tag lock poisoned") } @@ -857,6 +853,27 @@ pub fn clear_block_tags() { *BLOCK_TAGS.write().expect("block tag lock poisoned") = BlockTags::default(); } +#[cfg(test)] +pub(crate) fn block_tags_for_test(entries: &[(&str, &[&str])]) -> BlockTags { + let registry_names = block_registry_names(); + let tags = entries + .iter() + .map(|(tag, blocks)| { + let blocks = blocks + .iter() + .map(|name| { + *registry_names + .iter() + .find(|candidate| *candidate == name) + .unwrap_or_else(|| panic!("unknown block {name}")) + }) + .collect(); + (format!("minecraft:{tag}"), blocks) + }) + .collect(); + BlockTags { tags } +} + fn block_data(state: BlockState) -> &'static BlockData { static UNKNOWN: std::sync::LazyLock = std::sync::LazyLock::new(|| BlockData { id: "unknown", From 8bf68fc6fed80bf276282e7a41008e9d147a1e67 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:27:14 +0100 Subject: [PATCH 3/5] climbable push gate, powder snow TODO --- pomme-client/src/app/core.rs | 8 ++++---- pomme-client/src/physics/movement.rs | 29 ++++++++++++++-------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index f3d2fcce3..e0a865723 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -177,11 +177,11 @@ fn apply_server_block( /// The local player's box as a push target, gated on `LivingEntity.isPushable` /// and `EntitySelector.NO_SPECTATORS`. -// TODO: isPushable also needs !onClimbable (Player: unless flying); climbable -// movement isn't modeled yet (physics/movement.rs). fn local_push_box(game: &GameState) -> Option { - (game.player.health > 0.0 && !crate::player::is_spectator(game.player.game_mode)) - .then(|| game.player.bounding_box()) + (game.player.health > 0.0 + && !crate::player::is_spectator(game.player.game_mode) + && !movement::on_climbable(&game.player, &game.chunk_store)) + .then(|| game.player.bounding_box()) } /// Starts an entity's hurt animation, with a direction for the packets that diff --git a/pomme-client/src/physics/movement.rs b/pomme-client/src/physics/movement.rs index a7b48346a..afb8c1f8e 100644 --- a/pomme-client/src/physics/movement.rs +++ b/pomme-client/src/physics/movement.rs @@ -203,7 +203,7 @@ fn apply_living_immobility( /// Vanilla `LivingEntity.travel`: fluid travel only when fluids affect the /// player (not while flying). -// TODO: lava travel and climbable (ladder) movement. +// TODO: lava travel. fn travel( player: &mut LocalPlayer, input: &InputState, @@ -327,23 +327,18 @@ fn trapdoor_usable_as_ladder( trapdoor: azalea_block::BlockState, below: azalea_block::BlockState, ) -> bool { - if !block_id(trapdoor).ends_with("_trapdoor") - || block_properties(trapdoor).get("open") != Some("true") - || block_id(below) != "ladder" - { - return false; - } - - block_properties(trapdoor).get("facing") == block_properties(below).get("facing") + let props = block_properties(trapdoor); + block_id(trapdoor).ends_with("_trapdoor") + && props.get("open") == Some("true") + && block_id(below) == "ladder" + && props.get("facing") == block_properties(below).get("facing") } fn on_climbable_state( player: &LocalPlayer, chunk_store: &ChunkStore, ) -> Option { - // Vanilla `LivingEntity.onClimbable` also rejects fall-flying entities in - // `BlockTags.CAN_GLIDE_THROUGH`. TODO: add that branch when fall-flying - // state is modeled by local movement. + // TODO: `onClimbable` also rejects fall-flying in `can_glide_through`. if player.flying || is_spectator(player.game_mode) { return None; } @@ -383,6 +378,11 @@ fn handle_on_climbable(player: &mut LocalPlayer, chunk_store: &ChunkStore, suppr apply_climbable_velocity(player, state, suppress_slide); } +/// `Player.onClimbable`. +pub(crate) fn on_climbable(player: &LocalPlayer, chunk_store: &ChunkStore) -> bool { + on_climbable_state(player, chunk_store).is_some() +} + fn tick_land( player: &mut LocalPlayer, input: &InputState, @@ -414,8 +414,9 @@ fn tick_land( apply_collision(player, input, world, forward, strafe, sin_y_rot, cos_y_rot); + // TODO: `wasInPowderSnow && canEntityWalkOnPowderSnow` also takes the impulse. if (player.horizontal_collision || input.performing_action(input::Action::Jump)) - && on_climbable_state(player, world.chunks).is_some() + && on_climbable(player, world.chunks) { player.velocity.y = CLIMB_UP_VELOCITY; } @@ -503,7 +504,7 @@ fn tick_water( apply_collision(player, input, world, forward, strafe, sin_y_rot, cos_y_rot); // TODO: `Entity.move`'s block speed factor also applies in water. - if player.horizontal_collision && on_climbable_state(player, world.chunks).is_some() { + if player.horizontal_collision && on_climbable(player, world.chunks) { player.velocity.y = CLIMB_UP_VELOCITY; } From f76e4d276ea1b1bfe0b1a9e7b1429b136ad16b50 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:07:43 +0100 Subject: [PATCH 4/5] cleanup --- pomme-client/src/net/connection.rs | 25 +++++++++++++++++++------ pomme-client/src/net/mod.rs | 16 ---------------- pomme-client/src/world/block/mod.rs | 15 +++++++++++---- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/pomme-client/src/net/connection.rs b/pomme-client/src/net/connection.rs index 15cab9cf0..578f5c2d3 100644 --- a/pomme-client/src/net/connection.rs +++ b/pomme-client/src/net/connection.rs @@ -589,9 +589,7 @@ async fn config_sequence( if let Some(tags) = dialog_tags(&p.tags) { received_dialog_tags = Some(tags); } - if let Some(tags) = super::block_tags_from_packet(&p.tags) { - let _ = event_tx.try_send(NetworkEvent::BlockTags { tags }); - } + forward_block_tags(event_tx, &p.tags); } ClientboundConfigPacket::SelectKnownPacks(p) => { // Vanilla `handleSelectKnownPacks`: claim the offered packs we @@ -826,6 +824,23 @@ fn dialog_tags( ) } +/// Forwards the `minecraft:block` tags of an `update_tags` packet, if it has +/// any. +fn forward_block_tags( + event_tx: &Sender, + tags: &azalea_protocol::common::tags::TagMap, +) { + let key: azalea_registry::identifier::Identifier = "minecraft:block".into(); + let Some(tags) = tags.0.get(&key) else { + return; + }; + let tags = tags + .iter() + .map(|tag| (tag.name.to_string(), tag.elements.clone())) + .collect(); + let _ = event_tx.try_send(NetworkEvent::BlockTags { tags }); +} + fn nbt_string_from_compound(compound: &simdnbt::owned::NbtCompound, key: &str) -> Option { compound.get(key).and_then(|v| match v { simdnbt::owned::NbtTag::String(s) => Some(s.to_string()), @@ -1013,9 +1028,7 @@ async fn game_loop( let _ = event_tx .try_send(NetworkEvent::DialogRegistry(configured.dialogs.clone())); } - if let Some(tags) = super::block_tags_from_packet(&p.tags) { - let _ = event_tx.try_send(NetworkEvent::BlockTags { tags }); - } + forward_block_tags(event_tx, &p.tags); } if let ClientboundGamePacket::Login(login) = &mut packet { inbound_chat.reset(); diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 8eb6a3712..df1b937e1 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -41,22 +41,6 @@ pub struct PacketLightData { pub empty_block_y_mask: azalea_core::bitset::BitSet, } -/// The `minecraft:block` entries of an UpdateTags map, as (tag, registry ids). -pub(crate) fn block_tags_from_packet( - tags: &azalea_protocol::common::tags::TagMap, -) -> Option)>> { - let (_, block_tags) = tags - .0 - .iter() - .find(|(registry, _)| registry.namespace() == "minecraft" && registry.path() == "block")?; - Some( - block_tags - .iter() - .map(|tag| (tag.name.to_string(), tag.elements.clone())) - .collect(), - ) -} - impl From<&azalea_protocol::packets::game::c_light_update::ClientboundLightUpdatePacketData> for PacketLightData { diff --git a/pomme-client/src/world/block/mod.rs b/pomme-client/src/world/block/mod.rs index 4db5c3d1f..440858098 100644 --- a/pomme-client/src/world/block/mod.rs +++ b/pomme-client/src/world/block/mod.rs @@ -409,8 +409,6 @@ const SHARED_BLOCK_DATA: &[(i32, i32)] = &[ /// protocol currently spoken (see [`set_active_protocol`]). static BLOCK_TABLES: [OnceLock>; BLOCK_DATA.len()] = [const { OnceLock::new() }; BLOCK_DATA.len()]; -/// The [`BLOCK_DATA`] slot holding the native version's tables: the initial -/// active table and the fallback for protocols without their own data. /// Server-provided block tags, resolved into Pomme's native block resource /// names. Sent during configuration and replaced after a datapack reload. // TODO: the allows go once a gameplay check reads the tags (#620, #647). @@ -432,6 +430,8 @@ impl BlockTags { static BLOCK_TAGS: LazyLock> = LazyLock::new(|| RwLock::new(BlockTags::default())); +/// The [`BLOCK_DATA`] slot holding the native version's tables: the initial +/// active table and the fallback for protocols without their own data. const NATIVE_SLOT: usize = 0; static ACTIVE_TABLE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(NATIVE_SLOT); @@ -857,6 +857,14 @@ pub fn clear_block_tags() { *BLOCK_TAGS.write().expect("block tag lock poisoned") = BlockTags::default(); } +#[cfg(test)] +fn block_registry_id(names: &[&str], block: &str) -> i32 { + names + .iter() + .position(|name| *name == block) + .unwrap_or_else(|| panic!("unknown block {block}")) as i32 +} + fn block_data(state: BlockState) -> &'static BlockData { static UNKNOWN: std::sync::LazyLock = std::sync::LazyLock::new(|| BlockData { id: "unknown", @@ -1262,11 +1270,10 @@ mod tests { fn block_tag_registry_ids_resolve_to_native_block_names() { setup(); let names = block_registry_names(); - let id = |block| names.iter().position(|name| *name == block).unwrap() as i32; let tags = resolve_block_tags( vec![( "minecraft:mineable/pickaxe".to_owned(), - vec![id("stone"), -1, i32::MAX], + vec![block_registry_id(&names, "stone"), -1, i32::MAX], )], &names, ); From 69df274aa5ff6a2d52330feb516d0367f8a08bc1 Mon Sep 17 00:00:00 2001 From: Purdze <29306641+Purdze@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:08:52 +0100 Subject: [PATCH 5/5] cleanup --- pomme-client/src/physics/movement.rs | 88 +++++++++++----------------- 1 file changed, 33 insertions(+), 55 deletions(-) diff --git a/pomme-client/src/physics/movement.rs b/pomme-client/src/physics/movement.rs index afb8c1f8e..fc5486391 100644 --- a/pomme-client/src/physics/movement.rs +++ b/pomme-client/src/physics/movement.rs @@ -1186,6 +1186,7 @@ fn vanilla_look_y(pitch_degrees: f32) -> f64 { mod tests { use super::*; use crate::player::{CROUCH_EYE_HEIGHT, STANDING_EYE_HEIGHT}; + use crate::world::block::find_state; #[test] fn player_width_stops_at_negative_two_block_face() { @@ -1235,32 +1236,18 @@ mod tests { assert_eq!(MINOR_COLLISION_ANGLE.to_bits(), 0x3fc1df46a0000000); } - fn set_open_trapdoor_ladder(chunks: &ChunkStore, waterlogged: bool) { - chunks.set_block_state( - 8, - 64, - 8, - crate::world::block::find_state( - "oak_trapdoor", - &[ - ("open", "true"), - ("facing", "north"), - ("waterlogged", if waterlogged { "true" } else { "false" }), - ], - ), - ); - chunks.set_block_state( - 8, - 63, - 8, - crate::world::block::find_state("ladder", &[("facing", "north")]), - ); - } - + /// An open north-facing trapdoor at (8, 64, 8) over a matching ladder. fn trapdoor_ladder_store(waterlogged: bool) -> ChunkStore { crate::world::block::init("26.2"); let chunks = ChunkStore::with_origin_chunk(); - set_open_trapdoor_ladder(&chunks, waterlogged); + let waterlogged = if waterlogged { "true" } else { "false" }; + let trapdoor = [ + ("open", "true"), + ("facing", "north"), + ("waterlogged", waterlogged), + ]; + chunks.set_block_state(8, 64, 8, find_state("oak_trapdoor", &trapdoor)); + chunks.set_block_state(8, 63, 8, find_state("ladder", &[("facing", "north")])); chunks } @@ -1268,8 +1255,8 @@ mod tests { fn climbable_membership_comes_from_synchronized_tags() { crate::world::block::init("26.2"); let tags = crate::world::block::block_tags_for_test(&[("climbable", &["stone"])]); - let stone = crate::world::block::find_state("stone", &[]); - let ladder = crate::world::block::find_state("ladder", &[("facing", "north")]); + let stone = find_state("stone", &[]); + let ladder = find_state("ladder", &[("facing", "north")]); assert!(is_climbable_state(stone, &tags)); assert!(!is_climbable_state(ladder, &tags)); @@ -1278,20 +1265,11 @@ mod tests { #[test] fn open_matching_trapdoor_continues_ladder() { crate::world::block::init("26.2"); - let north_ladder = crate::world::block::find_state("ladder", &[("facing", "north")]); - let south_ladder = crate::world::block::find_state("ladder", &[("facing", "south")]); - let open_north = crate::world::block::find_state( - "oak_trapdoor", - &[("open", "true"), ("facing", "north")], - ); - let open_south = crate::world::block::find_state( - "oak_trapdoor", - &[("open", "true"), ("facing", "south")], - ); - let closed_north = crate::world::block::find_state( - "oak_trapdoor", - &[("open", "false"), ("facing", "north")], - ); + let north_ladder = find_state("ladder", &[("facing", "north")]); + let south_ladder = find_state("ladder", &[("facing", "south")]); + let open_north = find_state("oak_trapdoor", &[("open", "true"), ("facing", "north")]); + let open_south = find_state("oak_trapdoor", &[("open", "true"), ("facing", "south")]); + let closed_north = find_state("oak_trapdoor", &[("open", "false"), ("facing", "north")]); assert!(trapdoor_usable_as_ladder(open_north, north_ladder)); assert!(!trapdoor_usable_as_ladder(open_north, south_ladder)); @@ -1299,7 +1277,7 @@ mod tests { assert!(!trapdoor_usable_as_ladder(closed_north, north_ladder)); assert!(!trapdoor_usable_as_ladder( open_north, - crate::world::block::find_state("stone", &[]) + find_state("stone", &[]) )); } @@ -1326,7 +1304,7 @@ mod tests { #[test] fn scaffolding_does_not_suppress_downward_climbable_motion() { crate::world::block::init("26.2"); - let scaffolding = crate::world::block::find_state("scaffolding", &[("bottom", "false")]); + let scaffolding = find_state("scaffolding", &[("bottom", "false")]); let mut player = LocalPlayer::new(); player.velocity.y = -0.4; @@ -1355,7 +1333,7 @@ mod tests { #[test] fn land_travel_clamps_before_collision_then_applies_climb_impulse() { let chunks = trapdoor_ladder_store(false); - chunks.set_block_state(9, 64, 8, crate::world::block::find_state("stone", &[])); + chunks.set_block_state(9, 64, 8, find_state("stone", &[])); let mut player = LocalPlayer::new(); player.position = dvec3(8.6, 64.5, 8.5).into(); @@ -1374,7 +1352,7 @@ mod tests { #[test] fn water_travel_applies_post_collision_climb_impulse() { let chunks = trapdoor_ladder_store(true); - chunks.set_block_state(9, 64, 8, crate::world::block::find_state("stone", &[])); + chunks.set_block_state(9, 64, 8, find_state("stone", &[])); let mut player = LocalPlayer::new(); player.position = dvec3(8.6, 64.0, 8.5).into(); @@ -1553,7 +1531,7 @@ mod tests { ); // One block of headroom: swimming fits, crouching doesn't. - let stone = crate::world::block::find_state("stone", &[]); + let stone = find_state("stone", &[]); chunks.set_block_state(8, 65, 8, stone); let mut input = InputState::released(); input.set_key_pressed_for_test(KeyCode::ShiftLeft, true); @@ -1596,7 +1574,7 @@ mod tests { #[test] fn crouch_swim_cancels_near_point_zero_four_vertical_speed_before_pitch_steering() { let chunks = ChunkStore::with_origin_chunk(); - let source = crate::world::block::find_state("water", &[("level", "0")]); + let source = find_state("water", &[("level", "0")]); for x in 7..=9 { for y in 64..=66 { for z in 7..=9 { @@ -1638,9 +1616,9 @@ mod tests { #[test] fn falling_water_subthreshold_horizontal_current_is_cleaned_before_travel() { let chunks = ChunkStore::with_origin_chunk(); - let falling = crate::world::block::find_state("water", &[("level", "8")]); - let low = crate::world::block::find_state("water", &[("level", "7")]); - let stone = crate::world::block::find_state("stone", &[]); + let falling = find_state("water", &[("level", "8")]); + let low = find_state("water", &[("level", "7")]); + let stone = find_state("stone", &[]); chunks.set_block_state(8, 64, 8, falling); chunks.set_block_state(9, 64, 8, low); chunks.set_block_state(8, 64, 7, stone); @@ -1702,7 +1680,7 @@ mod tests { fn dry_move_into_partial_flowing_water_applies_current_before_ground_friction() { let wet = ChunkStore::with_origin_chunk(); let dry = ChunkStore::with_origin_chunk(); - let stone = crate::world::block::find_state("stone", &[]); + let stone = find_state("stone", &[]); for x in 6..=10 { for z in 7..=9 { wet.set_block_state(x, 63, z, stone); @@ -1711,8 +1689,8 @@ mod tests { } // A source to the east makes this level-3 cell flow west. - let partial = crate::world::block::find_state("water", &[("level", "3")]); - let source = crate::world::block::find_state("water", &[("level", "0")]); + let partial = find_state("water", &[("level", "3")]); + let source = find_state("water", &[("level", "0")]); wet.set_block_state(8, 64, 8, partial); wet.set_block_state(9, 64, 8, source); @@ -1745,7 +1723,7 @@ mod tests { #[test] fn jump_out_of_fluid_rejects_collision_free_boxes_that_still_contain_liquid() { let chunks = ChunkStore::with_origin_chunk(); - let water = crate::world::block::find_state("water", &[("level", "0")]); + let water = find_state("water", &[("level", "0")]); chunks.set_block_state(8, 64, 8, water); let wet_box = Aabb::new(dvec3(8.2, 64.2, 8.2), dvec3(8.8, 65.8, 8.8)); @@ -2054,7 +2032,7 @@ mod tests { #[test] fn flight_toggle_off_the_ground_skips_the_living_jump() { let chunks = ChunkStore::with_origin_chunk(); - chunks.set_block_state(8, 63, 8, crate::world::block::find_state("stone", &[])); + chunks.set_block_state(8, 63, 8, find_state("stone", &[])); let mut player = LocalPlayer::new(); player.position = dvec3(8.5, 64.0, 8.5).into(); player.on_ground = true; @@ -2082,7 +2060,7 @@ mod tests { #[test] fn flying_through_water_uses_air_travel() { let chunks = ChunkStore::with_origin_chunk(); - chunks.set_block_state(8, 64, 8, crate::world::block::find_state("water", &[])); + chunks.set_block_state(8, 64, 8, find_state("water", &[])); let mut player = LocalPlayer::new(); player.position = dvec3(8.5, 64.1, 8.5).into(); player.may_fly = true; @@ -2105,7 +2083,7 @@ mod tests { #[test] fn flying_ignores_the_block_speed_factor() { let chunks = ChunkStore::with_origin_chunk(); - chunks.set_block_state(8, 64, 8, crate::world::block::find_state("soul_sand", &[])); + chunks.set_block_state(8, 64, 8, find_state("soul_sand", &[])); let mut player = LocalPlayer::new(); player.position = dvec3(8.5, 65.0, 8.5).into(); assert_eq!(block_speed_factor(&player, &chunks), 0.4);