mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Synchronize raider celebration metadata
Send the celebration flag and its false default for all raiders, then apply it on clients. Exercise real tracking and packet decoding against native dirty metadata without loading shared resource state. Fixes #8848 Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
38df83828d
commit
b27922de61
4 files changed
+425
-1
No files matched your search
@@ -505,6 +505,7 @@ fn mob_meta(
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
"minecraft:guardian" | "minecraft:elder_guardian" => {
|
||||
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
|
||||
@@ -1067,6 +1068,21 @@ fn unhappy_counter(
|
||||
defaults.push((18, serializer::INT, Value::Int(0)));
|
||||
}
|
||||
|
||||
/// Raider.IS_CELEBRATING: also list false so tracking sends the end of a celebration.
|
||||
pub(super) fn mob_raider_meta(
|
||||
m: &Mob,
|
||||
meta: &mut Vec<(u8, i32, Value)>,
|
||||
defaults: &mut Vec<(u8, i32, Value)>,
|
||||
) {
|
||||
use horizon_client::mobs::raider;
|
||||
if raider::is_raider(m.species()) {
|
||||
if raider::state(m).is_some_and(|state| state.celebrating) {
|
||||
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
|
||||
}
|
||||
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
|
||||
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
|
||||
/// AgeableMob's two and DATA_BOOST_TIME).
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::Game;
|
||||
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
|
||||
use horizon_client::mobs::{
|
||||
Mob, Species, Who, armadillo, bat, bee, camel, creaking, drops, equine, finalize, illusioner,
|
||||
panda, phantom, sniffer, sync, tame, zombie_villager,
|
||||
panda, phantom, raider, sniffer, sync, tame, zombie_villager,
|
||||
};
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
|
||||
@@ -318,6 +318,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
// entities the side heads turn toward in `wither::client_tick`.
|
||||
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
|
||||
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
|
||||
// Raider.IS_CELEBRATING: shared by all six raider types.
|
||||
(species, 16, Value::Bool(celebrating)) if raider::is_raider(species) => {
|
||||
raider::state_mut(m).celebrating = *celebrating;
|
||||
}
|
||||
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
|
||||
// IS_CELEBRATING takes 16).
|
||||
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
|
||||
@@ -521,6 +525,242 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_protocol::entity_data::serializer as s;
|
||||
|
||||
fn celebration_mob(kind: &str) -> Mob {
|
||||
use horizon_client::mobs::{Anim, Brain, Flags, MobType, Part};
|
||||
use std::sync::Arc;
|
||||
let species = match kind {
|
||||
"minecraft:pillager" => Species::Pillager,
|
||||
"minecraft:vindicator" => Species::Vindicator,
|
||||
"minecraft:evoker" => Species::Evoker,
|
||||
"minecraft:illusioner" => Species::Illusioner,
|
||||
"minecraft:witch" => Species::Witch,
|
||||
"minecraft:ravager" => Species::Ravager,
|
||||
"minecraft:zombie" => Species::Zombie,
|
||||
"minecraft:cow" => Species::Cow,
|
||||
_ => panic!("unknown fixture species"),
|
||||
};
|
||||
// Metadata needs no AI or model. Avoid the resource loader, which also
|
||||
// initializes process-wide item tags used by unrelated mob tests.
|
||||
let kind = Arc::new(MobType {
|
||||
id: kind.into(),
|
||||
species,
|
||||
width: 0.6,
|
||||
height: 1.95,
|
||||
eye_height: 1.5,
|
||||
category: "monster".into(),
|
||||
max_health: 20.0,
|
||||
speed: 0.25,
|
||||
flying_speed: 0.0,
|
||||
attack_damage: 0.0,
|
||||
attack_knockback: 0.0,
|
||||
follow_range: 16.0,
|
||||
step_height: 0.6,
|
||||
armor: 0.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(),
|
||||
});
|
||||
Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raider_celebration_follows_metadata_and_clears() {
|
||||
use horizon_client::mobs::raider;
|
||||
for kind in [
|
||||
"pillager",
|
||||
"vindicator",
|
||||
"evoker",
|
||||
"illusioner",
|
||||
"witch",
|
||||
"ravager",
|
||||
] {
|
||||
let mut m = celebration_mob(&format!("minecraft:{kind}"));
|
||||
for on in [true, false, true, false] {
|
||||
super::apply(
|
||||
&mut m,
|
||||
&[(16, s::BOOLEAN, Value::Bool(on))],
|
||||
&Builtin::default(),
|
||||
99,
|
||||
);
|
||||
assert_eq!(
|
||||
raider::state(&m).is_some_and(|state| state.celebrating),
|
||||
on,
|
||||
"{kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for kind in ["zombie", "cow"] {
|
||||
let mut m = celebration_mob(&format!("minecraft:{kind}"));
|
||||
super::apply(
|
||||
&mut m,
|
||||
&[(16, s::BOOLEAN, Value::Bool(true))],
|
||||
&Builtin::default(),
|
||||
99,
|
||||
);
|
||||
assert!(raider::state(&m).is_none(), "{kind} is not a raider");
|
||||
}
|
||||
}
|
||||
|
||||
fn celebration_trace(kind: &str, sequence: &[bool]) -> Vec<(Vec<bool>, bool)> {
|
||||
use horizon_client::{
|
||||
lan::entity::{Entities, EntityState, Riding, Tracker, Viewer},
|
||||
mobs::raider,
|
||||
};
|
||||
use horizon_protocol::{buf::Reader, packets::play::Clientbound};
|
||||
let name = format!("minecraft:{kind}");
|
||||
let mut server = celebration_mob(&name);
|
||||
let mut client = celebration_mob(&name);
|
||||
let mut level = Entities::default();
|
||||
let mut tracker = Tracker::default();
|
||||
let chunks = [(0, 0)].into_iter().collect();
|
||||
let mut state = EntityState {
|
||||
id: 1,
|
||||
uuid: 1,
|
||||
kind: name,
|
||||
needs_sync: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut trace = Vec::new();
|
||||
for &on in sequence {
|
||||
raider::state_mut(&mut server).celebrating = on;
|
||||
state.meta.clear();
|
||||
state.defaults.clear();
|
||||
super::super::lan::mob_raider_meta(&server, &mut state.meta, &mut state.defaults);
|
||||
let mut packets = Vec::new();
|
||||
level.tick(
|
||||
&[&state],
|
||||
&Riding::default(),
|
||||
&mut [Viewer {
|
||||
entity_id: 99,
|
||||
spectator: false,
|
||||
permission: 0,
|
||||
moved: true,
|
||||
position: [0.0; 3],
|
||||
view_distance: 10,
|
||||
chunks: &chunks,
|
||||
tracker: &mut tracker,
|
||||
out: &mut packets,
|
||||
gone: None,
|
||||
}],
|
||||
|_| Some(100),
|
||||
);
|
||||
let mut sent = Vec::new();
|
||||
for body in packets {
|
||||
let mut reader = Reader::new(&body);
|
||||
let id = reader.varint().unwrap();
|
||||
if let Ok(Clientbound::SetEntityData(data)) = Clientbound::decode(id, reader.rest())
|
||||
{
|
||||
for (index, serializer, value) in &data.values {
|
||||
if *index == 16 {
|
||||
assert_eq!(*serializer, s::BOOLEAN);
|
||||
let Value::Bool(on) = value else {
|
||||
panic!("boolean metadata");
|
||||
};
|
||||
sent.push(*on);
|
||||
}
|
||||
}
|
||||
super::apply(&mut client, &data.values, &Builtin::default(), 99);
|
||||
}
|
||||
}
|
||||
trace.push((
|
||||
sent,
|
||||
raider::state(&client).is_some_and(|state| state.celebrating),
|
||||
));
|
||||
}
|
||||
trace
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn celebration_crosses_the_tracker_and_wire_without_sticking_or_repeating() {
|
||||
let sequence = [false, true, true, false, false, true, false];
|
||||
let expected = vec![
|
||||
(vec![], false),
|
||||
(vec![true], true),
|
||||
(vec![], true),
|
||||
(vec![false], false),
|
||||
(vec![], false),
|
||||
(vec![true], true),
|
||||
(vec![false], false),
|
||||
];
|
||||
for kind in [
|
||||
"pillager",
|
||||
"vindicator",
|
||||
"evoker",
|
||||
"illusioner",
|
||||
"witch",
|
||||
"ravager",
|
||||
] {
|
||||
assert_eq!(celebration_trace(kind, &sequence), expected, "{kind}");
|
||||
assert_eq!(
|
||||
celebration_trace(kind, &[true]),
|
||||
vec![(vec![true], true)],
|
||||
"joining mid-celebration: {kind}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by tools/test_raider_celebration_parity.py"]
|
||||
fn raider_celebration_parity_fixture() {
|
||||
use std::io::Write;
|
||||
let input =
|
||||
std::fs::read_to_string(std::env::var("HORIZON_RAIDER_SYNC_INPUT").unwrap()).unwrap();
|
||||
let mut output =
|
||||
std::fs::File::create(std::env::var("HORIZON_RAIDER_SYNC_OUTPUT").unwrap()).unwrap();
|
||||
for line in input.lines() {
|
||||
let mut fields = line.split_whitespace();
|
||||
let kind = fields.next().unwrap();
|
||||
let sequence: Vec<_> = fields
|
||||
.map(|value| match value {
|
||||
"0" => false,
|
||||
"1" => true,
|
||||
_ => panic!("invalid celebration flag"),
|
||||
})
|
||||
.collect();
|
||||
let trace = celebration_trace(kind, &sequence);
|
||||
let mut parts = Vec::new();
|
||||
for (sent, state) in trace {
|
||||
let sent = if sent.is_empty() {
|
||||
"-".into()
|
||||
} else {
|
||||
sent.into_iter()
|
||||
.map(|on| i32::from(on).to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
parts.push(format!("{sent}:{}", i32::from(state)));
|
||||
}
|
||||
writeln!(output, "{}", parts.join(" ")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A mob of the prepared resources' type `kind`, as a client spawns it
|
||||
/// (None without the resources).
|
||||
fn root() -> std::path::PathBuf {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import net.minecraft.network.syncher.SynchedEntityData;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.raid.Raider;
|
||||
|
||||
/** Original oracle: real Raider setters and SynchedEntityData spawn/dirty values. */
|
||||
public final class VanillaRaiderCelebrationOracle {
|
||||
static Field field(Class<?> owner, String name) throws Exception {
|
||||
var field = owner.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
return field;
|
||||
}
|
||||
|
||||
static Method definition(Class<?> owner) throws Exception {
|
||||
for (var type = owner; type != null; type = type.getSuperclass()) {
|
||||
try {
|
||||
var method = type.getDeclaredMethod("defineSynchedData", SynchedEntityData.Builder.class);
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
} catch (NoSuchMethodException ignored) { }
|
||||
}
|
||||
throw new NoSuchMethodException("defineSynchedData");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static <T> void base(SynchedEntityData.Builder builder, String name, T value) throws Exception {
|
||||
builder.define((net.minecraft.network.syncher.EntityDataAccessor<T>) field(Entity.class, name).get(null), value);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();
|
||||
net.minecraft.server.Bootstrap.bootStrap();
|
||||
var unsafeClass = Class.forName("sun.misc.Unsafe");
|
||||
var unsafe = field(unsafeClass, "theUnsafe").get(null);
|
||||
var allocate = unsafeClass.getMethod("allocateInstance", Class.class);
|
||||
var entityData = field(Entity.class, "entityData");
|
||||
var accessor = (net.minecraft.network.syncher.EntityDataAccessor<?>) field(Raider.class, "IS_CELEBRATING").get(null);
|
||||
if (accessor.id() != 16 || accessor.serializer() != net.minecraft.network.syncher.EntityDataSerializers.BOOLEAN) {
|
||||
throw new AssertionError("Unexpected pinned raider metadata contract");
|
||||
}
|
||||
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1]), StandardCharsets.UTF_8))) {
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
var parts = line.split(" ");
|
||||
String name = switch (parts[0]) {
|
||||
case "pillager" -> "illager.Pillager";
|
||||
case "vindicator" -> "illager.Vindicator";
|
||||
case "evoker" -> "illager.Evoker";
|
||||
case "illusioner" -> "illager.Illusioner";
|
||||
case "witch" -> "Witch";
|
||||
case "ravager" -> "Ravager";
|
||||
default -> throw new IllegalArgumentException(parts[0]);
|
||||
};
|
||||
var type = Class.forName("net.minecraft.world.entity.monster." + name);
|
||||
var raider = (Raider) allocate.invoke(unsafe, type);
|
||||
var builder = new SynchedEntityData.Builder(raider);
|
||||
// Entity's constructor defines its base entries before calling the
|
||||
// subclass method. Initialize those entries without constructing a level.
|
||||
base(builder, "DATA_SHARED_FLAGS_ID", (byte) 0);
|
||||
base(builder, "DATA_AIR_SUPPLY_ID", raider.getMaxAirSupply());
|
||||
base(builder, "DATA_CUSTOM_NAME", java.util.Optional.empty());
|
||||
base(builder, "DATA_CUSTOM_NAME_VISIBLE", false);
|
||||
base(builder, "DATA_SILENT", false);
|
||||
base(builder, "DATA_NO_GRAVITY", false);
|
||||
base(builder, "DATA_POSE", net.minecraft.world.entity.Pose.STANDING);
|
||||
base(builder, "DATA_TICKS_FROZEN", 0);
|
||||
definition(type).invoke(raider, builder);
|
||||
var data = builder.build();
|
||||
entityData.set(raider, data);
|
||||
var trace = new ArrayList<String>();
|
||||
for (int i = 1; i < parts.length; i++) {
|
||||
if (!parts[i].equals("0") && !parts[i].equals("1")) throw new IllegalArgumentException(parts[i]);
|
||||
raider.setCelebrating(parts[i].equals("1"));
|
||||
var values = i == 1 ? data.getNonDefaultValues() : data.packDirty();
|
||||
if (i == 1) data.packDirty();
|
||||
var sent = new ArrayList<String>();
|
||||
if (values != null) {
|
||||
for (var value : values) {
|
||||
if (value.id() == accessor.id()) sent.add((Boolean) value.value() ? "1" : "0");
|
||||
}
|
||||
}
|
||||
trace.add((sent.isEmpty() ? "-" : String.join(",", sent)) + ":" + (raider.isCelebrating() ? "1" : "0"));
|
||||
}
|
||||
out.println(String.join(" ", trace));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare native raider metadata changes with Horizon's tracker, wire codec and client."""
|
||||
import itertools
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import tempfile
|
||||
try:
|
||||
from . import lab, oracle_harness as harness, test_rust_parity as base, integration_env
|
||||
except ImportError:
|
||||
import lab, oracle_harness as harness, test_rust_parity as base, integration_env
|
||||
|
||||
|
||||
def cases():
|
||||
sequences = [bits for size in range(1, 6) for bits in itertools.product((0, 1), repeat=size)]
|
||||
rng = random.Random(0x52414944)
|
||||
sequences.extend(tuple(rng.randrange(2) for _ in range(64)) for _ in range(16))
|
||||
for kind in ('pillager', 'vindicator', 'evoker', 'illusioner', 'witch', 'ravager'):
|
||||
for sequence in sequences:
|
||||
yield kind + ' ' + ' '.join(map(str, sequence))
|
||||
|
||||
|
||||
def main():
|
||||
lock, directory = lab.config()
|
||||
manifest, client = lab.import_client(lock, directory)
|
||||
natives = {v['downloads']['artifact']['path'] for v in manifest['libraries']
|
||||
if v['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 natives]
|
||||
build = lab.LOCAL / 'rust-raider-celebration-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report = build / 'report.json'
|
||||
report.unlink(missing_ok=True)
|
||||
sources = [Path(__file__), lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/oracle_harness.py',
|
||||
lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'tools/integration_env.py',
|
||||
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
|
||||
lab.ROOT / 'crates/horizon-client/src/game/lan.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/game/remote_mob_data.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs/raider.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/mobs.rs',
|
||||
lab.ROOT / 'crates/horizon-client/src/lan/entity.rs',
|
||||
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRaiderCelebrationOracle.java']
|
||||
hashes = base.source_hashes(sources)
|
||||
rows = list(cases())
|
||||
inputs = build / 'inputs.txt'
|
||||
inputs.write_text('\n'.join(rows) + '\n', encoding='utf-8')
|
||||
input_hash = lab.digest(inputs, 'sha256')
|
||||
classes, dependencies, _ = harness.compile_oracle(build, 'VanillaRaiderCelebrationOracle',
|
||||
{'client': client}, libraries, lock['minecraft']['java_major'])
|
||||
harness.java_run(build, classes, dependencies, 'VanillaRaiderCelebrationOracle',
|
||||
[inputs, build / 'client.txt'], 'client')
|
||||
with tempfile.TemporaryDirectory(prefix='horizon-raider-parity-') as scratch:
|
||||
env = integration_env.game_environment(os.environ, scratch)
|
||||
env.update(HORIZON_RAIDER_SYNC_INPUT=str(inputs), HORIZON_RAIDER_SYNC_OUTPUT=str(build / 'rust.txt'))
|
||||
harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client', '--lib',
|
||||
'game::remote_mob_data::tests::raider_celebration_parity_fixture',
|
||||
'--', '--exact', '--ignored'], build, 'rust', env=env)
|
||||
matched = harness.compare(build / 'client.txt', build / 'rust.txt', len(rows))
|
||||
base.ensure_sources_unchanged(sources, hashes)
|
||||
if lab.digest(inputs, 'sha256') != input_hash:
|
||||
raise AssertionError('Input fixtures changed')
|
||||
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'], sources_sha256=hashes,
|
||||
client_jar_sha256=lab.digest(client, 'sha256'), input_sha256=input_hash,
|
||||
matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
|
||||
scope='Native Raider setters, spawn non-default values and dirty metadata for all six types, '
|
||||
'compared with production Horizon metadata generation, entity tracking, encoded packets and client apply. '
|
||||
'Covers joining while celebrating, repeated values and clearing the flag. '
|
||||
'Raid AI, rendered arm geometry and a live network transport are not exercised.'))
|
||||
print(f'{matched} raider metadata sequences match the pinned client: {report}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user