Synchronize zombie infection and cure sounds

Use the vanilla infection/conversion level events and the client cure-start entity event. Respect silent mobs, draw cure volume before pitch on each client and avoid duplicate captured sound packets.

Fixes #8892

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
voidsipandCodex committed 2026-11-04 07:52:45 -06:00
1 parent acd184c1d9
commit 850c43dedb
9 files changed
+385 -24

No files matched your search

@@ -108,6 +108,7 @@ mod vehicle_container_fixture;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
mod zombie_cure_sound_probe;
probes! {
ambient_fixture,
@@ -142,6 +143,7 @@ probes! {
environment_fixture,
finalize_spawn_fixture,
fire_starter_sound_probe,
zombie_cure_sound_probe,
firework_tooltip_fixture,
first_person_probe,
frame_limiter_probe,
@@ -0,0 +1,98 @@
#![forbid(unsafe_code)]
//! Replays tools/test_zombie_cure_sound_parity.py without prepared resources.
use horizon_client::{
Result,
mobs::{Anim, Brain, Flags, Mob, MobEvent, MobType, Part, Species, zombie_villager},
};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
sync::Arc,
};
fn kind(eye_height: f64) -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:zombie_villager".into(),
species: Species::ZombieVillager,
width: 0.6,
height: 1.95,
eye_height,
category: "monster".into(),
max_health: 20.0,
speed: 0.23,
flying_speed: 0.4,
attack_damage: 3.0,
attack_knockback: 0.0,
follow_range: 35.0,
step_height: 0.6,
armor: 2.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags::default(),
model: Arc::new(Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: Vec::new(),
}),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Zombie,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let line = line?;
let p: Vec<&str> = line.split_whitespace().collect();
let mut m = Mob::new(
1,
kind(p[5].parse()?),
Vec3::new(p[2].parse()?, p[3].parse()?, p[4].parse()?),
0.0,
);
m.random = LegacyRandom::new(p[0].parse()?);
m.silent = p[1] == "1";
if let Some(MobEvent::Sound(name, at, volume, pitch)) = zombie_villager::cure_sound(&mut m)
{
write!(
out,
"minecraft:{name} {:016x} {:016x} {:016x} {:08x} {:08x}",
at[0].to_bits(),
at[1].to_bits(),
at[2].to_bits(),
volume.to_bits(),
pitch.to_bits()
)?;
} else {
write!(out, "silent")?;
}
writeln!(out, " next {}", m.random.next_long())?;
}
out.flush()?;
Ok(())
}
+17 -1
View File
@@ -5400,6 +5400,8 @@ impl Game<'_> {
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
1026 => Some(("minecraft:entity.zombie.infect", 2.0)),
1027 => Some(("minecraft:entity.zombie_villager.converted", 2.0)),
1030 => Some(("minecraft:block.anvil.use", 1.0)),
1029 => Some(("minecraft:block.anvil.destroy", 1.0)),
1031 => Some(("minecraft:block.anvil.land", 0.3)),
@@ -5430,6 +5432,10 @@ impl Game<'_> {
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
1026 | 1027 => {
let r = self.particles.level_random();
(r.next_float() - r.next_float()) * 0.2 + 1.0
}
1019 | 1021 => {
(self.world.random.next_float() - self.world.random.next_float())
* 0.2
@@ -5439,7 +5445,17 @@ impl Game<'_> {
};
if matches!(
id,
1019 | 1021 | 1029 | 1030 | 1045 | 1052 | 1053 | 1054 | 1501 | 1503
1019 | 1021
| 1026
| 1027
| 1029
| 1030
| 1045
| 1052
| 1053
| 1054
| 1501
| 1503
) {
// Clients play these sounds from their level events;
// capturing it also sends a duplicate sound packet.
+6
View File
@@ -2646,6 +2646,8 @@ impl Game<'_> {
2015 | 1501
| 1503
| 1505
| 1026
| 1027
| 1029
| 1030
| 1045
@@ -2689,6 +2691,10 @@ impl Game<'_> {
if self.audio.capture.is_some() {
self.lan_entity_events.push((id, event, at));
}
// Event 16: zombie villagers play their cure sound on each client.
if event == 16 && self.options.execution_role.is_client() {
self.zombie_villager_cure_sound(id as u32);
}
// Event 63: a sniffer's digging sound loop starts (ClientPacketListener.handleEntityEvent 63).
if event == 63 && self.options.execution_role.is_client() {
self.start_sniffer_digging_sound(id as u32);
+4
View File
@@ -4015,6 +4015,10 @@ impl Game<'_> {
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
// ZombieVillager.handleEntityEvent 16: the cure starts.
if p.event == 16 {
self.zombie_villager_cure_sound(p.id as u32);
}
// Witch.handleEntityEvent 15: the magic puffs over its hat.
if p.event == 15 {
self.witch_magic_particles(p.id as u32);
@@ -34,6 +34,17 @@ fn infects(difficulty: Difficulty, coin: impl FnOnce() -> bool) -> bool {
}
impl Game<'_> {
/// `ZombieVillager.handleEntityEvent(16)`: only the client plays the cure sound.
pub(super) fn zombie_villager_cure_sound(&mut self, id: u32) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& let Some(horizon_client::mobs::MobEvent::Sound(name, at, volume, pitch)) =
zombie_villager::cure_sound(m)
{
self.audio
.play_unshared(&format!("minecraft:{name}"), Some(at), volume, pitch);
}
}
/// `Zombie.killedEntity`: a villager killed by a zombie may rise as a
/// zombie villager keeping its profession, trades and gossip.
pub(super) fn zombie_killed(&mut self, killer: u32, victim: u32) {
@@ -72,14 +83,18 @@ impl Game<'_> {
return;
};
convert_to_zombie_villager(&old, z);
let killer_at = self.mobs[k].position;
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(
"minecraft:entity.zombie.infect",
Some([killer_at.x, killer_at.y, killer_at.z]),
2.0,
pitch,
);
if !self.mobs[k].silent {
let at = self.mobs[k].position;
self.level_event_at(
1026,
[
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
],
0,
);
}
}
/// `ZombieVillager.mobInteract`: a golden apple on a weakened zombie
@@ -150,13 +165,17 @@ impl Game<'_> {
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
});
}
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(
"minecraft:entity.zombie_villager.converted",
Some(at),
2.0,
pitch,
);
if !old.silent {
self.level_event_at(
1027,
[
old.position.x.floor() as i32,
old.position.y.floor() as i32,
old.position.z.floor() as i32,
],
0,
);
}
}
/// `ServerLevel.getPlayerByUUID`: the player with `uuid` in this level.
@@ -24,6 +24,20 @@ pub struct Cure {
pub fn cure(m: &Mob) -> Option<&Cure> {
villager::ext(m).and_then(|e| e.cure.as_ref())
}
/// `ZombieVillager.handleEntityEvent(16)`: the client's cure sound.
pub fn cure_sound(m: &mut Mob) -> Option<MobEvent> {
if m.species() != Species::ZombieVillager || m.silent {
return None;
}
let volume = 1.0 + m.random.next_float();
let pitch = m.random.next_float() * 0.7 + 0.3;
Some(MobEvent::Sound(
"entity.zombie_villager.cure",
[m.position.x, m.eye_y(), m.position.z],
volume,
pitch,
))
}
/// `ZombieVillager.DATA_CONVERTING_ID` on a client: only `is_converting`
/// (the cure's shaking) reads the cure, so its time does not matter.
pub fn set_converting(m: &mut Mob, on: bool) {
@@ -53,14 +67,11 @@ pub fn start_converting(
m.extra.effects.retain(|e| e.0 != "minecraft:weakness");
// Strength amplifier: min(difficulty id - 1, 0), always 0 with monsters.
m.add_effect("minecraft:strength", time, 0);
let pitch = m.random.next_float() * 0.7 + 0.3;
let volume = 1.0 + m.random.next_float();
events.push(MobEvent::Sound(
"entity.zombie_villager.cure",
[m.position.x, m.eye_y(), m.position.z],
volume,
pitch,
));
events.push(MobEvent::EntityEvent {
mob: m.id,
event: 16,
at: [m.position.x, m.position.y, m.position.z],
});
}
/// `getConversionProgress`: 1 per tick, plus a chance per nearby iron bars or
@@ -151,6 +162,18 @@ pub fn read_nbt(m: &mut Mob, tag: &Tag) {
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::{geometry::Vec3, random::LegacyRandom};
fn zombie_villager() -> Mob {
let mut kind = super::super::sensor_scan_tests::kind();
let data = std::sync::Arc::make_mut(&mut kind);
data.id = "minecraft:zombie_villager".into();
data.species = Species::ZombieVillager;
data.eye_height = 1.5;
let mut m = Mob::new(42, kind, Vec3::new(1.25, 2.0, 3.75), 0.0);
m.random = LegacyRandom::new(0);
m
}
#[test]
fn a_cure_round_trips_through_nbt() {
@@ -270,4 +293,60 @@ mod tests {
assert_eq!(map.get(&"ConversionTime".into()), Some(&Tag::Int(-1)));
assert_eq!(parse_cure(&Tag::Compound(map)), None);
}
#[test]
fn starting_a_cure_broadcasts_even_when_silent_without_drawing_sound_randomness() {
for silent in [false, true] {
let mut m = zombie_villager();
m.silent = silent;
m.add_effect("minecraft:weakness", 100, 0);
let mut random = m.random.clone();
let starter = Some([1, 2, 3, 4]);
let mut events = Vec::new();
start_converting(&mut m, starter, 4000, &mut events);
assert_eq!(
cure(&m),
Some(&Cure {
time: 4000,
starter
})
);
assert!(!m.has_effect("minecraft:weakness"));
assert!(m.extra.effects.contains(&("minecraft:strength", 4000, 0)));
assert_eq!(events.len(), 1);
let MobEvent::EntityEvent { mob, event, at } = events[0] else {
panic!("Starting a cure must broadcast an entity event");
};
assert_eq!((mob, event, at), (42, 16, [1.25, 2.0, 3.75]));
assert_eq!(m.random.next_long(), random.next_long());
}
}
#[test]
fn cure_sound_draws_volume_before_pitch_at_eye_height() {
let mut m = zombie_villager();
let Some(MobEvent::Sound(name, at, volume, pitch)) = cure_sound(&mut m) else {
panic!("A non-silent zombie villager must play the cure sound");
};
assert_eq!(name, "entity.zombie_villager.cure");
assert_eq!(at, [1.25, 3.5, 3.75]);
// The first two java.util.Random(0) floats, volume before pitch.
assert_eq!(volume.to_bits(), 0x3fdd_905a);
assert_eq!(pitch.to_bits(), 0x3f61_cb52);
assert_eq!(m.random.next_float().to_bits(), 0x3e76_4f2c);
}
#[test]
fn silent_or_other_mobs_do_not_play_the_cure_sound_or_draw_randomness() {
let mut silent = zombie_villager();
silent.silent = true;
let other = Mob::new(43, super::super::sensor_scan_tests::kind(), Vec3::ZERO, 0.0);
for mut m in [silent, other] {
let mut random = m.random.clone();
assert!(cure_sound(&mut m).is_none());
assert_eq!(m.random.next_long(), random.next_long());
}
}
}
@@ -0,0 +1,63 @@
package dev.horizonlab.parity;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Path;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.monster.zombie.ZombieVillager;
import net.minecraft.world.phys.Vec3;
/** Records the real ZombieVillager.handleEntityEvent(16), without an audio engine. */
public final class VanillaZombieCureSoundOracle {
static Field field(Class<?> owner, String name) throws Exception {
var f = owner.getDeclaredField(name); f.setAccessible(true); return f;
}
static String d(double value) { return String.format("%016x", Double.doubleToRawLongBits(value)); }
static final class World extends ClientLevel {
String sound;
World() { super(null, null, null, null, 0, 0, null, false, 0L, 0); }
@Override public void playLocalSound(double x, double y, double z, SoundEvent event,
SoundSource source, float volume, float pitch, boolean delay) {
if (sound != null) throw new AssertionError("More than one cure sound");
if (delay) throw new AssertionError("Unexpected delayed cure sound");
sound = BuiltInRegistries.SOUND_EVENT.getKey(event) + " " + d(x) + " " + d(y) + " " + d(z)
+ " " + String.format("%08x %08x", Float.floatToRawIntBits(volume), Float.floatToRawIntBits(pitch));
}
}
static final class Actor extends ZombieVillager {
boolean silent;
Actor() { super(null, null); }
@Override public boolean isSilent() { return silent; }
@Override public SoundSource getSoundSource() { return SoundSource.HOSTILE; }
}
public static void main(String[] args) throws Exception {
net.minecraft.SharedConstants.tryDetectVersion(); net.minecraft.server.Bootstrap.bootStrap();
var u = Class.forName("sun.misc.Unsafe"); var unsafe = field(u, "theUnsafe").get(null);
var allocate = u.getMethod("allocateInstance", Class.class);
var world = (World) allocate.invoke(unsafe, World.class);
var actor = (Actor) allocate.invoke(unsafe, Actor.class);
field(Entity.class, "level").set(actor, world);
var position = field(Entity.class, "position");
var eye = field(Entity.class, "eyeHeight");
var random = field(Entity.class, "random");
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) {
for (String line : Files.readAllLines(Path.of(args[0]))) {
var p = line.split(" ");
var rng = RandomSource.create(Long.parseLong(p[0]));
actor.silent = p[1].equals("1");
position.set(actor, new Vec3(Double.parseDouble(p[2]), Double.parseDouble(p[3]), Double.parseDouble(p[4])));
eye.setFloat(actor, Float.parseFloat(p[5]));
random.set(actor, rng);
world.sound = null;
actor.handleEntityEvent((byte) 16);
out.println((world.sound == null ? "silent" : world.sound) + " next " + rng.nextLong());
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Compare ZombieVillager.handleEntityEvent(16)'s cure sound with vanilla 26.3.
The real method runs on a recording ClientLevel, without an audio device or
prepared game resources. Compare sound names, coordinates, volume/pitch bits,
and the entity random afterwards, including silent mobs.
"""
import json
import os
from pathlib import Path
import platform
import random
import subprocess
try:
from . import lab, oracle_harness as harness, test_rust_parity as base
except ImportError:
import lab, oracle_harness as harness, test_rust_parity as base
def main():
lock, directory = lab.config()
manifest, client = lab.import_client(lock, directory)
# This recording oracle uses Java classes, without loading audio or graphics natives.
native_paths = {library['downloads']['artifact']['path'] for library in manifest['libraries']
if library['name'].split(':')[-1].startswith('natives-')}
libraries = [lab.import_library(a, directory / 'libraries/client')
for a in lab.client_library_artifacts(manifest) if a['path'] not in native_paths]
build = lab.LOCAL / 'rust-zombie-cure-sound-parity'
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaZombieCureSoundOracle.java'
probe = lab.ROOT / 'crates/horizon-client/examples/probe/zombie_cure_sound_probe.rs'
sources = [Path(__file__), oracle, probe, lab.ROOT / 'tools/lab.py',
lab.ROOT / 'tools/oracle_harness.py', lab.ROOT / 'tools/test_rust_parity.py',
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
lab.ROOT / 'crates/horizon-client-lib/Cargo.toml',
lab.ROOT / 'crates/horizon-client/src/mobs/zombie_villager.rs']
tested = base.source_hashes(sources)
rng = random.Random(0x43555245)
seeds = [0, 1, -1, 42, -(2**63), 2**63 - 1] + [rng.randrange(-2**63, 2**63) for _ in range(128)]
rows = [f'{seed} {int(silent)} {x} {y} {z} {eye}'
for seed in seeds for silent in (False, True)
for x, y, z, eye in [(0, 0, 0, 1.5), (1.25, 2, -3.75, 1.25),
(-10.5, -64, 20.5, 0.75)]]
inputs = build / 'inputs.txt'
inputs.write_text('\n'.join(rows) + '\n')
input_hash = lab.digest(inputs, 'sha256')
classes, dependencies, _ = harness.compile_oracle(build, 'VanillaZombieCureSoundOracle',
{'client': client}, libraries, lock['minecraft']['java_major'])
command = harness.build_probe(build, 'zombie_cure_sound_probe')
if os.name == 'nt':
command[0] = command[0].with_suffix('.exe')
harness.run_logged([*command, inputs, build / 'rust.txt'], build, 'rust')
harness.java_run(build, classes, dependencies, 'VanillaZombieCureSoundOracle',
[inputs, build / 'client.txt'], 'client')
matched = harness.compare(build / 'client.txt', build / 'rust.txt', len(rows))
base.ensure_sources_unchanged(sources, tested)
if lab.digest(inputs, 'sha256') != input_hash:
raise AssertionError('Input fixtures changed')
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'],
platform=platform.platform(), java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
rustc=subprocess.check_output(['rustc', '--version'], text=True).strip(),
sources_sha256=tested, input_sha256=input_hash, client_jar_sha256=lab.digest(client, 'sha256'),
matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope='Real ZombieVillager.handleEntityEvent(16): silent suppression, sound name, eye-position coordinates, '
'volume/pitch float bits and entity RNG tail. Server infection/conversion triggers and network delivery '
'are not exercised here; audio engine resolution is not exercised.'))
print(f'{matched} zombie cure sound cases match the pinned client: {report}')
if __name__ == '__main__':
main()