Decode every item data component a server sends
Horizon decoded 25 of 26.3's 122 data component types. Item data has no length prefix, so a stack with any other component (an armour trim, banner patterns, pot sherds, a stew's effects, a plugin's item) made the whole packet undecodable and it was dropped: on a Paper server the inventory arrived empty until each slot was clicked, the advancements screen said "There doesn't seem to be anything here..." and the recipe book was empty. Every other component is now read to its end with its vanilla StreamCodec (item/skip.rs) and kept as its bytes, written back as they came; components without a network codec go as their NBT. The client leaves them out of the item's components, so the item keeps its prototype's values for them. tools/test_item_wire_parity.py checks it against vanilla: the oracle encodes 374 stacks with ItemStack.OPTIONAL_STREAM_CODEC (every default component value of every item, SNBT fixtures for the rest, direct holders and removals built in Java), covering all 122 component types, and the Rust probe must consume every byte, find the same component ids and encode the read-past ones back to the same bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -596,6 +596,8 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
let value = match c {
|
||||
// Read past, not decoded: the item keeps its prototype's value.
|
||||
Component::Raw(_) => continue,
|
||||
Component::Int(v) if *kind == component::RARITY => {
|
||||
let rarity = ["common", "uncommon", "rare", "epic"];
|
||||
Tag::String(rarity.get(*v as usize).copied().unwrap_or("common").into())
|
||||
|
||||
@@ -22,5 +22,9 @@ sha2 = "0.10"
|
||||
name = "protocol_probe"
|
||||
path = "examples/probe.rs"
|
||||
|
||||
[[example]]
|
||||
name = "item_wire_probe"
|
||||
path = "examples/item_wire_probe.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! The item wire harness's Rust side (tools/test_item_wire_parity.py): reads
|
||||
//! the oracle's stacks (JSON lines with `label` and `hex`), decodes each with
|
||||
//! `item::Slot` and prints, per stack, whether every byte was consumed, the
|
||||
//! patch's component ids (added, then removed), which of them Horizon
|
||||
//! decoded into its own types (the rest it reads past, keeping their bytes)
|
||||
//! and whether encoding the decoded stack gives the same bytes back.
|
||||
//!
|
||||
//! cargo run -p horizon-protocol --example item_wire_probe -- vanilla.jsonl rust.jsonl
|
||||
use horizon_protocol::{
|
||||
buf::{Codec, Reader, Writer},
|
||||
item::{Component, Slot},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
|
||||
fn hex(s: &str) -> Option<Vec<u8>> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let input = std::fs::read_to_string(args.get(1).ok_or("usage: item_wire_probe IN OUT")?)?;
|
||||
let mut out = std::io::BufWriter::new(std::fs::File::create(
|
||||
args.get(2).ok_or("usage: item_wire_probe IN OUT")?,
|
||||
)?);
|
||||
for line in input.lines().filter(|l| !l.trim().is_empty()) {
|
||||
let v: Value = serde_json::from_str(line)?;
|
||||
let label = v["label"].clone();
|
||||
let Some(bytes) = v["hex"].as_str().and_then(hex) else {
|
||||
continue;
|
||||
};
|
||||
let mut r = Reader::new(&bytes);
|
||||
let result = match Slot::read(&mut r) {
|
||||
Err(e) => json!({"label": label, "error": e.to_string()}),
|
||||
Ok(stack) => {
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&stack, &mut w);
|
||||
let (added, removed): (Vec<i32>, Vec<i32>) =
|
||||
stack.as_ref().map_or_else(Default::default, |s| {
|
||||
(s.added.iter().map(|(k, _)| *k).collect(), s.removed.clone())
|
||||
});
|
||||
let typed: Vec<i32> = stack
|
||||
.iter()
|
||||
.flat_map(|s| &s.added)
|
||||
.filter(|(_, c)| !matches!(c, Component::Raw(_)))
|
||||
.map(|(k, _)| *k)
|
||||
.collect();
|
||||
json!({
|
||||
"label": label,
|
||||
"left": r.remaining(),
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"typed": typed,
|
||||
"same_bytes": w.data == bytes,
|
||||
})
|
||||
}
|
||||
};
|
||||
writeln!(out, "{result}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
//! item's registry id and a data component patch, plus the hashed form the
|
||||
//! client sends back in container clicks (HashedStack).
|
||||
use crate::{
|
||||
Error, Result,
|
||||
Result,
|
||||
buf::{Codec, Reader, Writer},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
mod skip;
|
||||
|
||||
/// minecraft:data_component_type registry ids (26.3).
|
||||
pub mod component {
|
||||
pub const CUSTOM_DATA: i32 = 0;
|
||||
@@ -78,6 +80,9 @@ pub enum Component {
|
||||
WrittenBook(WrittenBook),
|
||||
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
|
||||
Stacks(Vec<Stack>),
|
||||
/// A component Horizon reads past without interpreting it: its encoded
|
||||
/// bytes, written back as they came.
|
||||
Raw(Vec<u8>),
|
||||
}
|
||||
|
||||
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
|
||||
@@ -224,11 +229,16 @@ impl Component {
|
||||
hidden: (0..n).map(|_| r.varint()).collect::<Result<_>>()?,
|
||||
}
|
||||
}
|
||||
other => return Err(Error::new(format!("unsupported item component {other}"))),
|
||||
other => {
|
||||
let start = r.position();
|
||||
skip::component(other, r)?;
|
||||
Self::Raw(r.since(start).to_vec())
|
||||
}
|
||||
})
|
||||
}
|
||||
fn write(&self, kind: i32, w: &mut Writer) {
|
||||
match self {
|
||||
Self::Raw(bytes) => w.bytes(bytes),
|
||||
Self::Int(v) if kind == component::DYED_COLOR => w.i32(*v),
|
||||
Self::Int(v) => w.varint(*v),
|
||||
Self::Unit => {}
|
||||
@@ -582,7 +592,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unknown_components_are_errors() {
|
||||
let bytes = [1, 5, 1, 0, 99];
|
||||
// 122 is past DataComponents' last id (cushion_color, 121).
|
||||
let bytes = [1, 5, 1, 0, 122];
|
||||
assert!(Slot::read(&mut Reader::new(&bytes)).is_err());
|
||||
}
|
||||
|
||||
@@ -631,4 +642,32 @@ mod tests {
|
||||
[("textures".to_owned(), "x".to_owned(), None)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components_read_past_keep_their_bytes() {
|
||||
// Vanilla 26.3's encoding (tools/test_item_wire_parity.py) of a chestplate's
|
||||
// armour trim, a banner's patterns (one direct), a pot's sherds and a stew's
|
||||
// effects: one server's inventory, recipe book and advancements had them.
|
||||
for (hex, kind) in [
|
||||
("01bd0801003a0502", 58),
|
||||
("01890b01004a02200e000b6d696e6563726166743a7801790b", 74),
|
||||
("01880301004c01f60801000001be0c0100000001d30c010000", 76),
|
||||
("01a408010037020f6412a001", 55),
|
||||
] {
|
||||
let bytes: Vec<u8> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
let mut r = Reader::new(&bytes);
|
||||
let stack = Slot::read(&mut r).unwrap();
|
||||
assert_eq!(r.remaining(), 0, "component {kind}");
|
||||
assert!(matches!(
|
||||
stack.as_ref().and_then(|s| s.component(kind)),
|
||||
Some(Component::Raw(_))
|
||||
));
|
||||
let mut w = Writer::new();
|
||||
Slot::write(&stack, &mut w);
|
||||
assert_eq!(w.data, bytes, "component {kind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
//! Reading past the data components Horizon doesn't interpret: each
|
||||
//! function consumes exactly what its vanilla StreamCodec writes
|
||||
//! (DataComponents' `networkSynchronized` codecs; a component without one
|
||||
//! goes as its persistent Codec's NBT, `ByteBufCodecs.fromCodecWithRegistries`).
|
||||
//! Item data has no length prefix, so a component read short or long makes
|
||||
//! everything after it unreadable.
|
||||
use super::{Component, Template};
|
||||
use crate::{
|
||||
Error, Result,
|
||||
buf::{Codec, Reader},
|
||||
};
|
||||
|
||||
type Read = fn(&mut Reader) -> Result<()>;
|
||||
|
||||
/// DataComponents' registry order (26.3) for the ids `Component::read`
|
||||
/// doesn't decode itself.
|
||||
pub(super) fn component(kind: i32, r: &mut Reader) -> Result<()> {
|
||||
match kind {
|
||||
// Unit.STREAM_CODEC: glider, waxed.
|
||||
34 | 120 => Ok(()),
|
||||
// Components without a network codec go as their NBT: intangible_projectile,
|
||||
// map_decorations, debug_stick_state, recipes, lock, container_loot.
|
||||
22 | 49 | 59 | 68 | 81 | 82 => nbt(r),
|
||||
// A VarInt (VAR_INT, an idMapper, a registry or holderRegistry id): damage_type,
|
||||
// enchantable, additional_trade_cost, block_transformer, villager_food, dye,
|
||||
// ominous_bottle_amplifier, base_color, the mob variants and colours, the pottery
|
||||
// pattern and cushion_color.
|
||||
8 | 31 | 42 | 43 | 44 | 46 | 65 | 75 | 88..=108 | 110..=117 | 121 => varint(r),
|
||||
// FLOAT: minimum_attack_charge, potion_duration_scale.
|
||||
7 | 54 => f32(r),
|
||||
// Identifier.STREAM_CODEC: tooltip_style, note_block_sound.
|
||||
35 | 73 => ident(r),
|
||||
// UseEffects: canSprint, interactVibrations, speedMultiplier.
|
||||
5 => {
|
||||
r.bool()?;
|
||||
r.bool()?;
|
||||
f32(r)
|
||||
}
|
||||
// AdventureModePredicate: can_place_on, can_break.
|
||||
14 | 15 => list(r, block_predicate),
|
||||
16 => list(r, attribute_modifier),
|
||||
// FoodProperties.DIRECT_STREAM_CODEC: nutrition, saturation, canAlwaysEat.
|
||||
23 => {
|
||||
varint(r)?;
|
||||
f32(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
// Consumable: consumeSeconds, animation, sound, hasConsumeParticles, onConsumeEffects.
|
||||
24 => {
|
||||
f32(r)?;
|
||||
varint(r)?;
|
||||
sound(r)?;
|
||||
boolean(r)?;
|
||||
list(r, consume_effect)
|
||||
}
|
||||
// UseRemainder, SulfurCubeContent: one template.
|
||||
25 | 80 => template(r),
|
||||
// UseCooldown: seconds, cooldownGroup.
|
||||
26 => {
|
||||
f32(r)?;
|
||||
optional(r, ident)
|
||||
}
|
||||
// DamageResistant, Repairable, provides_banner_patterns: a holder set.
|
||||
27 | 33 | 67 => holder_set(r),
|
||||
28 => tool(r),
|
||||
// Weapon: itemDamagePerAttack, disableBlockingForSeconds.
|
||||
29 => {
|
||||
varint(r)?;
|
||||
f32(r)
|
||||
}
|
||||
32 => equippable(r),
|
||||
// DeathProtection: deathEffects.
|
||||
36 => list(r, consume_effect),
|
||||
37 => blocks_attacks(r),
|
||||
// PiercingWeapon: dealsKnockback, dismounts, sound, hitSound.
|
||||
38 => {
|
||||
boolean(r)?;
|
||||
boolean(r)?;
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
39 => kinetic_weapon(r),
|
||||
// SwingAnimation: type, duration.
|
||||
40 | 41 => {
|
||||
varint(r)?;
|
||||
varint(r)
|
||||
}
|
||||
// BundleContents: templates.
|
||||
52 => list(r, template),
|
||||
53 => potion_contents(r),
|
||||
// SuspiciousStewEffects: effect, duration.
|
||||
55 => list(r, |r| {
|
||||
varint(r)?;
|
||||
varint(r)
|
||||
}),
|
||||
// ArmorTrim: material, then Holder<TrimPattern> (assetId, description, decal).
|
||||
58 => {
|
||||
trim_material(r)?;
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
nbt(r)?;
|
||||
boolean(r)
|
||||
})
|
||||
}
|
||||
// TypedEntityData (entity_data, block_entity_data): the type id, then a compound.
|
||||
60 | 62 => {
|
||||
varint(r)?;
|
||||
nbt(r)
|
||||
}
|
||||
// CustomData: bucket_entity_data.
|
||||
61 => nbt(r),
|
||||
// InstrumentComponent: Holder<Instrument> (soundEvent, useDuration, range,
|
||||
// durabilityDamage, description).
|
||||
63 => holder(r, |r| {
|
||||
sound(r)?;
|
||||
f32(r)?;
|
||||
f32(r)?;
|
||||
varint(r)?;
|
||||
nbt(r)
|
||||
}),
|
||||
64 => trim_material(r),
|
||||
// JukeboxPlayable: Holder<JukeboxSong> (soundEvent, description, lengthInSeconds,
|
||||
// comparatorOutput).
|
||||
66 => holder(r, |r| {
|
||||
sound(r)?;
|
||||
nbt(r)?;
|
||||
f32(r)?;
|
||||
varint(r)
|
||||
}),
|
||||
// LodestoneTracker: target (GlobalPos: dimension, BlockPos), tracked.
|
||||
69 => {
|
||||
optional(r, |r| {
|
||||
ident(r)?;
|
||||
r.i64().map(drop)
|
||||
})?;
|
||||
boolean(r)
|
||||
}
|
||||
70 => firework_explosion(r),
|
||||
// Fireworks: flightDuration, explosions.
|
||||
71 => {
|
||||
varint(r)?;
|
||||
list(r, firework_explosion)
|
||||
}
|
||||
// BannerPatternLayers: Holder<BannerPattern> (assetId, translationKey), colour.
|
||||
74 => list(r, |r| {
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
string(r)
|
||||
})?;
|
||||
varint(r)
|
||||
}),
|
||||
// PotDecorations: back, left, right, front.
|
||||
76 => (0..4).try_for_each(|_| optional(r, template)),
|
||||
// ItemContainerContents: optional templates.
|
||||
77 => list(r, |r| optional(r, template)),
|
||||
// BlockItemStateProperties: a string to string map.
|
||||
78 => list(r, |r| {
|
||||
string(r)?;
|
||||
string(r)
|
||||
}),
|
||||
// Bees: Occupant (entity type and data, ticksInHive, minTicksInHive).
|
||||
79 => list(r, |r| {
|
||||
varint(r)?;
|
||||
nbt(r)?;
|
||||
varint(r)?;
|
||||
varint(r)
|
||||
}),
|
||||
83 => sound(r),
|
||||
// Compostable: layers.
|
||||
84 => resolvable(r, i32),
|
||||
// CookingFuel (burnTime, speedMultiplier), BrewingFuel (uses, speedMultiplier).
|
||||
85 | 86 => {
|
||||
resolvable(r, i32)?;
|
||||
resolvable(r, f32)
|
||||
}
|
||||
// MobVisibility: targetingEntityTypes, visibility.
|
||||
87 => {
|
||||
holder_set(r)?;
|
||||
f32(r)
|
||||
}
|
||||
// Holder<PaintingVariant>: width, height, assetId, title, author.
|
||||
109 => holder(r, |r| {
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
ident(r)?;
|
||||
optional(r, nbt)?;
|
||||
optional(r, nbt)
|
||||
}),
|
||||
// SignText: four messages, four optional filtered ones, colour, hasGlowingText.
|
||||
118 | 119 => {
|
||||
(0..4).try_for_each(|_| nbt(r))?;
|
||||
optional(r, |r| (0..4).try_for_each(|_| nbt(r)))?;
|
||||
varint(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
other => Err(Error::new(format!("unsupported item component {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn boolean(r: &mut Reader) -> Result<()> {
|
||||
r.bool().map(drop)
|
||||
}
|
||||
fn varint(r: &mut Reader) -> Result<()> {
|
||||
r.varint().map(drop)
|
||||
}
|
||||
fn f32(r: &mut Reader) -> Result<()> {
|
||||
r.f32().map(drop)
|
||||
}
|
||||
fn i32(r: &mut Reader) -> Result<()> {
|
||||
r.i32().map(drop)
|
||||
}
|
||||
/// ByteBufCodecs.STRING_UTF8.
|
||||
fn string(r: &mut Reader) -> Result<()> {
|
||||
r.string(32767).map(drop)
|
||||
}
|
||||
/// Identifier.STREAM_CODEC, ResourceKey.streamCodec.
|
||||
fn ident(r: &mut Reader) -> Result<()> {
|
||||
string(r)
|
||||
}
|
||||
/// A text component or compound (fromCodecWithRegistries, COMPOUND_TAG).
|
||||
fn nbt(r: &mut Reader) -> Result<()> {
|
||||
r.nbt().map(drop)
|
||||
}
|
||||
fn template(r: &mut Reader) -> Result<()> {
|
||||
Template::read(r).map(drop)
|
||||
}
|
||||
/// ByteBufCodecs.optional: a flag, then the value.
|
||||
fn optional(r: &mut Reader, value: Read) -> Result<()> {
|
||||
if r.bool()? { value(r) } else { Ok(()) }
|
||||
}
|
||||
/// ByteBufCodecs.list and map: a VarInt count, then the elements (a map's
|
||||
/// key and value each). Every element is at least a byte, so the count
|
||||
/// can't pass what is left.
|
||||
fn list(r: &mut Reader, element: Read) -> Result<()> {
|
||||
let n = r.count(r.remaining())?;
|
||||
(0..n).try_for_each(|_| element(r))
|
||||
}
|
||||
/// ByteBufCodecs.holder: 0 and the value itself, or the registry id plus one.
|
||||
fn holder(r: &mut Reader, direct: Read) -> Result<()> {
|
||||
if r.varint()? == 0 { direct(r) } else { Ok(()) }
|
||||
}
|
||||
/// ByteBufCodecs.holderSet: 0 and a tag id, or the size plus one and the ids.
|
||||
fn holder_set(r: &mut Reader) -> Result<()> {
|
||||
match r.varint()? {
|
||||
0 => ident(r),
|
||||
n => {
|
||||
let n = usize::try_from(n - 1).map_err(|_| Error::new("negative holder set size"))?;
|
||||
if n > r.remaining() {
|
||||
return Err(Error::new("holder set past the end"));
|
||||
}
|
||||
(0..n).try_for_each(|_| varint(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// SoundEvent.STREAM_CODEC: a holder of a location and an optional fixed range.
|
||||
fn sound(r: &mut Reader) -> Result<()> {
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
optional(r, f32)
|
||||
})
|
||||
}
|
||||
/// ResolvableInt, ResolvableFloat: Either a constant (true) or a context provider's key.
|
||||
fn resolvable(r: &mut Reader, constant: Read) -> Result<()> {
|
||||
if r.bool()? { constant(r) } else { ident(r) }
|
||||
}
|
||||
/// TrimMaterial.STREAM_CODEC: a holder of a palette id and a description.
|
||||
fn trim_material(r: &mut Reader) -> Result<()> {
|
||||
holder(r, |r| {
|
||||
ident(r)?;
|
||||
nbt(r)
|
||||
})
|
||||
}
|
||||
/// MobEffectInstance: the effect id, then its Details.
|
||||
fn mob_effect(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
effect_details(r)
|
||||
}
|
||||
/// MobEffectInstance.Details: amplifier, duration, ambient, showParticles, showIcon, then the
|
||||
/// hidden effect's Details, recursively.
|
||||
fn effect_details(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
boolean(r)?;
|
||||
boolean(r)?;
|
||||
boolean(r)?;
|
||||
optional(r, effect_details)
|
||||
}
|
||||
/// ConsumeEffect.STREAM_CODEC: the consume_effect_type id, then that type's fields.
|
||||
fn consume_effect(r: &mut Reader) -> Result<()> {
|
||||
match r.varint()? {
|
||||
// apply_effects: effects, probability.
|
||||
0 => {
|
||||
list(r, mob_effect)?;
|
||||
f32(r)
|
||||
}
|
||||
// remove_effects: a mob effect holder set.
|
||||
1 => holder_set(r),
|
||||
// clear_all_effects.
|
||||
2 => Ok(()),
|
||||
// teleport_randomly: diameter, directionalParticles.
|
||||
3 => {
|
||||
f32(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
// play_sound.
|
||||
4 => sound(r),
|
||||
other => Err(Error::new(format!("unknown consume effect type {other}"))),
|
||||
}
|
||||
}
|
||||
/// BlockPredicate: blocks, properties, nbt, then DataComponentMatchers (exact, partial).
|
||||
fn block_predicate(r: &mut Reader) -> Result<()> {
|
||||
optional(r, holder_set)?;
|
||||
// StatePropertiesPredicate: PropertyMatchers, each a name and an
|
||||
// Either<ExactMatcher (value), RangedMatcher (min, max)>.
|
||||
optional(r, |r| {
|
||||
list(r, |r| {
|
||||
string(r)?;
|
||||
if r.bool()? {
|
||||
string(r)
|
||||
} else {
|
||||
optional(r, string)?;
|
||||
optional(r, string)
|
||||
}
|
||||
})
|
||||
})?;
|
||||
optional(r, nbt)?;
|
||||
// DataComponentExactPredicate: TypedDataComponents, a component type id and its value.
|
||||
list(r, |r| {
|
||||
let kind = r.varint()?;
|
||||
Component::read(kind, r).map(drop)
|
||||
})?;
|
||||
// DataComponentPredicate.STREAM_CODEC: an Either of a predicate type id or a component
|
||||
// type id, then the predicate as NBT (fromCodecWithRegistries).
|
||||
list(r, |r| {
|
||||
boolean(r)?;
|
||||
varint(r)?;
|
||||
nbt(r)
|
||||
})
|
||||
}
|
||||
/// ItemAttributeModifiers.Entry: attribute, modifier (id, amount, operation), slot, display.
|
||||
fn attribute_modifier(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
ident(r)?;
|
||||
r.f64()?;
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
// Display.Type: default, hidden, or override and its text.
|
||||
match r.varint()? {
|
||||
0 | 1 => Ok(()),
|
||||
2 => nbt(r),
|
||||
other => Err(Error::new(format!(
|
||||
"unknown attribute modifier display {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
/// Tool: rules (blocks, speed, correctForDrops), defaultMiningSpeed, damagePerBlock,
|
||||
/// canDestroyBlocksInCreative.
|
||||
fn tool(r: &mut Reader) -> Result<()> {
|
||||
list(r, |r| {
|
||||
holder_set(r)?;
|
||||
optional(r, f32)?;
|
||||
optional(r, boolean)
|
||||
})?;
|
||||
f32(r)?;
|
||||
varint(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
/// Equippable: slot, equipSound, assetId, cameraOverlay, allowedEntities, dispensable,
|
||||
/// swappable, damageOnHurt, equipOnInteract, canBeSheared, shearingSound.
|
||||
fn equippable(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
sound(r)?;
|
||||
optional(r, ident)?;
|
||||
optional(r, ident)?;
|
||||
optional(r, holder_set)?;
|
||||
(0..5).try_for_each(|_| boolean(r))?;
|
||||
sound(r)
|
||||
}
|
||||
/// BlocksAttacks: blockDelaySeconds, disableCooldownScale, damageReductions (angle, type,
|
||||
/// base, factor), itemDamage (threshold, base, factor), bypassedBy, blockSound, disableSound.
|
||||
fn blocks_attacks(r: &mut Reader) -> Result<()> {
|
||||
f32(r)?;
|
||||
f32(r)?;
|
||||
list(r, |r| {
|
||||
f32(r)?;
|
||||
optional(r, holder_set)?;
|
||||
f32(r)?;
|
||||
f32(r)
|
||||
})?;
|
||||
(0..3).try_for_each(|_| f32(r))?;
|
||||
optional(r, holder_set)?;
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
/// KineticWeapon: contactCooldownTicks, delayTicks, the dismount, knockback and damage
|
||||
/// conditions (maxDurationTicks, minSpeed, minRelativeSpeed), forwardMovement,
|
||||
/// damageMultiplier, sound, hitSound.
|
||||
fn kinetic_weapon(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
varint(r)?;
|
||||
(0..3).try_for_each(|_| {
|
||||
optional(r, |r| {
|
||||
varint(r)?;
|
||||
f32(r)?;
|
||||
f32(r)
|
||||
})
|
||||
})?;
|
||||
f32(r)?;
|
||||
f32(r)?;
|
||||
optional(r, sound)?;
|
||||
optional(r, sound)
|
||||
}
|
||||
/// PotionContents: potion, customColor, customEffects, customName.
|
||||
fn potion_contents(r: &mut Reader) -> Result<()> {
|
||||
optional(r, varint)?;
|
||||
optional(r, i32)?;
|
||||
list(r, mob_effect)?;
|
||||
optional(r, string)
|
||||
}
|
||||
/// FireworkExplosion: shape, colors, fadeColors, hasTrail, hasTwinkle.
|
||||
fn firework_explosion(r: &mut Reader) -> Result<()> {
|
||||
varint(r)?;
|
||||
list(r, i32)?;
|
||||
list(r, i32)?;
|
||||
boolean(r)?;
|
||||
boolean(r)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package dev.horizonlab.parity;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import it.unimi.dsi.fastutil.objects.Reference2ObjectMap;
|
||||
import it.unimi.dsi.fastutil.objects.Reference2ObjectMaps;
|
||||
import java.io.PrintWriter;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.Optional;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.component.DataComponentPatch;
|
||||
import net.minecraft.core.component.DataComponentType;
|
||||
import net.minecraft.core.component.DataComponents;
|
||||
import net.minecraft.core.component.TypedDataComponent;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.network.RegistryFriendlyByteBuf;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.sounds.SoundEvents;
|
||||
import net.minecraft.world.entity.decoration.painting.PaintingVariant;
|
||||
import net.minecraft.world.item.JukeboxPlayable;
|
||||
import net.minecraft.world.item.JukeboxSong;
|
||||
import net.minecraft.world.item.component.MapPostProcessing;
|
||||
import net.minecraft.util.Unit;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
|
||||
/**
|
||||
* Item stacks as a server sends them (ItemStack.OPTIONAL_STREAM_CODEC), for
|
||||
* every data component: each default component value of every vanilla item
|
||||
* set on a stick (so it is in the patch), the SNBT fixtures given in a file
|
||||
* (one `label<TAB>stack SNBT` per line), and a few patches built directly.
|
||||
* Prints one JSON line per stack: its label, the encoded bytes in hex and the
|
||||
* patch's component type ids in encoding order (added, then removed), or
|
||||
* the error.
|
||||
*/
|
||||
public final class VanillaItemWireOracle {
|
||||
private static final int PER_TYPE = 12;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
ParityData.init();
|
||||
List<String[]> fixtures = new ArrayList<>();
|
||||
for (String line : Files.readAllLines(Path.of(args[0]))) {
|
||||
if (line.isBlank() || line.startsWith("#")) continue;
|
||||
fixtures.add(line.split("\t", 2));
|
||||
}
|
||||
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) {
|
||||
// Each distinct default value, once per component type and
|
||||
// encoding, and at most PER_TYPE of them for one type.
|
||||
Set<String> seen = new HashSet<>();
|
||||
Map<DataComponentType<?>, Integer> perType = new HashMap<>();
|
||||
for (var item : BuiltInRegistries.ITEM) {
|
||||
for (TypedDataComponent<?> c : item.components()) {
|
||||
ItemStack stack = new ItemStack(Items.STICK);
|
||||
if (!set(stack, c)) continue;
|
||||
String hex = encode(stack);
|
||||
if (hex == null || perType.getOrDefault(c.type(), 0) >= PER_TYPE) continue;
|
||||
if (!seen.add(BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()) + hex)) continue;
|
||||
perType.merge(c.type(), 1, Integer::sum);
|
||||
print(out, "default " + BuiltInRegistries.ITEM.getKey(item) + " " + BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(c.type()), stack);
|
||||
}
|
||||
}
|
||||
for (String[] f : fixtures) {
|
||||
try {
|
||||
print(out, f[0], ParityData.stack(f[1]));
|
||||
} catch (Exception e) {
|
||||
out.println("{\"label\":" + json(f[0]) + ",\"error\":" + json(String.valueOf(e)) + "}");
|
||||
}
|
||||
}
|
||||
// Components without a persistent codec, direct holders item SNBT
|
||||
// can't name, and removals.
|
||||
ItemStack direct = new ItemStack(Items.STICK);
|
||||
direct.set(DataComponents.ADDITIONAL_TRADE_COST, 4);
|
||||
direct.set(DataComponents.MAP_POST_PROCESSING, MapPostProcessing.SCALE);
|
||||
direct.set(DataComponents.JUKEBOX_PLAYABLE, new JukeboxPlayable(Holder.direct(
|
||||
new JukeboxSong(SoundEvents.ARMOR_EQUIP_CHAIN, Component.literal("S"), 10f, 3))));
|
||||
direct.set(DataComponents.PAINTING_VARIANT, Holder.direct(new PaintingVariant(
|
||||
2, 1, Identifier.withDefaultNamespace("kebab"), Optional.of(Component.literal("T")), Optional.empty())));
|
||||
print(out, "non-persistent and direct", direct);
|
||||
ItemStack locked = new ItemStack(Items.STONE);
|
||||
locked.set(DataComponents.CREATIVE_SLOT_LOCK, Unit.INSTANCE);
|
||||
print(out, "creative_slot_lock", locked);
|
||||
ItemStack removed = new ItemStack(Items.APPLE, 3);
|
||||
removed.remove(DataComponents.FOOD);
|
||||
removed.remove(DataComponents.CONSUMABLE);
|
||||
removed.set(DataComponents.DAMAGE, 4);
|
||||
print(out, "removed food and consumable", removed);
|
||||
print(out, "empty", ItemStack.EMPTY);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> boolean set(ItemStack stack, TypedDataComponent<T> c) {
|
||||
if (stack.getPrototype().has(c.type()) && java.util.Objects.equals(stack.getPrototype().get(c.type()), c.value())) return false;
|
||||
stack.set((DataComponentType<T>) c.type(), c.value());
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String encode(ItemStack stack) {
|
||||
var buf = new RegistryFriendlyByteBuf(Unpooled.buffer(), ParityData.access);
|
||||
try {
|
||||
ItemStack.OPTIONAL_STREAM_CODEC.encode(buf, stack);
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
byte[] bytes = new byte[buf.readableBytes()];
|
||||
buf.readBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
private static void print(PrintWriter out, String label, ItemStack stack) {
|
||||
String hex = encode(stack);
|
||||
if (hex == null) {
|
||||
out.println("{\"label\":" + json(label) + ",\"error\":\"encode failed\"}");
|
||||
return;
|
||||
}
|
||||
// DataComponentPatch's encoding order: its map's fast iteration, added
|
||||
// components, then removals.
|
||||
StringBuilder kinds = new StringBuilder();
|
||||
StringBuilder removed = new StringBuilder();
|
||||
for (var e : Reference2ObjectMaps.fastIterable(patchMap(stack.getComponentsPatch()))) {
|
||||
// Removed (package-private) marks a removal.
|
||||
StringBuilder to = e.getValue().getClass().getName().equals("net.minecraft.core.component.Removed") ? removed : kinds;
|
||||
to.append(to.isEmpty() ? "" : ",").append(id(e.getKey()));
|
||||
}
|
||||
out.println("{\"label\":" + json(label) + ",\"hex\":\"" + hex + "\",\"added\":[" + kinds + "],\"removed\":[" + removed + "]}");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Reference2ObjectMap<DataComponentType<?>, Object> patchMap(DataComponentPatch patch) {
|
||||
try {
|
||||
Field map = DataComponentPatch.class.getDeclaredField("map");
|
||||
map.setAccessible(true);
|
||||
return (Reference2ObjectMap<DataComponentType<?>, Object>) map.get(patch);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static int id(DataComponentType<?> type) {
|
||||
return BuiltInRegistries.DATA_COMPONENT_TYPE.getId(type);
|
||||
}
|
||||
|
||||
private static String json(String s) {
|
||||
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\"";
|
||||
}
|
||||
}
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Item stacks on the wire: every data component, as vanilla 26.3 encodes it.
|
||||
|
||||
VanillaItemWireOracle encodes stacks with ItemStack.OPTIONAL_STREAM_CODEC
|
||||
(what a server sends in inventories, recipe books and advancements): each
|
||||
default component value of every vanilla item, the SNBT fixtures below
|
||||
(direct holders, tags and lists, optional fields, every consume effect,
|
||||
block predicates with component matchers, the mob variants) and patches
|
||||
built in Java (components without a persistent codec, removals). The Rust
|
||||
probe decodes each with horizon_protocol::item::Slot and must consume every
|
||||
byte and find the same component ids in the same order; a stack whose
|
||||
components Horizon only reads past must also encode back to the same bytes.
|
||||
Every id in DataComponents must be covered."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
try:
|
||||
from . import lab, test_rust_parity as base
|
||||
except ImportError:
|
||||
import lab
|
||||
import test_rust_parity as base
|
||||
|
||||
COMPONENT_TYPES = 122
|
||||
|
||||
FIXTURES = [
|
||||
('custom_data', '{custom_data:{a:1,b:"x",c:[1,2]}}'),
|
||||
('unbreakable', '{unbreakable:{}}'),
|
||||
('custom_name', '{custom_name:{text:"Hi",color:"red",bold:1b}}'),
|
||||
('lore', '{lore:["a",{text:"b",italic:0b}]}'),
|
||||
('enchantments', '{enchantments:{"minecraft:sharpness":5,"minecraft:unbreaking":3}}'),
|
||||
('can_break tag', '{can_break:{blocks:"#minecraft:logs"}}'),
|
||||
('can_break list state nbt', '{can_break:[{blocks:["minecraft:stone","minecraft:dirt"],state:{snowy:"true",age:{min:"1",max:"3"},level:{min:"2"}},nbt:"{a:1b}"}]}'),
|
||||
('can_place_on components', '{can_place_on:{blocks:"minecraft:chest",components:{"minecraft:custom_name":"x","minecraft:damage":3},predicates:{"minecraft:custom_data":{a:1},"minecraft:damage":{durability:{min:1}}}}}'),
|
||||
('can_place_on empty', '{can_place_on:[{}]}'),
|
||||
('custom_model_data', '{custom_model_data:{floats:[1.0f,2.5f],flags:[1b,0b],strings:["a"],colors:[16711680]}}'),
|
||||
('tooltip_display', '{tooltip_display:{hide_tooltip:1b,hidden_components:["minecraft:enchantments","minecraft:damage"]}}'),
|
||||
('repair_cost', '{repair_cost:7}'),
|
||||
('intangible_projectile', '{intangible_projectile:{}}'),
|
||||
('tooltip_style', '{tooltip_style:"minecraft:fancy"}'),
|
||||
('interact_animation', '{interact_animation:{type:"stab",duration:9}}'),
|
||||
('dyed_color', '{dyed_color:16711935}'),
|
||||
('map_id', '{map_id:42}'),
|
||||
('written_book_content', '{written_book_content:{title:{raw:"T",filtered:"F"},author:"me",generation:1,pages:[{raw:"p1",filtered:"q1"},"p2"],resolved:1b}}'),
|
||||
('trim registry', '{trim:{material:"minecraft:gold",pattern:"minecraft:coast"}}'),
|
||||
('trim direct', '{trim:{material:{palette_id:"minecraft:x",description:"X"},pattern:{asset_id:"minecraft:y",description:"Y",decal:1b}}}'),
|
||||
('block_entity_data', '{block_entity_data:{id:"minecraft:chest",Items:[]}}'),
|
||||
('lodestone_tracker target', '{lodestone_tracker:{target:{dimension:"minecraft:the_nether",pos:[1,-64,3]},tracked:0b}}'),
|
||||
('lodestone_tracker empty', '{lodestone_tracker:{}}'),
|
||||
('firework_explosion', '{firework_explosion:{shape:"star",colors:[1,2],fade_colors:[3],has_trail:1b,has_twinkle:0b}}'),
|
||||
('profile name', '{profile:{name:"Notch"}}'),
|
||||
('profile full', '{profile:{name:"jeb_",id:[I;1,2,3,4],properties:[{name:"textures",value:"abc",signature:"sig"}]}}'),
|
||||
('note_block_sound', '{note_block_sound:"minecraft:block.note_block.bell"}'),
|
||||
('base_color', '{base_color:"lime"}'),
|
||||
('sulfur_cube_content', '{sulfur_cube_content:{id:"minecraft:stone",count:1}}'),
|
||||
('lock', '{lock:{items:"minecraft:diamond"}}'),
|
||||
('container_loot', '{container_loot:{loot_table:"minecraft:chests/simple_dungeon",seed:5L}}'),
|
||||
('waxed', '{waxed:{}}'),
|
||||
('banner_patterns', '{banner_patterns:[{pattern:"minecraft:stripe_bottom",color:"red"},{pattern:{asset_id:"minecraft:x",translation_key:"y"},color:"blue"}]}'),
|
||||
('pot_decorations', '{pot_decorations:{back:{id:"minecraft:brick"},left:{id:"minecraft:angler_pottery_sherd"},front:{id:"minecraft:skull_pottery_sherd",count:1}}}'),
|
||||
('suspicious_stew_effects', '{suspicious_stew_effects:[{id:"minecraft:night_vision",duration:100},{id:"minecraft:poison"}]}'),
|
||||
('potion_contents full', '{potion_contents:{potion:"minecraft:swiftness",custom_color:123,custom_effects:[{id:"minecraft:speed",amplifier:1b,duration:200,ambient:1b,show_particles:0b,show_icon:1b,hidden_effect:{id:"minecraft:speed",duration:100,hidden_effect:{id:"minecraft:speed",duration:50}}}],custom_name:"x"}}'),
|
||||
('potion_contents bare', '{potion_contents:{}}'),
|
||||
('container', '{container:[{slot:0,item:{id:"minecraft:stone",count:3}},{slot:5,item:{id:"minecraft:diamond_sword",components:{"minecraft:damage":5,"minecraft:trim":{material:"minecraft:gold",pattern:"minecraft:coast"}}}}]}'),
|
||||
('bundle_contents', '{bundle_contents:[{id:"minecraft:stone",count:2},{id:"minecraft:potion",components:{"minecraft:potion_contents":{potion:"minecraft:water"}}}]}'),
|
||||
('charged_projectiles', '{charged_projectiles:[{id:"minecraft:arrow"},{id:"minecraft:firework_rocket",components:{"minecraft:fireworks":{flight_duration:2b,explosions:[{shape:"burst",colors:[5]}]}}}]}'),
|
||||
('block_state', '{block_state:{facing:"north",half:"top"}}'),
|
||||
('bees', '{bees:[{entity_data:{id:"minecraft:bee",Health:5f},ticks_in_hive:5,min_ticks_in_hive:10}]}'),
|
||||
('entity_data', '{entity_data:{id:"minecraft:pig",NoAI:1b}}'),
|
||||
('bucket_entity_data', '{bucket_entity_data:{Health:5f}}'),
|
||||
('instrument direct', '{instrument:{sound_event:{sound_id:"minecraft:x",range:8f},use_duration:3f,range:64f,description:"Horn"}}'),
|
||||
('painting variant', '{"minecraft:painting/variant":"minecraft:kebab"}'),
|
||||
('sign_text_front', '{sign_text_front:{messages:["a","b","c","d"],filtered_messages:["e","f","g","h"],color:"red",has_glowing_text:1b}}'),
|
||||
('sign_text_back', '{sign_text_back:{messages:["a","","",""]}}'),
|
||||
('map_decorations', '{map_decorations:{a:{type:"minecraft:player",x:1.0d,z:2.0d,rotation:0f}}}'),
|
||||
('debug_stick_state', '{debug_stick_state:{"minecraft:oak_stairs":"facing"}}'),
|
||||
('recipes', '{recipes:["minecraft:stone_bricks"]}'),
|
||||
('attribute_modifiers', '{attribute_modifiers:[{type:"minecraft:attack_damage",id:"minecraft:x",amount:1.5d,operation:"add_value",slot:"mainhand",display:{type:"override",value:"hi"}},{type:"minecraft:armor",id:"minecraft:y",amount:2d,operation:"add_multiplied_total",display:{type:"hidden"}}]}'),
|
||||
('consumable all effects', '{consumable:{consume_seconds:2f,animation:"drink",sound:{sound_id:"minecraft:x",range:4f},has_consume_particles:0b,on_consume_effects:[{type:"apply_effects",effects:[{id:"minecraft:speed",duration:20}],probability:0.5f},{type:"remove_effects",effects:"minecraft:poison"},{type:"remove_effects",effects:["minecraft:poison","minecraft:wither"]},{type:"clear_all_effects"},{type:"teleport_randomly",diameter:8f},{type:"play_sound",sound:"minecraft:entity.generic.eat"}]}}'),
|
||||
('death_protection', '{death_protection:{death_effects:[{type:"clear_all_effects"}]}}'),
|
||||
('equippable full', '{equippable:{slot:"head",equip_sound:"minecraft:item.armor.equip_gold",asset_id:"minecraft:gold",camera_overlay:"minecraft:misc/pumpkinblur",allowed_entities:"#minecraft:skeletons",dispensable:0b,swappable:0b,damage_on_hurt:0b,equip_on_interact:1b,can_be_sheared:1b,shearing_sound:{sound_id:"minecraft:y"}}}'),
|
||||
('equippable bare', '{equippable:{slot:"chest"}}'),
|
||||
('tool rules', '{tool:{rules:[{blocks:"#minecraft:mineable/pickaxe",speed:4f,correct_for_drops:1b},{blocks:["minecraft:stone"]}],default_mining_speed:2f,damage_per_block:2,can_destroy_blocks_in_creative:0b}}'),
|
||||
('blocks_attacks full', '{blocks_attacks:{block_delay_seconds:0.5f,disable_cooldown_scale:2f,damage_reductions:[{horizontal_blocking_angle:45f,type:"#minecraft:is_fire",base:1f,factor:0.5f},{base:0f,factor:1f}],item_damage:{threshold:3f,base:1f,factor:2f},bypassed_by:"#minecraft:bypasses_shield",block_sound:"minecraft:item.shield.block",disable_sound:{sound_id:"minecraft:z"}}}'),
|
||||
('damage_resistant', '{damage_resistant:{types:"#minecraft:is_fire"}}'),
|
||||
('repairable list', '{repairable:{items:["minecraft:diamond","minecraft:emerald"]}}'),
|
||||
('use_cooldown group', '{use_cooldown:{seconds:1.5f,cooldown_group:"minecraft:x"}}'),
|
||||
('use_remainder', '{use_remainder:{id:"minecraft:bowl",count:2}}'),
|
||||
('fireworks', '{fireworks:{flight_duration:3b,explosions:[{shape:"creeper",colors:[1],fade_colors:[2,3],has_trail:1b},{shape:"small_ball"}]}}'),
|
||||
('provides_banner_patterns', '{provides_banner_patterns:"#minecraft:pattern_item/globe"}'),
|
||||
('wolf variant', '{"minecraft:wolf/variant":"minecraft:ashen","minecraft:wolf/sound_variant":"minecraft:angry","minecraft:wolf/collar":"red"}'),
|
||||
('fox salmon parrot', '{"minecraft:fox/variant":"snow","minecraft:salmon/size":"large","minecraft:parrot/variant":"blue"}'),
|
||||
('tropical fish', '{"minecraft:tropical_fish/pattern":"flopper","minecraft:tropical_fish/base_color":"white","minecraft:tropical_fish/pattern_color":"black"}'),
|
||||
('mooshroom rabbit', '{"minecraft:mooshroom/variant":"brown","minecraft:rabbit/variant":"gold"}'),
|
||||
('pig cow chicken', '{"minecraft:pig/variant":"minecraft:cold","minecraft:pig/sound_variant":"minecraft:big","minecraft:cow/variant":"minecraft:warm","minecraft:cow/sound_variant":"minecraft:classic","minecraft:chicken/variant":"minecraft:cold","minecraft:chicken/sound_variant":"minecraft:classic"}'),
|
||||
('nautilus frog horse', '{"minecraft:zombie_nautilus/variant":"minecraft:warm","minecraft:frog/variant":"minecraft:cold","minecraft:horse/variant":"chestnut"}'),
|
||||
('llama axolotl cat', '{"minecraft:llama/variant":"brown","minecraft:axolotl/variant":"blue","minecraft:cat/variant":"minecraft:tabby","minecraft:cat/sound_variant":"minecraft:classic","minecraft:cat/collar":"blue"}'),
|
||||
('sheep shulker villager', '{"minecraft:sheep/color":"pink","minecraft:shulker/color":"purple","minecraft:villager/variant":"minecraft:taiga"}'),
|
||||
('trim material direct provider', '{provides_trim_material:{palette_id:"minecraft:x",description:"X"}}'),
|
||||
('everything at once', '{damage:3,unbreakable:{},custom_name:"n",enchantments:{"minecraft:mending":1},trim:{material:"minecraft:gold",pattern:"minecraft:coast"},banner_patterns:[{pattern:"minecraft:globe",color:"red"}],pot_decorations:{right:{id:"minecraft:brick"}},lore:["x"],rarity:"epic"}'),
|
||||
]
|
||||
# Fixtures on another item than a stick (the components need it, or read better there).
|
||||
ITEMS = {'profile name': 'player_head', 'profile full': 'player_head', 'trim registry': 'diamond_chestplate',
|
||||
'trim direct': 'diamond_chestplate', 'banner_patterns': 'white_banner', 'pot_decorations': 'decorated_pot',
|
||||
'everything at once': 'diamond_chestplate'}
|
||||
|
||||
|
||||
def compare(vanilla, rust):
|
||||
"""Failures, stacks checked, and the component ids covered."""
|
||||
results = {r['label']: r for r in rust}
|
||||
failures, covered = [], set()
|
||||
for v in vanilla:
|
||||
if 'error' in v:
|
||||
failures.append(f"oracle {v['label']}: {v['error']}")
|
||||
continue
|
||||
covered.update(v['added'] + v['removed'])
|
||||
r = results.get(v['label'])
|
||||
if r is None or 'error' in r:
|
||||
failures.append(f"{v['label']}: {r and r['error']}")
|
||||
elif r['left'] or r['added'] != v['added'] or r['removed'] != v['removed']:
|
||||
failures.append(f"{v['label']}: {r['left']} bytes left, ids {r['added']}/{r['removed']}, vanilla {v['added']}/{v['removed']}")
|
||||
elif not r['same_bytes'] and not r['typed']:
|
||||
failures.append(f"{v['label']}: read past, but encoded back differently")
|
||||
missing = sorted(set(range(COMPONENT_TYPES)) - covered)
|
||||
if missing:
|
||||
failures.append(f'component ids no stack covers: {missing}')
|
||||
return failures, len(vanilla), covered
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--prepare', action='store_true')
|
||||
args = parser.parse_args()
|
||||
build = lab.LOCAL / 'item-wire-parity'
|
||||
build.mkdir(parents=True, exist_ok=True)
|
||||
report_path = build / 'report.json'
|
||||
report_path.unlink(missing_ok=True)
|
||||
if args.prepare:
|
||||
lab.prepare()
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaItemWireOracle.java'
|
||||
classes = build / 'classes'
|
||||
classes.mkdir(exist_ok=True)
|
||||
|
||||
def run(command, name):
|
||||
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
|
||||
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
|
||||
dependency_path = os.pathsep.join(map(str, libraries))
|
||||
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-sourcepath',
|
||||
lab.ROOT / 'mods/horizon/src/test/java', '-cp', str(jars['server']) + os.pathsep + dependency_path,
|
||||
'-d', classes, oracle], 'compile-java')
|
||||
fixtures = build / 'fixtures.tsv'
|
||||
fixtures.write_text(''.join(f'{label}\t{{id:"minecraft:{ITEMS.get(label, "stick")}",count:1,components:{snbt}}}\n'
|
||||
for label, snbt in FIXTURES), encoding='utf-8')
|
||||
vanilla_path, rust_path = build / 'vanilla.jsonl', build / 'rust.jsonl'
|
||||
run(['java', '-Xmx1G', '-cp', os.pathsep.join([str(classes), str(jars['server']), dependency_path]),
|
||||
'dev.horizonlab.parity.VanillaItemWireOracle', fixtures, vanilla_path], 'oracle')
|
||||
run(['cargo', 'build', '--release', '-p', 'horizon-protocol', '--example', 'item_wire_probe'], 'compile-rust')
|
||||
binary = lab.cargo_target() / 'release/examples' / ('item_wire_probe.exe' if os.name == 'nt' else 'item_wire_probe')
|
||||
run([binary, vanilla_path, rust_path], 'rust')
|
||||
read = lambda path: [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines() if line.strip()]
|
||||
failures, stacks, covered = compare(read(vanilla_path), read(rust_path))
|
||||
for failure in failures[:40]:
|
||||
print(failure)
|
||||
if failures:
|
||||
raise SystemExit(f'{len(failures)} item wire mismatches (of {stacks} stacks)')
|
||||
lab.write_json(report_path, {
|
||||
'schema': 1, 'minecraft': lock['minecraft']['version'], 'platform': platform.platform(),
|
||||
'stacks': stacks, 'component_types_covered': len(covered),
|
||||
'oracle_sha256': lab.digest(vanilla_path, 'sha256'), 'rust_sha256': lab.digest(rust_path, 'sha256')})
|
||||
print(f'Item wire parity passed: {stacks} stacks, all {len(covered)} component types: {report_path}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user