From 19c4682b97231317e0f8afa71bece35560f03f36 Mon Sep 17 00:00:00 2001 From: Purdze Date: Fri, 7 Aug 2026 18:47:04 +0100 Subject: [PATCH 01/19] add chickens --- pomme-client/src/app/core.rs | 11 +- pomme-client/src/app/phases/in_game.rs | 14 ++ pomme-client/src/entity/mod.rs | 43 +++++- pomme-client/src/net/handler.rs | 51 +++++-- pomme-client/src/net/mod.rs | 4 + pomme-client/src/renderer/entity_model.rs | 141 ++++++++++++++++++ .../src/renderer/pipelines/entity_renderer.rs | 49 ++++++ 7 files changed, 295 insertions(+), 18 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 92a850d1..ee123730 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -961,7 +961,8 @@ impl AppCore { dz, on_ground, } => { - game.entity_store.move_living_delta(id, dx, dy, dz); + game.entity_store + .move_living_delta(id, dx, dy, dz, on_ground); game.item_entity_store.move_delta(id, dx, dy, dz, on_ground); } NetworkEvent::EntityMovedRotated { @@ -973,7 +974,8 @@ impl AppCore { x_rot_deg, on_ground, } => { - game.entity_store.move_living_delta(id, dx, dy, dz); + game.entity_store + .move_living_delta(id, dx, dy, dz, on_ground); game.entity_store .update_living_rotation(id, y_rot_deg, x_rot_deg); game.item_entity_store.move_delta(id, dx, dy, dz, on_ground); @@ -989,7 +991,7 @@ impl AppCore { x_rot_deg, on_ground, } => { - game.entity_store.teleport_living(id, position); + game.entity_store.teleport_living(id, position, on_ground); game.entity_store .update_living_rotation(id, y_rot_deg, x_rot_deg); game.item_entity_store @@ -1104,6 +1106,9 @@ impl AppCore { NetworkEvent::CowVariant { id, variant } => { game.entity_store.set_cow_variant(id, variant); } + NetworkEvent::ChickenVariant { id, variant } => { + game.entity_store.set_chicken_variant(id, variant); + } NetworkEvent::VillagerData { id, kind, diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index db406421..0bfa8fdf 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -2267,6 +2267,8 @@ pub fn update_game( head_x_rot_deg_override: extras.head_x_rot_deg_override, has_red_overlay: e.hurt_time > 0, aggressive: e.aggressive, + flap: extras.flap, + flap_speed: extras.flap_speed, age_in_ticks: e.age_in_ticks as f32 + partial_tick, attack_time: e.swing_progress(partial_tick), skip_cull: false, @@ -2308,6 +2310,8 @@ pub fn update_game( head_x_rot_deg_override: None, has_red_overlay: false, aggressive: false, + flap: 0.0, + flap_speed: 0.0, age_in_ticks: 0.0, attack_time: 0.0, skip_cull: true, @@ -2779,6 +2783,8 @@ struct EntityExtras { overlay_variants: [u32; MAX_OVERLAYS], head_y_offset: f32, head_x_rot_deg_override: Option, + flap: f32, + flap_speed: f32, } const EMPTY_EXTRAS: EntityExtras = EntityExtras { @@ -2787,6 +2793,8 @@ const EMPTY_EXTRAS: EntityExtras = EntityExtras { overlay_variants: [0; MAX_OVERLAYS], head_y_offset: 0.0, head_x_rot_deg_override: None, + flap: 0.0, + flap_speed: 0.0, }; /// Only the first overlay slot visible, untinted. @@ -2802,6 +2810,12 @@ fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> variant_index: e.cow_variant as u32, ..EMPTY_EXTRAS }, + EntityKind::Chicken => EntityExtras { + variant_index: e.chicken_variant as u32, + flap: e.prev_flap + (e.flap - e.prev_flap) * alpha, + flap_speed: e.prev_flap_speed + (e.flap_speed - e.prev_flap_speed) * alpha, + ..EMPTY_EXTRAS + }, EntityKind::Sheep => sheep_extras(entity_id, e, alpha), EntityKind::Villager => villager_extras(e), // Spider eyes overlay is always visible (slot 0). diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 920c676d..3badf509 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -41,6 +41,13 @@ pub struct LivingEntity { pub wool_color: Option, pub is_sheared: bool, pub cow_variant: u8, + pub chicken_variant: u8, + /// Chicken wing-flap state (vanilla `Chicken.aiStep`): `flap` is the + /// unbounded wing-cycle phase, `flap_speed` the 0..1 amplitude. + pub flap: f32, + pub prev_flap: f32, + pub flap_speed: f32, + pub prev_flap_speed: f32, pub villager_kind: VillagerKind, pub villager_profession: VillagerProfession, pub villager_level: u32, @@ -61,6 +68,8 @@ pub struct LivingEntity { /// (driven by the server `Animate` packet). Drives the zombie attack /// swing. pub swing_time: u8, + /// Chicken `flapping` decay factor. + flapping: f32, interp_target: Position, interp_look_dir: LookDirection, interp_steps: i32, @@ -97,6 +106,11 @@ impl LivingEntity { wool_color: None, is_sheared: false, cow_variant: 0, + chicken_variant: 0, + flap: 0.0, + prev_flap: 0.0, + flap_speed: 0.0, + prev_flap_speed: 0.0, villager_kind: VillagerKind::default(), villager_profession: VillagerProfession::default(), villager_level: 0, @@ -109,6 +123,7 @@ impl LivingEntity { aggressive: false, powered: false, swing_time: 0, + flapping: 1.0, interp_target: position, interp_look_dir: look_dir, interp_steps: 0, @@ -163,6 +178,19 @@ impl LivingEntity { .clamp(0.0, 1.0) } + /// Vanilla `Chicken.aiStep` wing flap; the update order matters. + fn tick_flap(&mut self) { + self.prev_flap = self.flap; + self.prev_flap_speed = self.flap_speed; + let delta = if self.on_ground { -0.3 } else { 1.2 }; + self.flap_speed = (self.flap_speed + delta).clamp(0.0, 1.0); + if !self.on_ground && self.flapping < 1.0 { + self.flapping = 1.0; + } + self.flapping *= 0.9; + self.flap += self.flapping * 2.0; + } + pub fn tick_body_rotation(&mut self) { let dx = self.position.x - self.prev_position.x; let dz = self.position.z - self.prev_position.z; @@ -520,16 +548,18 @@ impl EntityStore { ); } - pub fn move_living_delta(&mut self, id: i32, dx: f64, dy: f64, dz: f64) { + pub fn move_living_delta(&mut self, id: i32, dx: f64, dy: f64, dz: f64, on_ground: bool) { if let Some(entity) = self.living.get_mut(&id) { let target = entity.interp_target + DVec3::new(dx, dy, dz); entity.interpolate_to_pos(target); + entity.on_ground = on_ground; } } - pub fn teleport_living(&mut self, id: i32, position: Position) { + pub fn teleport_living(&mut self, id: i32, position: Position, on_ground: bool) { if let Some(entity) = self.living.get_mut(&id) { entity.interpolate_to_pos(position); + entity.on_ground = on_ground; } } @@ -562,6 +592,14 @@ impl EntityStore { } } + pub fn set_chicken_variant(&mut self, id: i32, variant: u8) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::Chicken + { + entity.chicken_variant = variant; + } + } + pub fn set_villager_data( &mut self, id: i32, @@ -674,6 +712,7 @@ impl EntityStore { &mut entity.walk_anim_speed, &mut entity.prev_walk_anim_speed, ); + entity.tick_flap(); entity.prev_eat_anim_tick = entity.eat_anim_tick; if entity.eat_anim_tick > 0 { entity.eat_anim_tick -= 1; diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 0f38f367..26db9681 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -502,21 +502,29 @@ pub fn handle_game_packet( && let azalea_entity::EntityDataValue::CowVariant(variant) = &item.value { use azalea_registry::DataRegistry; - let resolved = registry_holder - .protocol_id_to_identifier( - azalea_registry::identifier::Identifier::new("minecraft:cow_variant"), - variant.protocol_id(), - ) - .map(|id| match id.path() { - "temperate" => 0u8, - "cold" => 1, - "warm" => 2, - _ => 0, - }) - .unwrap_or(0); let _ = event_tx.try_send(NetworkEvent::CowVariant { id: p.id.0, - variant: resolved, + variant: variant_index( + registry_holder, + "minecraft:cow_variant", + variant.protocol_id(), + &["temperate", "cold", "warm"], + ), + }); + } + // Index 18 on chickens = ChickenVariant Holder. + if item.index == 18 + && let azalea_entity::EntityDataValue::ChickenVariant(variant) = &item.value + { + use azalea_registry::DataRegistry; + let _ = event_tx.try_send(NetworkEvent::ChickenVariant { + id: p.id.0, + variant: variant_index( + registry_holder, + "minecraft:chicken_variant", + variant.protocol_id(), + &["temperate", "warm", "cold"], + ), }); } // Index 18 on villagers = unhappy counter (head-shake while > 0). @@ -686,6 +694,23 @@ fn send_chat(event_tx: &Sender, message: &azalea_chat::FormattedTe let _ = event_tx.try_send(NetworkEvent::ChatMessage { spans }); } +/// Resolves a variant registry holder id to its index in `order` — the +/// renderer's texture-variant order for that mob. Unknown ids fall back to 0. +fn variant_index( + registry_holder: &RegistryHolder, + registry: &str, + protocol_id: u32, + order: &[&str], +) -> u8 { + registry_holder + .protocol_id_to_identifier( + azalea_registry::identifier::Identifier::new(registry), + protocol_id, + ) + .and_then(|id| order.iter().position(|p| *p == id.path())) + .unwrap_or(0) as u8 +} + fn lp_to_dvec3(v: &azalea_core::delta::LpVec3) -> glam::DVec3 { let v = v.to_vec3(); glam::DVec3::new(v.x, v.y, v.z) diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index ecc2bbbc..74a1d6be 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -305,6 +305,10 @@ pub enum NetworkEvent { id: i32, variant: u8, }, + ChickenVariant { + id: i32, + variant: u8, + }, VillagerData { id: i32, kind: VillagerKind, diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 4e6009c7..c1b83a2b 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -945,6 +945,115 @@ pub fn bake_baby_cow_model() -> BakedEntityModel { bake_model(parts, 64, 64) } +/// Vanilla `AdultChickenModel.createBaseChickenModel()`. The beak and wattle +/// are children with a zero pose in vanilla, so they fold into the head part. +/// Legs share identical UVs and are not mirrored (vanilla quirk). +fn chicken_parts() -> Vec { + let leg = |name: &str, x: f32| { + vpart( + name, + None, + Vec3::new(x, 19.0, 1.0), + vec![vbox((26, 0), (-1.0, 0.0, -3.0), (3.0, 5.0, 3.0))], + ) + }; + let wing = |name: &str, x: f32, origin_x: f32| { + vpart( + name, + None, + Vec3::new(x, 13.0, 0.0), + vec![vbox((24, 13), (origin_x, 0.0, -3.0), (1.0, 4.0, 6.0))], + ) + }; + vec![ + vpart( + "head", + None, + Vec3::new(0.0, 15.0, -4.0), + vec![ + vbox((0, 0), (-2.0, -6.0, -2.0), (4.0, 6.0, 3.0)), + // Beak. + vbox((14, 0), (-2.0, -4.0, -4.0), (4.0, 2.0, 2.0)), + // Wattle ("red_thing"). + vbox((14, 4), (-1.0, -2.0, -3.0), (2.0, 2.0, 2.0)), + ], + ), + EntityPart { + default_rotation: Vec3::new(std::f32::consts::FRAC_PI_2, 0.0, 0.0), + ..vpart( + "body", + None, + Vec3::new(0.0, 16.0, 0.0), + vec![vbox((0, 9), (-3.0, -4.0, -3.0), (6.0, 8.0, 6.0))], + ) + }, + leg("right_leg", -2.0), + leg("left_leg", 1.0), + wing("right_wing", -4.0, 0.0), + wing("left_wing", 4.0, -1.0), + ] +} + +pub fn bake_chicken_model() -> BakedEntityModel { + bake_model(chicken_parts(), 64, 32) +} + +/// Vanilla `ColdChickenModel`: the base chicken plus a head crest and a +/// zero-width tail-feather plane. +pub fn bake_cold_chicken_model() -> BakedEntityModel { + let mut parts = chicken_parts(); + // Head crest (its -2.015 z avoids z-fighting), then body tail feathers. + parts[0] + .cubes + .push(vbox((44, 0), (-3.0, -7.0, -2.015), (6.0, 3.0, 4.0))); + parts[1] + .cubes + .push(vbox((38, 9), (0.0, 3.0, -1.0), (0.0, 3.0, 5.0))); + bake_model(parts, 64, 32) +} + +/// Vanilla `BabyChickenModel`: a wholly separate 16x16 mesh with the head +/// fused into the body (so no head look). The wing pivots are X-flipped +/// relative to the adult (vanilla quirk; the legs are not). +pub fn bake_baby_chicken_model() -> BakedEntityModel { + let leg = |name: &str, x: f32, shin_uv: (u32, u32), foot_uv: (u32, u32)| { + vpart( + name, + None, + Vec3::new(x, 22.0, 0.5), + vec![ + vbox(shin_uv, (-0.5, 0.0, 0.0), (1.0, 2.0, 0.0)), + vbox(foot_uv, (-0.5, 2.0, -1.0), (1.0, 0.0, 1.0)), + ], + ) + }; + let wing = |name: &str, x: f32, origin_x: f32, uv: (u32, u32)| { + vpart( + name, + None, + Vec3::new(x, 20.0, 0.0), + vec![vbox(uv, (origin_x, 0.0, -1.0), (1.0, 0.0, 2.0))], + ) + }; + let parts = vec![ + vpart( + "body", + None, + Vec3::new(0.0, 20.25, -1.25), + vec![ + vbox((0, 0), (-2.0, -2.25, -0.75), (4.0, 4.0, 4.0)), + // Beak. + vbox((10, 8), (-1.0, -0.25, -1.75), (2.0, 1.0, 1.0)), + ], + ), + leg("left_leg", 1.0, (2, 2), (0, 1)), + leg("right_leg", -1.0, (0, 2), (0, 0)), + wing("right_wing", 2.0, 0.0, (6, 8)), + wing("left_wing", -2.0, -1.0, (4, 8)), + ]; + bake_model(parts, 16, 16) +} + pub fn bake_sheep_model() -> BakedEntityModel { let mut parts = vec![ EntityPart { @@ -1477,6 +1586,38 @@ pub fn compute_quadruped_anim( anim } +/// Chicken (`ChickenModel.setupAnim` + `AdultChickenModel.setupAnim`). The +/// baby model has no head part, so its match arm never fires there (vanilla +/// babies don't turn their heads either). +pub fn compute_chicken_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + flap: f32, + flap_speed: f32, +) -> PartAnim { + let mut anim = PartAnim::default(); + let flap_angle = (flap.sin() + 1.0) * flap_speed; + // The negation is vanilla's `+ PI` leg phase: cos(x + PI) = -cos(x). + let leg_swing = (walk_pos * 0.6662).cos() * 1.4 * walk_speed; + + for (i, part) in model.parts.iter().enumerate() { + let rot = match part.name.as_str() { + "head" => head_rotation(head_x_rot_deg, local_head_y_rot_deg), + "right_leg" => Vec3::new(leg_swing, 0.0, 0.0), + "left_leg" => Vec3::new(-leg_swing, 0.0, 0.0), + "right_wing" => Vec3::new(0.0, 0.0, flap_angle), + "left_wing" => Vec3::new(0.0, 0.0, -flap_angle), + _ => continue, + }; + anim.rotation.push((i, rot)); + } + + anim +} + fn head_rotation(head_x_rot_deg: f32, local_head_y_rot_deg: f32) -> Vec3 { let rot = Quat::from_rotation_y(local_head_y_rot_deg.to_radians()) * Quat::from_rotation_x(head_x_rot_deg.to_radians()); diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 7a91bfe2..0a661b02 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -54,6 +54,9 @@ pub struct EntityRenderInfo { pub has_red_overlay: bool, /// Mob is targeting/attacking — raises zombie/skeleton arms. pub aggressive: bool, + /// Chicken wing-flap phase and 0..1 amplitude, interpolated. + pub flap: f32, + pub flap_speed: f32, /// Interpolated entity age in ticks; drives the undead idle arm bob. pub age_in_ticks: f32, /// Arm-swing progress 0..1; drives the zombie attack swing. @@ -216,6 +219,7 @@ pub(super) enum BlendMode { #[derive(Clone, Copy, PartialEq, Eq)] enum AnimationType { Quadruped, + Chicken, Humanoid, Zombie, Skeleton, @@ -260,6 +264,22 @@ fn mob_definitions() -> Vec { &["minecraft/textures/entity/cow/cow_cold_baby.png"], &["minecraft/textures/entity/cow/cow_warm_baby.png"], ]; + // Variant order is temperate/warm/cold: the two normal-mesh variants share + // one VariantDef, the cold mesh gets its own. The handler's index mapping + // must match. + const CHICKEN_NORMAL_TEX: &[&[&str]] = &[ + &[ + "minecraft/textures/entity/chicken/chicken_temperate.png", + "minecraft/textures/entity/chicken.png", + ], + &["minecraft/textures/entity/chicken/chicken_warm.png"], + ]; + const CHICKEN_COLD_TEX: &[&[&str]] = &[&["minecraft/textures/entity/chicken/chicken_cold.png"]]; + const CHICKEN_BABY_TEX: &[&[&str]] = &[ + &["minecraft/textures/entity/chicken/chicken_temperate_baby.png"], + &["minecraft/textures/entity/chicken/chicken_warm_baby.png"], + &["minecraft/textures/entity/chicken/chicken_cold_baby.png"], + ]; const SHEEP_ADULT_TEX: &[&[&str]] = &[&["minecraft/textures/entity/sheep/sheep.png"]]; const SHEEP_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/sheep/sheep_baby.png"]]; const SHEEP_WOOL_UNDERCOAT_TEX: &[&[&str]] = @@ -363,6 +383,25 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Chicken, + anim: AnimationType::Chicken, + adult: vec![ + opaque(entity_model::bake_chicken_model(), CHICKEN_NORMAL_TEX, 64), + opaque( + entity_model::bake_cold_chicken_model(), + CHICKEN_COLD_TEX, + 64, + ), + ], + baby: Some(opaque( + entity_model::bake_baby_chicken_model(), + CHICKEN_BABY_TEX, + 16, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, MobDef { kind: EntityKind::Sheep, anim: AnimationType::Quadruped, @@ -808,6 +847,15 @@ impl EntityRenderer { info.head_y_offset, info.head_x_rot_deg_override, ), + AnimationType::Chicken => entity_model::compute_chicken_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.flap, + info.flap_speed, + ), AnimationType::Humanoid => entity_model::compute_humanoid_anim( model, info.head_x_rot_deg, @@ -1274,6 +1322,7 @@ fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { let (w, h) = match kind { EntityKind::Pig => (0.9, 0.9), EntityKind::Cow => (0.9, 1.4), + EntityKind::Chicken => (0.4, 0.7), EntityKind::Sheep => (0.9, 1.3), EntityKind::Zombie => (0.6, 1.95), EntityKind::Skeleton => (0.6, 1.99), From 41665595613001f1f8978b5176de474b40921fff Mon Sep 17 00:00:00 2001 From: Purdze Date: Fri, 7 Aug 2026 19:42:38 +0100 Subject: [PATCH 02/19] add enderman, slime and witch --- pomme-client/src/app/core.rs | 9 + pomme-client/src/app/phases/in_game.rs | 52 +++- pomme-client/src/entity/mod.rs | 60 ++++ pomme-client/src/net/handler.rs | 28 +- pomme-client/src/net/mod.rs | 12 + pomme-client/src/particle.rs | 2 +- pomme-client/src/renderer/entity_model.rs | 279 +++++++++++++++++- .../src/renderer/pipelines/entity_renderer.rs | 209 ++++++++++--- 8 files changed, 593 insertions(+), 58 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index ee123730..2e1c8403 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1109,6 +1109,15 @@ impl AppCore { NetworkEvent::ChickenVariant { id, variant } => { game.entity_store.set_chicken_variant(id, variant); } + NetworkEvent::EndermanCreepy { id, creepy } => { + game.entity_store.set_enderman_creepy(id, creepy); + } + NetworkEvent::WitchDrinking { id, drinking } => { + game.entity_store.set_witch_drinking(id, drinking); + } + NetworkEvent::SlimeSize { id, size } => { + game.entity_store.set_slime_size(id, size); + } NetworkEvent::VillagerData { id, kind, diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 0bfa8fdf..60f8ca43 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -2233,7 +2233,7 @@ pub fn update_game( let extras = entity_extras(entity_id, e, partial_tick); EntityRenderInfo { - position: interp_pos, + position: interp_pos + extras.render_offset, head_y_rot_deg: lerp_angle( e.prev_head_y_rot_deg, e.head_y_rot_deg, @@ -2269,6 +2269,10 @@ pub fn update_game( aggressive: e.aggressive, flap: extras.flap, flap_speed: extras.flap_speed, + is_creepy: e.is_creepy, + is_holding_item: e.witch_drinking, + nose_wobble_speed: extras.nose_wobble_speed, + body_transform: extras.body_transform, age_in_ticks: e.age_in_ticks as f32 + partial_tick, attack_time: e.swing_progress(partial_tick), skip_cull: false, @@ -2312,6 +2316,10 @@ pub fn update_game( aggressive: false, flap: 0.0, flap_speed: 0.0, + is_creepy: false, + is_holding_item: false, + nose_wobble_speed: 0.0, + body_transform: None, age_in_ticks: 0.0, attack_time: 0.0, skip_cull: true, @@ -2785,6 +2793,9 @@ struct EntityExtras { head_x_rot_deg_override: Option, flap: f32, flap_speed: f32, + body_transform: Option, + render_offset: glam::DVec3, + nose_wobble_speed: f32, } const EMPTY_EXTRAS: EntityExtras = EntityExtras { @@ -2795,6 +2806,9 @@ const EMPTY_EXTRAS: EntityExtras = EntityExtras { head_x_rot_deg_override: None, flap: 0.0, flap_speed: 0.0, + body_transform: None, + render_offset: glam::DVec3::ZERO, + nose_wobble_speed: 0.0, }; /// Only the first overlay slot visible, untinted. @@ -2823,6 +2837,30 @@ fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> overlay_tints: SLOT0_TINTS, ..EMPTY_EXTRAS }, + EntityKind::Enderman => EntityExtras { + overlay_tints: SLOT0_TINTS, + // Vanilla `EndermanRenderer.getRenderOffset`: per-frame gaussian + // x/z shake while screaming. + render_offset: if e.is_creepy { + glam::DVec3::new( + crate::particle::next_gaussian() * 0.02, + 0.0, + crate::particle::next_gaussian() * 0.02, + ) + } else { + glam::DVec3::ZERO + }, + ..EMPTY_EXTRAS + }, + EntityKind::Slime => EntityExtras { + overlay_tints: SLOT0_TINTS, + body_transform: Some(slime_body_transform(e, alpha)), + ..EMPTY_EXTRAS + }, + EntityKind::Witch => EntityExtras { + nose_wobble_speed: 0.01 * (entity_id % 10) as f32, + ..EMPTY_EXTRAS + }, // Charged-creeper aura overlay (slot 0) only when powered. EntityKind::Creeper if e.powered => EntityExtras { overlay_tints: SLOT0_TINTS, @@ -2832,6 +2870,18 @@ fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> } } +/// Vanilla `AbstractCubeMobRenderer.applySizeAndSquish` plus the slime-only +/// `downscaleSlightly` (0.999 shrink + 0.001 lift against shell z-fighting). +fn slime_body_transform(e: &crate::entity::LivingEntity, alpha: f32) -> glam::Mat4 { + let squish = e.prev_squish + (e.squish - e.prev_squish) * alpha; + let size = e.slime_size as f32; + let ss = squish / (size * 0.5 + 1.0); + let w = 1.0 / (ss + 1.0); + glam::Mat4::from_scale(glam::Vec3::splat(0.999)) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.001, 0.0)) + * glam::Mat4::from_scale(glam::Vec3::new(w * size, size / w, w * size)) +} + fn sheep_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> EntityExtras { let is_jeb = e.custom_name.as_deref() == Some("jeb_"); let tint = if is_jeb { diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 3badf509..e44226de 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -48,6 +48,16 @@ pub struct LivingEntity { pub prev_flap: f32, pub flap_speed: f32, pub prev_flap_speed: f32, + /// Slime squish spring (vanilla `AbstractCubeMob`): negative = squashed + /// on landing, positive = stretched in the air. + pub squish: f32, + pub prev_squish: f32, + pub slime_size: u8, + /// Enderman screaming flag — raises the head and jitters the render + /// position. + pub is_creepy: bool, + /// Witch drinking flag — swings the nose down toward the potion. + pub witch_drinking: bool, pub villager_kind: VillagerKind, pub villager_profession: VillagerProfession, pub villager_level: u32, @@ -70,6 +80,8 @@ pub struct LivingEntity { pub swing_time: u8, /// Chicken `flapping` decay factor. flapping: f32, + target_squish: f32, + prev_on_ground: bool, interp_target: Position, interp_look_dir: LookDirection, interp_steps: i32, @@ -111,6 +123,11 @@ impl LivingEntity { prev_flap: 0.0, flap_speed: 0.0, prev_flap_speed: 0.0, + squish: 0.0, + prev_squish: 0.0, + slime_size: 1, + is_creepy: false, + witch_drinking: false, villager_kind: VillagerKind::default(), villager_profession: VillagerProfession::default(), villager_level: 0, @@ -124,6 +141,8 @@ impl LivingEntity { powered: false, swing_time: 0, flapping: 1.0, + target_squish: 0.0, + prev_on_ground: false, interp_target: position, interp_look_dir: look_dir, interp_steps: 0, @@ -191,6 +210,19 @@ impl LivingEntity { self.flap += self.flapping * 2.0; } + /// Vanilla `AbstractCubeMob.tick` squish spring; the update order matters. + fn tick_squish(&mut self) { + self.prev_squish = self.squish; + self.squish += (self.target_squish - self.squish) * 0.5; + if self.on_ground && !self.prev_on_ground { + self.target_squish = -0.5; + } else if !self.on_ground && self.prev_on_ground { + self.target_squish = 1.0; + } + self.prev_on_ground = self.on_ground; + self.target_squish *= 0.6; + } + pub fn tick_body_rotation(&mut self) { let dx = self.position.x - self.prev_position.x; let dz = self.position.z - self.prev_position.z; @@ -600,6 +632,30 @@ impl EntityStore { } } + pub fn set_enderman_creepy(&mut self, id: i32, creepy: bool) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::Enderman + { + entity.is_creepy = creepy; + } + } + + pub fn set_witch_drinking(&mut self, id: i32, drinking: bool) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::Witch + { + entity.witch_drinking = drinking; + } + } + + pub fn set_slime_size(&mut self, id: i32, size: i32) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::Slime + { + entity.slime_size = size.clamp(1, 127) as u8; + } + } + pub fn set_villager_data( &mut self, id: i32, @@ -713,6 +769,7 @@ impl EntityStore { &mut entity.prev_walk_anim_speed, ); entity.tick_flap(); + entity.tick_squish(); entity.prev_eat_anim_tick = entity.eat_anim_tick; if entity.eat_anim_tick > 0 { entity.eat_anim_tick -= 1; @@ -773,5 +830,8 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::Creeper | EntityKind::Spider | EntityKind::Villager + | EntityKind::Enderman + | EntityKind::Slime + | EntityKind::Witch ) } diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 26db9681..99be7301 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -480,14 +480,22 @@ pub fn handle_game_packet( sheared: (*packed & 0x10) != 0, }); } - // Index 17 (Boolean) = creeper "powered"/charged flag. Disambiguated - // from the sheep byte above by value type. + // Index 17 (Boolean) = creeper powered / enderman creepy / witch + // drinking. Emit all three; consumers filter by entity type. if item.index == 17 - && let azalea_entity::EntityDataValue::Boolean(powered) = &item.value + && let azalea_entity::EntityDataValue::Boolean(flag) = &item.value { let _ = event_tx.try_send(NetworkEvent::CreeperPowered { id: p.id.0, - powered: *powered, + powered: *flag, + }); + let _ = event_tx.try_send(NetworkEvent::EndermanCreepy { + id: p.id.0, + creepy: *flag, + }); + let _ = event_tx.try_send(NetworkEvent::WitchDrinking { + id: p.id.0, + drinking: *flag, }); } // Index 2 = custom name (Optional); needed for jeb_ sheep detection. @@ -527,14 +535,18 @@ pub fn handle_game_packet( ), }); } - // Index 18 on villagers = unhappy counter (head-shake while > 0). - // Emit unconditionally; consumer filters by entity type. + // Index 18 (Int) = villager unhappy counter / slime size. Emit + // both; consumers filter by entity type. if item.index == 18 - && let azalea_entity::EntityDataValue::Int(counter) = &item.value + && let azalea_entity::EntityDataValue::Int(value) = &item.value { let _ = event_tx.try_send(NetworkEvent::VillagerUnhappy { id: p.id.0, - counter: *counter, + counter: *value, + }); + let _ = event_tx.try_send(NetworkEvent::SlimeSize { + id: p.id.0, + size: *value, }); } // Index 19 on villagers = VillagerData (type/profession/level). diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 74a1d6be..3f090c5b 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -309,6 +309,18 @@ pub enum NetworkEvent { id: i32, variant: u8, }, + EndermanCreepy { + id: i32, + creepy: bool, + }, + WitchDrinking { + id: i32, + drinking: bool, + }, + SlimeSize { + id: i32, + size: i32, + }, VillagerData { id: i32, kind: VillagerKind, diff --git a/pomme-client/src/particle.rs b/pomme-client/src/particle.rs index 1a5d26fd..1700653c 100644 --- a/pomme-client/src/particle.rs +++ b/pomme-client/src/particle.rs @@ -567,7 +567,7 @@ impl ParticleStore { /// `java.util.Random.nextGaussian` (Marsaglia polar method), minus the /// second-sample cache. -fn next_gaussian() -> f64 { +pub(crate) fn next_gaussian() -> f64 { loop { let v1 = 2.0 * fastrand::f64() - 1.0; let v2 = 2.0 * fastrand::f64() - 1.0; diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index c1b83a2b..9b54d0c2 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -1453,16 +1453,12 @@ fn clear_head_subtree(parts: &mut [EntityPart]) { /// grounded (`PartPose.scaled(f).translated(0, 24.016 * (1 - f), 0)`). const VILLAGER_SCALE: f32 = 0.9375; -pub fn bake_villager_model(no_hat: bool) -> BakedEntityModel { - let mut parts = villager_parts(); - if no_hat { - clear_head_subtree(&mut parts); - } - // Root-only: the transform chain propagates a root's scale to child - // pivots and geometry like vanilla's pose stack (children would - // double-scale). +/// Bakes with `VILLAGER_SCALE` applied to the roots only: the transform chain +/// propagates a root's scale to child pivots and geometry like vanilla's pose +/// stack (children would double-scale). +fn bake_villager_like(mut parts: Vec, tex_h: u32) -> BakedEntityModel { let mut scales = Vec::with_capacity(parts.len()); - for part in &mut parts { + for part in parts.iter_mut() { let is_root = part.parent.is_none(); if is_root { part.offset = @@ -1470,11 +1466,19 @@ pub fn bake_villager_model(no_hat: bool) -> BakedEntityModel { } scales.push(if is_root { VILLAGER_SCALE } else { 1.0 }); } - let mut model = bake_model(parts, 64, 64); + let mut model = bake_model(parts, 64, tex_h); model.part_scales = scales; model } +pub fn bake_villager_model(no_hat: bool) -> BakedEntityModel { + let mut parts = villager_parts(); + if no_hat { + clear_head_subtree(&mut parts); + } + bake_villager_like(parts, 64) +} + pub fn bake_baby_villager_model(no_hat: bool) -> BakedEntityModel { let mut parts = baby_villager_parts(); if no_hat { @@ -1483,6 +1487,168 @@ pub fn bake_baby_villager_model(no_hat: bool) -> BakedEntityModel { bake_model(parts, 64, 64) } +/// Vanilla `EndermanModel`: `HumanoidModel.createMesh` output is fully +/// replaced, so only the literal parts below matter. The hat stays a separate +/// child part (unlike the flattened player/zombie hats): the creepy pose +/// shifts the head up and the hat back down so the inset overlay keeps its +/// place. The feet sink ~1px into the ground -- vanilla. +pub fn bake_enderman_model() -> BakedEntityModel { + let limb = |name: &str, pivot: Vec3, origin_y: f32, mirror: bool| { + vpart( + name, + None, + pivot, + vec![ModelCube { + mirror, + ..vbox((56, 0), (-1.0, origin_y, -1.0), (2.0, 30.0, 2.0)) + }], + ) + }; + let parts = vec![ + vpart( + "head", + None, + Vec3::new(0.0, -13.0, 0.0), + vec![vbox((0, 0), (-4.0, -8.0, -4.0), (8.0, 8.0, 8.0))], + ), + vpart( + "hat", + Some(0), + Vec3::ZERO, + vec![ModelCube { + deformation: -0.5, + ..vbox((0, 16), (-4.0, -8.0, -4.0), (8.0, 8.0, 8.0)) + }], + ), + vpart( + "body", + None, + Vec3::new(0.0, -14.0, 0.0), + vec![vbox((32, 16), (-4.0, 0.0, -2.0), (8.0, 12.0, 4.0))], + ), + limb("right_arm", Vec3::new(-5.0, -12.0, 0.0), -2.0, false), + limb("left_arm", Vec3::new(5.0, -12.0, 0.0), -2.0, true), + limb("right_leg", Vec3::new(-2.0, -5.0, 0.0), 0.0, false), + limb("left_leg", Vec3::new(2.0, -5.0, 0.0), 0.0, true), + ]; + bake_model(parts, 64, 32) +} + +/// Vanilla `SlimeModel.createInnerBodyLayer`: the gel core with eyes and +/// mouth. All parts are static; size and squish scale the whole entity via +/// the render-info body transform. +pub fn bake_slime_inner_model() -> BakedEntityModel { + let parts = vec![ + vpart( + "cube", + None, + Vec3::ZERO, + vec![vbox((0, 16), (-3.0, 17.0, -3.0), (6.0, 6.0, 6.0))], + ), + // Eyes and mouth poke past the cube faces (z -3.5, x beyond +-3) to + // avoid z-fighting. + vpart( + "right_eye", + None, + Vec3::ZERO, + vec![vbox((32, 0), (-3.25, 18.0, -3.5), (2.0, 2.0, 2.0))], + ), + vpart( + "left_eye", + None, + Vec3::ZERO, + vec![vbox((32, 4), (1.25, 18.0, -3.5), (2.0, 2.0, 2.0))], + ), + vpart( + "mouth", + None, + Vec3::ZERO, + vec![vbox((32, 8), (0.0, 21.0, -3.5), (1.0, 1.0, 1.0))], + ), + ]; + bake_model(parts, 64, 32) +} + +/// Vanilla `SlimeModel.createOuterBodyLayer`: the translucent shell (its +/// alpha lives in the texture). Padded with empty parts so the part list +/// matches the inner model (`assert_part_order_matches` shares anim indices). +pub fn bake_slime_outer_model() -> BakedEntityModel { + let parts = vec![ + vpart( + "cube", + None, + Vec3::ZERO, + vec![vbox((0, 0), (-4.0, 16.0, -4.0), (8.0, 8.0, 8.0))], + ), + vpart("right_eye", None, Vec3::ZERO, vec![]), + vpart("left_eye", None, Vec3::ZERO, vec![]), + vpart("mouth", None, Vec3::ZERO, vec![]), + ]; + bake_model(parts, 64, 32) +} + +/// Vanilla `WitchModel`: `VillagerModel.createBodyModel()` with the hat +/// replaced by the witch's stacked cone, a mole added to the nose, and a +/// 64x128 sheet. The villager `hat_rim` survives vanilla's child merge but +/// its witch.png region is fully transparent, so its cubes are dropped. +fn witch_parts() -> Vec { + let mut parts = villager_parts(); + parts[1] = vpart( + "hat", + Some(0), + Vec3::new(-5.0, -10.03125, -5.0), + vec![vbox((0, 64), (0.0, 0.0, 0.0), (10.0, 2.0, 10.0))], + ); + parts[2].cubes.clear(); // hat_rim + parts.extend([ + EntityPart { + default_rotation: Vec3::new(-0.05235988, 0.0, 0.02617994), + ..vpart( + "hat2", + Some(1), + Vec3::new(1.75, -4.0, 2.0), + vec![vbox((0, 76), (0.0, 0.0, 0.0), (7.0, 4.0, 7.0))], + ) + }, + EntityPart { + default_rotation: Vec3::new(-0.10471976, 0.0, 0.05235988), + ..vpart( + "hat3", + Some(9), + Vec3::new(1.75, -4.0, 2.0), + vec![vbox((0, 87), (0.0, 0.0, 0.0), (4.0, 4.0, 4.0))], + ) + }, + EntityPart { + default_rotation: Vec3::new(-0.20943952, 0.0, 0.10471976), + ..vpart( + "hat4", + Some(10), + Vec3::new(1.75, -2.0, 2.0), + vec![ModelCube { + deformation: 0.25, + ..vbox((0, 95), (0.0, 0.0, 0.0), (1.0, 2.0, 1.0)) + }], + ) + }, + // The mole samples the unused top-left corner of the head texture. + vpart( + "mole", + Some(3), + Vec3::new(0.0, -2.0, 0.0), + vec![ModelCube { + deformation: -0.25, + ..vbox((0, 0), (0.0, 3.0, -6.75), (1.0, 1.0, 1.0)) + }], + ), + ]); + parts +} + +pub fn bake_witch_model() -> BakedEntityModel { + bake_villager_like(witch_parts(), 128) +} + pub fn compute_humanoid_anim( model: &BakedEntityModel, head_x_rot_deg: f32, @@ -1825,6 +1991,99 @@ pub fn compute_villager_anim( anim } +/// Vanilla `EndermanModel.setupAnim` on top of the humanoid walk: limb x-swing +/// halved and clamped to +-0.4 AFTER the arm bob, tiny fixed leg y/z splay, +/// and the creepy head raise (the hat counter-shifts so the inset overlay +/// stays put). +// TODO: carried-block arm pose and the humanoid attack swing once carried +// blocks / attack animation land. +pub fn compute_enderman_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + age_in_ticks: f32, + is_creepy: bool, +) -> PartAnim { + let mut anim = PartAnim::default(); + let half_clamp = |x: f32| (x * 0.5).clamp(-0.4, 0.4); + // Left limbs are the exact negation of the right (vanilla's `+ PI` swing + // phase and `side = -1` bob), and `half_clamp` is odd, so one value per + // pair suffices. The vanilla 2.0 * 0.5 arm-swing factors cancel. + let swing = (walk_pos * 0.6662).cos(); + let (bob_x, bob_z) = bob_arm(age_in_ticks, 1.0); + let arm_x = half_clamp(bob_x - swing * walk_speed); + let leg_x = half_clamp(swing * 1.4 * walk_speed); + + for (i, part) in model.parts.iter().enumerate() { + let rot = match part.name.as_str() { + "head" => { + if is_creepy { + anim.translation.push((i, Vec3::new(0.0, -5.0, 0.0))); + } + head_rotation(head_x_rot_deg, local_head_y_rot_deg) + } + "hat" => { + if is_creepy { + anim.translation.push((i, Vec3::new(0.0, 5.0, 0.0))); + } + continue; + } + "right_arm" => Vec3::new(arm_x, 0.0, bob_z), + "left_arm" => Vec3::new(-arm_x, 0.0, -bob_z), + "right_leg" => Vec3::new(leg_x, 0.005, 0.005), + "left_leg" => Vec3::new(-leg_x, -0.005, -0.005), + _ => continue, + }; + anim.rotation.push((i, rot)); + } + + anim +} + +/// Vanilla `WitchModel.setupAnim`: the villager pose (`super.setupAnim`) plus +/// the nose. `nose_wobble_speed` is `0.01 * (entity_id % 10)` -- an id +/// divisible by 10 means a still nose (vanilla). Drinking overrides only the +/// x rotation and pivot; the z wobble keeps going. +#[allow(clippy::too_many_arguments)] +pub fn compute_witch_anim( + model: &BakedEntityModel, + head_x_rot_deg: f32, + local_head_y_rot_deg: f32, + walk_pos: f32, + walk_speed: f32, + age_in_ticks: f32, + nose_wobble_speed: f32, + is_holding_item: bool, +) -> PartAnim { + let mut anim = compute_villager_anim( + model, + head_x_rot_deg, + local_head_y_rot_deg, + walk_pos, + walk_speed, + false, + age_in_ticks, + ); + + for (i, part) in model.parts.iter().enumerate() { + if part.name == "nose" { + let (sin, cos) = (age_in_ticks * nose_wobble_speed).sin_cos(); + let mut rot = Vec3::new(sin * 4.5_f32.to_radians(), 0.0, cos * 2.5_f32.to_radians()); + if is_holding_item { + // Vanilla moves the nose pivot from (0, -2, 0) to + // (0, 1, -1.5); the translation is additive. + anim.translation.push((i, Vec3::new(0.0, 3.0, -1.5))); + rot.x = -0.9; + } + anim.rotation.push((i, rot)); + } + } + + anim +} + /// The four corner positions of each cube face, in render space (Y already /// flipped). Face order: 0 -Z, 1 +Z, 2 +Y, 3 -Y, 4 -X, 5 +X. fn cube_face_positions(cube: &ModelCube) -> [[[f32; 3]; 4]; 6] { diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 0a661b02..436dd342 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -57,6 +57,15 @@ pub struct EntityRenderInfo { /// Chicken wing-flap phase and 0..1 amplitude, interpolated. pub flap: f32, pub flap_speed: f32, + /// Enderman screaming state — raises the head. + pub is_creepy: bool, + /// Witch drinking (vanilla `isHoldingItem`). + pub is_holding_item: bool, + /// Witch per-entity nose-wobble rate, resolved from the entity id. + pub nose_wobble_speed: f32, + /// Extra scale applied after the entity rotation (slime size + squish), + /// shared by base and overlay draws. + pub body_transform: Option, /// Interpolated entity age in ticks; drives the undead idle arm bob. pub age_in_ticks: f32, /// Arm-swing progress 0..1; drives the zombie attack swing. @@ -71,9 +80,12 @@ pub struct EntityRenderInfo { enum OverlayKind { /// Cutout, depth-writing — sheep wool and all base models. Opaque, + /// Translucent, depth-writing — the slime shell (vanilla + /// `entityTranslucent`; the alpha lives in the texture). + BodyTranslucent, /// Translucent, full-bright, depth-write off — spider glowing eyes. EyesTranslucent, - /// Additive, full-bright, depth-write off, scrolling UV — charged creeper + /// Additive, full-bright, depth-writing, scrolling UV — charged creeper /// swirl. SwirlAdditive, } @@ -187,6 +199,8 @@ pub fn jeb_sheep_tint(entity_id: i32, age_in_ticks: u32) -> [f32; 4] { pub struct EntityRenderer { pipeline: vk::Pipeline, + /// Translucent, depth-writing — slime shell. + body_translucent_pipeline: vk::Pipeline, /// Translucent, depth-write off — spider eyes. eyes_pipeline: vk::Pipeline, /// Additive, depth-write off — charged-creeper energy swirl. @@ -213,6 +227,9 @@ pub struct EntityRenderer { pub(super) enum BlendMode { Opaque, Translucent, + /// Same blend as `Translucent` but keeps depth writes (vanilla + /// `entityTranslucent` vs `EYES`). + TranslucentDepthWrite, Additive, } @@ -221,10 +238,14 @@ enum AnimationType { Quadruped, Chicken, Humanoid, + Enderman, Zombie, Skeleton, Spider, Villager, + Witch, + /// No part animation (slime — size/squish live in the body transform). + Static, } struct VariantDef { @@ -295,6 +316,11 @@ fn mob_definitions() -> Vec { &[&["minecraft/textures/entity/creeper/creeper_armor.png"]]; const SPIDER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/spider/spider.png"]]; const SPIDER_EYES_TEX: &[&[&str]] = &[&["minecraft/textures/entity/spider/spider_eyes.png"]]; + const ENDERMAN_TEX: &[&[&str]] = &[&["minecraft/textures/entity/enderman/enderman.png"]]; + const ENDERMAN_EYES_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/enderman/enderman_eyes.png"]]; + const SLIME_TEX: &[&[&str]] = &[&["minecraft/textures/entity/slime/slime.png"]]; + const WITCH_TEX: &[&[&str]] = &[&["minecraft/textures/entity/witch/witch.png"]]; const VILLAGER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/villager/villager.png"]]; const VILLAGER_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/villager/villager_baby.png"]]; @@ -550,6 +576,48 @@ fn mob_definitions() -> Vec { }], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Enderman, + anim: AnimationType::Enderman, + adult: vec![opaque( + entity_model::bake_enderman_model(), + ENDERMAN_TEX, + 64, + )], + baby: None, + adult_overlays: vec![VariantDef { + model: entity_model::bake_enderman_model(), + tex_variants: ENDERMAN_EYES_TEX, + tex_size: 64, + overlay_kind: OverlayKind::EyesTranslucent, + }], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Slime, + anim: AnimationType::Static, + adult: vec![opaque( + entity_model::bake_slime_inner_model(), + SLIME_TEX, + 64, + )], + baby: None, + adult_overlays: vec![VariantDef { + model: entity_model::bake_slime_outer_model(), + tex_variants: SLIME_TEX, + tex_size: 64, + overlay_kind: OverlayKind::BodyTranslucent, + }], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Witch, + anim: AnimationType::Witch, + adult: vec![opaque(entity_model::bake_witch_model(), WITCH_TEX, 64)], + baby: None, + adult_overlays: vec![], + baby_overlays: vec![], + }, ] } @@ -584,8 +652,12 @@ impl EntityRenderer { .create_pipeline_layout(&layout_info, None) .expect("failed to create entity pipeline layout"); - let [pipeline, eyes_pipeline, swirl_pipeline] = - create_pipelines(device, render_pass, pipeline_layout); + let [ + pipeline, + body_translucent_pipeline, + eyes_pipeline, + swirl_pipeline, + ] = create_pipelines(device, render_pass, pipeline_layout); let defs = mob_definitions(); let tex_count: u32 = defs @@ -690,6 +762,7 @@ impl EntityRenderer { Self { pipeline, + body_translucent_pipeline, eyes_pipeline, swirl_pipeline, pipeline_layout, @@ -864,6 +937,15 @@ impl EntityRenderer { info.walk_anim_speed, info.is_crouching, ), + AnimationType::Enderman => entity_model::compute_enderman_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.age_in_ticks, + info.is_creepy, + ), AnimationType::Zombie => entity_model::compute_zombie_anim( model, info.head_x_rot_deg, @@ -899,14 +981,26 @@ impl EntityRenderer { info.is_unhappy, info.age_in_ticks, ), + AnimationType::Witch => entity_model::compute_witch_anim( + model, + info.head_x_rot_deg, + local_head_y, + info.walk_anim_pos, + info.walk_anim_speed, + info.age_in_ticks, + info.nose_wobble_speed, + info.is_holding_item, + ), + AnimationType::Static => entity_model::PartAnim::default(), } } /// The translation is anchor-relative, subtracted in f64 (see /// `Camera::anchor`). fn entity_matrix(info: &EntityRenderInfo, anchor: glam::DVec3) -> glam::Mat4 { - glam::Mat4::from_translation((*info.position - anchor).as_vec3()) - * glam::Mat4::from_rotation_y((180.0 - info.body_y_rot_deg).to_radians()) + let base = glam::Mat4::from_translation((*info.position - anchor).as_vec3()) + * glam::Mat4::from_rotation_y((180.0 - info.body_y_rot_deg).to_radians()); + info.body_transform.map_or(base, |m| base * m) } #[allow(clippy::too_many_arguments)] @@ -930,7 +1024,7 @@ impl EntityRenderer { // and are dropped at the end of this block, before the buffer write below. let cull_dist_sq = cull_dist * cull_dist; let mut instances: Vec = Vec::new(); - let (opaque, eyes, swirl) = { + let (opaque, body, eyes, swirl) = { let mut vis: Vec = Vec::new(); for info in entities { let Some(entry) = self.mobs.get(&info.entity_kind) else { @@ -960,13 +1054,6 @@ impl EntityRenderer { // 1, ...) — interleaving per entity would let a shared group // created by an earlier entity draw a later entity's lower layer // after its upper one. - let hurt_color = |info: &EntityRenderInfo| { - if info.has_red_overlay { - HURT_OVERLAY - } else { - NO_OVERLAY - } - }; let mut opaque = VariantGroups::default(); for (vi, v) in vis.iter().enumerate() { let base = v @@ -1002,11 +1089,13 @@ impl EntityRenderer { } } - let eyes = collect_emissive(&vis, OverlayKind::EyesTranslucent); - let swirl = collect_emissive(&vis, OverlayKind::SwirlAdditive); + let body = collect_overlays(&vis, OverlayKind::BodyTranslucent); + let eyes = collect_overlays(&vis, OverlayKind::EyesTranslucent); + let swirl = collect_overlays(&vis, OverlayKind::SwirlAdditive); ( opaque.emit(&vis, &mut instances), + body.emit(&vis, &mut instances), eyes.emit(&vis, &mut instances), swirl.emit(&vis, &mut instances), ) @@ -1027,6 +1116,7 @@ impl EntityRenderer { .copy_from_slice(bytes); self.record_pass(cmd, frame, self.pipeline, &opaque, count); + self.record_pass(cmd, frame, self.body_translucent_pipeline, &body, count); self.record_pass(cmd, frame, self.eyes_pipeline, &eyes, count); self.record_pass(cmd, frame, self.swirl_pipeline, &swirl, count); } @@ -1075,10 +1165,15 @@ impl EntityRenderer { pub fn recreate_pipeline(&mut self, device: &vk::Device, render_pass: vk::RenderPass) { device.destroy_pipeline(self.pipeline, None); + device.destroy_pipeline(self.body_translucent_pipeline, None); device.destroy_pipeline(self.eyes_pipeline, None); device.destroy_pipeline(self.swirl_pipeline, None); - [self.pipeline, self.eyes_pipeline, self.swirl_pipeline] = - create_pipelines(device, render_pass, self.pipeline_layout); + [ + self.pipeline, + self.body_translucent_pipeline, + self.eyes_pipeline, + self.swirl_pipeline, + ] = create_pipelines(device, render_pass, self.pipeline_layout); } pub fn destroy(&mut self, device: &vk::Device, allocator: &Arc>) { @@ -1133,6 +1228,7 @@ impl EntityRenderer { drop(alloc); device.destroy_pipeline(self.pipeline, None); + device.destroy_pipeline(self.body_translucent_pipeline, None); device.destroy_pipeline(self.eyes_pipeline, None); device.destroy_pipeline(self.swirl_pipeline, None); device.destroy_pipeline_layout(self.pipeline_layout, None); @@ -1288,8 +1384,8 @@ impl<'a> VariantGroups<'a> { } } -/// Group the emissive overlays of one kind (eyes / swirl) by variant. -fn collect_emissive<'a>(vis: &[VisEntity<'a>], kind: OverlayKind) -> VariantGroups<'a> { +/// Group the non-opaque overlays of one kind (body / eyes / swirl) by variant. +fn collect_overlays<'a>(vis: &[VisEntity<'a>], kind: OverlayKind) -> VariantGroups<'a> { let mut groups = VariantGroups::default(); for (vi, v) in vis.iter().enumerate() { // Energy swirl scrolls its UVs over time (vanilla `EnergySwirlLayer`). @@ -1299,6 +1395,13 @@ fn collect_emissive<'a>(vis: &[VisEntity<'a>], kind: OverlayKind) -> VariantGrou } else { [0.0, 0.0] }; + // The body layer flashes red with the entity (vanilla passes the hurt + // overlay coords); the emissive eyes/swirl layers never do. + let overlay_color = if kind == OverlayKind::BodyTranslucent { + hurt_color(v.info) + } else { + NO_OVERLAY + }; for slot in 0..v.entry.overlays(v.info.is_baby).len() { let overlay = v.entry @@ -1307,13 +1410,21 @@ fn collect_emissive<'a>(vis: &[VisEntity<'a>], kind: OverlayKind) -> VariantGrou continue; } if let Some(tint) = v.info.overlay_tints[slot] { - groups.add(overlay, overlay.texture_set, (vi, tint, NO_OVERLAY, uv)); + groups.add(overlay, overlay.texture_set, (vi, tint, overlay_color, uv)); } } } groups } +fn hurt_color(info: &EntityRenderInfo) -> [f32; 4] { + if info.has_red_overlay { + HURT_OVERLAY + } else { + NO_OVERLAY + } +} + const ANIM_MARGIN: f32 = 0.5; /// Vanilla (width, height) hitbox per supported mob, scaled for babies; used to @@ -1329,6 +1440,9 @@ fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { EntityKind::Creeper => (0.6, 1.7), EntityKind::Spider => (1.4, 0.9), EntityKind::Villager => (0.6, 1.95), + EntityKind::Enderman => (0.6, 2.9), + EntityKind::Slime => (0.52, 0.52), + EntityKind::Witch => (0.6, 1.95), EntityKind::Player => (0.6, 1.8), _ => (1.0, 1.0), }; @@ -1346,7 +1460,18 @@ fn entity_visible( cull_dist_sq: f32, ) -> bool { let (w, h) = entity_bounds(info.entity_kind, info.is_baby); - let radius = 0.5 * (2.0 * w * w + h * h).sqrt() + ANIM_MARGIN; + let mut radius = 0.5 * (2.0 * w * w + h * h).sqrt() + ANIM_MARGIN; + // A body transform (slime size/squish) can grow the entity well past its + // base bounds; inflate the sphere by its largest axis scale. + if let Some(m) = &info.body_transform { + let s = m + .x_axis + .length_squared() + .max(m.y_axis.length_squared()) + .max(m.z_axis.length_squared()) + .sqrt(); + radius *= s.max(1.0); + } let mut q = (*info.position - eye).as_vec3(); q.y += h * 0.5; if q.length_squared() > cull_dist_sq { @@ -1580,7 +1705,7 @@ fn create_pipelines( device: &vk::Device, render_pass: vk::RenderPass, layout: vk::PipelineLayout, -) -> [vk::Pipeline; 3] { +) -> [vk::Pipeline; 4] { [ create_pipeline( device, @@ -1589,6 +1714,13 @@ fn create_pipelines( BlendMode::Opaque, ModelInput::Instanced, ), + create_pipeline( + device, + render_pass, + layout, + BlendMode::TranslucentDepthWrite, + ModelInput::Instanced, + ), create_pipeline( device, render_pass, @@ -1699,11 +1831,10 @@ pub(super) fn create_pipeline( }; // Only the translucent eyes overlay skips depth-write (vanilla `EYES`); the - // opaque base and additive swirl write depth (vanilla `ENERGY_SWIRL`). - let depth_write = if blend == BlendMode::Translucent { - vk::FALSE - } else { - vk::TRUE + // opaque base, slime shell, and additive swirl write depth. + let depth_write = match blend { + BlendMode::Translucent => vk::FALSE, + _ => vk::TRUE, }; let depth_stencil = vk::PipelineDepthStencilStateCreateInfo { depth_test_enable: vk::TRUE, @@ -1718,17 +1849,19 @@ pub(super) fn create_pipeline( color_write_mask: vk::ColorComponentFlags::RGBA, ..Default::default() }, - // Standard src-alpha over (glowing eyes). - BlendMode::Translucent => vk::PipelineColorBlendAttachmentState { - blend_enable: vk::TRUE, - src_color_blend_factor: vk::BlendFactor::SrcAlpha, - dst_color_blend_factor: vk::BlendFactor::OneMinusSrcAlpha, - color_blend_op: vk::BlendOp::Add, - src_alpha_blend_factor: vk::BlendFactor::One, - dst_alpha_blend_factor: vk::BlendFactor::OneMinusSrcAlpha, - alpha_blend_op: vk::BlendOp::Add, - color_write_mask: vk::ColorComponentFlags::RGBA, - }, + // Standard src-alpha over (glowing eyes, slime shell). + BlendMode::Translucent | BlendMode::TranslucentDepthWrite => { + vk::PipelineColorBlendAttachmentState { + blend_enable: vk::TRUE, + src_color_blend_factor: vk::BlendFactor::SrcAlpha, + dst_color_blend_factor: vk::BlendFactor::OneMinusSrcAlpha, + color_blend_op: vk::BlendOp::Add, + src_alpha_blend_factor: vk::BlendFactor::One, + dst_alpha_blend_factor: vk::BlendFactor::OneMinusSrcAlpha, + alpha_blend_op: vk::BlendOp::Add, + color_write_mask: vk::ColorComponentFlags::RGBA, + } + } // Additive (energy swirl glow). BlendMode::Additive => vk::PipelineColorBlendAttachmentState { blend_enable: vk::TRUE, From dff87983bba972998543aa1dccf85743ca4760df Mon Sep 17 00:00:00 2001 From: Purdze Date: Fri, 7 Aug 2026 20:18:56 +0100 Subject: [PATCH 03/19] add husk, drowned, zombie villager, stray and bogged --- pomme-client/src/app/core.rs | 10 + pomme-client/src/app/phases/in_game.rs | 23 +- pomme-client/src/entity/mod.rs | 48 ++- pomme-client/src/net/handler.rs | 33 +- pomme-client/src/net/mod.rs | 12 + pomme-client/src/renderer/entity_model.rs | 359 ++++++++++++++++-- .../src/renderer/pipelines/entity_renderer.rs | 277 ++++++++++---- 7 files changed, 638 insertions(+), 124 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 2e1c8403..a441fc45 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1118,6 +1118,16 @@ impl AppCore { NetworkEvent::SlimeSize { id, size } => { game.entity_store.set_slime_size(id, size); } + NetworkEvent::BoggedSheared { id, sheared } => { + game.entity_store.set_bogged_sheared(id, sheared); + } + NetworkEvent::ZombieConverting { id, converting } => { + game.entity_store.set_zombie_converting(id, converting); + } + NetworkEvent::ZombieVillagerConverting { id, converting } => { + game.entity_store + .set_zombie_villager_converting(id, converting); + } NetworkEvent::VillagerData { id, kind, diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 60f8ca43..52dfa782 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -2270,6 +2270,7 @@ pub fn update_game( flap: extras.flap, flap_speed: extras.flap_speed, is_creepy: e.is_creepy, + is_converting: e.is_converting, is_holding_item: e.witch_drinking, nose_wobble_speed: extras.nose_wobble_speed, body_transform: extras.body_transform, @@ -2317,6 +2318,7 @@ pub fn update_game( flap: 0.0, flap_speed: 0.0, is_creepy: false, + is_converting: false, is_holding_item: false, nose_wobble_speed: 0.0, body_transform: None, @@ -2831,9 +2833,15 @@ fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> ..EMPTY_EXTRAS }, EntityKind::Sheep => sheep_extras(entity_id, e, alpha), - EntityKind::Villager => villager_extras(e), - // Spider eyes overlay is always visible (slot 0). - EntityKind::Spider => EntityExtras { + EntityKind::Villager => villager_like_extras(e, &VILLAGER_TYPE_HAT), + EntityKind::ZombieVillager => villager_like_extras(e, &ZOMBIE_VILLAGER_TYPE_HAT), + EntityKind::Bogged => EntityExtras { + overlay_tints: SLOT0_TINTS, + variant_index: e.is_sheared as u32, + ..EMPTY_EXTRAS + }, + // Always-visible slot-0 overlay (spider eyes, drowned/stray clothing). + EntityKind::Spider | EntityKind::Drowned | EntityKind::Stray => EntityExtras { overlay_tints: SLOT0_TINTS, ..EMPTY_EXTRAS }, @@ -2925,6 +2933,8 @@ fn sheep_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> /// `.png.mcmeta` files under `textures/entity/villager/` (hardcoded — no /// resource-pack support). 0 = none, 1 = partial, 2 = full. const VILLAGER_TYPE_HAT: [u8; 7] = [2, 0, 0, 0, 2, 0, 0]; // desert, snow = full +// `zombie_villager/type/` ships no `.mcmeta` files at all. +const ZOMBIE_VILLAGER_TYPE_HAT: [u8; 7] = [0; 7]; const VILLAGER_PROFESSION_HAT: [u8; 15] = [ 0, // none 0, // armorer @@ -2945,14 +2955,15 @@ const VILLAGER_PROFESSION_HAT: [u8; 15] = [ /// Overlay slots: 0 = biome type (full model), 1 = biome type (no-hat model), /// 2 = profession, 3 = profession level. Mirrors vanilla -/// `VillagerProfessionLayer.submit`. -fn villager_extras(e: &crate::entity::LivingEntity) -> EntityExtras { +/// `VillagerProfessionLayer.submit`, shared by villager and zombie villager +/// (which differ only in their type-hat `.mcmeta` tables). +fn villager_like_extras(e: &crate::entity::LivingEntity, type_hat_table: &[u8; 7]) -> EntityExtras { use crate::entity::villager::VillagerProfession; let kind = e.villager_kind as usize; let profession = e.villager_profession as usize; - let type_hat = VILLAGER_TYPE_HAT[kind]; + let type_hat = type_hat_table[kind]; let prof_hat = VILLAGER_PROFESSION_HAT[profession]; let type_hat_visible = prof_hat == 0 || (prof_hat == 1 && type_hat != 2); diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index e44226de..03bf5fa8 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -39,6 +39,7 @@ pub struct LivingEntity { pub is_crouching: bool, pub on_ground: bool, pub wool_color: Option, + /// Sheep wool shorn / bogged mushrooms shorn. pub is_sheared: bool, pub cow_variant: u8, pub chicken_variant: u8, @@ -56,6 +57,8 @@ pub struct LivingEntity { /// Enderman screaming flag — raises the head and jitters the render /// position. pub is_creepy: bool, + /// Zombie-family conversion (drowning / villager cure) — body-yaw shake. + pub is_converting: bool, /// Witch drinking flag — swings the nose down toward the potion. pub witch_drinking: bool, pub villager_kind: VillagerKind, @@ -127,6 +130,7 @@ impl LivingEntity { prev_squish: 0.0, slime_size: 1, is_creepy: false, + is_converting: false, witch_drinking: false, villager_kind: VillagerKind::default(), villager_profession: VillagerProfession::default(), @@ -596,11 +600,43 @@ impl EntityStore { } pub fn set_baby(&mut self, id: i32, is_baby: bool) { - if let Some(entity) = self.living.get_mut(&id) { + if let Some(entity) = self.living.get_mut(&id) + // On bogged, entity-data index 16 is the sheared flag, not baby. + && entity.entity_type != EntityKind::Bogged + { entity.is_baby = is_baby; } } + pub fn set_bogged_sheared(&mut self, id: i32, sheared: bool) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::Bogged + { + entity.is_sheared = sheared; + } + } + + /// Zombie-family underwater conversion. + pub fn set_zombie_converting(&mut self, id: i32, converting: bool) { + if let Some(entity) = self.living.get_mut(&id) + && matches!( + entity.entity_type, + EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned + ) + { + entity.is_converting = converting; + } + } + + /// Zombie villager curing. + pub fn set_zombie_villager_converting(&mut self, id: i32, converting: bool) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == EntityKind::ZombieVillager + { + entity.is_converting = converting; + } + } + pub fn set_crouching(&mut self, id: i32, is_crouching: bool) { if let Some(entity) = self.living.get_mut(&id) { entity.is_crouching = is_crouching; @@ -664,7 +700,10 @@ impl EntityStore { level: u32, ) { if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Villager + && matches!( + entity.entity_type, + EntityKind::Villager | EntityKind::ZombieVillager + ) { entity.villager_kind = kind; entity.villager_profession = profession; @@ -833,5 +872,10 @@ pub fn is_living_mob(kind: &EntityKind) -> bool { | EntityKind::Enderman | EntityKind::Slime | EntityKind::Witch + | EntityKind::Husk + | EntityKind::Drowned + | EntityKind::ZombieVillager + | EntityKind::Stray + | EntityKind::Bogged ) } diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 99be7301..f66fc4db 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -443,12 +443,18 @@ pub fn handle_game_packet( is_crouching: matches!(pose, azalea_entity::Pose::Crouching), }); } + // Index 16 (Boolean) = baby flag / bogged sheared. Emit both; + // consumers filter by entity type. if item.index == 16 - && let azalea_entity::EntityDataValue::Boolean(is_baby) = &item.value + && let azalea_entity::EntityDataValue::Boolean(flag) = &item.value { let _ = event_tx.try_send(NetworkEvent::EntityBabyFlag { id: p.id.0, - is_baby: *is_baby, + is_baby: *flag, + }); + let _ = event_tx.try_send(NetworkEvent::BoggedSheared { + id: p.id.0, + sheared: *flag, }); } // Entity data index 16 = player score (1.21.4 protocol) @@ -535,6 +541,24 @@ pub fn handle_game_packet( ), }); } + // Index 18 (Boolean) = zombie-family underwater conversion. + if item.index == 18 + && let azalea_entity::EntityDataValue::Boolean(converting) = &item.value + { + let _ = event_tx.try_send(NetworkEvent::ZombieConverting { + id: p.id.0, + converting: *converting, + }); + } + // Index 19 (Boolean) = zombie villager curing. + if item.index == 19 + && let azalea_entity::EntityDataValue::Boolean(converting) = &item.value + { + let _ = event_tx.try_send(NetworkEvent::ZombieVillagerConverting { + id: p.id.0, + converting: *converting, + }); + } // Index 18 (Int) = villager unhappy counter / slime size. Emit // both; consumers filter by entity type. if item.index == 18 @@ -549,8 +573,9 @@ pub fn handle_game_packet( size: *value, }); } - // Index 19 on villagers = VillagerData (type/profession/level). - if item.index == 19 + // Index 19 on villagers / 20 on zombie villagers = VillagerData + // (type/profession/level). + if (item.index == 19 || item.index == 20) && let azalea_entity::EntityDataValue::VillagerData(data) = &item.value { let _ = event_tx.try_send(NetworkEvent::VillagerData { diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 3f090c5b..34c4ebac 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -321,6 +321,18 @@ pub enum NetworkEvent { id: i32, size: i32, }, + BoggedSheared { + id: i32, + sheared: bool, + }, + ZombieConverting { + id: i32, + converting: bool, + }, + ZombieVillagerConverting { + id: i32, + converting: bool, + }, VillagerData { id: i32, kind: VillagerKind, diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 9b54d0c2..361db923 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -545,31 +545,249 @@ pub fn bake_zombie_model() -> BakedEntityModel { bake_model(zombie_parts(), 64, 64) } -/// Baby zombie: adult mesh transformed per vanilla `BabyModelTransform` — head -/// scales 0.75, body/limbs 0.5, each pivot shifted so the feet stay grounded. -/// Per-part scaling is geometry-only, so UVs are untouched. +/// Vanilla `CubeDeformation` inflate applied mesh-wide (vanilla rebuilds +/// layer meshes at `createMesh(g)`): every cube grows by `g` on top of its +/// own deformation. +fn inflate(parts: &mut [EntityPart], g: f32) { + for part in parts { + for cube in &mut part.cubes { + cube.deformation += g; + } + } +} + +/// Vanilla `BabyZombieModel.createBodyLayer(g)`: a dedicated 64x64 baby mesh +/// with its own UVs, not a scaled adult. `g` inflates everything but the +/// head, whose second cube is a fixed 0.25 overlay. +fn baby_zombie_parts(g: f32) -> Vec { + let limb = |name: &str, pivot: Vec3, uv: (u32, u32), origin_y: f32, h: f32| { + vpart( + name, + None, + pivot, + vec![ModelCube { + deformation: g, + ..vbox(uv, (-1.0, origin_y, -1.0), (2.0, h, 2.0)) + }], + ) + }; + vec![ + vpart( + "body", + None, + Vec3::new(0.0, 17.5, 0.0), + vec![ModelCube { + deformation: g, + ..vbox((16, 16), (-2.0, -2.5, -1.0), (4.0, 5.0, 2.0)) + }], + ), + vpart( + "head", + None, + Vec3::new(0.0, 15.25, 0.0), + vec![ + vbox((3, 3), (-3.0, -6.25, -3.0), (6.0, 6.0, 6.0)), + ModelCube { + deformation: 0.25, + ..vbox((35, 3), (-3.0, -6.15, -3.0), (6.0, 6.0, 6.0)) + }, + ], + ), + limb("right_arm", Vec3::new(-3.0, 15.5, 0.0), (36, 16), -0.5, 5.0), + limb("left_arm", Vec3::new(3.0, 15.5, 0.0), (28, 16), -0.5, 5.0), + limb("right_leg", Vec3::new(-1.0, 20.0, 0.0), (8, 16), 0.0, 4.0), + limb("left_leg", Vec3::new(1.0, 20.0, 0.0), (0, 16), 0.0, 4.0), + ] +} + pub fn bake_baby_zombie_model() -> BakedEntityModel { - const HEAD_SCALE: f32 = 0.75; - const BODY_SCALE: f32 = 0.5; + bake_model(baby_zombie_parts(0.0), 64, 64) +} + +/// Husk: the zombie mesh under vanilla's `MeshTransformer.scaling(1.0625)` +/// (`ModelLayers.HUSK`). The baby husk is NOT scaled. +pub fn bake_husk_model() -> BakedEntityModel { + bake_scaled(zombie_parts(), 1.0625, 64) +} + +/// Vanilla `DrownedModel.createBodyLayer(g)`: the zombie mesh with the left +/// arm and leg given their own UV regions instead of mirrored ones. `g` 0.25 +/// is the clothing layer. +// TODO: swim pose and body pitch (`DrownedModel.setupAnim` swimAmount path) +// once a swim_amount ramp from the pose metadata exists. +pub fn bake_drowned_model(g: f32) -> BakedEntityModel { let mut parts = zombie_parts(); - let mut scales = Vec::with_capacity(parts.len()); - for part in &mut parts { - let (scale, lift) = if part.name == "head" { - (HEAD_SCALE, 16.0) - } else { - (BODY_SCALE, 24.0) - }; - part.offset = (part.offset + Vec3::new(0.0, lift, 0.0)) * scale; - scales.push(scale); + // humanoid_parts order: 3 = left_arm, 5 = left_leg. + parts[3].cubes = vec![vbox((32, 48), (-1.0, -2.0, -2.0), (4.0, 12.0, 4.0))]; + parts[5].cubes = vec![vbox((16, 48), (-2.0, 0.0, -2.0), (4.0, 12.0, 4.0))]; + inflate(&mut parts, g); + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyDrownedModel` delegates to `BabyZombieModel` — zombie UVs, +/// not the drowned left-limb remap. +pub fn bake_baby_drowned_outer_model() -> BakedEntityModel { + bake_model(baby_zombie_parts(0.25), 64, 64) +} + +/// Vanilla `ZombieVillagerModel`: villager-shaped 10-tall head (the nose is a +/// cube inside the head part) and jacketed body on zombie-animated limbs, +/// 64x64 sheet. NOT villager-scaled (`LayerDefinitions` applies no +/// `villagerLikeScale` here). +fn zombie_villager_parts() -> Vec { + vec![ + vpart( + "head", + None, + Vec3::ZERO, + vec![ + vbox((0, 0), (-4.0, -10.0, -4.0), (8.0, 10.0, 8.0)), + // Nose. + vbox((24, 0), (-1.0, -3.0, -6.0), (2.0, 4.0, 2.0)), + ], + ), + vpart( + "hat", + Some(0), + Vec3::ZERO, + vec![ModelCube { + deformation: 0.5, + ..vbox((32, 0), (-4.0, -10.0, -4.0), (8.0, 10.0, 8.0)) + }], + ), + EntityPart { + default_rotation: Vec3::new(-std::f32::consts::FRAC_PI_2, 0.0, 0.0), + ..vpart( + "hat_rim", + Some(1), + Vec3::ZERO, + vec![vbox((30, 47), (-8.0, -8.0, -6.0), (16.0, 16.0, 1.0))], + ) + }, + vpart( + "body", + None, + Vec3::ZERO, + vec![ + vbox((16, 20), (-4.0, 0.0, -3.0), (8.0, 12.0, 6.0)), + // Jacket. + ModelCube { + deformation: 0.05, + ..vbox((0, 38), (-4.0, 0.0, -3.0), (8.0, 20.0, 6.0)) + }, + ], + ), + vpart( + "right_arm", + None, + Vec3::new(-5.0, 2.0, 0.0), + vec![vbox((44, 22), (-3.0, -2.0, -2.0), (4.0, 12.0, 4.0))], + ), + vpart( + "left_arm", + None, + Vec3::new(5.0, 2.0, 0.0), + vec![ModelCube { + mirror: true, + ..vbox((44, 22), (-1.0, -2.0, -2.0), (4.0, 12.0, 4.0)) + }], + ), + vpart( + "right_leg", + None, + Vec3::new(-2.0, 12.0, 0.0), + vec![vbox((0, 22), (-2.0, 0.0, -2.0), (4.0, 12.0, 4.0))], + ), + vpart( + "left_leg", + None, + Vec3::new(2.0, 12.0, 0.0), + vec![ModelCube { + mirror: true, + ..vbox((0, 22), (-2.0, 0.0, -2.0), (4.0, 12.0, 4.0)) + }], + ), + ] +} + +pub fn bake_zombie_villager_model(no_hat: bool) -> BakedEntityModel { + let mut parts = zombie_villager_parts(); + if no_hat { + clear_head_subtree(&mut parts); } - let mut model = bake_model(parts, 64, 64); - model.part_scales = scales; - model + bake_model(parts, 64, 64) +} + +/// Vanilla `BabyZombieVillagerModel`: hand-authored 64x64 baby mesh with real +/// arm/leg parts (humanoid-animated, unlike the crossed-arm baby villager). +/// The hat_rim hangs off the head, not the hat. +fn baby_zombie_villager_parts() -> Vec { + let limb = |name: &str, pivot: Vec3, uv: (u32, u32), h: f32| { + vpart( + name, + None, + pivot, + vec![vbox(uv, (-1.0, -0.5, -1.0), (2.0, h, 2.0))], + ) + }; + vec![ + vpart( + "body", + None, + Vec3::new(0.0, 18.75, 0.0), + vec![ + vbox((0, 15), (-2.0, -2.75, -1.5), (4.0, 5.0, 3.0)), + ModelCube { + deformation: 0.1, + ..vbox((16, 22), (-2.0, -2.75, -1.5), (4.0, 6.0, 3.0)) + }, + ], + ), + vpart( + "head", + None, + Vec3::new(0.0, 16.0, 0.0), + vec![vbox((0, 0), (-4.0, -8.0, -3.5), (8.0, 8.0, 7.0))], + ), + vpart( + "hat", + Some(1), + Vec3::new(0.0, -4.0, 0.0), + vec![ModelCube { + deformation: 0.3, + ..vbox((0, 31), (-4.0, -4.0, -3.5), (8.0, 8.0, 7.0)) + }], + ), + vpart( + "hat_rim", + Some(1), + Vec3::new(0.0, -4.5, 0.0), + vec![vbox((0, 46), (-7.0, -0.5, -6.0), (14.0, 1.0, 12.0))], + ), + vpart( + "nose", + Some(1), + Vec3::new(0.0, -1.0, -4.0), + vec![vbox((23, 0), (-1.0, -1.0, -0.5), (2.0, 2.0, 1.0))], + ), + limb("right_arm", Vec3::new(-3.0, 15.5, 0.0), (24, 15), 5.0), + limb("left_arm", Vec3::new(3.0, 15.5, 0.0), (16, 15), 5.0), + limb("right_leg", Vec3::new(-1.0, 21.5, 0.0), (8, 23), 3.0), + limb("left_leg", Vec3::new(1.0, 21.5, 0.0), (0, 23), 3.0), + ] +} + +pub fn bake_baby_zombie_villager_model(no_hat: bool) -> BakedEntityModel { + let mut parts = baby_zombie_villager_parts(); + if no_hat { + clear_head_subtree(&mut parts); + } + bake_model(parts, 64, 64) } /// Skeleton: humanoid layout with thin 2×12×2 limbs, 64×32 sheet /// (`SkeletonModel.createDefaultSkeletonMesh`). -pub fn bake_skeleton_model() -> BakedEntityModel { +fn skeleton_parts() -> Vec { let arm = ModelCube { origin: Vec3::new(-1.0, -2.0, -1.0), size: Vec3::new(2.0, 12.0, 2.0), @@ -584,7 +802,93 @@ pub fn bake_skeleton_model() -> BakedEntityModel { deformation: 0.0, mirror: false, }; - bake_model(humanoid_parts(arm, leg, 2.0), 64, 32) + humanoid_parts(arm, leg, 2.0) +} + +pub fn bake_skeleton_model() -> BakedEntityModel { + bake_model(skeleton_parts(), 64, 32) +} + +/// Stray/bogged clothing (`SkeletonClothingLayer`): the thick humanoid mesh +/// inflated by `g`, worn over the thin skeleton bones. 64×32 sheet. +fn skeleton_clothing_parts(g: f32) -> Vec { + let mut parts = zombie_parts(); + inflate(&mut parts, g); + parts +} + +pub fn bake_skeleton_clothing_model(g: f32) -> BakedEntityModel { + bake_model(skeleton_clothing_parts(g), 64, 32) +} + +/// The six mushroom quads on a bogged's head, three crossed pairs at 45/135 +/// degrees (vanilla `BoggedModel`; the empty `mushrooms` container part is +/// flattened away, angle literals are pi/4, 3pi/4 and -pi/2 rounded to f32). +/// Sheared keeps the parts with no cubes so every bogged model shares one +/// part order. +fn mushroom_parts(sheared: bool) -> Vec { + use std::f32::consts::{FRAC_PI_2, FRAC_PI_4}; + // (name, first index, texOffs, origin y, pivot, laid flat on the back) + let pairs = [ + ( + "red_mushroom", + 1, + (50, 16), + -3.0, + Vec3::new(3.0, -8.0, 3.0), + false, + ), + ( + "brown_mushroom", + 1, + (50, 22), + -3.0, + Vec3::new(-3.0, -8.0, -3.0), + false, + ), + ( + "brown_mushroom", + 3, + (50, 28), + -4.0, + Vec3::new(-2.0, -1.0, 4.0), + true, + ), + ]; + let mut parts = Vec::with_capacity(6); + for (name, first, uv, origin_y, pivot, flat) in pairs { + for (i, angle) in [FRAC_PI_4, 3.0 * FRAC_PI_4].into_iter().enumerate() { + let rot = if flat { + Vec3::new(-FRAC_PI_2, 0.0, angle) + } else { + Vec3::new(0.0, angle, 0.0) + }; + let cubes = if sheared { + vec![] + } else { + vec![vbox(uv, (-3.0, origin_y, 0.0), (6.0, 4.0, 0.0))] + }; + parts.push(EntityPart { + default_rotation: rot, + ..vpart(&format!("{name}_{}", first + i), Some(0), pivot, cubes) + }); + } + } + parts +} + +pub fn bake_bogged_model(sheared: bool) -> BakedEntityModel { + let mut parts = skeleton_parts(); + parts.extend(mushroom_parts(sheared)); + bake_model(parts, 64, 32) +} + +/// Bogged clothing padded with the empty mushroom parts (overlay part order +/// must match the base's). +pub fn bake_bogged_clothing_model() -> BakedEntityModel { + let mut parts = skeleton_clothing_parts(0.2); + parts.extend(mushroom_parts(true)); + bake_model(parts, 64, 32) } /// Creeper: head + upright body + four legs, animated as a quadruped @@ -1453,18 +1757,17 @@ fn clear_head_subtree(parts: &mut [EntityPart]) { /// grounded (`PartPose.scaled(f).translated(0, 24.016 * (1 - f), 0)`). const VILLAGER_SCALE: f32 = 0.9375; -/// Bakes with `VILLAGER_SCALE` applied to the roots only: the transform chain -/// propagates a root's scale to child pivots and geometry like vanilla's pose -/// stack (children would double-scale). -fn bake_villager_like(mut parts: Vec, tex_h: u32) -> BakedEntityModel { +/// Bakes with vanilla's `MeshTransformer.scaling(factor)` applied to the +/// roots only: the transform chain propagates a root's scale to child pivots +/// and geometry like vanilla's pose stack (children would double-scale). +fn bake_scaled(mut parts: Vec, factor: f32, tex_h: u32) -> BakedEntityModel { let mut scales = Vec::with_capacity(parts.len()); for part in parts.iter_mut() { let is_root = part.parent.is_none(); if is_root { - part.offset = - part.offset * VILLAGER_SCALE + Vec3::new(0.0, 24.016 * (1.0 - VILLAGER_SCALE), 0.0); + part.offset = part.offset * factor + Vec3::new(0.0, 24.016 * (1.0 - factor), 0.0); } - scales.push(if is_root { VILLAGER_SCALE } else { 1.0 }); + scales.push(if is_root { factor } else { 1.0 }); } let mut model = bake_model(parts, 64, tex_h); model.part_scales = scales; @@ -1476,7 +1779,7 @@ pub fn bake_villager_model(no_hat: bool) -> BakedEntityModel { if no_hat { clear_head_subtree(&mut parts); } - bake_villager_like(parts, 64) + bake_scaled(parts, VILLAGER_SCALE, 64) } pub fn bake_baby_villager_model(no_hat: bool) -> BakedEntityModel { @@ -1646,7 +1949,7 @@ fn witch_parts() -> Vec { } pub fn bake_witch_model() -> BakedEntityModel { - bake_villager_like(witch_parts(), 128) + bake_scaled(witch_parts(), VILLAGER_SCALE, 128) } pub fn compute_humanoid_anim( diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 436dd342..07611afd 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -59,6 +59,8 @@ pub struct EntityRenderInfo { pub flap_speed: f32, /// Enderman screaming state — raises the head. pub is_creepy: bool, + /// Zombie-family conversion — shakes the whole body. + pub is_converting: bool, /// Witch drinking (vanilla `isHoldingItem`). pub is_holding_item: bool, /// Witch per-entity nose-wobble rate, resolved from the entity id. @@ -310,7 +312,45 @@ fn mob_definitions() -> Vec { &[&["minecraft/textures/entity/sheep/sheep_wool_baby.png"]]; const PLAYER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/player/wide/steve.png"]]; const ZOMBIE_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/zombie.png"]]; + const ZOMBIE_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/zombie_baby.png"]]; + const HUSK_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/husk.png"]]; + const HUSK_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/husk_baby.png"]]; + const DROWNED_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/drowned.png"]]; + const DROWNED_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/drowned_baby.png"]]; + const DROWNED_OUTER_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/zombie/drowned_outer_layer.png"]]; + const DROWNED_OUTER_BABY_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/zombie/drowned_outer_layer_baby.png"]]; + // One single-fallback texture entry per name under an entity texture dir. + macro_rules! tex_table { + ($dir:literal: $($name:literal),+ $(,)?) => { + &[$(&[concat!("minecraft/textures/entity/", $dir, "/", $name, ".png")]),+] + }; + } + const ZOMBIE_VILLAGER_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/zombie_villager/zombie_villager.png"]]; + const ZOMBIE_VILLAGER_BABY_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/zombie_villager/zombie_villager_baby.png"]]; + // Indexed by the builtin VillagerKind registry order. + const ZOMBIE_VILLAGER_TYPE_TEX: &[&[&str]] = tex_table!("zombie_villager/type": + "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); + const ZOMBIE_VILLAGER_BABY_TYPE_TEX: &[&[&str]] = tex_table!("zombie_villager/baby": + "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); + // Indexed by VillagerProfession registry order minus one ("none" has no + // texture). + const ZOMBIE_VILLAGER_PROFESSION_TEX: &[&[&str]] = tex_table!("zombie_villager/profession": + "armorer", "butcher", "cartographer", "cleric", "farmer", "fisherman", "fletcher", + "leatherworker", "librarian", "mason", "nitwit", "shepherd", "toolsmith", "weaponsmith"); + // Indexed by profession level 1-5 minus one. + const ZOMBIE_VILLAGER_LEVEL_TEX: &[&[&str]] = tex_table!("zombie_villager/profession_level": + "stone", "iron", "gold", "emerald", "diamond"); const SKELETON_TEX: &[&[&str]] = &[&["minecraft/textures/entity/skeleton/skeleton.png"]]; + const STRAY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/skeleton/stray.png"]]; + const STRAY_OVERLAY_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/skeleton/stray_overlay.png"]]; + const BOGGED_TEX: &[&[&str]] = &[&["minecraft/textures/entity/skeleton/bogged.png"]]; + const BOGGED_OVERLAY_TEX: &[&[&str]] = + &[&["minecraft/textures/entity/skeleton/bogged_overlay.png"]]; const CREEPER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/creeper/creeper.png"]]; const CREEPER_ARMOR_TEX: &[&[&str]] = &[&["minecraft/textures/entity/creeper/creeper_armor.png"]]; @@ -325,50 +365,18 @@ fn mob_definitions() -> Vec { const VILLAGER_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/villager/villager_baby.png"]]; // Indexed by the builtin VillagerKind registry order. - const VILLAGER_TYPE_TEX: &[&[&str]] = &[ - &["minecraft/textures/entity/villager/type/desert.png"], - &["minecraft/textures/entity/villager/type/jungle.png"], - &["minecraft/textures/entity/villager/type/plains.png"], - &["minecraft/textures/entity/villager/type/savanna.png"], - &["minecraft/textures/entity/villager/type/snow.png"], - &["minecraft/textures/entity/villager/type/swamp.png"], - &["minecraft/textures/entity/villager/type/taiga.png"], - ]; - const VILLAGER_BABY_TYPE_TEX: &[&[&str]] = &[ - &["minecraft/textures/entity/villager/baby/desert.png"], - &["minecraft/textures/entity/villager/baby/jungle.png"], - &["minecraft/textures/entity/villager/baby/plains.png"], - &["minecraft/textures/entity/villager/baby/savanna.png"], - &["minecraft/textures/entity/villager/baby/snow.png"], - &["minecraft/textures/entity/villager/baby/swamp.png"], - &["minecraft/textures/entity/villager/baby/taiga.png"], - ]; + const VILLAGER_TYPE_TEX: &[&[&str]] = tex_table!("villager/type": + "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); + const VILLAGER_BABY_TYPE_TEX: &[&[&str]] = tex_table!("villager/baby": + "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); // Indexed by VillagerProfession registry order minus one ("none" has no // texture). - const VILLAGER_PROFESSION_TEX: &[&[&str]] = &[ - &["minecraft/textures/entity/villager/profession/armorer.png"], - &["minecraft/textures/entity/villager/profession/butcher.png"], - &["minecraft/textures/entity/villager/profession/cartographer.png"], - &["minecraft/textures/entity/villager/profession/cleric.png"], - &["minecraft/textures/entity/villager/profession/farmer.png"], - &["minecraft/textures/entity/villager/profession/fisherman.png"], - &["minecraft/textures/entity/villager/profession/fletcher.png"], - &["minecraft/textures/entity/villager/profession/leatherworker.png"], - &["minecraft/textures/entity/villager/profession/librarian.png"], - &["minecraft/textures/entity/villager/profession/mason.png"], - &["minecraft/textures/entity/villager/profession/nitwit.png"], - &["minecraft/textures/entity/villager/profession/shepherd.png"], - &["minecraft/textures/entity/villager/profession/toolsmith.png"], - &["minecraft/textures/entity/villager/profession/weaponsmith.png"], - ]; + const VILLAGER_PROFESSION_TEX: &[&[&str]] = tex_table!("villager/profession": + "armorer", "butcher", "cartographer", "cleric", "farmer", "fisherman", "fletcher", + "leatherworker", "librarian", "mason", "nitwit", "shepherd", "toolsmith", "weaponsmith"); // Indexed by profession level 1-5 minus one. - const VILLAGER_LEVEL_TEX: &[&[&str]] = &[ - &["minecraft/textures/entity/villager/profession_level/stone.png"], - &["minecraft/textures/entity/villager/profession_level/iron.png"], - &["minecraft/textures/entity/villager/profession_level/gold.png"], - &["minecraft/textures/entity/villager/profession_level/emerald.png"], - &["minecraft/textures/entity/villager/profession_level/diamond.png"], - ]; + const VILLAGER_LEVEL_TEX: &[&[&str]] = tex_table!("villager/profession_level": + "stone", "iron", "gold", "emerald", "diamond"); // Base and baby models, plus opaque overlays (sheep wool), are all Opaque. fn opaque( @@ -384,6 +392,36 @@ fn mob_definitions() -> Vec { } } + // Cutout layers over a villager-like base skin (vanilla + // `VillagerProfessionLayer`, shared by villager and zombie villager): + // slot 0 = biome type, slot 1 = biome type on the no-hat model (used when + // the profession texture brings its own hat), slot 2 = profession, slot 3 + // = profession level badge. entity_extras gates slot 0 xor 1 and picks + // each slot's texture variant. The `bake` parameter takes `no_hat`. + fn villager_like_overlays( + bake: fn(bool) -> BakedEntityModel, + type_tex: &'static [&'static [&'static str]], + profession_tex: &'static [&'static [&'static str]], + level_tex: &'static [&'static [&'static str]], + ) -> Vec { + vec![ + opaque(bake(false), type_tex, 64), + opaque(bake(true), type_tex, 64), + opaque(bake(false), profession_tex, 64), + opaque(bake(false), level_tex, 64), + ] + } + + fn villager_like_baby_overlays( + bake: fn(bool) -> BakedEntityModel, + type_tex: &'static [&'static [&'static str]], + ) -> Vec { + vec![ + opaque(bake(false), type_tex, 64), + opaque(bake(true), type_tex, 64), + ] + } + vec![ MobDef { kind: EntityKind::Pig, @@ -474,12 +512,72 @@ fn mob_definitions() -> Vec { adult: vec![opaque(entity_model::bake_zombie_model(), ZOMBIE_TEX, 64)], baby: Some(opaque( entity_model::bake_baby_zombie_model(), - ZOMBIE_TEX, + ZOMBIE_BABY_TEX, + 64, + )), + adult_overlays: vec![], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Husk, + anim: AnimationType::Zombie, + adult: vec![opaque(entity_model::bake_husk_model(), HUSK_TEX, 64)], + baby: Some(opaque( + entity_model::bake_baby_zombie_model(), + HUSK_BABY_TEX, 64, )), adult_overlays: vec![], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Drowned, + anim: AnimationType::Zombie, + adult: vec![opaque( + entity_model::bake_drowned_model(0.0), + DROWNED_TEX, + 64, + )], + baby: Some(opaque( + entity_model::bake_baby_zombie_model(), + DROWNED_BABY_TEX, + 64, + )), + adult_overlays: vec![opaque( + entity_model::bake_drowned_model(0.25), + DROWNED_OUTER_TEX, + 64, + )], + baby_overlays: vec![opaque( + entity_model::bake_baby_drowned_outer_model(), + DROWNED_OUTER_BABY_TEX, + 64, + )], + }, + MobDef { + kind: EntityKind::ZombieVillager, + anim: AnimationType::Zombie, + adult: vec![opaque( + entity_model::bake_zombie_villager_model(false), + ZOMBIE_VILLAGER_TEX, + 64, + )], + baby: Some(opaque( + entity_model::bake_baby_zombie_villager_model(false), + ZOMBIE_VILLAGER_BABY_TEX, + 64, + )), + adult_overlays: villager_like_overlays( + entity_model::bake_zombie_villager_model, + ZOMBIE_VILLAGER_TYPE_TEX, + ZOMBIE_VILLAGER_PROFESSION_TEX, + ZOMBIE_VILLAGER_LEVEL_TEX, + ), + baby_overlays: villager_like_baby_overlays( + entity_model::bake_baby_zombie_villager_model, + ZOMBIE_VILLAGER_BABY_TYPE_TEX, + ), + }, MobDef { kind: EntityKind::Skeleton, anim: AnimationType::Skeleton, @@ -492,6 +590,34 @@ fn mob_definitions() -> Vec { adult_overlays: vec![], baby_overlays: vec![], }, + MobDef { + kind: EntityKind::Stray, + anim: AnimationType::Skeleton, + adult: vec![opaque(entity_model::bake_skeleton_model(), STRAY_TEX, 64)], + baby: None, + adult_overlays: vec![opaque( + entity_model::bake_skeleton_clothing_model(0.25), + STRAY_OVERLAY_TEX, + 64, + )], + baby_overlays: vec![], + }, + MobDef { + kind: EntityKind::Bogged, + anim: AnimationType::Skeleton, + // Variant 0 = mushrooms, 1 = sheared (empty mushroom parts). + adult: vec![ + opaque(entity_model::bake_bogged_model(false), BOGGED_TEX, 64), + opaque(entity_model::bake_bogged_model(true), BOGGED_TEX, 64), + ], + baby: None, + adult_overlays: vec![opaque( + entity_model::bake_bogged_clothing_model(), + BOGGED_OVERLAY_TEX, + 64, + )], + baby_overlays: vec![], + }, MobDef { kind: EntityKind::Creeper, anim: AnimationType::Quadruped, @@ -520,47 +646,18 @@ fn mob_definitions() -> Vec { VILLAGER_BABY_TEX, 64, )), - // Cutout layers over the base skin (vanilla `VillagerProfessionLayer`): - // slot 0 = biome type, slot 1 = biome type on the no-hat model (used - // when the profession texture brings its own hat), slot 2 = - // profession, slot 3 = profession level badge. entity_extras gates - // slot 0 xor 1 and picks each slot's texture variant. // TODO: CustomHeadLayer (worn head items) and CrossedArmsItemLayer // (held item) need a held-item layer first. - adult_overlays: vec![ - opaque( - entity_model::bake_villager_model(false), - VILLAGER_TYPE_TEX, - 64, - ), - opaque( - entity_model::bake_villager_model(true), - VILLAGER_TYPE_TEX, - 64, - ), - opaque( - entity_model::bake_villager_model(false), - VILLAGER_PROFESSION_TEX, - 64, - ), - opaque( - entity_model::bake_villager_model(false), - VILLAGER_LEVEL_TEX, - 64, - ), - ], - baby_overlays: vec![ - opaque( - entity_model::bake_baby_villager_model(false), - VILLAGER_BABY_TYPE_TEX, - 64, - ), - opaque( - entity_model::bake_baby_villager_model(true), - VILLAGER_BABY_TYPE_TEX, - 64, - ), - ], + adult_overlays: villager_like_overlays( + entity_model::bake_villager_model, + VILLAGER_TYPE_TEX, + VILLAGER_PROFESSION_TEX, + VILLAGER_LEVEL_TEX, + ), + baby_overlays: villager_like_baby_overlays( + entity_model::bake_baby_villager_model, + VILLAGER_BABY_TYPE_TEX, + ), }, MobDef { kind: EntityKind::Spider, @@ -998,8 +1095,17 @@ impl EntityRenderer { /// The translation is anchor-relative, subtracted in f64 (see /// `Camera::anchor`). fn entity_matrix(info: &EntityRenderInfo, anchor: glam::DVec3) -> glam::Mat4 { + let mut body_y_rot_deg = info.body_y_rot_deg; + if info.is_converting { + // Vanilla `setupRotations` isShaking: a per-tick body-yaw jitter. + // The addend is a radians-magnitude value applied to degrees — + // vanilla's own unit mixing, ported literally (~±1.26 degrees). + // Applied here, after the head-vs-body split, so the head shakes + // with the body like vanilla. + body_y_rot_deg += (info.age_in_ticks.floor() * 3.25).cos() * std::f32::consts::PI * 0.4; + } let base = glam::Mat4::from_translation((*info.position - anchor).as_vec3()) - * glam::Mat4::from_rotation_y((180.0 - info.body_y_rot_deg).to_radians()); + * glam::Mat4::from_rotation_y((180.0 - body_y_rot_deg).to_radians()); info.body_transform.map_or(base, |m| base * m) } @@ -1435,8 +1541,11 @@ fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { EntityKind::Cow => (0.9, 1.4), EntityKind::Chicken => (0.4, 0.7), EntityKind::Sheep => (0.9, 1.3), - EntityKind::Zombie => (0.6, 1.95), - EntityKind::Skeleton => (0.6, 1.99), + EntityKind::Zombie + | EntityKind::Husk + | EntityKind::Drowned + | EntityKind::ZombieVillager => (0.6, 1.95), + EntityKind::Skeleton | EntityKind::Stray | EntityKind::Bogged => (0.6, 1.99), EntityKind::Creeper => (0.6, 1.7), EntityKind::Spider => (1.4, 0.9), EntityKind::Villager => (0.6, 1.95), From d23b20994b62e3e7a068524d283db0790c5ccab8 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 10:28:22 +0100 Subject: [PATCH 04/19] cleanup --- pomme-client/src/app/core.rs | 16 ++-- pomme-client/src/app/phases/in_game.rs | 33 +++----- pomme-client/src/entity/mod.rs | 42 +++++----- pomme-client/src/net/handler.rs | 76 ++++++++++++++++--- pomme-client/src/net/mod.rs | 17 +++-- pomme-client/src/renderer/entity_model.rs | 37 +++++---- .../src/renderer/pipelines/entity_renderer.rs | 42 +++++++++- 7 files changed, 185 insertions(+), 78 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index ee123730..ef9bcb13 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -980,6 +980,15 @@ impl AppCore { .update_living_rotation(id, y_rot_deg, x_rot_deg); game.item_entity_store.move_delta(id, dx, dy, dz, on_ground); } + NetworkEvent::EntityRotated { + id, + y_rot_deg, + x_rot_deg, + on_ground, + } => { + game.entity_store + .rotate_living(id, y_rot_deg, x_rot_deg, on_ground); + } NetworkEvent::EntityMotion { id, velocity } => { game.item_entity_store.set_motion(id, velocity); } @@ -1103,11 +1112,8 @@ impl AppCore { ); } } - NetworkEvent::CowVariant { id, variant } => { - game.entity_store.set_cow_variant(id, variant); - } - NetworkEvent::ChickenVariant { id, variant } => { - game.entity_store.set_chicken_variant(id, variant); + NetworkEvent::EntityVariant { id, variant } => { + game.entity_store.set_variant(id, variant); } NetworkEvent::VillagerData { id, diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 0bfa8fdf..7c53795f 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -2777,6 +2777,7 @@ fn build_item_render_infos( infos } +#[derive(Default)] struct EntityExtras { variant_index: u32, overlay_tints: [Option<[f32; 4]>; MAX_OVERLAYS], @@ -2787,16 +2788,6 @@ struct EntityExtras { flap_speed: f32, } -const EMPTY_EXTRAS: EntityExtras = EntityExtras { - variant_index: 0, - overlay_tints: [None; MAX_OVERLAYS], - overlay_variants: [0; MAX_OVERLAYS], - head_y_offset: 0.0, - head_x_rot_deg_override: None, - flap: 0.0, - flap_speed: 0.0, -}; - /// Only the first overlay slot visible, untinted. const SLOT0_TINTS: [Option<[f32; 4]>; MAX_OVERLAYS] = { let mut tints = [None; MAX_OVERLAYS]; @@ -2807,28 +2798,28 @@ const SLOT0_TINTS: [Option<[f32; 4]>; MAX_OVERLAYS] = { fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> EntityExtras { match e.entity_type { EntityKind::Cow => EntityExtras { - variant_index: e.cow_variant as u32, - ..EMPTY_EXTRAS + variant_index: e.variant, + ..Default::default() }, EntityKind::Chicken => EntityExtras { - variant_index: e.chicken_variant as u32, - flap: e.prev_flap + (e.flap - e.prev_flap) * alpha, - flap_speed: e.prev_flap_speed + (e.flap_speed - e.prev_flap_speed) * alpha, - ..EMPTY_EXTRAS + variant_index: e.variant, + flap: e.prev_flap.lerp(e.flap, alpha), + flap_speed: e.prev_flap_speed.lerp(e.flap_speed, alpha), + ..Default::default() }, EntityKind::Sheep => sheep_extras(entity_id, e, alpha), EntityKind::Villager => villager_extras(e), // Spider eyes overlay is always visible (slot 0). EntityKind::Spider => EntityExtras { overlay_tints: SLOT0_TINTS, - ..EMPTY_EXTRAS + ..Default::default() }, // Charged-creeper aura overlay (slot 0) only when powered. EntityKind::Creeper if e.powered => EntityExtras { overlay_tints: SLOT0_TINTS, - ..EMPTY_EXTRAS + ..Default::default() }, - _ => EMPTY_EXTRAS, + _ => EntityExtras::default(), } } @@ -2866,7 +2857,7 @@ fn sheep_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> overlay_tints, head_y_offset, head_x_rot_deg_override, - ..EMPTY_EXTRAS + ..Default::default() } } @@ -2924,7 +2915,7 @@ fn villager_extras(e: &crate::entity::LivingEntity) -> EntityExtras { (profession as u32).saturating_sub(1), e.villager_level.clamp(1, 5) - 1, ], - ..EMPTY_EXTRAS + ..Default::default() } } diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 3badf509..bc5949fa 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -40,8 +40,9 @@ pub struct LivingEntity { pub on_ground: bool, pub wool_color: Option, pub is_sheared: bool, - pub cow_variant: u8, - pub chicken_variant: u8, + /// Registry/wire variant slot; meaning is per-kind (pool index for + /// cow/chicken). Normalized in `EntityStore::set_variant`. + pub variant: u32, /// Chicken wing-flap state (vanilla `Chicken.aiStep`): `flap` is the /// unbounded wing-cycle phase, `flap_speed` the 0..1 amplitude. pub flap: f32, @@ -105,8 +106,7 @@ impl LivingEntity { on_ground: false, wool_color: None, is_sheared: false, - cow_variant: 0, - chicken_variant: 0, + variant: 0, flap: 0.0, prev_flap: 0.0, flap_speed: 0.0, @@ -584,19 +584,11 @@ impl EntityStore { } } - pub fn set_cow_variant(&mut self, id: i32, variant: u8) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Cow - { - entity.cow_variant = variant; - } - } - - pub fn set_chicken_variant(&mut self, id: i32, variant: u8) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Chicken - { - entity.chicken_variant = variant; + pub fn set_variant(&mut self, id: i32, raw: u32) { + if let Some(entity) = self.living.get_mut(&id) { + // Cow and chicken indices are pre-resolved by the net handler; + // per-kind normalization arms arrive with their mobs. + entity.variant = raw; } } @@ -678,6 +670,18 @@ impl EntityStore { } } + /// Rotation-only movement (`MoveEntityRot`): vanilla applies the new + /// rotation via `moveOrInterpolateTo` and sets onGround; position + /// interpolation is untouched (extending an in-flight lerp matches the + /// `EntityMovedRotated` convention rather than vanilla's fixed re-target). + pub fn rotate_living(&mut self, id: i32, y_rot_deg: f32, x_rot_deg: f32, on_ground: bool) { + if let Some(entity) = self.living.get_mut(&id) { + entity.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); + entity.interp_steps = entity.interp_steps.max(INTERPOLATION_STEPS); + entity.on_ground = on_ground; + } + } + pub fn update_head_rotation(&mut self, id: i32, head_y_rot_deg: f32) { if let Some(entity) = self.living.get_mut(&id) { entity.interp_head_y_rot_deg = head_y_rot_deg; @@ -712,7 +716,9 @@ impl EntityStore { &mut entity.walk_anim_speed, &mut entity.prev_walk_anim_speed, ); - entity.tick_flap(); + if entity.entity_type == EntityKind::Chicken { + entity.tick_flap(); + } entity.prev_eat_anim_tick = entity.eat_anim_tick; if entity.eat_anim_tick > 0 { entity.eat_anim_tick -= 1; diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 26db9681..707c1b65 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -8,6 +8,7 @@ use super::NetworkEvent; use super::commands::{CommandTree, SharedCommandTree}; use super::sender::PacketSender; use crate::entity::components::Position; +use crate::renderer::pipelines::entity_renderer::{CHICKEN_VARIANT_ORDER, COW_VARIANT_ORDER}; use crate::ui::text::format_text_spans; /// Dimension info from a login/respawn registry entry. `has_skylight` lives @@ -379,6 +380,15 @@ pub fn handle_game_packet( on_ground: p.on_ground, }); } + ClientboundGamePacket::MoveEntityRot(p) => { + let look: azalea_entity::LookDirection = p.look_direction.into(); + let _ = event_tx.try_send(NetworkEvent::EntityRotated { + id: p.entity_id.0, + y_rot_deg: look.y_rot(), + x_rot_deg: look.x_rot(), + on_ground: p.on_ground, + }); + } ClientboundGamePacket::TeleportEntity(p) => { let delta = p.change.delta; let _ = event_tx.try_send(NetworkEvent::EntityTeleported { @@ -498,17 +508,19 @@ pub fn handle_game_packet( let _ = event_tx.try_send(NetworkEvent::EntityCustomName { id: p.id.0, name }); } // Index 18 on cows = CowVariant Holder. + // TODO: resolve datapack entries via the synced registry NBT + // like chicken_variant_index does. if item.index == 18 && let azalea_entity::EntityDataValue::CowVariant(variant) = &item.value { use azalea_registry::DataRegistry; - let _ = event_tx.try_send(NetworkEvent::CowVariant { + let _ = event_tx.try_send(NetworkEvent::EntityVariant { id: p.id.0, variant: variant_index( registry_holder, "minecraft:cow_variant", variant.protocol_id(), - &["temperate", "cold", "warm"], + COW_VARIANT_ORDER, ), }); } @@ -517,14 +529,9 @@ pub fn handle_game_packet( && let azalea_entity::EntityDataValue::ChickenVariant(variant) = &item.value { use azalea_registry::DataRegistry; - let _ = event_tx.try_send(NetworkEvent::ChickenVariant { + let _ = event_tx.try_send(NetworkEvent::EntityVariant { id: p.id.0, - variant: variant_index( - registry_holder, - "minecraft:chicken_variant", - variant.protocol_id(), - &["temperate", "warm", "cold"], - ), + variant: chicken_variant_index(registry_holder, variant.protocol_id()), }); } // Index 18 on villagers = unhappy counter (head-shake while > 0). @@ -695,20 +702,65 @@ fn send_chat(event_tx: &Sender, message: &azalea_chat::FormattedTe } /// Resolves a variant registry holder id to its index in `order` — the -/// renderer's texture-variant order for that mob. Unknown ids fall back to 0. +/// renderer's variant-pool order for that mob. Unknown ids fall back to 0. fn variant_index( registry_holder: &RegistryHolder, registry: &str, protocol_id: u32, order: &[&str], -) -> u8 { +) -> u32 { registry_holder .protocol_id_to_identifier( azalea_registry::identifier::Identifier::new(registry), protocol_id, ) .and_then(|id| order.iter().position(|p| *p == id.path())) - .unwrap_or(0) as u8 + .unwrap_or(0) as u32 +} + +/// Chicken ids resolve by path against CHICKEN_VARIANT_ORDER; datapack +/// entries fall back to their synced ModelAndTexture NBT — a known asset_id +/// picks the exact pool slot, otherwise the model field picks the mesh +/// (normal -> temperate slot, cold -> cold slot). +fn chicken_variant_index(registry_holder: &RegistryHolder, protocol_id: u32) -> u32 { + let Some((ident, nbt)) = registry_holder + .extra + .get(&azalea_registry::identifier::Identifier::new( + "minecraft:chicken_variant", + )) + .and_then(|r| r.map.get_index(protocol_id as usize)) + else { + return 0; + }; + let order_pos = |name: &str| { + CHICKEN_VARIANT_ORDER + .iter() + .position(|p| *p == name) + .map(|i| i as u32) + }; + if let Some(i) = order_pos(ident.path()) { + return i; + } + if let Some(asset) = nbt.string("asset_id").map(|s| s.to_str().into_owned()) + && let Some(i) = CHICKEN_VARIANT_ORDER + .iter() + .position(|p| { + asset.strip_prefix("minecraft:").unwrap_or(asset.as_str()) + == format!("entity/chicken/chicken_{p}") + }) + .map(|i| i as u32) + { + return i; + } + // "normal" is the codec default when the model field is absent. + match nbt + .string("model") + .map(|s| s.to_str().into_owned()) + .as_deref() + { + Some("cold") => order_pos("cold").unwrap_or(0), + _ => 0, + } } fn lp_to_dvec3(v: &azalea_core::delta::LpVec3) -> glam::DVec3 { diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 74a1d6be..b0342aa0 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -235,6 +235,12 @@ pub enum NetworkEvent { x_rot_deg: f32, on_ground: bool, }, + EntityRotated { + id: i32, + y_rot_deg: f32, + x_rot_deg: f32, + on_ground: bool, + }, EntityMotion { id: i32, velocity: DVec3, @@ -301,13 +307,12 @@ pub enum NetworkEvent { FinishUseItem { id: i32, }, - CowVariant { - id: i32, - variant: u8, - }, - ChickenVariant { + /// Registry/wire variant slot; meaning is per-kind (pool index for + /// cow/chicken). Per-kind normalization lives in + /// `EntityStore::set_variant`. + EntityVariant { id: i32, - variant: u8, + variant: u32, }, VillagerData { id: i32, diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index c1b83a2b..e9d3f163 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -34,6 +34,16 @@ fn quadruped_legs( ] } +/// Mirror a cube's geometry across x=0 WITHOUT flipping UVs (e.g. chicken +/// wings and legs share one un-mirrored texture — vanilla quirk). Pair with +/// `mirror: true` where vanilla's `.mirror()` UV flip is also wanted. +fn mirror_x_geom(c: ModelCube) -> ModelCube { + ModelCube { + origin: Vec3::new(-(c.origin.x + c.size.x), c.origin.y, c.origin.z), + ..c + } +} + #[derive(Clone)] pub struct EntityPart { pub name: String, @@ -448,9 +458,8 @@ fn humanoid_parts( // Pomme's `mirror` flag only flips UVs, so mirror the geometry origin across // x=0 too (vanilla `.mirror()` does both, e.g. arm origin -3 -> -1). let mirror_x = |c: ModelCube| ModelCube { - origin: Vec3::new(-(c.origin.x + c.size.x), c.origin.y, c.origin.z), mirror: true, - ..c + ..mirror_x_geom(c) }; let arm_cube_left = mirror_x(arm_cube_right); let leg_cube_left = mirror_x(leg_cube_right); @@ -957,13 +966,9 @@ fn chicken_parts() -> Vec { vec![vbox((26, 0), (-1.0, 0.0, -3.0), (3.0, 5.0, 3.0))], ) }; - let wing = |name: &str, x: f32, origin_x: f32| { - vpart( - name, - None, - Vec3::new(x, 13.0, 0.0), - vec![vbox((24, 13), (origin_x, 0.0, -3.0), (1.0, 4.0, 6.0))], - ) + let wing_cube = vbox((24, 13), (0.0, 0.0, -3.0), (1.0, 4.0, 6.0)); + let wing = |name: &str, x: f32, cube: ModelCube| { + vpart(name, None, Vec3::new(x, 13.0, 0.0), vec![cube]) }; vec![ vpart( @@ -989,8 +994,8 @@ fn chicken_parts() -> Vec { }, leg("right_leg", -2.0), leg("left_leg", 1.0), - wing("right_wing", -4.0, 0.0), - wing("left_wing", 4.0, -1.0), + wing("right_wing", -4.0, wing_cube), + wing("left_wing", 4.0, mirror_x_geom(wing_cube)), ] } @@ -1003,10 +1008,16 @@ pub fn bake_chicken_model() -> BakedEntityModel { pub fn bake_cold_chicken_model() -> BakedEntityModel { let mut parts = chicken_parts(); // Head crest (its -2.015 z avoids z-fighting), then body tail feathers. - parts[0] + parts + .iter_mut() + .find(|p| p.name == "head") + .expect("chicken mesh has a head") .cubes .push(vbox((44, 0), (-3.0, -7.0, -2.015), (6.0, 3.0, 4.0))); - parts[1] + parts + .iter_mut() + .find(|p| p.name == "body") + .expect("chicken mesh has a body") .cubes .push(vbox((38, 9), (0.0, 3.0, -1.0), (0.0, 3.0, 5.0))); bake_model(parts, 64, 32) diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 0a661b02..adb41a57 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -135,6 +135,22 @@ impl MobEntry { pub const WHITE_TINT: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; +/// Registry-path order of each mob's flattened variant pool; the net handler +/// resolves synced registry ids against these same slices, and the renderer +/// constructor asserts the pools line up. +pub const CHICKEN_VARIANT_ORDER: &[&str] = &["temperate", "warm", "cold"]; +pub const COW_VARIANT_ORDER: &[&str] = &["temperate", "cold", "warm"]; + +/// Pool length the `*_VARIANT_ORDER` slice implies for mobs whose variant +/// index comes from a synced registry. +fn expected_variant_count(kind: EntityKind) -> Option { + match kind { + EntityKind::Chicken => Some(CHICKEN_VARIANT_ORDER.len()), + EntityKind::Cow => Some(COW_VARIANT_ORDER.len()), + _ => None, + } +} + /// Vanilla `OverlayTexture` hurt pixel (ARGB 0xB2FF0000): rgb is the overlay /// color, `a` is how much of the base color survives the mix. const HURT_OVERLAY: [f32; 4] = [1.0, 0.0, 0.0, 178.0 / 255.0]; @@ -264,9 +280,8 @@ fn mob_definitions() -> Vec { &["minecraft/textures/entity/cow/cow_cold_baby.png"], &["minecraft/textures/entity/cow/cow_warm_baby.png"], ]; - // Variant order is temperate/warm/cold: the two normal-mesh variants share - // one VariantDef, the cold mesh gets its own. The handler's index mapping - // must match. + // The two normal-mesh variants share one VariantDef, the cold mesh gets + // its own; the flattened pool follows CHICKEN_VARIANT_ORDER. const CHICKEN_NORMAL_TEX: &[&[&str]] = &[ &[ "minecraft/textures/entity/chicken/chicken_temperate.png", @@ -676,6 +691,25 @@ impl EntityRenderer { assert_part_order_matches(baby, &baby_overlays); } + // The net handler resolves variant ids against the + // *_VARIANT_ORDER slices; the flattened pools must line up. + if let Some(n) = expected_variant_count(def.kind) { + assert_eq!( + adult_variants.len(), + n, + "{:?} adult variant pool != variant order length", + def.kind + ); + if let Some(baby) = &baby_variants { + assert_eq!( + baby.len(), + n, + "{:?} baby variant pool != variant order length", + def.kind + ); + } + } + mobs.insert( def.kind, MobEntry { @@ -1322,6 +1356,8 @@ fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { let (w, h) = match kind { EntityKind::Pig => (0.9, 0.9), EntityKind::Cow => (0.9, 1.4), + // Vanilla Chicken.BABY_DIMENSIONS is an explicit 0.3x0.4, not half scale. + EntityKind::Chicken if is_baby => return (0.3, 0.4), EntityKind::Chicken => (0.4, 0.7), EntityKind::Sheep => (0.9, 1.3), EntityKind::Zombie => (0.6, 1.95), From dfb3fe18d915fb22147e5756cc9b39ffecc6cbdb Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 10:30:46 +0100 Subject: [PATCH 05/19] cleanup --- pomme-client/src/app/phases/in_game.rs | 2 +- .../src/renderer/pipelines/entity_renderer.rs | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 6e7bab24..5a712402 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -2826,7 +2826,7 @@ fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> EntityKind::Bogged => EntityExtras { overlay_tints: SLOT0_TINTS, variant_index: e.is_sheared as u32, - ..EMPTY_EXTRAS + ..Default::default() }, // Always-visible slot-0 overlay (spider eyes, drowned/stray clothing). EntityKind::Spider | EntityKind::Drowned | EntityKind::Stray => EntityExtras { diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 3e40444b..05d35508 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -1577,6 +1577,17 @@ fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { EntityKind::Chicken if is_baby => return (0.3, 0.4), EntityKind::Chicken => (0.4, 0.7), EntityKind::Sheep => (0.9, 1.3), + // Vanilla Zombie.BABY_DIMENSIONS / Villager baby: explicit 0.49x0.98, + // not half scale. + EntityKind::Zombie + | EntityKind::Husk + | EntityKind::Drowned + | EntityKind::ZombieVillager + | EntityKind::Villager + if is_baby => + { + return (0.49, 0.98); + } EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned From 0e9c35c5def353ffb827e378c8795918e0c0f803 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 10:43:29 +0100 Subject: [PATCH 06/19] cleanup --- pomme-client/src/entity/mod.rs | 19 ++++++++------- pomme-client/src/net/handler.rs | 24 +++++++++---------- .../src/renderer/pipelines/entity_renderer.rs | 2 -- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index bc5949fa..585a9606 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -137,6 +137,13 @@ impl LivingEntity { self.interp_steps = INTERPOLATION_STEPS; } + /// Extends any in-flight position lerp instead of re-targeting it + /// (vanilla `moveOrInterpolateTo` rotation overloads). + fn interpolate_to_rotation(&mut self, y_rot_deg: f32, x_rot_deg: f32) { + self.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); + self.interp_steps = self.interp_steps.max(INTERPOLATION_STEPS); + } + pub fn tick_interpolation(&mut self) { self.prev_position = self.position; self.prev_look_dir = self.look_dir; @@ -665,19 +672,15 @@ impl EntityStore { pub fn update_living_rotation(&mut self, id: i32, y_rot_deg: f32, x_rot_deg: f32) { if let Some(entity) = self.living.get_mut(&id) { - entity.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); - entity.interp_steps = entity.interp_steps.max(INTERPOLATION_STEPS); + entity.interpolate_to_rotation(y_rot_deg, x_rot_deg); } } - /// Rotation-only movement (`MoveEntityRot`): vanilla applies the new - /// rotation via `moveOrInterpolateTo` and sets onGround; position - /// interpolation is untouched (extending an in-flight lerp matches the - /// `EntityMovedRotated` convention rather than vanilla's fixed re-target). + /// Rotation-only movement (`MoveEntityRot`): rotation plus onGround, + /// position interpolation untouched. pub fn rotate_living(&mut self, id: i32, y_rot_deg: f32, x_rot_deg: f32, on_ground: bool) { if let Some(entity) = self.living.get_mut(&id) { - entity.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); - entity.interp_steps = entity.interp_steps.max(INTERPOLATION_STEPS); + entity.interpolate_to_rotation(y_rot_deg, x_rot_deg); entity.on_ground = on_ground; } } diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 707c1b65..e464e256 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -738,26 +738,24 @@ fn chicken_variant_index(registry_holder: &RegistryHolder, protocol_id: u32) -> .position(|p| *p == name) .map(|i| i as u32) }; + let nbt_string = |name: &str| nbt.string(name).map(|s| s.to_str().into_owned()); if let Some(i) = order_pos(ident.path()) { return i; } - if let Some(asset) = nbt.string("asset_id").map(|s| s.to_str().into_owned()) - && let Some(i) = CHICKEN_VARIANT_ORDER - .iter() - .position(|p| { - asset.strip_prefix("minecraft:").unwrap_or(asset.as_str()) - == format!("entity/chicken/chicken_{p}") - }) - .map(|i| i as u32) + if let Some(i) = nbt_string("asset_id") + .and_then(|asset| { + asset + .strip_prefix("minecraft:") + .unwrap_or(&asset) + .strip_prefix("entity/chicken/chicken_") + .map(str::to_owned) + }) + .and_then(|suffix| order_pos(&suffix)) { return i; } // "normal" is the codec default when the model field is absent. - match nbt - .string("model") - .map(|s| s.to_str().into_owned()) - .as_deref() - { + match nbt_string("model").as_deref() { Some("cold") => order_pos("cold").unwrap_or(0), _ => 0, } diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index adb41a57..b44a8f63 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -691,8 +691,6 @@ impl EntityRenderer { assert_part_order_matches(baby, &baby_overlays); } - // The net handler resolves variant ids against the - // *_VARIANT_ORDER slices; the flattened pools must line up. if let Some(n) = expected_variant_count(def.kind) { assert_eq!( adult_variants.len(), From cfdb58a50b1343ab74b8894c8c7d8b97e7e5d6fd Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 11:56:26 +0100 Subject: [PATCH 07/19] cleanup --- pomme-client/src/app/core.rs | 8 ++-- pomme-client/src/entity/mod.rs | 50 ++++++++++--------- pomme-client/src/net/connection.rs | 3 ++ pomme-client/src/net/handler.rs | 77 +++++++++++++----------------- pomme-client/src/net/mod.rs | 4 +- 5 files changed, 72 insertions(+), 70 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index ef9bcb13..ac95ec34 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -977,7 +977,7 @@ impl AppCore { game.entity_store .move_living_delta(id, dx, dy, dz, on_ground); game.entity_store - .update_living_rotation(id, y_rot_deg, x_rot_deg); + .rotate_living(id, y_rot_deg, x_rot_deg, on_ground); game.item_entity_store.move_delta(id, dx, dy, dz, on_ground); } NetworkEvent::EntityRotated { @@ -1002,7 +1002,7 @@ impl AppCore { } => { game.entity_store.teleport_living(id, position, on_ground); game.entity_store - .update_living_rotation(id, y_rot_deg, x_rot_deg); + .rotate_living(id, y_rot_deg, x_rot_deg, on_ground); game.item_entity_store .teleport(id, position, velocity, on_ground); } @@ -1112,8 +1112,8 @@ impl AppCore { ); } } - NetworkEvent::EntityVariant { id, variant } => { - game.entity_store.set_variant(id, variant); + NetworkEvent::EntityVariant { id, kind, variant } => { + game.entity_store.set_variant(id, kind, variant); } NetworkEvent::VillagerData { id, diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 585a9606..96b19cfe 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -103,7 +103,10 @@ impl LivingEntity { prev_walk_anim_speed: 0.0, is_baby: false, is_crouching: false, - on_ground: false, + // on_ground is packet-driven and a stationary entity gets no + // movement packet for up to 60 ticks, so spawn grounded (vanilla + // grounds remotes via local physics pomme doesn't run). + on_ground: true, wool_color: None, is_sheared: false, variant: 0, @@ -137,13 +140,6 @@ impl LivingEntity { self.interp_steps = INTERPOLATION_STEPS; } - /// Extends any in-flight position lerp instead of re-targeting it - /// (vanilla `moveOrInterpolateTo` rotation overloads). - fn interpolate_to_rotation(&mut self, y_rot_deg: f32, x_rot_deg: f32) { - self.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); - self.interp_steps = self.interp_steps.max(INTERPOLATION_STEPS); - } - pub fn tick_interpolation(&mut self) { self.prev_position = self.position; self.prev_look_dir = self.look_dir; @@ -198,6 +194,16 @@ impl LivingEntity { self.flap += self.flapping * 2.0; } + /// Per-kind per-tick animation state (the kind-specific tail of vanilla + /// `aiStep`); arms accrue as mobs land. + fn tick_kind_anims(&mut self) { + #[allow(clippy::single_match)] + match self.entity_type { + EntityKind::Chicken => self.tick_flap(), + _ => {} + } + } + pub fn tick_body_rotation(&mut self) { let dx = self.position.x - self.prev_position.x; let dz = self.position.z - self.prev_position.z; @@ -591,8 +597,13 @@ impl EntityStore { } } - pub fn set_variant(&mut self, id: i32, raw: u32) { - if let Some(entity) = self.living.get_mut(&id) { + /// `kind` is the mob the emitting handler arm resolved the value for; + /// metadata indices are overloaded across kinds, so a mismatched entity + /// ignores the write. + pub fn set_variant(&mut self, id: i32, kind: EntityKind, raw: u32) { + if let Some(entity) = self.living.get_mut(&id) + && entity.entity_type == kind + { // Cow and chicken indices are pre-resolved by the net handler; // per-kind normalization arms arrive with their mobs. entity.variant = raw; @@ -670,17 +681,14 @@ impl EntityStore { } } - pub fn update_living_rotation(&mut self, id: i32, y_rot_deg: f32, x_rot_deg: f32) { - if let Some(entity) = self.living.get_mut(&id) { - entity.interpolate_to_rotation(y_rot_deg, x_rot_deg); - } - } - - /// Rotation-only movement (`MoveEntityRot`): rotation plus onGround, - /// position interpolation untouched. + /// Rotation half of any movement packet: rotation plus onGround, + /// position interpolation untouched. Extends any in-flight position lerp + /// instead of re-targeting it (vanilla `moveOrInterpolateTo` rotation + /// overloads). pub fn rotate_living(&mut self, id: i32, y_rot_deg: f32, x_rot_deg: f32, on_ground: bool) { if let Some(entity) = self.living.get_mut(&id) { - entity.interpolate_to_rotation(y_rot_deg, x_rot_deg); + entity.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); + entity.interp_steps = entity.interp_steps.max(INTERPOLATION_STEPS); entity.on_ground = on_ground; } } @@ -719,9 +727,7 @@ impl EntityStore { &mut entity.walk_anim_speed, &mut entity.prev_walk_anim_speed, ); - if entity.entity_type == EntityKind::Chicken { - entity.tick_flap(); - } + entity.tick_kind_anims(); entity.prev_eat_anim_tick = entity.eat_anim_tick; if entity.eat_anim_tick > 0 { entity.eat_anim_tick -= 1; diff --git a/pomme-client/src/net/connection.rs b/pomme-client/src/net/connection.rs index 1ebb09ff..65ad94af 100644 --- a/pomme-client/src/net/connection.rs +++ b/pomme-client/src/net/connection.rs @@ -351,6 +351,9 @@ async fn config_sequence( tracing::debug!("Received tags"); } ClientboundConfigPacket::SelectKnownPacks(_) => { + // Claiming no known packs forces the server to send NBT for + // every registry entry; `variant_index` (handler.rs) relies on + // that to equate registry-map position with protocol id. conn.write(ServerboundConfigPacket::SelectKnownPacks( s_select_known_packs::ServerboundSelectKnownPacks { known_packs: vec![], diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index e464e256..cd831cf0 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -2,6 +2,7 @@ use azalea_buf::{AzBuf, AzBufVar}; use azalea_core::position::ChunkPos; use azalea_core::registry_holder::RegistryHolder; use azalea_protocol::packets::game::{ClientboundGamePacket, ServerboundGamePacket}; +use azalea_registry::builtin::EntityKind; use crossbeam_channel::Sender; use super::NetworkEvent; @@ -508,19 +509,19 @@ pub fn handle_game_packet( let _ = event_tx.try_send(NetworkEvent::EntityCustomName { id: p.id.0, name }); } // Index 18 on cows = CowVariant Holder. - // TODO: resolve datapack entries via the synced registry NBT - // like chicken_variant_index does. if item.index == 18 && let azalea_entity::EntityDataValue::CowVariant(variant) = &item.value { use azalea_registry::DataRegistry; let _ = event_tx.try_send(NetworkEvent::EntityVariant { id: p.id.0, + kind: EntityKind::Cow, variant: variant_index( registry_holder, "minecraft:cow_variant", variant.protocol_id(), COW_VARIANT_ORDER, + "entity/cow/cow_", ), }); } @@ -531,7 +532,14 @@ pub fn handle_game_packet( use azalea_registry::DataRegistry; let _ = event_tx.try_send(NetworkEvent::EntityVariant { id: p.id.0, - variant: chicken_variant_index(registry_holder, variant.protocol_id()), + kind: EntityKind::Chicken, + variant: variant_index( + registry_holder, + "minecraft:chicken_variant", + variant.protocol_id(), + CHICKEN_VARIANT_ORDER, + "entity/chicken/chicken_", + ), }); } // Index 18 on villagers = unhappy counter (head-shake while > 0). @@ -702,63 +710,46 @@ fn send_chat(event_tx: &Sender, message: &azalea_chat::FormattedTe } /// Resolves a variant registry holder id to its index in `order` — the -/// renderer's variant-pool order for that mob. Unknown ids fall back to 0. +/// renderer's variant-pool order for that mob. Matches vanilla's renderer, +/// which reads the entry's synced ModelAndTexture NBT and never the registry +/// id: a known `asset_id` (prefix-stripped, e.g. `entity/chicken/chicken_`) +/// picks the exact pool slot, otherwise the `model` field picks the mesh +/// (its values name pool slots; absent means "normal" = slot 0). The registry +/// path is only a last resort for entries synced without NBT. fn variant_index( registry_holder: &RegistryHolder, registry: &str, protocol_id: u32, order: &[&str], + asset_prefix: &str, ) -> u32 { - registry_holder - .protocol_id_to_identifier( - azalea_registry::identifier::Identifier::new(registry), - protocol_id, - ) - .and_then(|id| order.iter().position(|p| *p == id.path())) - .unwrap_or(0) as u32 -} - -/// Chicken ids resolve by path against CHICKEN_VARIANT_ORDER; datapack -/// entries fall back to their synced ModelAndTexture NBT — a known asset_id -/// picks the exact pool slot, otherwise the model field picks the mesh -/// (normal -> temperate slot, cold -> cold slot). -fn chicken_variant_index(registry_holder: &RegistryHolder, protocol_id: u32) -> u32 { + // Position == protocol id only holds because pomme answers + // SelectKnownPacks with an empty list (connection.rs), forcing the server + // to send NBT for every entry; azalea shift_removes NBT-less entries, + // which would shift the indices here. let Some((ident, nbt)) = registry_holder .extra - .get(&azalea_registry::identifier::Identifier::new( - "minecraft:chicken_variant", - )) + .get(&azalea_registry::identifier::Identifier::new(registry)) .and_then(|r| r.map.get_index(protocol_id as usize)) else { return 0; }; - let order_pos = |name: &str| { - CHICKEN_VARIANT_ORDER - .iter() - .position(|p| *p == name) - .map(|i| i as u32) - }; - let nbt_string = |name: &str| nbt.string(name).map(|s| s.to_str().into_owned()); - if let Some(i) = order_pos(ident.path()) { + let order_pos = |name: &str| order.iter().position(|p| *p == name).map(|i| i as u32); + if let Some(asset) = nbt.string("asset_id").map(|s| s.to_str()) + && let Some(suffix) = asset + .strip_prefix("minecraft:") + .unwrap_or(&asset) + .strip_prefix(asset_prefix) + && let Some(i) = order_pos(suffix) + { return i; } - if let Some(i) = nbt_string("asset_id") - .and_then(|asset| { - asset - .strip_prefix("minecraft:") - .unwrap_or(&asset) - .strip_prefix("entity/chicken/chicken_") - .map(str::to_owned) - }) - .and_then(|suffix| order_pos(&suffix)) + if let Some(model) = nbt.string("model").map(|s| s.to_str()) + && let Some(i) = order_pos(&model) { return i; } - // "normal" is the codec default when the model field is absent. - match nbt_string("model").as_deref() { - Some("cold") => order_pos("cold").unwrap_or(0), - _ => 0, - } + order_pos(ident.path()).unwrap_or(0) } fn lp_to_dvec3(v: &azalea_core::delta::LpVec3) -> glam::DVec3 { diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index b0342aa0..1bd1c031 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -309,9 +309,11 @@ pub enum NetworkEvent { }, /// Registry/wire variant slot; meaning is per-kind (pool index for /// cow/chicken). Per-kind normalization lives in - /// `EntityStore::set_variant`. + /// `EntityStore::set_variant`; `kind` is the mob the emitting arm + /// resolved for, guarding overloaded metadata indices. EntityVariant { id: i32, + kind: EntityKind, variant: u32, }, VillagerData { From d336db5f98215ca27d26a7ab9ff948f17ff207d2 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 12:10:30 +0100 Subject: [PATCH 08/19] cleanup --- pomme-client/src/entity/mod.rs | 14 ++---- pomme-client/src/net/handler.rs | 84 ++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 96b19cfe..2edfae1e 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -103,9 +103,8 @@ impl LivingEntity { prev_walk_anim_speed: 0.0, is_baby: false, is_crouching: false, - // on_ground is packet-driven and a stationary entity gets no - // movement packet for up to 60 ticks, so spawn grounded (vanilla - // grounds remotes via local physics pomme doesn't run). + // Spawn grounded: on_ground is packet-driven and a stationary + // entity gets no movement packet for up to 60 ticks. on_ground: true, wool_color: None, is_sheared: false, @@ -604,8 +603,6 @@ impl EntityStore { if let Some(entity) = self.living.get_mut(&id) && entity.entity_type == kind { - // Cow and chicken indices are pre-resolved by the net handler; - // per-kind normalization arms arrive with their mobs. entity.variant = raw; } } @@ -681,10 +678,9 @@ impl EntityStore { } } - /// Rotation half of any movement packet: rotation plus onGround, - /// position interpolation untouched. Extends any in-flight position lerp - /// instead of re-targeting it (vanilla `moveOrInterpolateTo` rotation - /// overloads). + /// Rotation half of any movement packet: rotation plus onGround. Extends + /// any in-flight position lerp instead of re-targeting it (vanilla + /// `moveOrInterpolateTo` rotation overloads). pub fn rotate_living(&mut self, id: i32, y_rot_deg: f32, x_rot_deg: f32, on_ground: bool) { if let Some(entity) = self.living.get_mut(&id) { entity.interp_look_dir = LookDirection::new(y_rot_deg, x_rot_deg); diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index cd831cf0..f646608b 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -513,34 +513,24 @@ pub fn handle_game_packet( && let azalea_entity::EntityDataValue::CowVariant(variant) = &item.value { use azalea_registry::DataRegistry; - let _ = event_tx.try_send(NetworkEvent::EntityVariant { - id: p.id.0, - kind: EntityKind::Cow, - variant: variant_index( - registry_holder, - "minecraft:cow_variant", - variant.protocol_id(), - COW_VARIANT_ORDER, - "entity/cow/cow_", - ), - }); + let _ = event_tx.try_send(variant_event( + registry_holder, + p.id.0, + EntityKind::Cow, + variant.protocol_id(), + )); } // Index 18 on chickens = ChickenVariant Holder. if item.index == 18 && let azalea_entity::EntityDataValue::ChickenVariant(variant) = &item.value { use azalea_registry::DataRegistry; - let _ = event_tx.try_send(NetworkEvent::EntityVariant { - id: p.id.0, - kind: EntityKind::Chicken, - variant: variant_index( - registry_holder, - "minecraft:chicken_variant", - variant.protocol_id(), - CHICKEN_VARIANT_ORDER, - "entity/chicken/chicken_", - ), - }); + let _ = event_tx.try_send(variant_event( + registry_holder, + p.id.0, + EntityKind::Chicken, + variant.protocol_id(), + )); } // Index 18 on villagers = unhappy counter (head-shake while > 0). // Emit unconditionally; consumer filters by entity type. @@ -709,24 +699,28 @@ fn send_chat(event_tx: &Sender, message: &azalea_chat::FormattedTe let _ = event_tx.try_send(NetworkEvent::ChatMessage { spans }); } -/// Resolves a variant registry holder id to its index in `order` — the -/// renderer's variant-pool order for that mob. Matches vanilla's renderer, -/// which reads the entry's synced ModelAndTexture NBT and never the registry -/// id: a known `asset_id` (prefix-stripped, e.g. `entity/chicken/chicken_`) -/// picks the exact pool slot, otherwise the `model` field picks the mesh -/// (its values name pool slots; absent means "normal" = slot 0). The registry -/// path is only a last resort for entries synced without NBT. -fn variant_index( - registry_holder: &RegistryHolder, - registry: &str, - protocol_id: u32, - order: &[&str], - asset_prefix: &str, -) -> u32 { +/// Resolves a variant registry holder id to the mob's renderer pool slot. +/// Matches vanilla, which reads the entry's synced NBT and never the registry +/// id: a known `asset_id` picks the exact slot, else the `model` field picks +/// the mesh (its values name slots; absent means "normal" = slot 0), else the +/// registry path as a last resort for entries synced without NBT. +fn variant_index(registry_holder: &RegistryHolder, kind: EntityKind, protocol_id: u32) -> u32 { + let (registry, order, asset_prefix) = match kind { + EntityKind::Cow => ( + "minecraft:cow_variant", + COW_VARIANT_ORDER, + "entity/cow/cow_", + ), + EntityKind::Chicken => ( + "minecraft:chicken_variant", + CHICKEN_VARIANT_ORDER, + "entity/chicken/chicken_", + ), + _ => return 0, + }; // Position == protocol id only holds because pomme answers // SelectKnownPacks with an empty list (connection.rs), forcing the server - // to send NBT for every entry; azalea shift_removes NBT-less entries, - // which would shift the indices here. + // to send NBT for every entry (azalea shift_removes NBT-less ones). let Some((ident, nbt)) = registry_holder .extra .get(&azalea_registry::identifier::Identifier::new(registry)) @@ -752,6 +746,20 @@ fn variant_index( order_pos(ident.path()).unwrap_or(0) } +/// The kind-tagged variant event for a synced-registry holder value. +fn variant_event( + registry_holder: &RegistryHolder, + id: i32, + kind: EntityKind, + protocol_id: u32, +) -> NetworkEvent { + NetworkEvent::EntityVariant { + id, + kind, + variant: variant_index(registry_holder, kind, protocol_id), + } +} + fn lp_to_dvec3(v: &azalea_core::delta::LpVec3) -> glam::DVec3 { let v = v.to_vec3(); glam::DVec3::new(v.x, v.y, v.z) From 42b3550e92f3160a3f878e57c0eed1f1fdbb8d53 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 13:22:52 +0100 Subject: [PATCH 09/19] cleanup --- pomme-client/src/renderer/entity_model.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index e9d3f163..a87385f2 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -141,10 +141,13 @@ impl BakedEntityModel { let pivot = part.offset + extra_translation; let offset = match self.convention { ModelConvention::EntityYDown => { - // The +24 re-bases vanilla's y-down ground plane onto the - // engine's y-up origin; child pivots are relative to their - // parent (already re-based), so they only mirror. - let rebase = if part.parent.is_some() { 0.0 } else { 24.0 }; + // 24.016 re-bases vanilla's y-down ground plane onto the + // engine's y-up origin: vanilla `EntityModel.MODEL_Y_OFFSET` + // (-1.501, i.e. 24.016/16) lifts models 0.001 above the + // ground so feet don't z-fight the block top. Child pivots + // are relative to their parent (already re-based), so they + // only mirror. + let rebase = if part.parent.is_some() { 0.0 } else { 24.016 }; Vec3::new(pivot.x, rebase - pivot.y, pivot.z) } ModelConvention::BlockYUp => pivot, From 5aa40bd8fde7568166f5eb98a261dd7421b6bcc9 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 13:25:33 +0100 Subject: [PATCH 10/19] cleanup --- pomme-client/src/renderer/entity_model.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index a87385f2..43382c9d 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -2,6 +2,11 @@ use glam::{Mat4, Quat, Vec3}; use super::chunk::mesher::ChunkVertex; +/// Vanilla `EntityModel.MODEL_Y_OFFSET` (-1.501) in model pixels: re-bases the +/// y-down ground plane (y=24) onto the y-up origin, with a 0.001-block lift so +/// feet don't z-fight the block top. +const MODEL_REBASE_Y: f32 = 24.016; + #[derive(Clone, Copy)] pub struct ModelCube { pub origin: Vec3, @@ -141,13 +146,13 @@ impl BakedEntityModel { let pivot = part.offset + extra_translation; let offset = match self.convention { ModelConvention::EntityYDown => { - // 24.016 re-bases vanilla's y-down ground plane onto the - // engine's y-up origin: vanilla `EntityModel.MODEL_Y_OFFSET` - // (-1.501, i.e. 24.016/16) lifts models 0.001 above the - // ground so feet don't z-fight the block top. Child pivots - // are relative to their parent (already re-based), so they - // only mirror. - let rebase = if part.parent.is_some() { 0.0 } else { 24.016 }; + // Child pivots are relative to their parent (already + // re-based), so they only mirror. + let rebase = if part.parent.is_some() { + 0.0 + } else { + MODEL_REBASE_Y + }; Vec3::new(pivot.x, rebase - pivot.y, pivot.z) } ModelConvention::BlockYUp => pivot, @@ -1479,8 +1484,8 @@ pub fn bake_villager_model(no_hat: bool) -> BakedEntityModel { for part in &mut parts { let is_root = part.parent.is_none(); if is_root { - part.offset = - part.offset * VILLAGER_SCALE + Vec3::new(0.0, 24.016 * (1.0 - VILLAGER_SCALE), 0.0); + part.offset = part.offset * VILLAGER_SCALE + + Vec3::new(0.0, MODEL_REBASE_Y * (1.0 - VILLAGER_SCALE), 0.0); } scales.push(if is_root { VILLAGER_SCALE } else { 1.0 }); } From 83d6badfdea34e3c548973e29cf7ed077dc8d992 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 16:19:52 +0100 Subject: [PATCH 11/19] fix entity mirroring --- .../src/renderer/block_entity_model.rs | 21 +- pomme-client/src/renderer/entity_model.rs | 184 ++++++++++-------- .../src/renderer/pipelines/block_entity.rs | 4 + .../src/renderer/pipelines/entity_renderer.rs | 5 +- 4 files changed, 118 insertions(+), 96 deletions(-) diff --git a/pomme-client/src/renderer/block_entity_model.rs b/pomme-client/src/renderer/block_entity_model.rs index b899737e..34aeafea 100644 --- a/pomme-client/src/renderer/block_entity_model.rs +++ b/pomme-client/src/renderer/block_entity_model.rs @@ -45,23 +45,26 @@ pub fn bake_shulker_box_model() -> BakedEntityModel { /// the model bakes against a 16x16 reference even though the texture /// (`block/_sign.png`) is 32x32. Face order: -Z, +Z, +Y, -Y, -X, +X. pub fn bake_sign_model() -> BakedEntityModel { + // Face order -Z, +Z, top, bottom, -X, +X; the render-space X flip puts + // the model's -X face on the world's +X side, so the side rects are + // assigned crosswise. const BOARD_UVS: [[f32; 4]; 6] = [ [0.0, 8.0, 12.0, 14.0], // -Z (back) [0.0, 1.0, 12.0, 7.0], // +Z (front) - [0.0, 0.0, 12.0, 1.0], // +Y (top) - [0.0, 14.0, 12.0, 15.0], // -Y (bottom) - [12.0, 8.0, 13.0, 14.0], // -X - [12.0, 1.0, 13.0, 7.0], // +X + [0.0, 0.0, 12.0, 1.0], // top + [0.0, 14.0, 12.0, 15.0], // bottom + [12.0, 1.0, 13.0, 7.0], // -X + [12.0, 8.0, 13.0, 14.0], // +X ]; - // The post's top is hidden under the board, so its +Y face reuses the + // The post's top is hidden under the board, so its top face reuses the // bottom rect rather than claiming texture vanilla never assigns it. const POST_UVS: [[f32; 4]; 6] = [ [14.0, 8.0, 15.0, 15.0], // -Z [14.0, 0.0, 15.0, 7.0], // +Z - [14.0, 15.0, 15.0, 16.0], // +Y (hidden) - [14.0, 15.0, 15.0, 16.0], // -Y - [15.0, 8.0, 16.0, 15.0], // -X - [15.0, 0.0, 16.0, 7.0], // +X + [14.0, 15.0, 15.0, 16.0], // top (hidden) + [14.0, 15.0, 15.0, 16.0], // bottom + [15.0, 0.0, 16.0, 7.0], // -X + [15.0, 8.0, 16.0, 15.0], // +X ]; let board = ModelCube { diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 43382c9d..e7a51e7e 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -159,15 +159,16 @@ impl BakedEntityModel { } / 16.0; // A quaternion override expresses the exact render-space orientation - // directly; otherwise use the per-axis euler product: the y-down - // convention needs the engine's mixed signs (-x, -y, +z), y-up - // matches vanilla's `translateAndRotate` ZYX order verbatim. + // directly; otherwise use vanilla's `translateAndRotate` ZYX euler + // product. The y-down convention conjugates it by the render-space + // flip (bake y-negate x matrix x-flip = vanilla `scale(-1,-1,1)`): + // x and y angles negate, z keeps its sign, order stays ZYX. let rot_mat = match (quat_rot, self.convention) { (Some(q), _) => Mat4::from_quat(q), (None, ModelConvention::EntityYDown) => { - Mat4::from_rotation_x(-rot.x) + Mat4::from_rotation_z(rot.z) * Mat4::from_rotation_y(-rot.y) - * Mat4::from_rotation_z(rot.z) + * Mat4::from_rotation_x(-rot.x) } (None, ModelConvention::BlockYUp) => { Mat4::from_rotation_z(rot.z) @@ -254,7 +255,12 @@ pub fn bake_pig_model() -> BakedEntityModel { deformation: 0.0, mirror: false, }; - parts.extend(quadruped_legs(3.0, 18.0, -5.0, 7.0, pig_leg, pig_leg)); + // Vanilla `createBodyMesh(6, /*mirrorLeftLeg*/ true, false, g)`. + let pig_leg_left = ModelCube { + mirror: true, + ..pig_leg + }; + parts.extend(quadruped_legs(3.0, 18.0, -5.0, 7.0, pig_leg, pig_leg_left)); bake_model(parts, 64, 64) } @@ -1102,16 +1108,18 @@ pub fn bake_sheep_model() -> BakedEntityModel { parent: None, }, ]; - let sheep_leg_right = ModelCube { + // Vanilla `createBodyMesh(12, false, /*mirrorRightLeg*/ true, ...)` — + // sheep mirror the RIGHT legs, not the left. + let sheep_leg_left = ModelCube { origin: Vec3::new(-2.0, 0.0, -2.0), size: Vec3::new(4.0, 12.0, 4.0), tex_offset: (0, 16), deformation: 0.0, mirror: false, }; - let sheep_leg_left = ModelCube { + let sheep_leg_right = ModelCube { mirror: true, - ..sheep_leg_right + ..sheep_leg_left }; parts.extend(quadruped_legs( 3.0, @@ -1238,25 +1246,15 @@ pub fn bake_sheep_wool_model() -> BakedEntityModel { parent: None, }, ]; - let wool_leg_right = ModelCube { + // Vanilla `SheepFurModel` shares one unmirrored cube across all four legs. + let wool_leg = ModelCube { origin: Vec3::new(-2.0, 0.0, -2.0), size: Vec3::new(4.0, 6.0, 4.0), tex_offset: (0, 16), deformation: 0.5, mirror: false, }; - let wool_leg_left = ModelCube { - mirror: true, - ..wool_leg_right - }; - parts.extend(quadruped_legs( - 3.0, - 12.0, - -5.0, - 7.0, - wool_leg_right, - wool_leg_left, - )); + parts.extend(quadruped_legs(3.0, 12.0, -5.0, 7.0, wool_leg, wool_leg)); bake_model(parts, 64, 32) } @@ -1844,54 +1842,72 @@ pub fn compute_villager_anim( anim } -/// The four corner positions of each cube face, in render space (Y already -/// flipped). Face order: 0 -Z, 1 +Z, 2 +Y, 3 -Y, 4 -X, 5 +X. +/// The four corner positions of each cube face, ported from vanilla +/// `ModelPart.Cube`: eight shared corners (`t*` on minZ, `l*` on maxZ) with +/// model Y negated for the engine's y-up render space (the entity matrix +/// supplies the X half of vanilla's `scale(-1,-1,1)`). Face order: 0 -Z, +/// 1 +Z, 2 minY (rendered top), 3 maxY (rendered bottom), 4 -X, 5 +X — +/// vanilla NORTH, SOUTH, DOWN, UP, WEST, EAST. `mirror` swaps the minX/maxX +/// corner labels (vanilla's UV-only mirror; `push_face` also reverses the +/// quad). fn cube_face_positions(cube: &ModelCube) -> [[[f32; 3]; 4]; 6] { - let w = cube.size.x; - let h = cube.size.y; - let d = cube.size.z; - let inf = cube.deformation; - let x0 = (cube.origin.x - inf) / 16.0; - let y0 = (cube.origin.y - inf) / 16.0; + let mut x0 = (cube.origin.x - inf) / 16.0; + let mut x1 = (cube.origin.x + cube.size.x + inf) / 16.0; + let y0 = -((cube.origin.y - inf) / 16.0); + let y1 = -((cube.origin.y + cube.size.y + inf) / 16.0); let z0 = (cube.origin.z - inf) / 16.0; - let x1 = (cube.origin.x + w + inf) / 16.0; - let y1 = (cube.origin.y + h + inf) / 16.0; - let z1 = (cube.origin.z + d + inf) / 16.0; - - let yb = -y1; - let yt = -y0; - + let z1 = (cube.origin.z + cube.size.z + inf) / 16.0; + if cube.mirror { + std::mem::swap(&mut x0, &mut x1); + } + let t0 = [x0, y0, z0]; + let t1 = [x1, y0, z0]; + let t2 = [x1, y1, z0]; + let t3 = [x0, y1, z0]; + let l0 = [x0, y0, z1]; + let l1 = [x1, y0, z1]; + let l2 = [x1, y1, z1]; + let l3 = [x0, y1, z1]; [ - [[x1, yb, z0], [x0, yb, z0], [x0, yt, z0], [x1, yt, z0]], - [[x0, yb, z1], [x1, yb, z1], [x1, yt, z1], [x0, yt, z1]], - [[x0, yt, z0], [x0, yt, z1], [x1, yt, z1], [x1, yt, z0]], - [[x0, yb, z1], [x0, yb, z0], [x1, yb, z0], [x1, yb, z1]], - [[x0, yb, z1], [x0, yb, z0], [x0, yt, z0], [x0, yt, z1]], - [[x1, yb, z0], [x1, yb, z1], [x1, yt, z1], [x1, yt, z0]], + [t1, t0, t3, t2], + [l0, l1, l2, l3], + [l1, l0, t0, t1], + [t2, t3, l3, l2], + [t0, l0, l3, t3], + [l1, t1, t2, l2], ] } -/// Emit two triangles for one quad face, mapping the normalized UV rect onto -/// its corners (`u_min`/`v_min` is the texture's top-left). +/// Emit one quad as two triangles with vanilla `ModelPart.Polygon` UV +/// corners: vertex 0 gets `(u1, v0)`, then `(u0, v0)`, `(u0, v1)`, +/// `(u1, v1)` — the rect params are used as passed (the box unwrap hands the +/// maxY face a V-reversed rect on purpose). `mirror` reverses the quad, +/// completing vanilla's mirror alongside the corner swap in +/// `cube_face_positions`. fn push_face( positions: &[[f32; 3]; 4], - u_min: f32, - u_max: f32, - v_min: f32, - v_max: f32, + u0: f32, + v0: f32, + u1: f32, + v1: f32, + mirror: bool, vertices: &mut Vec, ) { - let uvs = [ - [u_min, v_max], - [u_max, v_max], - [u_max, v_min], - [u_min, v_min], + let mut corners = [ + (positions[0], [u1, v0]), + (positions[1], [u0, v0]), + (positions[2], [u0, v1]), + (positions[3], [u1, v1]), ]; + if mirror { + corners.reverse(); + } for &i in &[0usize, 1, 2, 0, 2, 3] { + let (position, uv) = corners[i]; vertices.push(ChunkVertex { - position: positions[i], - tex_coords: crate::renderer::chunk::mesher::pack_uv(uvs[i][0], uvs[i][1]), + position, + tex_coords: crate::renderer::chunk::mesher::pack_uv(uv[0], uv[1]), light_tint: crate::renderer::chunk::mesher::pack_light_tint( 1.0, crate::renderer::chunk::mesher::PACKED_WHITE_SHIFTED, @@ -1908,47 +1924,42 @@ fn generate_cube_vertices( ) { let tw = tex_w as f32; let th = tex_h as f32; - let u0 = cube.tex_offset.0 as f32; - let v0 = cube.tex_offset.1 as f32; + let u = cube.tex_offset.0 as f32; + let v = cube.tex_offset.1 as f32; let w = cube.size.x; let h = cube.size.y; let d = cube.size.z; - // Entity box-unwrap UV rects, face order matching `cube_face_positions`. + // Vanilla box-unwrap rects (`ModelPart.Cube`), face order matching + // `cube_face_positions`, as `(u0, v0, u1, v1)` polygon params: the maxY + // face's V runs reversed, and its right edge is `u+d+2w`, not `u+2d+w` + // (they differ whenever w != d). let face_uv = [ - [u0 + d, v0 + d, u0 + d + w, v0 + d + h], - [u0 + d + w + d, v0 + d, u0 + d + w + d + w, v0 + d + h], - [u0 + d, v0, u0 + d + w, v0 + d], - [u0 + d + w, v0, u0 + d + w + w, v0 + d], - [u0, v0 + d, u0 + d, v0 + d + h], - [u0 + d + w, v0 + d, u0 + d + w + d, v0 + d + h], + [u + d, v + d, u + d + w, v + d + h], + [u + 2.0 * d + w, v + d, u + 2.0 * d + 2.0 * w, v + d + h], + [u + d, v, u + d + w, v + d], + [u + d + w, v + d, u + d + 2.0 * w, v], + [u, v + d, u + d, v + d + h], + [u + d + w, v + d, u + 2.0 * d + w, v + d + h], ]; let positions = cube_face_positions(cube); - - // Indices 4 (-X) and 5 (+X) are the side faces. When mirror is set, vanilla's - // minX/maxX swap effectively exchanges their UV regions; every face also has - // its U flipped. - for (idx, pos) in positions.iter().enumerate() { - let src = match (cube.mirror, idx) { - (true, 4) => &face_uv[5], - (true, 5) => &face_uv[4], - _ => &face_uv[idx], - }; - let v_min = src[1] / th; - let v_max = src[3] / th; - let (u_min, u_max) = if cube.mirror { - (src[2] / tw, src[0] / tw) - } else { - (src[0] / tw, src[2] / tw) - }; - push_face(pos, u_min, u_max, v_min, v_max, vertices); + for (pos, uv) in positions.iter().zip(&face_uv) { + push_face( + pos, + uv[0] / tw, + uv[1] / th, + uv[2] / tw, + uv[3] / th, + cube.mirror, + vertices, + ); } } /// Like [`generate_cube_vertices`] but with explicit per-face UV rects (face -/// order -Z, +Z, +Y, -Y, -X, +X) instead of the entity box-unwrap, for block -/// models whose texture layout isn't a box-unwrap (e.g. signs). +/// order -Z, +Z, minY, maxY, -X, +X) instead of the entity box-unwrap, for +/// block models whose texture layout isn't a box-unwrap (e.g. signs). pub(crate) fn generate_cube_vertices_faces( cube: &ModelCube, face_uvs: &[[f32; 4]; 6], @@ -1963,9 +1974,10 @@ pub(crate) fn generate_cube_vertices_faces( push_face( pos, uv[0] / tw, - uv[2] / tw, uv[1] / th, + uv[2] / tw, uv[3] / th, + cube.mirror, vertices, ); } diff --git a/pomme-client/src/renderer/pipelines/block_entity.rs b/pomme-client/src/renderer/pipelines/block_entity.rs index 0111c247..7ec229fc 100644 --- a/pomme-client/src/renderer/pipelines/block_entity.rs +++ b/pomme-client/src/renderer/pipelines/block_entity.rs @@ -538,9 +538,13 @@ impl BlockEntityPipeline { ) - anchor) .as_vec3(); let model_mat = match model.convention { + // The X flip pairs with the bake's Y negation to reproduce + // vanilla's `scale(1,-1,-1)` (= the 180 yaw offset times + // `scale(-1,-1,1)`), same as the entity renderer. ModelConvention::EntityYDown => { glam::Mat4::from_translation(block_center) * glam::Mat4::from_rotation_y((180.0f32 - info.yaw).to_radians()) + * glam::Mat4::from_scale(glam::Vec3::new(-1.0, 1.0, 1.0)) } // Vanilla `ChestRenderer`: rotate by -facing.toYRot() about the // block center; coords are relative to the block's min corner. diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index b44a8f63..b4ba9100 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -935,10 +935,13 @@ impl EntityRenderer { } /// The translation is anchor-relative, subtracted in f64 (see - /// `Camera::anchor`). + /// `Camera::anchor`). The trailing X flip is the other half of vanilla's + /// `scale(-1,-1,1)` (the bake negates Y); without it every model renders + /// left-right mirrored. fn entity_matrix(info: &EntityRenderInfo, anchor: glam::DVec3) -> glam::Mat4 { glam::Mat4::from_translation((*info.position - anchor).as_vec3()) * glam::Mat4::from_rotation_y((180.0 - info.body_y_rot_deg).to_radians()) + * glam::Mat4::from_scale(glam::Vec3::new(-1.0, 1.0, 1.0)) } #[allow(clippy::too_many_arguments)] From 33926763505b3b235e4175ab9261709c70bfc2e8 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 16:26:42 +0100 Subject: [PATCH 12/19] cleanup --- pomme-client/src/app/core.rs | 10 +--- pomme-client/src/app/phases/in_game.rs | 31 ++++++------ pomme-client/src/entity/mod.rs | 50 ++++++++++--------- pomme-client/src/net/handler.rs | 36 +++++++------ pomme-client/src/net/mod.rs | 17 +++---- pomme-client/src/renderer/entity_model.rs | 27 +++++++--- .../src/renderer/pipelines/entity_renderer.rs | 38 ++++++++------ 7 files changed, 115 insertions(+), 94 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 448407ca..2b176192 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1115,11 +1115,8 @@ impl AppCore { NetworkEvent::EntityVariant { id, kind, variant } => { game.entity_store.set_variant(id, kind, variant); } - NetworkEvent::EndermanCreepy { id, creepy } => { - game.entity_store.set_enderman_creepy(id, creepy); - } - NetworkEvent::WitchDrinking { id, drinking } => { - game.entity_store.set_witch_drinking(id, drinking); + NetworkEvent::MobFlag { id, flag, value } => { + game.entity_store.set_mob_flag(id, flag, value); } NetworkEvent::SlimeSize { id, size } => { game.entity_store.set_slime_size(id, size); @@ -1145,9 +1142,6 @@ impl AppCore { NetworkEvent::EntitySwing { id } => { game.entity_store.start_swing(id); } - NetworkEvent::CreeperPowered { id, powered } => { - game.entity_store.set_powered(id, powered); - } NetworkEvent::EntityDamaged { id } => { game.entity_store.mark_hurt(id); } diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index a806edfd..bace1896 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -220,7 +220,7 @@ pub struct GameState { pub vis_mask: HashMap, /// Per-section generation for edits only (bulk uses the column /// `content_gen` above). Bumped per edited section so a result is - /// dropped only when *that* section was edited again — editing one + /// dropped only when *that* section was edited again — editing one /// section never invalidates a sibling section's in-flight result. /// Sections meshed together as one edit span share one gen value. pub section_gen: HashMap<(ChunkPos, i32), u64>, @@ -237,8 +237,8 @@ pub struct GameState { /// overlay reads it now. pub vis_tiers: HashMap, pub vis_valid: bool, - /// Camera 8-block bucket that last triggered an occlusion walk — - /// movement, not rotation, drives recomputes (vanilla's cadence). + /// Camera 8-block bucket that last triggered an occlusion walk — movement, + /// not rotation, drives recomputes (vanilla's cadence). pub last_vis_cam: (i32, i32, i32), /// In-flight async occlusion walk; its result is applied a few frames /// later. @@ -848,9 +848,9 @@ impl GameState { /// Drive the cave-cull occlusion walk: apply a finished async walk to the /// per-column draw masks, then schedule the next one on 8-block camera - /// movement or chunk loads (one at a time, off the main thread — - /// vanilla's async, movement-gated cadence). The walk is - /// rotation-independent; frustum culling runs per-frame on the GPU. + /// movement or chunk loads (one at a time, off the main thread — vanilla's + /// async, movement-gated cadence). The walk is rotation-independent; + /// frustum culling runs per-frame on the GPU. pub fn update_visibility( &mut self, renderer: &mut Renderer, @@ -967,8 +967,8 @@ impl GameState { /// Enqueue every loaded column's not-yet-meshed sections (re-meshing the /// whole column on a lod/content change). Like vanilla, every section in - /// render distance meshes regardless of visibility — occlusion gates only - /// drawing — and the queue orders the backlog nearest-first. Runs every + /// render distance meshes regardless of visibility — occlusion gates only + /// drawing — and the queue orders the backlog nearest-first. Runs every /// frame to drain it. pub fn rescan_mesh_jobs(&mut self, player_chunk: ChunkPos, chunk_detail: u32) { let n = self.chunk_store.section_count(); @@ -1044,7 +1044,7 @@ fn section_mask(n: i32) -> u32 { } /// Contiguous `(start, end)` index runs of set bits in `mask`, so a (usually -/// contiguous) visible set enqueues as a few range jobs — one gather per run. +/// contiguous) visible set enqueues as a few range jobs — one gather per run. fn contiguous_runs(mask: u32) -> Vec<(i32, i32)> { let mut runs = Vec::new(); let mut i = 0i32; @@ -1130,7 +1130,7 @@ fn apply_result_action( } /// Set the active render distance (the persisted menu value) and push it to the -/// server — used by the chunk-load benchmark as it ramps the distance up and +/// server — used by the chunk-load benchmark as it ramps the distance up and /// down. fn apply_render_distance( core: &mut AppCore, @@ -1563,8 +1563,8 @@ pub fn update_game( None }; // The chunk-load benchmark renders a clean top-down view: only terrain, no HUD, - // entities/player, held item, clouds, or weather — and skipping them also - // keeps the measured frame times honest. + // entities/player, held item, clouds, or weather — and skipping them also keeps + // the measured frame times honest. let benchmark_running = game.chunk_load_bench.is_some(); if !benchmark_running && game.hide_gui { // F1: vanilla still renders the debug overlay with the GUI hidden. @@ -2859,14 +2859,15 @@ fn entity_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> } /// Vanilla `AbstractCubeMobRenderer.applySizeAndSquish` plus the slime-only -/// `downscaleSlightly` (0.999 shrink + 0.001 lift against shell z-fighting). +/// `downscaleSlightly` (0.999 shrink + a 0.001 drop that tucks the inner body +/// under the shell surface; vanilla's +0.001 is in flipped space = down). fn slime_body_transform(e: &crate::entity::LivingEntity, alpha: f32) -> glam::Mat4 { let squish = e.prev_squish + (e.squish - e.prev_squish) * alpha; let size = e.slime_size as f32; let ss = squish / (size * 0.5 + 1.0); let w = 1.0 / (ss + 1.0); glam::Mat4::from_scale(glam::Vec3::splat(0.999)) - * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.001, 0.0)) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.001, 0.0)) * glam::Mat4::from_scale(glam::Vec3::new(w * size, size / w, w * size)) } @@ -2910,7 +2911,7 @@ fn sheep_extras(entity_id: i32, e: &crate::entity::LivingEntity, alpha: f32) -> /// Whether the type texture's built-in hat is fully or partially covered by /// the profession texture's own hat, per the `villager` sections of the -/// `.png.mcmeta` files under `textures/entity/villager/` (hardcoded — no +/// `.png.mcmeta` files under `textures/entity/villager/` (hardcoded — no /// resource-pack support). 0 = none, 1 = partial, 2 = full. const VILLAGER_TYPE_HAT: [u8; 7] = [2, 0, 0, 0, 2, 0, 0]; // desert, snow = full const VILLAGER_PROFESSION_HAT: [u8; 15] = [ diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 055f8783..5517139e 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -14,6 +14,15 @@ use crate::physics::collision::resolve_collision; use crate::world::block::{FluidKind, fluid}; use crate::world::chunk::ChunkStore; +/// Kind-gated boolean mob states; each flag belongs to one mob kind and +/// [`EntityStore::set_mob_flag`] drops writes for a mismatched entity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MobFlag { + CreeperPowered, + EndermanCreepy, + WitchDrinking, +} + const INTERPOLATION_STEPS: i32 = 3; const HURT_DURATION: u8 = 10; /// Vanilla default arm-swing duration in ticks @@ -144,7 +153,10 @@ impl LivingEntity { swing_time: 0, flapping: 1.0, target_squish: 0.0, - prev_on_ground: true, + // Vanilla `AbstractCubeMob.wasOnGround` starts false; with the + // grounded spawn above this reproduces vanilla's first-track + // landing squash and skips the airborne-spawn stretch. + prev_on_ground: false, interp_target: position, interp_look_dir: look_dir, interp_steps: 0, @@ -228,9 +240,9 @@ impl LivingEntity { /// Per-kind per-tick animation state (the kind-specific tail of vanilla /// `aiStep`); arms accrue as mobs land. fn tick_kind_anims(&mut self) { - #[allow(clippy::single_match)] match self.entity_type { EntityKind::Chicken => self.tick_flap(), + EntityKind::Slime => self.tick_squish(), _ => {} } } @@ -639,19 +651,18 @@ impl EntityStore { } } - pub fn set_enderman_creepy(&mut self, id: i32, creepy: bool) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Enderman - { - entity.is_creepy = creepy; - } - } - - pub fn set_witch_drinking(&mut self, id: i32, drinking: bool) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Witch - { - entity.witch_drinking = drinking; + /// Applies a [`MobFlag`] write, dropping it when the entity isn't the + /// flag's mob (metadata indices are overloaded across kinds, so the net + /// handler emits every candidate flag for an ambiguous boolean). + pub fn set_mob_flag(&mut self, id: i32, flag: MobFlag, value: bool) { + let Some(entity) = self.living.get_mut(&id) else { + return; + }; + match (flag, entity.entity_type) { + (MobFlag::CreeperPowered, EntityKind::Creeper) => entity.powered = value, + (MobFlag::EndermanCreepy, EntityKind::Enderman) => entity.is_creepy = value, + (MobFlag::WitchDrinking, EntityKind::Witch) => entity.witch_drinking = value, + _ => {} } } @@ -715,14 +726,6 @@ impl EntityStore { } } - pub fn set_powered(&mut self, id: i32, powered: bool) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Creeper - { - entity.powered = powered; - } - } - /// Begins an arm swing (server `Animate` packet). Restarts when idle or /// past the halfway point (vanilla `LivingEntity.swing`); `swing_time` /// counts down, so that is `swing_time <= SWING_DURATION / 2`. @@ -780,7 +783,6 @@ impl EntityStore { &mut entity.prev_walk_anim_speed, ); entity.tick_kind_anims(); - entity.tick_squish(); entity.prev_eat_anim_tick = entity.eat_anim_tick; if entity.eat_anim_tick > 0 { entity.eat_anim_tick -= 1; diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 76f07254..76aad7de 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -8,6 +8,7 @@ use crossbeam_channel::Sender; use super::NetworkEvent; use super::commands::{CommandTree, SharedCommandTree}; use super::sender::PacketSender; +use crate::entity::MobFlag; use crate::entity::components::Position; use crate::renderer::pipelines::entity_renderer::{CHICKEN_VARIANT_ORDER, COW_VARIANT_ORDER}; use crate::ui::text::format_text_spans; @@ -462,7 +463,10 @@ pub fn handle_game_packet( is_baby: *is_baby, }); } - // Entity data index 16 = player score (1.21.4 protocol) + // Index 16 (Int) = player score / slime size (Slime's + // DATA_ID_SIZE sits at 16 on 1.21.9-26.1.x; 26.2 moved it to + // 18 when Slime gained AgeableMob's fields). Emit both; + // consumers filter by entity type. if item.index == 16 && let azalea_entity::EntityDataValue::Int(score) = &item.value { @@ -470,6 +474,10 @@ pub fn handle_game_packet( entity_id: p.id.0, score: *score, }); + let _ = event_tx.try_send(NetworkEvent::SlimeSize { + id: p.id.0, + size: *score, + }); } // Index 15 = mob flags byte (AbstractInsentient): bit 0x04 = aggressive. if item.index == 15 @@ -492,22 +500,22 @@ pub fn handle_game_packet( }); } // Index 17 (Boolean) = creeper powered / enderman creepy / witch - // drinking. Emit all three; consumers filter by entity type. + // drinking. Emit every candidate; the store applies only the + // flag matching the entity's kind. if item.index == 17 && let azalea_entity::EntityDataValue::Boolean(flag) = &item.value { - let _ = event_tx.try_send(NetworkEvent::CreeperPowered { - id: p.id.0, - powered: *flag, - }); - let _ = event_tx.try_send(NetworkEvent::EndermanCreepy { - id: p.id.0, - creepy: *flag, - }); - let _ = event_tx.try_send(NetworkEvent::WitchDrinking { - id: p.id.0, - drinking: *flag, - }); + for f in [ + MobFlag::CreeperPowered, + MobFlag::EndermanCreepy, + MobFlag::WitchDrinking, + ] { + let _ = event_tx.try_send(NetworkEvent::MobFlag { + id: p.id.0, + flag: f, + value: *flag, + }); + } } // Index 2 = custom name (Optional); needed for jeb_ sheep detection. if item.index == 2 diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index 5e5d68cd..9c105c9e 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -17,6 +17,7 @@ use azalea_registry::builtin::{BlockEntityKind, EntityKind}; use glam::DVec3; use simdnbt::owned::NbtCompound; +use crate::entity::MobFlag; use crate::entity::components::Position; use crate::entity::villager::{VillagerKind, VillagerProfession}; @@ -316,13 +317,13 @@ pub enum NetworkEvent { kind: EntityKind, variant: u32, }, - EndermanCreepy { + /// Kind-gated boolean mob state; the store drops writes whose entity + /// kind doesn't match the flag's mob (metadata indices are overloaded + /// across kinds). + MobFlag { id: i32, - creepy: bool, - }, - WitchDrinking { - id: i32, - drinking: bool, + flag: MobFlag, + value: bool, }, SlimeSize { id: i32, @@ -349,10 +350,6 @@ pub enum NetworkEvent { EntitySwing { id: i32, }, - CreeperPowered { - id: i32, - powered: bool, - }, EntityDamaged { id: i32, }, diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 2e5dd360..5c0dee6c 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -1610,19 +1610,32 @@ pub fn bake_slime_outer_model() -> BakedEntityModel { /// its witch.png region is fully transparent, so its cubes are dropped. fn witch_parts() -> Vec { let mut parts = villager_parts(); - parts[1] = vpart( + let index_of = |parts: &[EntityPart], name: &str| { + parts + .iter() + .position(|p| p.name == name) + .unwrap_or_else(|| panic!("villager mesh has a {name}")) + }; + let head = index_of(&parts, "head"); + let hat = index_of(&parts, "hat"); + let nose = index_of(&parts, "nose"); + parts[hat] = vpart( "hat", - Some(0), + Some(head), Vec3::new(-5.0, -10.03125, -5.0), vec![vbox((0, 64), (0.0, 0.0, 0.0), (10.0, 2.0, 10.0))], ); - parts[2].cubes.clear(); // hat_rim + let hat_rim = index_of(&parts, "hat_rim"); + parts[hat_rim].cubes.clear(); + // The cone stacks parent hat -> hat2 -> hat3 -> hat4; the appended parts + // land at indices n, n+1, n+2. + let n = parts.len(); parts.extend([ EntityPart { default_rotation: Vec3::new(-0.05235988, 0.0, 0.02617994), ..vpart( "hat2", - Some(1), + Some(hat), Vec3::new(1.75, -4.0, 2.0), vec![vbox((0, 76), (0.0, 0.0, 0.0), (7.0, 4.0, 7.0))], ) @@ -1631,7 +1644,7 @@ fn witch_parts() -> Vec { default_rotation: Vec3::new(-0.10471976, 0.0, 0.05235988), ..vpart( "hat3", - Some(9), + Some(n), Vec3::new(1.75, -4.0, 2.0), vec![vbox((0, 87), (0.0, 0.0, 0.0), (4.0, 4.0, 4.0))], ) @@ -1640,7 +1653,7 @@ fn witch_parts() -> Vec { default_rotation: Vec3::new(-0.20943952, 0.0, 0.10471976), ..vpart( "hat4", - Some(10), + Some(n + 1), Vec3::new(1.75, -2.0, 2.0), vec![ModelCube { deformation: 0.25, @@ -1651,7 +1664,7 @@ fn witch_parts() -> Vec { // The mole samples the unused top-left corner of the head texture. vpart( "mole", - Some(3), + Some(nose), Vec3::new(0.0, -2.0, 0.0), vec![ModelCube { deformation: -0.25, diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index a293e836..bbf25710 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -59,7 +59,9 @@ pub struct EntityRenderInfo { pub flap_speed: f32, /// Enderman screaming state — raises the head. pub is_creepy: bool, - /// Witch drinking (vanilla `isHoldingItem`). + /// Witch drinking. Driven by the using-item metadata flag rather than + /// vanilla's `isHoldingItem` (main-hand item check) — pomme tracks no + /// mob equipment; the two only diverge for command-equipped witches. pub is_holding_item: bool, /// Witch per-entity nose-wobble rate, resolved from the entity id. pub nose_wobble_speed: f32, @@ -219,7 +221,7 @@ pub struct EntityRenderer { body_translucent_pipeline: vk::Pipeline, /// Translucent, depth-write off — spider eyes. eyes_pipeline: vk::Pipeline, - /// Additive, depth-write off — charged-creeper energy swirl. + /// Additive, depth-writing — charged-creeper energy swirl. swirl_pipeline: vk::Pipeline, pipeline_layout: vk::PipelineLayout, camera_layout: vk::DescriptorSetLayout, @@ -335,7 +337,10 @@ fn mob_definitions() -> Vec { const ENDERMAN_EYES_TEX: &[&[&str]] = &[&["minecraft/textures/entity/enderman/enderman_eyes.png"]]; const SLIME_TEX: &[&[&str]] = &[&["minecraft/textures/entity/slime/slime.png"]]; - const WITCH_TEX: &[&[&str]] = &[&["minecraft/textures/entity/witch/witch.png"]]; + const WITCH_TEX: &[&[&str]] = &[&[ + "minecraft/textures/entity/witch/witch.png", + "minecraft/textures/entity/witch.png", + ]]; const VILLAGER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/villager/villager.png"]]; const VILLAGER_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/villager/villager_baby.png"]]; @@ -1059,7 +1064,6 @@ impl EntityRenderer { // (immutable reads of self.mobs), grouped by variant so each (variant, // part) becomes a single instanced draw. `vis`/`groups` borrow self.mobs // and are dropped at the end of this block, before the buffer write below. - let cull_dist_sq = cull_dist * cull_dist; let mut instances: Vec = Vec::new(); let (opaque, body, eyes, swirl) = { let mut vis: Vec = Vec::new(); @@ -1067,7 +1071,7 @@ impl EntityRenderer { let Some(entry) = self.mobs.get(&info.entity_kind) else { continue; }; - if !info.skip_cull && !entity_visible(info, frustum, eye, cull_dist_sq) { + if !info.skip_cull && !entity_visible(info, frustum, eye, cull_dist) { continue; } let variant = entry.base_variant(info.is_baby, self.effective_variant_index(info)); @@ -1496,24 +1500,26 @@ fn entity_visible( info: &EntityRenderInfo, frustum: &[[f32; 4]; 6], eye: glam::DVec3, - cull_dist_sq: f32, + cull_dist: f32, ) -> bool { let (w, h) = entity_bounds(info.entity_kind, info.is_baby); - let mut radius = 0.5 * (2.0 * w * w + h * h).sqrt() + ANIM_MARGIN; // A body transform (slime size/squish) can grow the entity well past its - // base bounds; inflate the sphere by its largest axis scale. - if let Some(m) = &info.body_transform { - let s = m - .x_axis + // base bounds; scale the sphere and its center by the largest axis scale. + let scale = info.body_transform.map_or(1.0, |m| { + m.x_axis .length_squared() .max(m.y_axis.length_squared()) .max(m.z_axis.length_squared()) - .sqrt(); - radius *= s.max(1.0); - } + .sqrt() + .max(1.0) + }); + let radius = (0.5 * (2.0 * w * w + h * h).sqrt() + ANIM_MARGIN) * scale; let mut q = (*info.position - eye).as_vec3(); - q.y += h * 0.5; - if q.length_squared() > cull_dist_sq { + q.y += h * 0.5 * scale; + // Distance-cull with the radius as margin so an oversized entity stays + // visible while any of its body is in range. + let max_dist = cull_dist + radius; + if q.length_squared() > max_dist * max_dist { return false; } for pl in frustum { From 38f40bd03f61acf6da77ccb2df7c0c6127cd8e4f Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 16:49:55 +0100 Subject: [PATCH 13/19] cleanup --- pomme-client/src/renderer/entity_model.rs | 108 +++++++----------- .../src/renderer/pipelines/block_entity.rs | 1 - 2 files changed, 39 insertions(+), 70 deletions(-) diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index e7a51e7e..465b7b78 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -1,4 +1,4 @@ -use glam::{Mat4, Quat, Vec3}; +use glam::{Mat4, Vec3}; use super::chunk::mesher::ChunkVertex; @@ -88,10 +88,6 @@ pub struct BakedEntityModel { #[derive(Default)] pub struct PartAnim { pub rotation: Vec<(usize, Vec3)>, - /// Quaternion rotation override; takes precedence over `rotation` for a - /// part. Used where the engine's fixed euler order can't reproduce - /// vanilla's composition (e.g. spider legs with combined yaw + tilt). - pub rotation_quat: Vec<(usize, Quat)>, pub translation: Vec<(usize, Vec3)>, } @@ -121,13 +117,6 @@ impl BakedEntityModel { let mut transforms = Vec::with_capacity(self.parts.len()); for (i, part) in self.parts.iter().enumerate() { - let mut quat_rot = None; - for &(idx, q) in &anim.rotation_quat { - if idx == i { - quat_rot = Some(q); - break; - } - } let mut rot = part.default_rotation; for &(idx, r) in &anim.rotation { if idx == i { @@ -158,19 +147,17 @@ impl BakedEntityModel { ModelConvention::BlockYUp => pivot, } / 16.0; - // A quaternion override expresses the exact render-space orientation - // directly; otherwise use vanilla's `translateAndRotate` ZYX euler - // product. The y-down convention conjugates it by the render-space - // flip (bake y-negate x matrix x-flip = vanilla `scale(-1,-1,1)`): - // x and y angles negate, z keeps its sign, order stays ZYX. - let rot_mat = match (quat_rot, self.convention) { - (Some(q), _) => Mat4::from_quat(q), - (None, ModelConvention::EntityYDown) => { + // Vanilla's `translateAndRotate` ZYX euler product. The y-down + // convention conjugates it by the render-space flip (bake + // y-negate x matrix x-flip = vanilla `scale(-1,-1,1)`): x and y + // angles negate, z keeps its sign, order stays ZYX. + let rot_mat = match self.convention { + ModelConvention::EntityYDown => { Mat4::from_rotation_z(rot.z) * Mat4::from_rotation_y(-rot.y) * Mat4::from_rotation_x(-rot.x) } - (None, ModelConvention::BlockYUp) => { + ModelConvention::BlockYUp => { Mat4::from_rotation_z(rot.z) * Mat4::from_rotation_y(rot.y) * Mat4::from_rotation_x(rot.x) @@ -1513,12 +1500,7 @@ pub fn compute_humanoid_anim( for (i, part) in model.parts.iter().enumerate() { let rot = match part.name.as_str() { - "head" => { - let rot = Quat::from_rotation_y(local_head_y_rot_deg.to_radians()) - * Quat::from_rotation_x(head_x_rot_deg.to_radians()); - let (x, y, z) = rot.to_euler(glam::EulerRot::XYZ); - Vec3::new(x, y, z) - } + "head" => head_rotation(head_x_rot_deg, local_head_y_rot_deg), "body" if is_crouching => Vec3::new(0.5, 0.0, 0.0), "right_arm" => Vec3::new( (walk_pos * 0.6662 + std::f32::consts::PI).cos() * 2.0 * walk_speed * 0.5 @@ -1569,16 +1551,10 @@ pub fn compute_quadruped_anim( for (i, part) in model.parts.iter().enumerate() { let rot = match part.name.as_str() { - "head" => { - let rot = Quat::from_rotation_y(local_head_y_rot_deg.to_radians()) - * Quat::from_rotation_x( - head_x_rot_deg_override - .unwrap_or(head_x_rot_deg) - .to_radians(), - ); - let (x, y, z) = rot.to_euler(glam::EulerRot::XYZ); - Vec3::new(x, y, z) - } + "head" => head_rotation( + head_x_rot_deg_override.unwrap_or(head_x_rot_deg), + local_head_y_rot_deg, + ), "right_hind_leg" => Vec3::new((walk_pos * 0.6662).cos() * 1.4 * walk_speed, 0.0, 0.0), "left_hind_leg" => Vec3::new( (walk_pos * 0.6662 + std::f32::consts::PI).cos() * 1.4 * walk_speed, @@ -1635,11 +1611,15 @@ pub fn compute_chicken_anim( anim } +/// Vanilla head look, `Ry(yaw)·Rx(pitch)`: `compute_part_transforms` composes +/// vanilla's ZYX order with the render-space sign conjugation itself, so the +/// angles pass through unchanged. fn head_rotation(head_x_rot_deg: f32, local_head_y_rot_deg: f32) -> Vec3 { - let rot = Quat::from_rotation_y(local_head_y_rot_deg.to_radians()) - * Quat::from_rotation_x(head_x_rot_deg.to_radians()); - let (x, y, z) = rot.to_euler(glam::EulerRot::XYZ); - Vec3::new(x, y, z) + Vec3::new( + head_x_rot_deg.to_radians(), + local_head_y_rot_deg.to_radians(), + 0.0, + ) } /// Vanilla `AnimationUtils.bobModelPart`: a gentle idle sway added to undead @@ -1761,39 +1741,29 @@ pub fn compute_spider_anim( let step = |phase: f32| ((pos + phase).sin() * 0.4).abs() * walk_speed; let three_half_pi = 3.0 * FRAC_PI_2; - // Each leg's exact render-space orientation = F·vanilla·F = Rz(-z)·Ry(+y) - // (Y unchanged under the Y-flip; X/Z negate). Build it as a quaternion so the - // engine reproduces vanilla's composition order exactly. - let leg_quat = - |full_y: f32, full_z: f32| Quat::from_rotation_z(-full_z) * Quat::from_rotation_y(full_y); + let leg_rot = |full_y: f32, full_z: f32| Vec3::new(0.0, full_y, full_z); for (i, part) in model.parts.iter().enumerate() { let base = part.default_rotation; - let q = match part.name.as_str() { - "head" => { - anim.rotation - .push((i, head_rotation(head_x_rot_deg, local_head_y_rot_deg))); - continue; - } - "right_hind_leg" => leg_quat(base.y + swing(0.0), base.z + step(0.0)), - "left_hind_leg" => leg_quat(base.y - swing(0.0), base.z - step(0.0)), - "right_middle_hind_leg" => leg_quat(base.y + swing(PI), base.z + step(PI)), - "left_middle_hind_leg" => leg_quat(base.y - swing(PI), base.z - step(PI)), + let rot = match part.name.as_str() { + "head" => head_rotation(head_x_rot_deg, local_head_y_rot_deg), + "right_hind_leg" => leg_rot(base.y + swing(0.0), base.z + step(0.0)), + "left_hind_leg" => leg_rot(base.y - swing(0.0), base.z - step(0.0)), + "right_middle_hind_leg" => leg_rot(base.y + swing(PI), base.z + step(PI)), + "left_middle_hind_leg" => leg_rot(base.y - swing(PI), base.z - step(PI)), "right_middle_front_leg" => { - leg_quat(base.y + swing(FRAC_PI_2), base.z + step(FRAC_PI_2)) - } - "left_middle_front_leg" => { - leg_quat(base.y - swing(FRAC_PI_2), base.z - step(FRAC_PI_2)) + leg_rot(base.y + swing(FRAC_PI_2), base.z + step(FRAC_PI_2)) } + "left_middle_front_leg" => leg_rot(base.y - swing(FRAC_PI_2), base.z - step(FRAC_PI_2)), "right_front_leg" => { - leg_quat(base.y + swing(three_half_pi), base.z + step(three_half_pi)) + leg_rot(base.y + swing(three_half_pi), base.z + step(three_half_pi)) } "left_front_leg" => { - leg_quat(base.y - swing(three_half_pi), base.z - step(three_half_pi)) + leg_rot(base.y - swing(three_half_pi), base.z - step(three_half_pi)) } _ => continue, }; - anim.rotation_quat.push((i, q)); + anim.rotation.push((i, rot)); } anim @@ -1818,12 +1788,12 @@ pub fn compute_villager_anim( let rot = match part.name.as_str() { "head" => { if is_unhappy { - // Vanilla composes ZYX: zRot = shake, yRot = yaw, xRot = 0.4. - let rot = Quat::from_rotation_z(0.3 * (0.45 * age_in_ticks).sin()) - * Quat::from_rotation_y(local_head_y_rot_deg.to_radians()) - * Quat::from_rotation_x(0.4); - let (x, y, z) = rot.to_euler(glam::EulerRot::XYZ); - Vec3::new(x, y, z) + // zRot = shake, yRot = yaw, xRot = 0.4 (looking down). + Vec3::new( + 0.4, + local_head_y_rot_deg.to_radians(), + 0.3 * (0.45 * age_in_ticks).sin(), + ) } else { head_rotation(head_x_rot_deg, local_head_y_rot_deg) } diff --git a/pomme-client/src/renderer/pipelines/block_entity.rs b/pomme-client/src/renderer/pipelines/block_entity.rs index 7ec229fc..8d96d4b2 100644 --- a/pomme-client/src/renderer/pipelines/block_entity.rs +++ b/pomme-client/src/renderer/pipelines/block_entity.rs @@ -168,7 +168,6 @@ fn lid_anim(kind: BlockEntityKind, openness: f32) -> PartAnim { BlockEntityKind::ShulkerBox => PartAnim { rotation: vec![(0, glam::Vec3::new(0.0, eased * 270.0f32.to_radians(), 0.0))], translation: vec![(0, glam::Vec3::new(0.0, -eased * 8.0, 0.0))], - ..Default::default() }, _ => PartAnim::default(), } From 84439d728cba8662c47233c5c7d127ea461db3da Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 16:56:39 +0100 Subject: [PATCH 14/19] cleanup --- pomme-client/src/renderer/entity_model.rs | 7 +++++++ pomme-client/src/renderer/pipelines/block_entity.rs | 9 ++++----- pomme-client/src/renderer/pipelines/entity_renderer.rs | 6 ++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 465b7b78..41db0da8 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -7,6 +7,13 @@ use super::chunk::mesher::ChunkVertex; /// feet don't z-fight the block top. const MODEL_REBASE_Y: f32 = 24.016; +/// The X half of vanilla's `scale(-1,-1,1)` (the bake negates Y); composed +/// innermost into the entity and block-entity model matrices. Without it +/// every model renders left-right mirrored. +pub(crate) fn render_x_flip() -> Mat4 { + Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)) +} + #[derive(Clone, Copy)] pub struct ModelCube { pub origin: Vec3, diff --git a/pomme-client/src/renderer/pipelines/block_entity.rs b/pomme-client/src/renderer/pipelines/block_entity.rs index 8d96d4b2..16609034 100644 --- a/pomme-client/src/renderer/pipelines/block_entity.rs +++ b/pomme-client/src/renderer/pipelines/block_entity.rs @@ -11,7 +11,7 @@ use pyronyx::vk; use crate::assets::{AssetIndex, resolve_asset_path}; use crate::renderer::camera::CameraUniform; use crate::renderer::chunk::mesher::ChunkVertex; -use crate::renderer::entity_model::{BakedEntityModel, ModelConvention, PartAnim}; +use crate::renderer::entity_model::{BakedEntityModel, ModelConvention, PartAnim, render_x_flip}; use crate::renderer::pipelines::entity_renderer::{ BlendMode, ModelInput, WHITE_TINT, create_pipeline, fallback_texture, }; @@ -537,13 +537,12 @@ impl BlockEntityPipeline { ) - anchor) .as_vec3(); let model_mat = match model.convention { - // The X flip pairs with the bake's Y negation to reproduce - // vanilla's `scale(1,-1,-1)` (= the 180 yaw offset times - // `scale(-1,-1,1)`), same as the entity renderer. + // With the 180 yaw offset the flip reproduces vanilla's + // block-entity `scale(1,-1,-1)`. ModelConvention::EntityYDown => { glam::Mat4::from_translation(block_center) * glam::Mat4::from_rotation_y((180.0f32 - info.yaw).to_radians()) - * glam::Mat4::from_scale(glam::Vec3::new(-1.0, 1.0, 1.0)) + * render_x_flip() } // Vanilla `ChestRenderer`: rotate by -facing.toYRot() about the // block center; coords are relative to the block's min corner. diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index b4ba9100..89fd9cd6 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -935,13 +935,11 @@ impl EntityRenderer { } /// The translation is anchor-relative, subtracted in f64 (see - /// `Camera::anchor`). The trailing X flip is the other half of vanilla's - /// `scale(-1,-1,1)` (the bake negates Y); without it every model renders - /// left-right mirrored. + /// `Camera::anchor`). fn entity_matrix(info: &EntityRenderInfo, anchor: glam::DVec3) -> glam::Mat4 { glam::Mat4::from_translation((*info.position - anchor).as_vec3()) * glam::Mat4::from_rotation_y((180.0 - info.body_y_rot_deg).to_radians()) - * glam::Mat4::from_scale(glam::Vec3::new(-1.0, 1.0, 1.0)) + * entity_model::render_x_flip() } #[allow(clippy::too_many_arguments)] From 5ef446849483c68d39c72db113f0ddba751a6203 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 18:11:16 +0100 Subject: [PATCH 15/19] cleanup --- .../src/renderer/block_entity_model.rs | 115 +++--------------- pomme-client/src/renderer/entity_model.rs | 93 +++++++++----- .../src/renderer/pipelines/block_entity.rs | 7 +- .../src/renderer/pipelines/entity_renderer.rs | 1 - .../src/renderer/pipelines/skin_preview.rs | 14 ++- 5 files changed, 92 insertions(+), 138 deletions(-) diff --git a/pomme-client/src/renderer/block_entity_model.rs b/pomme-client/src/renderer/block_entity_model.rs index 34aeafea..cdd2e792 100644 --- a/pomme-client/src/renderer/block_entity_model.rs +++ b/pomme-client/src/renderer/block_entity_model.rs @@ -1,9 +1,8 @@ use glam::Vec3; -use super::chunk::mesher::{ChunkVertex, PACKED_WHITE_SHIFTED, pack_light_tint, pack_uv}; use super::entity_model::{ - BakedEntityModel, EntityPart, ModelConvention, ModelCube, bake_model, - generate_cube_vertices_faces, + BakedEntityModel, EntityPart, FACE_ALL, FACE_NEG_X, FACE_POS_X, ModelConvention, ModelCube, + bake_model, generate_cube_vertices, generate_cube_vertices_faces, }; /// Shulker box, closed state. Matches vanilla `ShulkerModel`: a 16x12x16 lid @@ -43,7 +42,8 @@ pub fn bake_shulker_box_model() -> BakedEntityModel { /// board (one block wide, centered) raised on a 1.33x9.33x1.33 post. Geometry /// and UVs are in block-model units (16 = one block); UVs are in 0-16 space so /// the model bakes against a 16x16 reference even though the texture -/// (`block/_sign.png`) is 32x32. Face order: -Z, +Z, +Y, -Y, -X, +X. +/// (`block/_sign.png`) is 32x32. Face order: -Z, +Z, top, bottom, +/// -X, +X. pub fn bake_sign_model() -> BakedEntityModel { // Face order -Z, +Z, top, bottom, -X, +X; the render-space X flip puts // the model's -X face on the world's +X side, so the side rects are @@ -101,100 +101,12 @@ pub fn bake_sign_model() -> BakedEntityModel { BakedEntityModel::new(parts, vertices, part_ranges) } -const FACE_DOWN: u8 = 1 << 0; -const FACE_UP: u8 = 1 << 1; -const FACE_WEST: u8 = 1 << 2; -const FACE_NORTH: u8 = 1 << 3; -const FACE_EAST: u8 = 1 << 4; -const FACE_SOUTH: u8 = 1 << 5; -const FACE_ALL: u8 = 0x3F; - -/// Emit one vanilla `ModelPart.Cube` in literal y-up part-local space with the -/// exact vanilla box unwrap (unlike `generate_cube_vertices`, which negates Y -/// and lays UVs out for the entity convention). `origin`/`size` are in model -/// pixels; `faces` masks which quads are emitted (double-chest halves cull the -/// seam face). -fn emit_vanilla_cube( - origin: Vec3, - size: Vec3, - tex_offset: (u32, u32), - tex_w: u32, - tex_h: u32, - faces: u8, - vertices: &mut Vec, -) { - let (w, h, d) = (size.x, size.y, size.z); - let (x0, y0, z0) = (origin.x, origin.y, origin.z); - let (x1, y1, z1) = (x0 + w, y0 + h, z0 + d); - - let t0 = [x0, y0, z0]; - let t1 = [x1, y0, z0]; - let t2 = [x1, y1, z0]; - let t3 = [x0, y1, z0]; - let l0 = [x0, y0, z1]; - let l1 = [x1, y0, z1]; - let l2 = [x1, y1, z1]; - let l3 = [x0, y1, z1]; - - let u0 = tex_offset.0 as f32; - let v0 = tex_offset.1 as f32; - let u1 = u0 + d; - let u2 = u1 + w; - let u22 = u2 + w; - let u3 = u2 + d; - let u4 = u3 + w; - let v1 = v0 + d; - let v2 = v1 + h; - - // Vertex order and per-corner UVs match vanilla's Cube constructor; the UP - // face's v runs reversed there too. - let quads = [ - ( - FACE_DOWN, - [(l1, u2, v0), (l0, u1, v0), (t0, u1, v1), (t1, u2, v1)], - ), - ( - FACE_UP, - [(t2, u22, v1), (t3, u2, v1), (l3, u2, v0), (l2, u22, v0)], - ), - ( - FACE_WEST, - [(t0, u1, v1), (l0, u0, v1), (l3, u0, v2), (t3, u1, v2)], - ), - ( - FACE_NORTH, - [(t1, u2, v1), (t0, u1, v1), (t3, u1, v2), (t2, u2, v2)], - ), - ( - FACE_EAST, - [(l1, u3, v1), (t1, u2, v1), (t2, u2, v2), (l2, u3, v2)], - ), - ( - FACE_SOUTH, - [(l0, u4, v1), (l1, u3, v1), (l2, u3, v2), (l3, u4, v2)], - ), - ]; - - for (face, corners) in quads { - if faces & face == 0 { - continue; - } - for &i in &[0usize, 1, 2, 0, 2, 3] { - let (pos, u, v) = corners[i]; - vertices.push(ChunkVertex { - position: [pos[0] / 16.0, pos[1] / 16.0, pos[2] / 16.0], - tex_coords: pack_uv(u / tex_w as f32, v / tex_h as f32), - // TODO: full-bright; vanilla samples the lightmap at the block - // (pending lighting support in the entity pipeline). - light_tint: pack_light_tint(1.0, PACKED_WHITE_SHIFTED), - }); - } - } -} - /// One chest layer as parts [bottom, lid, lock], matching vanilla `ChestModel` /// (single/double-left/double-right differ only in body/lock x extents and the /// culled seam face). Texture 64x64; lid and lock pivot at offset (0, 9, 1). +/// Baked in literal y-up block space (`y_down: false`). +// TODO: full-bright; vanilla samples the lightmap at the block (pending +// lighting support in the entity pipeline). fn bake_chest_layer( body_x0: f32, body_w: f32, @@ -231,7 +143,14 @@ fn bake_chest_layer( let mut parts = Vec::new(); for (name, offset, origin, size, tex_offset) in cubes { let start = vertices.len() as u32; - emit_vanilla_cube(origin, size, tex_offset, 64, 64, faces, &mut vertices); + let cube = ModelCube { + origin, + size, + tex_offset, + deformation: 0.0, + mirror: false, + }; + generate_cube_vertices(&cube, 64, 64, faces, false, &mut vertices); part_ranges.push((start, vertices.len() as u32 - start)); parts.push(EntityPart { name: name.into(), @@ -251,7 +170,7 @@ fn bake_chest_layer( pub fn bake_chest_models() -> Vec { vec![ bake_chest_layer(1.0, 14.0, 7.0, 2.0, FACE_ALL), - bake_chest_layer(0.0, 15.0, 0.0, 1.0, FACE_ALL & !FACE_WEST), - bake_chest_layer(1.0, 15.0, 15.0, 1.0, FACE_ALL & !FACE_EAST), + bake_chest_layer(0.0, 15.0, 0.0, 1.0, FACE_ALL & !FACE_NEG_X), + bake_chest_layer(1.0, 15.0, 15.0, 1.0, FACE_ALL & !FACE_POS_X), ] } diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 41db0da8..3038f0e7 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -7,10 +7,10 @@ use super::chunk::mesher::ChunkVertex; /// feet don't z-fight the block top. const MODEL_REBASE_Y: f32 = 24.016; -/// The X half of vanilla's `scale(-1,-1,1)` (the bake negates Y); composed -/// innermost into the entity and block-entity model matrices. Without it -/// every model renders left-right mirrored. -pub(crate) fn render_x_flip() -> Mat4 { +/// The X half of vanilla's `scale(-1,-1,1)` (the bake negates Y); prepended +/// to every root part transform of an `EntityYDown` model. Without it every +/// model renders left-right mirrored. +fn render_x_flip() -> Mat4 { Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)) } @@ -69,8 +69,9 @@ pub struct EntityPart { #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum ModelConvention { /// Vanilla entity convention: cube Y negated at bake, root pivots at - /// `(24 - y)/16` (child pivots just negate y), euler signs (-x, -y, +z). - /// All mob models use this. + /// `(24.016 - y)/16` (child pivots just negate y), and the X half of + /// vanilla's `scale(-1,-1,1)` prepended to root transforms — callers' + /// model matrices need no flip of their own. All mob models use this. #[default] EntityYDown, /// Vanilla block-entity literal space: y-up, coords/16 relative to the @@ -154,14 +155,15 @@ impl BakedEntityModel { ModelConvention::BlockYUp => pivot, } / 16.0; - // Vanilla's `translateAndRotate` ZYX euler product. The y-down - // convention conjugates it by the render-space flip (bake - // y-negate x matrix x-flip = vanilla `scale(-1,-1,1)`): x and y - // angles negate, z keeps its sign, order stays ZYX. + // Vanilla's `translateAndRotate` ZYX euler product. Only the + // Y-negate lives inside the part frames (the X flip is prepended + // outside, below), so the y-down convention conjugates rotations + // by diag(1,-1,1): x and z angles negate, y keeps its sign, + // order stays ZYX — matching the pivot's y-only mirror above. let rot_mat = match self.convention { ModelConvention::EntityYDown => { - Mat4::from_rotation_z(rot.z) - * Mat4::from_rotation_y(-rot.y) + Mat4::from_rotation_z(-rot.z) + * Mat4::from_rotation_y(rot.y) * Mat4::from_rotation_x(-rot.x) } ModelConvention::BlockYUp => { @@ -177,6 +179,8 @@ impl BakedEntityModel { let transform = if let Some(parent_idx) = part.parent { transforms[parent_idx] * local + } else if self.convention == ModelConvention::EntityYDown { + render_x_flip() * local } else { local }; @@ -195,7 +199,7 @@ pub fn bake_model(parts: Vec, tex_w: u32, tex_h: u32) -> BakedEntity for part in &parts { let start = vertices.len() as u32; for cube in &part.cubes { - generate_cube_vertices(cube, tex_w, tex_h, &mut vertices); + generate_cube_vertices(cube, tex_w, tex_h, FACE_ALL, true, &mut vertices); } let count = vertices.len() as u32 - start; part_ranges.push((start, count)); @@ -1618,11 +1622,17 @@ pub fn compute_chicken_anim( anim } -/// Vanilla head look, `Ry(yaw)·Rx(pitch)`: `compute_part_transforms` composes -/// vanilla's ZYX order with the render-space sign conjugation itself, so the -/// angles pass through unchanged. +/// A vanilla `(xRot, yRot, zRot)` triple, passed through unchanged: +/// `compute_part_transforms` composes vanilla's ZYX order with the +/// render-space sign conjugation itself. Kept as a marker for vanilla-sourced +/// multi-axis rotations. +fn vanilla_rot(x: f32, y: f32, z: f32) -> Vec3 { + Vec3::new(x, y, z) +} + +/// Vanilla head look, `Ry(yaw)·Rx(pitch)`. fn head_rotation(head_x_rot_deg: f32, local_head_y_rot_deg: f32) -> Vec3 { - Vec3::new( + vanilla_rot( head_x_rot_deg.to_radians(), local_head_y_rot_deg.to_radians(), 0.0, @@ -1748,7 +1758,7 @@ pub fn compute_spider_anim( let step = |phase: f32| ((pos + phase).sin() * 0.4).abs() * walk_speed; let three_half_pi = 3.0 * FRAC_PI_2; - let leg_rot = |full_y: f32, full_z: f32| Vec3::new(0.0, full_y, full_z); + let leg_rot = |full_y: f32, full_z: f32| vanilla_rot(0.0, full_y, full_z); for (i, part) in model.parts.iter().enumerate() { let base = part.default_rotation; @@ -1796,7 +1806,7 @@ pub fn compute_villager_anim( "head" => { if is_unhappy { // zRot = shake, yRot = yaw, xRot = 0.4 (looking down). - Vec3::new( + vanilla_rot( 0.4, local_head_y_rot_deg.to_radians(), 0.3 * (0.45 * age_in_ticks).sin(), @@ -1827,12 +1837,16 @@ pub fn compute_villager_anim( /// vanilla NORTH, SOUTH, DOWN, UP, WEST, EAST. `mirror` swaps the minX/maxX /// corner labels (vanilla's UV-only mirror; `push_face` also reverses the /// quad). -fn cube_face_positions(cube: &ModelCube) -> [[[f32; 3]; 4]; 6] { +fn cube_face_positions(cube: &ModelCube, y_down: bool) -> [[[f32; 3]; 4]; 6] { let inf = cube.deformation; let mut x0 = (cube.origin.x - inf) / 16.0; let mut x1 = (cube.origin.x + cube.size.x + inf) / 16.0; - let y0 = -((cube.origin.y - inf) / 16.0); - let y1 = -((cube.origin.y + cube.size.y + inf) / 16.0); + let mut y0 = (cube.origin.y - inf) / 16.0; + let mut y1 = (cube.origin.y + cube.size.y + inf) / 16.0; + if y_down { + y0 = -y0; + y1 = -y1; + } let z0 = (cube.origin.z - inf) / 16.0; let z1 = (cube.origin.z + cube.size.z + inf) / 16.0; if cube.mirror { @@ -1859,9 +1873,9 @@ fn cube_face_positions(cube: &ModelCube) -> [[[f32; 3]; 4]; 6] { /// Emit one quad as two triangles with vanilla `ModelPart.Polygon` UV /// corners: vertex 0 gets `(u1, v0)`, then `(u0, v0)`, `(u0, v1)`, /// `(u1, v1)` — the rect params are used as passed (the box unwrap hands the -/// maxY face a V-reversed rect on purpose). `mirror` reverses the quad, -/// completing vanilla's mirror alongside the corner swap in -/// `cube_face_positions`. +/// maxY face a V-reversed rect on purpose). The visible half of vanilla's +/// mirror is the minX/maxX swap in `cube_face_positions`; `mirror` here only +/// reverses the quad to restore vanilla's winding parity. fn push_face( positions: &[[f32; 3]; 4], u0: f32, @@ -1893,10 +1907,21 @@ fn push_face( } } -fn generate_cube_vertices( +/// Face-mask bits for the box emitters, by face slot (0 -Z, 1 +Z, 2 minY, +/// 3 maxY, 4 -X, 5 +X); double-chest halves cull their seam face. +pub(crate) const FACE_NEG_X: u8 = 1 << 4; +pub(crate) const FACE_POS_X: u8 = 1 << 5; +pub(crate) const FACE_ALL: u8 = 0x3F; + +/// Emits one vanilla `ModelPart.Cube` with the vanilla box unwrap. `y_down` +/// picks the coordinate space: negated Y for entity models, literal y-up for +/// block-entity models (chests). +pub(crate) fn generate_cube_vertices( cube: &ModelCube, tex_w: u32, tex_h: u32, + faces: u8, + y_down: bool, vertices: &mut Vec, ) { let tw = tex_w as f32; @@ -1920,8 +1945,11 @@ fn generate_cube_vertices( [u + d + w, v + d, u + 2.0 * d + w, v + d + h], ]; - let positions = cube_face_positions(cube); - for (pos, uv) in positions.iter().zip(&face_uv) { + let positions = cube_face_positions(cube, y_down); + for (slot, (pos, uv)) in positions.iter().zip(&face_uv).enumerate() { + if faces & (1 << slot) == 0 { + continue; + } push_face( pos, uv[0] / tw, @@ -1936,7 +1964,10 @@ fn generate_cube_vertices( /// Like [`generate_cube_vertices`] but with explicit per-face UV rects (face /// order -Z, +Z, minY, maxY, -X, +X) instead of the entity box-unwrap, for -/// block models whose texture layout isn't a box-unwrap (e.g. signs). +/// block models whose texture layout isn't a box-unwrap (e.g. signs). Rects +/// apply in plain corner order on every slot — unlike the box unwrap, slot 3 +/// (maxY) gets no V reversal, so an asymmetric down face needs a +/// pre-reversed rect. pub(crate) fn generate_cube_vertices_faces( cube: &ModelCube, face_uvs: &[[f32; 4]; 6], @@ -1946,7 +1977,7 @@ pub(crate) fn generate_cube_vertices_faces( ) { let tw = tex_w as f32; let th = tex_h as f32; - let positions = cube_face_positions(cube); + let positions = cube_face_positions(cube, true); for (pos, uv) in positions.iter().zip(face_uvs) { push_face( pos, @@ -1954,7 +1985,7 @@ pub(crate) fn generate_cube_vertices_faces( uv[1] / th, uv[2] / tw, uv[3] / th, - cube.mirror, + false, vertices, ); } diff --git a/pomme-client/src/renderer/pipelines/block_entity.rs b/pomme-client/src/renderer/pipelines/block_entity.rs index 16609034..f1c0f368 100644 --- a/pomme-client/src/renderer/pipelines/block_entity.rs +++ b/pomme-client/src/renderer/pipelines/block_entity.rs @@ -11,7 +11,7 @@ use pyronyx::vk; use crate::assets::{AssetIndex, resolve_asset_path}; use crate::renderer::camera::CameraUniform; use crate::renderer::chunk::mesher::ChunkVertex; -use crate::renderer::entity_model::{BakedEntityModel, ModelConvention, PartAnim, render_x_flip}; +use crate::renderer::entity_model::{BakedEntityModel, ModelConvention, PartAnim}; use crate::renderer::pipelines::entity_renderer::{ BlendMode, ModelInput, WHITE_TINT, create_pipeline, fallback_texture, }; @@ -537,12 +537,11 @@ impl BlockEntityPipeline { ) - anchor) .as_vec3(); let model_mat = match model.convention { - // With the 180 yaw offset the flip reproduces vanilla's - // block-entity `scale(1,-1,-1)`. + // With the 180 yaw offset and the convention's baked-in flip + // this reproduces vanilla's block-entity `scale(1,-1,-1)`. ModelConvention::EntityYDown => { glam::Mat4::from_translation(block_center) * glam::Mat4::from_rotation_y((180.0f32 - info.yaw).to_radians()) - * render_x_flip() } // Vanilla `ChestRenderer`: rotate by -facing.toYRot() about the // block center; coords are relative to the block's min corner. diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index 89fd9cd6..b44a8f63 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -939,7 +939,6 @@ impl EntityRenderer { fn entity_matrix(info: &EntityRenderInfo, anchor: glam::DVec3) -> glam::Mat4 { glam::Mat4::from_translation((*info.position - anchor).as_vec3()) * glam::Mat4::from_rotation_y((180.0 - info.body_y_rot_deg).to_radians()) - * entity_model::render_x_flip() } #[allow(clippy::too_many_arguments)] diff --git a/pomme-client/src/renderer/pipelines/skin_preview.rs b/pomme-client/src/renderer/pipelines/skin_preview.rs index 7edbf7cc..f8f27a3a 100644 --- a/pomme-client/src/renderer/pipelines/skin_preview.rs +++ b/pomme-client/src/renderer/pipelines/skin_preview.rs @@ -669,8 +669,11 @@ pub(crate) fn create_pipeline( pipeline } -// Vanilla model coordinates: Y-down, 1 unit = 1 pixel -// We convert to Y-up by negating Y, then scale by PX +// Vanilla model coordinates: Y-down, 1 unit = 1 pixel; converted to Y-up by +// negating Y, then scaled by PX. Unlike the entity renderer (X flip + face +// rect on -Z), this mesh is built Z-reflected — face rect on +Z, viewed from +// -Z — which yields the identical image; don't "fix" one convention without +// the other. const PX: f32 = 1.0 / 16.0; fn uv(x: u32, y: u32, w: u32, h: u32) -> [[f32; 2]; 4] { @@ -750,11 +753,14 @@ fn add_box( [[x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1]], uv(tx + td, ty, tw, td), ); - // Bottom (-Y in our space = +Y in vanilla = bottom) + // Bottom (-Y in our space = +Y in vanilla = bottom). Vanilla reads the + // maxY face's rect with V reversed (`ModelPart.Cube`'s UP polygon). + let mut bottom_uv = uv(tx + td + tw, ty, tw, td); + bottom_uv.reverse(); quad( verts, [[x0, y0, z1], [x1, y0, z1], [x1, y0, z0], [x0, y0, z0]], - uv(tx + td + tw, ty, tw, td), + bottom_uv, ); } From 5b1c629b5fe6a3e3c646f511c53cb532acc833be Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 18:18:43 +0100 Subject: [PATCH 16/19] cleanup --- pomme-client/src/renderer/entity_model.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 3038f0e7..838a6dc8 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -7,13 +7,6 @@ use super::chunk::mesher::ChunkVertex; /// feet don't z-fight the block top. const MODEL_REBASE_Y: f32 = 24.016; -/// The X half of vanilla's `scale(-1,-1,1)` (the bake negates Y); prepended -/// to every root part transform of an `EntityYDown` model. Without it every -/// model renders left-right mirrored. -fn render_x_flip() -> Mat4 { - Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)) -} - #[derive(Clone, Copy)] pub struct ModelCube { pub origin: Vec3, @@ -180,7 +173,9 @@ impl BakedEntityModel { let transform = if let Some(parent_idx) = part.parent { transforms[parent_idx] * local } else if self.convention == ModelConvention::EntityYDown { - render_x_flip() * local + // The X half of vanilla's `scale(-1,-1,1)` (the bake negates + // Y); without it every model renders left-right mirrored. + Mat4::from_scale(Vec3::new(-1.0, 1.0, 1.0)) * local } else { local }; From d7a61ddecdba378fde9e5a7a2c61b0109b71997f Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 19:47:43 +0100 Subject: [PATCH 17/19] cleanup --- pomme-client/src/app/phases/in_game.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index bace1896..e1aa3097 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -2270,6 +2270,8 @@ pub fn update_game( flap: extras.flap, flap_speed: extras.flap_speed, is_creepy: e.is_creepy, + // TODO: derive from the main-hand item (vanilla + // `isHoldingItem`) once mob equipment tracking lands. is_holding_item: e.witch_drinking, nose_wobble_speed: extras.nose_wobble_speed, body_transform: extras.body_transform, From ecc2428f57521a64f70fb1e1cf8fe357a16ec283 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 21:12:33 +0100 Subject: [PATCH 18/19] cleanup --- pomme-client/src/app/core.rs | 22 +- pomme-client/src/entity/mod.rs | 146 ++++++------- pomme-client/src/net/handler.rs | 113 ++-------- pomme-client/src/net/mod.rs | 39 +--- pomme-client/src/renderer/entity_model.rs | 38 ++-- .../src/renderer/pipelines/entity_renderer.rs | 206 +++++++++--------- 6 files changed, 228 insertions(+), 336 deletions(-) diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 0b7488eb..e9e5d388 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -1085,15 +1085,12 @@ impl AppCore { mesh.z_size, ); } - NetworkEvent::EntityBabyFlag { id, is_baby } => { - game.entity_store.set_baby(id, is_baby); + NetworkEvent::EntityData { id, index, value } => { + game.entity_store.apply_entity_data(id, index, value); } NetworkEvent::EntityPose { id, is_crouching } => { game.entity_store.set_crouching(id, is_crouching); } - NetworkEvent::SheepWoolData { id, color, sheared } => { - game.entity_store.set_sheep_wool(id, color, sheared); - } NetworkEvent::SheepEatStart { id } => { game.entity_store.start_sheep_eat(id); } @@ -1115,15 +1112,6 @@ impl AppCore { NetworkEvent::EntityVariant { id, kind, variant } => { game.entity_store.set_variant(id, kind, variant); } - NetworkEvent::MobFlag { id, flag, value } => { - game.entity_store.set_mob_flag(id, flag, value); - } - NetworkEvent::SlimeSize { id, size } => { - game.entity_store.set_slime_size(id, size); - } - NetworkEvent::BoggedSheared { id, sheared } => { - game.entity_store.set_bogged_sheared(id, sheared); - } NetworkEvent::VillagerData { id, kind, @@ -1133,15 +1121,9 @@ impl AppCore { game.entity_store .set_villager_data(id, kind, profession, level); } - NetworkEvent::VillagerUnhappy { id, counter } => { - game.entity_store.set_villager_unhappy(id, counter); - } NetworkEvent::EntityCustomName { id, name } => { game.entity_store.set_custom_name(id, name); } - NetworkEvent::EntityAggressive { id, aggressive } => { - game.entity_store.set_aggressive(id, aggressive); - } NetworkEvent::EntitySwing { id } => { game.entity_store.start_swing(id); } diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index ca5d7b47..1973c59c 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -14,17 +14,35 @@ use crate::physics::collision::resolve_collision; use crate::world::block::{FluidKind, fluid}; use crate::world::chunk::ChunkStore; -/// Kind-gated boolean mob states; each flag belongs to one mob kind and -/// [`EntityStore::set_mob_flag`] drops writes for a mismatched entity. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum MobFlag { - CreeperPowered, - EndermanCreepy, - WitchDrinking, - /// Zombie-family underwater conversion. - ZombieConverting, - /// Zombie villager curing. - ZombieVillagerConverting, +/// A scalar synched-entity-data value, forwarded raw from the wire; +/// [`EntityStore::apply_entity_data`] gives it meaning per (kind, index). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum MetaValue { + Bool(bool), + Int(i32), + Byte(u8), + Float(f32), + Long(i64), +} + +/// Kinds whose entity-data index 16 is the baby flag: `AgeableMob` +/// descendants plus the zombie family (which defines its own baby flag at +/// the same index). NOT baby at 16: Bogged (sheared), Skeleton (stray +/// conversion), Witch (Raider celebrating), fish (from-bucket). +fn is_baby_kind(kind: EntityKind) -> bool { + matches!( + kind, + EntityKind::Pig + | EntityKind::Cow + | EntityKind::Sheep + | EntityKind::Chicken + | EntityKind::Villager + | EntityKind::Slime + | EntityKind::Zombie + | EntityKind::Husk + | EntityKind::Drowned + | EntityKind::ZombieVillager + ) } const INTERPOLATION_STEPS: i32 = 3; @@ -627,20 +645,45 @@ impl EntityStore { } } - pub fn set_baby(&mut self, id: i32, is_baby: bool) { - if let Some(entity) = self.living.get_mut(&id) - // On bogged, entity-data index 16 is the sheared flag, not baby. - && entity.entity_type != EntityKind::Bogged - { - entity.is_baby = is_baby; - } - } - - pub fn set_bogged_sheared(&mut self, id: i32, sheared: bool) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Bogged - { - entity.is_sheared = sheared; + /// Resolves a raw synched-entity-data scalar per (kind, index), the + /// direct analogue of vanilla's per-class `onSyncedDataUpdated`. Index + /// arithmetic follows the registration chain: `Entity` 0-7, + /// `LivingEntity` 8-14, `Mob` 15, `AgeableMob` 16 baby + 17 age-locked, + /// first subclass field 18. Where an index moved between supported + /// versions both spots are accepted (the other index carries an + /// incompatible type or kind on each version). + pub fn apply_entity_data(&mut self, id: i32, index: u8, value: MetaValue) { + use MetaValue::{Bool, Byte, Int}; + let Some(entity) = self.living.get_mut(&id) else { + return; + }; + match (entity.entity_type, index, value) { + // Mob flags byte: bit 0x04 = aggressive. + (_, 15, Byte(f)) => entity.aggressive = f & 0x04 != 0, + (k, 16, Bool(b)) if is_baby_kind(k) => entity.is_baby = b, + // Skeleton: powder-snow stray conversion; drives the vanilla + // `isShaking` body jitter. + (EntityKind::Skeleton, 16, Bool(b)) => entity.is_converting = b, + (EntityKind::Bogged, 16, Bool(b)) => entity.is_sheared = b, + // Slime size: 16 on 1.21.9-26.1.x, 18 since Slime joined + // AgeableMob in 26.2. + (EntityKind::Slime, 16 | 18, Int(s)) => entity.slime_size = s.clamp(1, 127) as u8, + // Sheep wool byte (low nibble = DyeColor, bit 0x10 = sheared): + // 17 on 1.21.9-26.1.x, 18 on 26.2. + (EntityKind::Sheep, 17 | 18, Byte(w)) => { + entity.wool_color = Some(w & 0x0F); + entity.is_sheared = w & 0x10 != 0; + } + (EntityKind::Creeper, 17, Bool(b)) => entity.powered = b, + (EntityKind::Enderman, 17, Bool(b)) => entity.is_creepy = b, + (EntityKind::Witch, 17, Bool(b)) => entity.witch_drinking = b, + // Zombie-family underwater conversion / zombie villager curing. + (EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned, 18, Bool(b)) => { + entity.is_converting = b + } + (EntityKind::ZombieVillager, 19, Bool(b)) => entity.is_converting = b, + (EntityKind::Villager, 18, Int(c)) => entity.unhappy_counter = c, + _ => {} } } @@ -650,15 +693,6 @@ impl EntityStore { } } - pub fn set_sheep_wool(&mut self, id: i32, color: u8, sheared: bool) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Sheep - { - entity.wool_color = Some(color); - entity.is_sheared = sheared; - } - } - /// `kind` is the mob the emitting handler arm resolved the value for; /// metadata indices are overloaded across kinds, so a mismatched entity /// ignores the write. @@ -670,36 +704,6 @@ impl EntityStore { } } - /// Applies a [`MobFlag`] write, dropping it when the entity isn't the - /// flag's mob (metadata indices are overloaded across kinds, so the net - /// handler emits every candidate flag for an ambiguous boolean). - pub fn set_mob_flag(&mut self, id: i32, flag: MobFlag, value: bool) { - let Some(entity) = self.living.get_mut(&id) else { - return; - }; - match (flag, entity.entity_type) { - (MobFlag::CreeperPowered, EntityKind::Creeper) => entity.powered = value, - (MobFlag::EndermanCreepy, EntityKind::Enderman) => entity.is_creepy = value, - (MobFlag::WitchDrinking, EntityKind::Witch) => entity.witch_drinking = value, - ( - MobFlag::ZombieConverting, - EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned, - ) => entity.is_converting = value, - (MobFlag::ZombieVillagerConverting, EntityKind::ZombieVillager) => { - entity.is_converting = value - } - _ => {} - } - } - - pub fn set_slime_size(&mut self, id: i32, size: i32) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Slime - { - entity.slime_size = size.clamp(1, 127) as u8; - } - } - pub fn set_villager_data( &mut self, id: i32, @@ -719,14 +723,6 @@ impl EntityStore { } } - pub fn set_villager_unhappy(&mut self, id: i32, counter: i32) { - if let Some(entity) = self.living.get_mut(&id) - && entity.entity_type == EntityKind::Villager - { - entity.unhappy_counter = counter; - } - } - pub fn start_sheep_eat(&mut self, id: i32) { if let Some(entity) = self.living.get_mut(&id) && entity.entity_type == EntityKind::Sheep @@ -749,12 +745,6 @@ impl EntityStore { } } - pub fn set_aggressive(&mut self, id: i32, aggressive: bool) { - if let Some(entity) = self.living.get_mut(&id) { - entity.aggressive = aggressive; - } - } - /// Begins an arm swing (server `Animate` packet). Restarts when idle or /// past the halfway point (vanilla `LivingEntity.swing`); `swing_time` /// counts down, so that is `swing_time <= SWING_DURATION / 2`. diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 5eac5780..435dcc64 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -8,7 +8,7 @@ use crossbeam_channel::Sender; use super::NetworkEvent; use super::commands::{CommandTree, SharedCommandTree}; use super::sender::PacketSender; -use crate::entity::MobFlag; +use crate::entity::MetaValue; use crate::entity::components::Position; use crate::renderer::pipelines::entity_renderer::{CHICKEN_VARIANT_ORDER, COW_VARIANT_ORDER}; use crate::ui::text::format_text_spans; @@ -455,73 +455,34 @@ pub fn handle_game_packet( is_crouching: matches!(pose, azalea_entity::Pose::Crouching), }); } - // Index 16 (Boolean) = baby flag / bogged sheared. Emit both; - // consumers filter by entity type. - if item.index == 16 - && let azalea_entity::EntityDataValue::Boolean(flag) = &item.value - { - let _ = event_tx.try_send(NetworkEvent::EntityBabyFlag { - id: p.id.0, - is_baby: *flag, - }); - let _ = event_tx.try_send(NetworkEvent::BoggedSheared { + // Scalar values are forwarded raw; the store resolves their + // meaning per (kind, index) like vanilla `onSyncedDataUpdated` + // (`EntityStore::apply_entity_data`). + let scalar = match &item.value { + azalea_entity::EntityDataValue::Boolean(v) => Some(MetaValue::Bool(*v)), + azalea_entity::EntityDataValue::Int(v) => Some(MetaValue::Int(*v)), + azalea_entity::EntityDataValue::Byte(v) => Some(MetaValue::Byte(*v)), + azalea_entity::EntityDataValue::Float(v) => Some(MetaValue::Float(*v)), + azalea_entity::EntityDataValue::Long(v) => Some(MetaValue::Long(*v)), + _ => None, + }; + if let Some(value) = scalar { + let _ = event_tx.try_send(NetworkEvent::EntityData { id: p.id.0, - sheared: *flag, + index: item.index, + value, }); } - // Index 16 (Int) = player score / slime size (Slime's - // DATA_ID_SIZE sits at 16 on 1.21.9-26.1.x; 26.2 moved it to - // 18 when Slime gained AgeableMob's fields). Emit both; - // consumers filter by entity type. - if item.index == 16 + // Index 18 (Int) on players = score (16 on 1.21.9-26.1.x, + // before Avatar took 15/16). Kind-blind; the consumer applies + // it only to the local player. + if (item.index == 16 || item.index == 18) && let azalea_entity::EntityDataValue::Int(score) = &item.value { let _ = event_tx.try_send(NetworkEvent::PlayerScore { entity_id: p.id.0, score: *score, }); - let _ = event_tx.try_send(NetworkEvent::SlimeSize { - id: p.id.0, - size: *score, - }); - } - // Index 15 = mob flags byte (AbstractInsentient): bit 0x04 = aggressive. - if item.index == 15 - && let azalea_entity::EntityDataValue::Byte(flags) = &item.value - { - let _ = event_tx.try_send(NetworkEvent::EntityAggressive { - id: p.id.0, - aggressive: (*flags & 0x04) != 0, - }); - } - // Index 17 = sheep wool/sheared byte (low nibble = DyeColor, bit 4 = sheared). - // Emit unconditionally; consumer filters by entity type. - if item.index == 17 - && let azalea_entity::EntityDataValue::Byte(packed) = &item.value - { - let _ = event_tx.try_send(NetworkEvent::SheepWoolData { - id: p.id.0, - color: *packed & 0x0F, - sheared: (*packed & 0x10) != 0, - }); - } - // Index 17 (Boolean) = creeper powered / enderman creepy / witch - // drinking. Emit every candidate; the store applies only the - // flag matching the entity's kind. - if item.index == 17 - && let azalea_entity::EntityDataValue::Boolean(flag) = &item.value - { - for f in [ - MobFlag::CreeperPowered, - MobFlag::EndermanCreepy, - MobFlag::WitchDrinking, - ] { - let _ = event_tx.try_send(NetworkEvent::MobFlag { - id: p.id.0, - flag: f, - value: *flag, - }); - } } // Index 2 = custom name (Optional); needed for jeb_ sheep detection. if item.index == 2 @@ -554,40 +515,6 @@ pub fn handle_game_packet( variant.protocol_id(), )); } - // Index 18 (Boolean) = zombie-family underwater conversion. - if item.index == 18 - && let azalea_entity::EntityDataValue::Boolean(converting) = &item.value - { - let _ = event_tx.try_send(NetworkEvent::MobFlag { - id: p.id.0, - flag: MobFlag::ZombieConverting, - value: *converting, - }); - } - // Index 19 (Boolean) = zombie villager curing. - if item.index == 19 - && let azalea_entity::EntityDataValue::Boolean(converting) = &item.value - { - let _ = event_tx.try_send(NetworkEvent::MobFlag { - id: p.id.0, - flag: MobFlag::ZombieVillagerConverting, - value: *converting, - }); - } - // Index 18 (Int) = villager unhappy counter / slime size. Emit - // both; consumers filter by entity type. - if item.index == 18 - && let azalea_entity::EntityDataValue::Int(value) = &item.value - { - let _ = event_tx.try_send(NetworkEvent::VillagerUnhappy { - id: p.id.0, - counter: *value, - }); - let _ = event_tx.try_send(NetworkEvent::SlimeSize { - id: p.id.0, - size: *value, - }); - } // Index 19 on villagers / 20 on zombie villagers = VillagerData // (type/profession/level). if (item.index == 19 || item.index == 20) diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index ba48d20c..f08c3a7f 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -17,7 +17,7 @@ use azalea_registry::builtin::{BlockEntityKind, EntityKind}; use glam::DVec3; use simdnbt::owned::NbtCompound; -use crate::entity::MobFlag; +use crate::entity::MetaValue; use crate::entity::components::Position; use crate::entity::villager::{VillagerKind, VillagerProfession}; @@ -288,19 +288,18 @@ pub enum NetworkEvent { id: i32, head_y_rot_deg: f32, }, - EntityBabyFlag { + /// A raw scalar entity-data value; `EntityStore::apply_entity_data` + /// resolves its meaning per (kind, index) like vanilla's + /// `onSyncedDataUpdated`. + EntityData { id: i32, - is_baby: bool, + index: u8, + value: MetaValue, }, EntityPose { id: i32, is_crouching: bool, }, - SheepWoolData { - id: i32, - color: u8, - sheared: bool, - }, SheepEatStart { id: i32, }, @@ -317,40 +316,16 @@ pub enum NetworkEvent { kind: EntityKind, variant: u32, }, - /// Kind-gated boolean mob state; the store drops writes whose entity - /// kind doesn't match the flag's mob (metadata indices are overloaded - /// across kinds). - MobFlag { - id: i32, - flag: MobFlag, - value: bool, - }, - SlimeSize { - id: i32, - size: i32, - }, - BoggedSheared { - id: i32, - sheared: bool, - }, VillagerData { id: i32, kind: VillagerKind, profession: VillagerProfession, level: u32, }, - VillagerUnhappy { - id: i32, - counter: i32, - }, EntityCustomName { id: i32, name: Option, }, - EntityAggressive { - id: i32, - aggressive: bool, - }, EntitySwing { id: i32, }, diff --git a/pomme-client/src/renderer/entity_model.rs b/pomme-client/src/renderer/entity_model.rs index 9db43dcc..3c5a6ef1 100644 --- a/pomme-client/src/renderer/entity_model.rs +++ b/pomme-client/src/renderer/entity_model.rs @@ -39,6 +39,16 @@ fn quadruped_legs( ] } +/// Index of the named part, panicking at bake time if the mesh changed +/// shape — positional indexing into a shared parts builder goes stale +/// silently. +fn part_index(parts: &[EntityPart], name: &str) -> usize { + parts + .iter() + .position(|p| p.name == name) + .unwrap_or_else(|| panic!("mesh has a {name} part")) +} + /// Mirror a cube's geometry across x=0 WITHOUT flipping UVs (e.g. chicken /// wings and legs share one un-mirrored texture — vanilla quirk). Pair with /// `mirror: true` where vanilla's `.mirror()` UV flip is also wanted. @@ -132,6 +142,12 @@ impl BakedEntityModel { break; } } + // Scaled roots (`bake_scaled`) pre-bake `offset * factor`, so an + // anim translation must scale too; children inherit the scale + // through the parent matrix instead. No-op at the default 1.0. + if part.parent.is_none() { + extra_translation *= self.part_scales.get(i).copied().unwrap_or(1.0); + } let pivot = part.offset + extra_translation; let offset = match self.convention { @@ -633,9 +649,11 @@ pub fn bake_husk_model() -> BakedEntityModel { // once a swim_amount ramp from the pose metadata exists. pub fn bake_drowned_model(g: f32) -> BakedEntityModel { let mut parts = zombie_parts(); - // humanoid_parts order: 3 = left_arm, 5 = left_leg. - parts[3].cubes = vec![vbox((32, 48), (-1.0, -2.0, -2.0), (4.0, 12.0, 4.0))]; - parts[5].cubes = vec![vbox((16, 48), (-2.0, 0.0, -2.0), (4.0, 12.0, 4.0))]; + // Vanilla gives the drowned's left limbs their own UV regions. + let left_arm = part_index(&parts, "left_arm"); + parts[left_arm].cubes = vec![vbox((32, 48), (-1.0, -2.0, -2.0), (4.0, 12.0, 4.0))]; + let left_leg = part_index(&parts, "left_leg"); + parts[left_leg].cubes = vec![vbox((16, 48), (-2.0, 0.0, -2.0), (4.0, 12.0, 4.0))]; inflate(&mut parts, g); bake_model(parts, 64, 64) } @@ -1907,22 +1925,16 @@ pub fn bake_slime_outer_model() -> BakedEntityModel { /// its witch.png region is fully transparent, so its cubes are dropped. fn witch_parts() -> Vec { let mut parts = villager_parts(); - let index_of = |parts: &[EntityPart], name: &str| { - parts - .iter() - .position(|p| p.name == name) - .unwrap_or_else(|| panic!("villager mesh has a {name}")) - }; - let head = index_of(&parts, "head"); - let hat = index_of(&parts, "hat"); - let nose = index_of(&parts, "nose"); + let head = part_index(&parts, "head"); + let hat = part_index(&parts, "hat"); + let nose = part_index(&parts, "nose"); parts[hat] = vpart( "hat", Some(head), Vec3::new(-5.0, -10.03125, -5.0), vec![vbox((0, 64), (0.0, 0.0, 0.0), (10.0, 2.0, 10.0))], ); - let hat_rim = index_of(&parts, "hat_rim"); + let hat_rim = part_index(&parts, "hat_rim"); parts[hat_rim].cubes.clear(); // The cone stacks parent hat -> hat2 -> hat3 -> hat4; the appended parts // land at indices n, n+1, n+2. diff --git a/pomme-client/src/renderer/pipelines/entity_renderer.rs b/pomme-client/src/renderer/pipelines/entity_renderer.rs index d405791b..2eda723e 100644 --- a/pomme-client/src/renderer/pipelines/entity_renderer.rs +++ b/pomme-client/src/renderer/pipelines/entity_renderer.rs @@ -287,6 +287,35 @@ struct MobDef { } fn mob_definitions() -> Vec { + // One single-fallback texture entry per name under an entity texture dir. + macro_rules! tex_table { + ($dir:expr => $($name:literal),+ $(,)?) => { + &[$(&[concat!("minecraft/textures/entity/", $dir, "/", $name, ".png")]),+] + }; + } + // The villager and zombie-villager overlay dirs ship identical + // registry-ordered file names; each list is written once here and both + // mobs' tables expand from it. Types index by the builtin VillagerKind + // registry order, professions by VillagerProfession order minus "none" + // (which has no texture), levels by profession level 1-5 minus one. + macro_rules! villager_type_table { + ($dir:expr) => { + tex_table!($dir => "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga") + }; + } + macro_rules! villager_profession_table { + ($dir:expr) => { + tex_table!($dir => "armorer", "butcher", "cartographer", "cleric", "farmer", + "fisherman", "fletcher", "leatherworker", "librarian", "mason", "nitwit", + "shepherd", "toolsmith", "weaponsmith") + }; + } + macro_rules! villager_level_table { + ($dir:expr) => { + tex_table!($dir => "stone", "iron", "gold", "emerald", "diamond") + }; + } + const PIG_ADULT_TEX: &[&[&str]] = &[&[ "minecraft/textures/entity/pig/pig_temperate.png", "minecraft/textures/entity/pig/temperate_pig.png", @@ -320,83 +349,51 @@ fn mob_definitions() -> Vec { &["minecraft/textures/entity/chicken/chicken_warm_baby.png"], &["minecraft/textures/entity/chicken/chicken_cold_baby.png"], ]; - const SHEEP_ADULT_TEX: &[&[&str]] = &[&["minecraft/textures/entity/sheep/sheep.png"]]; - const SHEEP_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/sheep/sheep_baby.png"]]; - const SHEEP_WOOL_UNDERCOAT_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/sheep/sheep_wool_undercoat.png"]]; - const SHEEP_WOOL_TEX: &[&[&str]] = &[&["minecraft/textures/entity/sheep/sheep_wool.png"]]; - const SHEEP_BABY_WOOL_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/sheep/sheep_wool_baby.png"]]; - const PLAYER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/player/wide/steve.png"]]; - const ZOMBIE_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/zombie.png"]]; - const ZOMBIE_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/zombie_baby.png"]]; - const HUSK_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/husk.png"]]; - const HUSK_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/husk_baby.png"]]; - const DROWNED_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/drowned.png"]]; - const DROWNED_BABY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/zombie/drowned_baby.png"]]; - const DROWNED_OUTER_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/zombie/drowned_outer_layer.png"]]; - const DROWNED_OUTER_BABY_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/zombie/drowned_outer_layer_baby.png"]]; - // One single-fallback texture entry per name under an entity texture dir. - macro_rules! tex_table { - ($dir:literal: $($name:literal),+ $(,)?) => { - &[$(&[concat!("minecraft/textures/entity/", $dir, "/", $name, ".png")]),+] - }; - } - const ZOMBIE_VILLAGER_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/zombie_villager/zombie_villager.png"]]; + const SHEEP_ADULT_TEX: &[&[&str]] = tex_table!("sheep" => "sheep"); + const SHEEP_BABY_TEX: &[&[&str]] = tex_table!("sheep" => "sheep_baby"); + const SHEEP_WOOL_UNDERCOAT_TEX: &[&[&str]] = tex_table!("sheep" => "sheep_wool_undercoat"); + const SHEEP_WOOL_TEX: &[&[&str]] = tex_table!("sheep" => "sheep_wool"); + const SHEEP_BABY_WOOL_TEX: &[&[&str]] = tex_table!("sheep" => "sheep_wool_baby"); + const PLAYER_TEX: &[&[&str]] = tex_table!("player/wide" => "steve"); + const ZOMBIE_TEX: &[&[&str]] = tex_table!("zombie" => "zombie"); + const ZOMBIE_BABY_TEX: &[&[&str]] = tex_table!("zombie" => "zombie_baby"); + const HUSK_TEX: &[&[&str]] = tex_table!("zombie" => "husk"); + const HUSK_BABY_TEX: &[&[&str]] = tex_table!("zombie" => "husk_baby"); + const DROWNED_TEX: &[&[&str]] = tex_table!("zombie" => "drowned"); + const DROWNED_BABY_TEX: &[&[&str]] = tex_table!("zombie" => "drowned_baby"); + const DROWNED_OUTER_TEX: &[&[&str]] = tex_table!("zombie" => "drowned_outer_layer"); + const DROWNED_OUTER_BABY_TEX: &[&[&str]] = tex_table!("zombie" => "drowned_outer_layer_baby"); + const ZOMBIE_VILLAGER_TEX: &[&[&str]] = tex_table!("zombie_villager" => "zombie_villager"); const ZOMBIE_VILLAGER_BABY_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/zombie_villager/zombie_villager_baby.png"]]; - // Indexed by the builtin VillagerKind registry order. - const ZOMBIE_VILLAGER_TYPE_TEX: &[&[&str]] = tex_table!("zombie_villager/type": - "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); - const ZOMBIE_VILLAGER_BABY_TYPE_TEX: &[&[&str]] = tex_table!("zombie_villager/baby": - "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); - // Indexed by VillagerProfession registry order minus one ("none" has no - // texture). - const ZOMBIE_VILLAGER_PROFESSION_TEX: &[&[&str]] = tex_table!("zombie_villager/profession": - "armorer", "butcher", "cartographer", "cleric", "farmer", "fisherman", "fletcher", - "leatherworker", "librarian", "mason", "nitwit", "shepherd", "toolsmith", "weaponsmith"); - // Indexed by profession level 1-5 minus one. - const ZOMBIE_VILLAGER_LEVEL_TEX: &[&[&str]] = tex_table!("zombie_villager/profession_level": - "stone", "iron", "gold", "emerald", "diamond"); - const SKELETON_TEX: &[&[&str]] = &[&["minecraft/textures/entity/skeleton/skeleton.png"]]; - const STRAY_TEX: &[&[&str]] = &[&["minecraft/textures/entity/skeleton/stray.png"]]; - const STRAY_OVERLAY_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/skeleton/stray_overlay.png"]]; - const BOGGED_TEX: &[&[&str]] = &[&["minecraft/textures/entity/skeleton/bogged.png"]]; - const BOGGED_OVERLAY_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/skeleton/bogged_overlay.png"]]; - const CREEPER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/creeper/creeper.png"]]; - const CREEPER_ARMOR_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/creeper/creeper_armor.png"]]; - const SPIDER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/spider/spider.png"]]; - const SPIDER_EYES_TEX: &[&[&str]] = &[&["minecraft/textures/entity/spider/spider_eyes.png"]]; - const ENDERMAN_TEX: &[&[&str]] = &[&["minecraft/textures/entity/enderman/enderman.png"]]; - const ENDERMAN_EYES_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/enderman/enderman_eyes.png"]]; - const SLIME_TEX: &[&[&str]] = &[&["minecraft/textures/entity/slime/slime.png"]]; + tex_table!("zombie_villager" => "zombie_villager_baby"); + const ZOMBIE_VILLAGER_TYPE_TEX: &[&[&str]] = villager_type_table!("zombie_villager/type"); + const ZOMBIE_VILLAGER_BABY_TYPE_TEX: &[&[&str]] = villager_type_table!("zombie_villager/baby"); + const ZOMBIE_VILLAGER_PROFESSION_TEX: &[&[&str]] = + villager_profession_table!("zombie_villager/profession"); + const ZOMBIE_VILLAGER_LEVEL_TEX: &[&[&str]] = + villager_level_table!("zombie_villager/profession_level"); + const SKELETON_TEX: &[&[&str]] = tex_table!("skeleton" => "skeleton"); + const STRAY_TEX: &[&[&str]] = tex_table!("skeleton" => "stray"); + const STRAY_OVERLAY_TEX: &[&[&str]] = tex_table!("skeleton" => "stray_overlay"); + const BOGGED_TEX: &[&[&str]] = tex_table!("skeleton" => "bogged"); + const BOGGED_OVERLAY_TEX: &[&[&str]] = tex_table!("skeleton" => "bogged_overlay"); + const CREEPER_TEX: &[&[&str]] = tex_table!("creeper" => "creeper"); + const CREEPER_ARMOR_TEX: &[&[&str]] = tex_table!("creeper" => "creeper_armor"); + const SPIDER_TEX: &[&[&str]] = tex_table!("spider" => "spider"); + const SPIDER_EYES_TEX: &[&[&str]] = tex_table!("spider" => "spider_eyes"); + const ENDERMAN_TEX: &[&[&str]] = tex_table!("enderman" => "enderman"); + const ENDERMAN_EYES_TEX: &[&[&str]] = tex_table!("enderman" => "enderman_eyes"); + const SLIME_TEX: &[&[&str]] = tex_table!("slime" => "slime"); const WITCH_TEX: &[&[&str]] = &[&[ "minecraft/textures/entity/witch/witch.png", "minecraft/textures/entity/witch.png", ]]; - const VILLAGER_TEX: &[&[&str]] = &[&["minecraft/textures/entity/villager/villager.png"]]; - const VILLAGER_BABY_TEX: &[&[&str]] = - &[&["minecraft/textures/entity/villager/villager_baby.png"]]; - // Indexed by the builtin VillagerKind registry order. - const VILLAGER_TYPE_TEX: &[&[&str]] = tex_table!("villager/type": - "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); - const VILLAGER_BABY_TYPE_TEX: &[&[&str]] = tex_table!("villager/baby": - "desert", "jungle", "plains", "savanna", "snow", "swamp", "taiga"); - // Indexed by VillagerProfession registry order minus one ("none" has no - // texture). - const VILLAGER_PROFESSION_TEX: &[&[&str]] = tex_table!("villager/profession": - "armorer", "butcher", "cartographer", "cleric", "farmer", "fisherman", "fletcher", - "leatherworker", "librarian", "mason", "nitwit", "shepherd", "toolsmith", "weaponsmith"); - // Indexed by profession level 1-5 minus one. - const VILLAGER_LEVEL_TEX: &[&[&str]] = tex_table!("villager/profession_level": - "stone", "iron", "gold", "emerald", "diamond"); + const VILLAGER_TEX: &[&[&str]] = tex_table!("villager" => "villager"); + const VILLAGER_BABY_TEX: &[&[&str]] = tex_table!("villager" => "villager_baby"); + const VILLAGER_TYPE_TEX: &[&[&str]] = villager_type_table!("villager/type"); + const VILLAGER_BABY_TYPE_TEX: &[&[&str]] = villager_type_table!("villager/baby"); + const VILLAGER_PROFESSION_TEX: &[&[&str]] = villager_profession_table!("villager/profession"); + const VILLAGER_LEVEL_TEX: &[&[&str]] = villager_level_table!("villager/profession_level"); // Base and baby models, plus opaque overlays (sheep wool), are all Opaque. fn opaque( @@ -424,11 +421,13 @@ fn mob_definitions() -> Vec { profession_tex: &'static [&'static [&'static str]], level_tex: &'static [&'static [&'static str]], ) -> Vec { + // Slots 0/2/3 share one bake of the hatted model. + let hatted = bake(false); vec![ - opaque(bake(false), type_tex, 64), + opaque(hatted.clone(), type_tex, 64), opaque(bake(true), type_tex, 64), - opaque(bake(false), profession_tex, 64), - opaque(bake(false), level_tex, 64), + opaque(hatted.clone(), profession_tex, 64), + opaque(hatted, level_tex, 64), ] } @@ -626,6 +625,9 @@ fn mob_definitions() -> Vec { kind: EntityKind::Bogged, anim: AnimationType::Skeleton, // Variant 0 = mushrooms, 1 = sheared (empty mushroom parts). + // TODO: replace with a per-part visibility mask (vanilla + // `mushrooms.visible = !isSheared`) instead of a second baked + // model; would also drop the cubeless overlay padding. adult: vec![ opaque(entity_model::bake_bogged_model(false), BOGGED_TEX, 64), opaque(entity_model::bake_bogged_model(true), BOGGED_TEX, 64), @@ -1180,11 +1182,16 @@ impl EntityRenderer { let variant = entry.base_variant(info.is_baby, self.effective_variant_index(info)); let entity_mat = Self::entity_matrix(info, anchor); let anim = self.compute_anim(entry.anim, &variant.model, info); + // Computed once per entity and shared with the overlay draws: + // overlays match the base's part order AND pivots (only cube + // geometry differs), and cubeless padding parts are never + // drawn, so their transforms are unused. + let part_transforms = variant.model.compute_part_transforms(&anim); vis.push(VisEntity { info, entry, entity_mat, - anim, + part_transforms, }); } if vis.is_empty() { @@ -1446,13 +1453,13 @@ fn create_camera_sets( (sets, buffers, allocations) } -/// A culled, drawable entity with its world transform and animation -/// precomputed. +/// A culled, drawable entity with its world transform and per-part +/// animation matrices precomputed (shared by the base and overlay draws). struct VisEntity<'a> { info: &'a EntityRenderInfo, entry: &'a MobEntry, entity_mat: glam::Mat4, - anim: entity_model::PartAnim, + part_transforms: Vec, } /// One instanced (variant, part) draw: a run of `instance_count` instances from @@ -1495,18 +1502,13 @@ impl<'a> VariantGroups<'a> { fn emit(&self, vis: &[VisEntity], instances: &mut Vec) -> Vec { let mut records = Vec::new(); for (variant, texture_set, members) in &self.groups { - // Part transforms differ per entity (animation), so compute per member. - let pts: Vec> = members - .iter() - .map(|(vi, ..)| variant.model.compute_part_transforms(&vis[*vi].anim)) - .collect(); for (p, (start, part_count)) in variant.model.part_ranges.iter().enumerate() { if *part_count == 0 { continue; } let first_instance = instances.len() as u32; - for (k, (vi, tint, overlay, uv)) in members.iter().enumerate() { - let model = vis[*vi].entity_mat * pts[k][p]; + for (vi, tint, overlay, uv) in members.iter() { + let model = vis[*vi].entity_mat * vis[*vi].part_transforms[p]; instances.push(EntityInstance { model: model.to_cols_array_2d(), tint: *tint, @@ -1574,35 +1576,36 @@ const ANIM_MARGIN: f32 = 0.5; /// Vanilla (width, height) hitbox per supported mob, scaled for babies; used to /// build the cull bounding sphere. fn entity_bounds(kind: EntityKind, is_baby: bool) -> (f32, f32) { + // Vanilla babies declare explicit BABY_DIMENSIONS rather than a scale; + // list kinds whose constant isn't the half-scale the fallback below + // assumes. Every new baby mob must be checked against its class. + if is_baby { + match kind { + EntityKind::Chicken => return (0.3, 0.4), + EntityKind::Zombie + | EntityKind::Husk + | EntityKind::Drowned + | EntityKind::ZombieVillager + | EntityKind::Villager => return (0.49, 0.98), + _ => {} + } + } let (w, h) = match kind { EntityKind::Pig => (0.9, 0.9), EntityKind::Cow => (0.9, 1.4), - // Vanilla Chicken.BABY_DIMENSIONS is an explicit 0.3x0.4, not half scale. - EntityKind::Chicken if is_baby => return (0.3, 0.4), EntityKind::Chicken => (0.4, 0.7), EntityKind::Sheep => (0.9, 1.3), - // Vanilla Zombie.BABY_DIMENSIONS / Villager baby: explicit 0.49x0.98, - // not half scale. EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned | EntityKind::ZombieVillager | EntityKind::Villager - if is_baby => - { - return (0.49, 0.98); - } - EntityKind::Zombie - | EntityKind::Husk - | EntityKind::Drowned - | EntityKind::ZombieVillager => (0.6, 1.95), + | EntityKind::Witch => (0.6, 1.95), EntityKind::Skeleton | EntityKind::Stray | EntityKind::Bogged => (0.6, 1.99), EntityKind::Creeper => (0.6, 1.7), EntityKind::Spider => (1.4, 0.9), - EntityKind::Villager => (0.6, 1.95), EntityKind::Enderman => (0.6, 2.9), EntityKind::Slime => (0.52, 0.52), - EntityKind::Witch => (0.6, 1.95), EntityKind::Player => (0.6, 1.8), _ => (1.0, 1.0), }; @@ -1671,6 +1674,9 @@ fn assert_part_order_matches(base: &[MobVariant], overlays: &[Vec]) } } +// TODO: share one vertex buffer + model per distinct mesh across texture +// variants (a zombie villager's 33 texture variants clone 2 meshes), and +// batch the per-texture one-time upload submits into one fence wait. #[allow(clippy::too_many_arguments)] fn build_variants( device: &vk::Device, From d24c0cb7bb6cf2bd2bd6eaade26cbdfe7034d334 Mon Sep 17 00:00:00 2001 From: Purdze Date: Sat, 8 Aug 2026 21:40:05 +0100 Subject: [PATCH 19/19] cleanup --- pomme-client/src/entity/mod.rs | 11 +++++------ pomme-client/src/net/handler.rs | 10 ++++------ pomme-client/src/net/mod.rs | 6 ++---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/pomme-client/src/entity/mod.rs b/pomme-client/src/entity/mod.rs index 1973c59c..91677f65 100644 --- a/pomme-client/src/entity/mod.rs +++ b/pomme-client/src/entity/mod.rs @@ -72,8 +72,9 @@ pub struct LivingEntity { pub wool_color: Option, /// Sheep wool shorn / bogged mushrooms shorn. pub is_sheared: bool, - /// Registry/wire variant slot; meaning is per-kind (pool index for - /// cow/chicken). Normalized in `EntityStore::set_variant`. + /// Registry/wire variant slot; meaning is per-kind. Holder-backed values + /// are pre-resolved to pool indices by the net handler; raw-int kinds are + /// normalized in `EntityStore::apply_entity_data`. pub variant: u32, /// Chicken wing-flap state (vanilla `Chicken.aiStep`): `flap` is the /// unbounded wing-cycle phase, `flap_speed` the 0..1 amplitude. @@ -678,10 +679,8 @@ impl EntityStore { (EntityKind::Enderman, 17, Bool(b)) => entity.is_creepy = b, (EntityKind::Witch, 17, Bool(b)) => entity.witch_drinking = b, // Zombie-family underwater conversion / zombie villager curing. - (EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned, 18, Bool(b)) => { - entity.is_converting = b - } - (EntityKind::ZombieVillager, 19, Bool(b)) => entity.is_converting = b, + (EntityKind::Zombie | EntityKind::Husk | EntityKind::Drowned, 18, Bool(b)) + | (EntityKind::ZombieVillager, 19, Bool(b)) => entity.is_converting = b, (EntityKind::Villager, 18, Int(c)) => entity.unhappy_counter = c, _ => {} } diff --git a/pomme-client/src/net/handler.rs b/pomme-client/src/net/handler.rs index 435dcc64..2eea53cf 100644 --- a/pomme-client/src/net/handler.rs +++ b/pomme-client/src/net/handler.rs @@ -495,24 +495,22 @@ pub fn handle_game_packet( if item.index == 18 && let azalea_entity::EntityDataValue::CowVariant(variant) = &item.value { - use azalea_registry::DataRegistry; let _ = event_tx.try_send(variant_event( registry_holder, p.id.0, EntityKind::Cow, - variant.protocol_id(), + variant, )); } // Index 18 on chickens = ChickenVariant Holder. if item.index == 18 && let azalea_entity::EntityDataValue::ChickenVariant(variant) = &item.value { - use azalea_registry::DataRegistry; let _ = event_tx.try_send(variant_event( registry_holder, p.id.0, EntityKind::Chicken, - variant.protocol_id(), + variant, )); } // Index 19 on villagers / 20 on zombie villagers = VillagerData @@ -725,12 +723,12 @@ fn variant_event( registry_holder: &RegistryHolder, id: i32, kind: EntityKind, - protocol_id: u32, + holder: &impl azalea_registry::DataRegistry, ) -> NetworkEvent { NetworkEvent::EntityVariant { id, kind, - variant: variant_index(registry_holder, kind, protocol_id), + variant: variant_index(registry_holder, kind, holder.protocol_id()), } } diff --git a/pomme-client/src/net/mod.rs b/pomme-client/src/net/mod.rs index f08c3a7f..f3725a72 100644 --- a/pomme-client/src/net/mod.rs +++ b/pomme-client/src/net/mod.rs @@ -307,10 +307,8 @@ pub enum NetworkEvent { FinishUseItem { id: i32, }, - /// Registry/wire variant slot; meaning is per-kind (pool index for - /// cow/chicken). Per-kind normalization lives in - /// `EntityStore::set_variant`; `kind` is the mob the emitting arm - /// resolved for, guarding overloaded metadata indices. + /// Registry/wire variant slot; meaning is per-kind. `kind` is the mob + /// the emitting arm resolved for, guarding overloaded metadata indices. EntityVariant { id: i32, kind: EntityKind,