Merge pull request #4862 from levzzz5154/parity/K3-hash

Hash block predicates with nbt or partial predicates and books with filtered text
This commit is contained in:
levzzz authored and GitHub committed 2026-10-31 06:29:01 +05:00
commit 6eac540995
9 files changed
+514 -222

No files matched your search

+2 -173
View File
@@ -3,179 +3,8 @@
//! number formatting.
use crate::text::{self, Style, Text};
use horizon_world::nbt::Tag;
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}
use horizon_world::snbt_text::string_of;
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
// ---- the coloured form -----------------------------------------------------
+1 -1
View File
@@ -131,7 +131,7 @@ fn components_tag(s: &Stack, builtin: &Builtin, synced: &Dynamic) -> Option<Tag>
Component::Text(t) | Component::Nbt(t) => t.clone(),
Component::Ident(v) => Tag::String(v.as_str().into()),
Component::Lore(lines) => Tag::List(lines.clone()),
Component::WritableBook(pages) => Tag::compound([(
Component::WritableBook { pages, .. } => Tag::compound([(
"pages",
Tag::List(
pages
+5 -1
View File
@@ -880,7 +880,10 @@ fn network_stack(
if !d.writable_pages.is_empty() {
stack.added.push((
horizon_protocol::item::component::WRITABLE_BOOK_CONTENT,
horizon_protocol::item::Component::WritableBook(d.writable_pages.clone()),
horizon_protocol::item::Component::WritableBook {
pages: d.writable_pages.clone(),
filtered: Vec::new(),
},
));
}
if let Some(w) = &d.written {
@@ -900,6 +903,7 @@ fn network_stack(
.collect()
},
resolved: true,
..Default::default()
},
),
));
+73 -29
View File
@@ -59,15 +59,20 @@ pub mod component {
pub const PAINTING_VARIANT: i32 = 109;
}
/// A written book's content (`WrittenBookContent.STREAM_CODEC`): the
/// filtered variants of the title and pages are read and dropped.
#[derive(Clone, Debug, PartialEq)]
/// A written book's content (`WrittenBookContent.STREAM_CODEC`). The raw title
/// and pages are what a book shows; the filtered variants (`Filterable.filtered`,
/// which a server sends when chat filtering is on) are kept only so that the
/// component hashes as the server holds it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct WrittenBook {
pub title: String,
pub title_filtered: Option<String>,
pub author: String,
pub generation: i32,
/// Each page's text component.
pub pages: Vec<Tag>,
/// Each page's filtered variant: empty (none filtered) or one per page.
pub pages_filtered: Vec<Option<Tag>>,
pub resolved: bool,
}
@@ -102,8 +107,12 @@ pub enum Component {
hide: bool,
hidden: Vec<i32>,
},
/// `WritableBookContent`: the pages (raw text).
WritableBook(Vec<String>),
/// `WritableBookContent`: the pages (raw text) and their filtered variants
/// (empty, or one per page).
WritableBook {
pages: Vec<String>,
filtered: Vec<Option<String>>,
},
WrittenBook(WrittenBook),
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
Stacks(Vec<Stack>),
@@ -179,14 +188,20 @@ impl FireworkExplosion {
}
}
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
/// (dropped).
fn filterable<T>(r: &mut Reader, value: impl Fn(&mut Reader) -> Result<T>) -> Result<T> {
/// `Filterable.streamCodec`: the raw value, then an optional filtered one.
fn filterable<T>(
r: &mut Reader,
value: impl Fn(&mut Reader) -> Result<T>,
) -> Result<(T, Option<T>)> {
let raw = value(r)?;
if r.bool()? {
value(r)?;
}
Ok(raw)
let filtered = if r.bool()? { Some(value(r)?) } else { None };
Ok((raw, filtered))
}
/// The filtered variants of a list of pages, or none when no page has one.
fn filtered_variants<T>(pages: &mut Vec<(T, Option<T>)>) -> (Vec<T>, Vec<Option<T>>) {
let any = pages.iter().any(|(_, f)| f.is_some());
let (raw, filtered): (Vec<_>, Vec<_>) = pages.drain(..).unzip();
(raw, if any { filtered } else { Vec::new() })
}
/// `ResolvableProfile`: an optional name and UUID and the profile's property
@@ -252,25 +267,28 @@ impl Component {
| OMINOUS_BOTTLE_AMPLIFIER => Self::Int(r.varint()?),
WRITABLE_BOOK_CONTENT => {
let n = r.count(100)?;
Self::WritableBook(
(0..n)
.map(|_| filterable(r, |r| r.string(1024)))
.collect::<Result<_>>()?,
)
let mut pages = (0..n)
.map(|_| filterable(r, |r| r.string(1024)))
.collect::<Result<Vec<_>>>()?;
let (pages, filtered) = filtered_variants(&mut pages);
Self::WritableBook { pages, filtered }
}
WRITTEN_BOOK_CONTENT => {
let title = filterable(r, |r| r.string(32))?;
let (title, title_filtered) = filterable(r, |r| r.string(32))?;
let author = r.string(32767)?;
let generation = r.varint()?;
let n = r.count(r.remaining())?;
let pages = (0..n)
let mut pages = (0..n)
.map(|_| filterable(r, |r| r.nbt()))
.collect::<Result<_>>()?;
.collect::<Result<Vec<_>>>()?;
let (pages, pages_filtered) = filtered_variants(&mut pages);
Self::WrittenBook(WrittenBook {
title,
title_filtered,
author,
generation,
pages,
pages_filtered,
resolved: r.bool()?,
})
}
@@ -600,22 +618,40 @@ impl Component {
}
w.bool(*tracked);
}
Self::WritableBook(pages) => {
Self::WritableBook { pages, filtered } => {
w.varint(pages.len() as i32);
for page in pages {
for (i, page) in pages.iter().enumerate() {
w.string(page);
w.bool(false);
match filtered.get(i).and_then(Option::as_ref) {
Some(f) => {
w.bool(true);
w.string(f);
}
None => w.bool(false),
}
}
}
Self::WrittenBook(b) => {
w.string(&b.title);
w.bool(false);
match &b.title_filtered {
Some(f) => {
w.bool(true);
w.string(f);
}
None => w.bool(false),
}
w.string(&b.author);
w.varint(b.generation);
w.varint(b.pages.len() as i32);
for page in &b.pages {
for (i, page) in b.pages.iter().enumerate() {
w.nbt(page);
w.bool(false);
match b.pages_filtered.get(i).and_then(Option::as_ref) {
Some(f) => {
w.bool(true);
w.nbt(f);
}
None => w.bool(false),
}
}
w.bool(b.resolved);
}
@@ -1002,6 +1038,8 @@ mod tests {
author: "Guest".into(),
generation: 1,
pages: vec![literal("one"), literal("two")],
pages_filtered: vec![None, Some(literal("2"))],
title_filtered: Some("N".into()),
resolved: true,
}),
));
@@ -1020,7 +1058,10 @@ mod tests {
let mut quill = Stack::new(1099, 1);
quill.added.push((
component::WRITABLE_BOOK_CONTENT,
Component::WritableBook(vec!["a".into(), String::new()]),
Component::WritableBook {
pages: vec!["a".into(), String::new()],
filtered: vec![None, Some("b".into())],
},
));
round::<Option<Stack>, Slot>(Some(quill));
assert_eq!(sword.damage(), 17);
@@ -1098,13 +1139,16 @@ mod tests {
}
#[test]
fn filtered_book_pages_are_read_and_dropped() {
fn filtered_book_pages_are_read_and_kept() {
// A writable book with one page "hi" and its filtered "**".
let bytes = [1, 7, 1, 0, 56, 1, 2, b'h', b'i', 1, 2, b'*', b'*'];
let stack = Slot::read(&mut Reader::new(&bytes)).unwrap().unwrap();
assert_eq!(
stack.component(component::WRITABLE_BOOK_CONTENT),
Some(&Component::WritableBook(vec!["hi".into()]))
Some(&Component::WritableBook {
pages: vec!["hi".into()],
filtered: vec![Some("**".into())],
})
);
}
+30 -8
View File
@@ -77,9 +77,13 @@ fn pick(table: &[&str], id: i32) -> Option<HashCode> {
fn text(tag: &horizon_world::nbt::Tag) -> HashCode {
HashOps::from_nbt(tag, &TEXT_BOOLS)
}
/// `Filterable.codec`: the full record, with no filtered variant.
fn filterable(raw: HashCode) -> HashCode {
RecordHash::new().field("raw", raw).build()
/// `Filterable.codec`: the full record (`Codec.withAlternative` encodes with
/// the first codec), the filtered variant absent when there is none.
fn filterable(raw: HashCode, filtered: Option<HashCode>) -> HashCode {
RecordHash::new()
.field("raw", raw)
.optional("filtered", filtered)
.build()
}
/// `Codec.xxx.optionalFieldOf(name, default)`: absent when equal to the default.
fn or_default<T: PartialEq>(
@@ -283,18 +287,26 @@ impl Component {
}
.build()
}
Self::WritableBook(pages) => if pages.is_empty() {
Self::WritableBook { pages, filtered } => if pages.is_empty() {
RecordHash::new()
} else {
RecordHash::new().field(
"pages",
HashOps::create_list(pages.iter().map(|p| filterable(string(p)))),
HashOps::create_list(pages.iter().enumerate().map(|(i, p)| {
filterable(
string(p),
filtered.get(i).and_then(Option::as_ref).map(|f| string(f)),
)
})),
)
}
.build(),
Self::WrittenBook(b) => {
let rec = RecordHash::new()
.field("title", filterable(string(&b.title)))
.field(
"title",
filterable(string(&b.title), b.title_filtered.as_deref().map(string)),
)
.field("author", string(&b.author));
let rec = or_default(
rec,
@@ -308,7 +320,12 @@ impl Component {
} else {
rec.field(
"pages",
HashOps::create_list(b.pages.iter().map(|p| filterable(text(p)))),
HashOps::create_list(b.pages.iter().enumerate().map(|(i, p)| {
filterable(
text(p),
b.pages_filtered.get(i).and_then(Option::as_ref).map(text),
)
})),
)
};
or_default(
@@ -727,7 +744,10 @@ mod tests {
(
"writable",
k::WRITABLE_BOOK_CONTENT,
C::WritableBook(vec!["page one".into(), "two".into()]),
C::WritableBook {
pages: vec!["page one".into(), "two".into()],
filtered: vec![],
},
1900254632,
),
(
@@ -739,6 +759,7 @@ mod tests {
generation: 1,
pages: vec![text("p1")],
resolved: true,
..WrittenBook::default()
}),
-112185549,
),
@@ -751,6 +772,7 @@ mod tests {
generation: 0,
pages: vec![],
resolved: false,
..WrittenBook::default()
}),
-809804424,
),
@@ -3,6 +3,7 @@
//! the codec form. See [`super::raw`] for the rest.
use super::*;
use crate::buf::Reader;
use horizon_world::snbt_text::to_snbt;
const USE_ANIMATION: [&str; 12] = [
"none",
@@ -215,9 +216,57 @@ fn kinetic_condition(r: &mut Reader) -> Option<HashCode> {
Some(float_or(rec, "min_relative_speed", relative, 0.0).build())
}
/// `BlockPredicate.STREAM_CODEC` as `BlockPredicate.CODEC` writes it. A block
/// entity `nbt` (a lenient SNBT string) and `predicates` (partial component
/// matchers) have no codec form built here.
/// The `Codec.BOOL` fields of the component predicates' codecs
/// (`FireworkPredicate`, `WrittenBookPredicate`, `MobEffectInstancePredicate`):
/// they travel as NBT bytes and hash as booleans. The text components inside a
/// written book's page predicates add [`TEXT_BOOLS`].
const PREDICATE_BOOLS: [&str; 12] = [
"has_twinkle",
"has_trail",
"resolved",
"ambient",
"visible",
"bold",
"italic",
"underlined",
"strikethrough",
"obfuscated",
"interpret",
"plain",
];
/// Whether a compound anywhere in `tag` has a field `name`.
fn has_field(tag: &horizon_world::nbt::Tag, name: &str) -> bool {
use horizon_world::nbt::Tag;
match tag {
Tag::Compound(map) => map
.iter()
.any(|(k, v)| k.0.iter().copied().eq(name.encode_utf16()) || has_field(v, name)),
Tag::List(items) => items.iter().any(|t| has_field(t, name)),
_ => false,
}
}
/// `DataComponentPredicate.STREAM_CODEC`: each entry is a type (a predicate
/// type, or a component type for `AnyValue`) and the predicate as the NBT its
/// `CODEC` encodes (`fromCodecWithRegistries`), hashed as the map
/// `DataComponentPredicate.CODEC` (`Codec.dispatchedMap`) writes.
fn partial_predicate(r: &mut Reader, names: &dyn Names) -> Option<(HashCode, HashCode)> {
let registry = if boolean(r)? {
"minecraft:data_component_predicate_type"
} else {
"minecraft:data_component_type"
};
let key = entry(names, registry, int(r)?)?;
let tag = r.nbt().ok()?;
// The nested `components` of an item predicate (`container`,
// `bundle_contents`) are component values as NBT, whose booleans cannot be
// told from bytes here.
if has_field(&tag, "components") {
return None;
}
Some((key, HashOps::from_nbt(&tag, &PREDICATE_BOOLS)))
}
/// `BlockPredicate.STREAM_CODEC` as `BlockPredicate.CODEC` writes it.
fn block_predicate(r: &mut Reader, names: &dyn Names) -> Option<HashCode> {
let blocks = optional(r, |r| holder_set("minecraft:block", names, r))?;
// StatePropertiesPredicate: a name and an exact value or a (min, max) range.
@@ -238,9 +287,12 @@ fn block_predicate(r: &mut Reader, names: &dyn Names) -> Option<HashCode> {
})?;
Some(HashOps::create_map(props))
})?;
if boolean(r)? {
return None;
}
// NbtPredicate: `TagParser.LENIENT_CODEC` encodes with its flattened codec,
// the tag's SNBT string.
let nbt = optional(r, |r| match r.nbt().ok()? {
tag @ horizon_world::nbt::Tag::Compound(_) => Some(string(&to_snbt(&tag))),
_ => None,
})?;
// DataComponentExactPredicate: the expected components, by type name.
let exact = list(r, |r| {
let kind = int(r)?;
@@ -248,17 +300,25 @@ fn block_predicate(r: &mut Reader, names: &dyn Names) -> Option<HashCode> {
let value = Component::read(kind, r).ok()?.code(kind, names)?;
Some((name, value))
})?;
if r.count(r.remaining()).ok()? != 0 {
// DataComponentPredicate: the partial matchers (at most 64).
let partial = list(r, |r| partial_predicate(r, names))?;
if partial.len() > 64 {
return None;
}
let rec = RecordHash::new()
.optional("blocks", blocks)
.optional("state", state);
.optional("state", state)
.optional("nbt", nbt);
let rec = if exact.is_empty() {
rec
} else {
rec.field("components", HashOps::create_map(exact))
};
Some(
if exact.is_empty() {
if partial.is_empty() {
rec
} else {
rec.field("components", HashOps::create_map(exact))
rec.field("predicates", HashOps::create_map(partial))
}
.build(),
)
@@ -482,9 +482,26 @@ mod tests {
("minecraft:entity_type", 11) => "minecraft:bee",
("minecraft:entity_type", 30) => "minecraft:cow",
("minecraft:block_entity_type", 1) => "minecraft:chest",
("minecraft:data_component_type", 1) => "minecraft:max_stack_size",
("minecraft:data_component_type", 2) => "minecraft:max_damage",
("minecraft:data_component_type", 3) => "minecraft:damage",
("minecraft:data_component_type", 12) => "minecraft:rarity",
("minecraft:villager_type", 0) => "minecraft:desert",
("minecraft:data_component_predicate_type", 0) => "minecraft:damage",
("minecraft:data_component_predicate_type", 1) => "minecraft:enchantments",
("minecraft:data_component_predicate_type", 2) => "minecraft:stored_enchantments",
("minecraft:data_component_predicate_type", 3) => "minecraft:potion_contents",
("minecraft:data_component_predicate_type", 4) => "minecraft:custom_data",
("minecraft:data_component_predicate_type", 5) => "minecraft:container",
("minecraft:data_component_predicate_type", 6) => "minecraft:bundle_contents",
("minecraft:data_component_predicate_type", 7) => "minecraft:firework_explosion",
("minecraft:data_component_predicate_type", 8) => "minecraft:fireworks",
("minecraft:data_component_predicate_type", 9) => "minecraft:writable_book_content",
("minecraft:data_component_predicate_type", 10) => "minecraft:written_book_content",
("minecraft:data_component_predicate_type", 11) => "minecraft:attribute_modifiers",
("minecraft:data_component_predicate_type", 12) => "minecraft:trim",
("minecraft:data_component_predicate_type", 13) => "minecraft:jukebox_playable",
("minecraft:data_component_predicate_type", 14) => "minecraft:villager/variant",
("minecraft:attribute", 1) => "minecraft:armor",
("minecraft:attribute", 3) => "minecraft:attack_damage",
("minecraft:attribute", 23) => "minecraft:max_health",
@@ -854,4 +871,142 @@ mod tests {
// Bytes left over after the value.
assert_eq!(Component::Raw(vec![0, 0]).hash(34, &Pinned), None);
}
/// Block predicates with an `nbt` or partial component predicates, and books
/// with filtered text: stream bytes and hashes vanilla 26.3 printed (the same
/// driver as above, SNBT through the component's codec). The one `None` is a
/// container predicate whose nested item predicate holds exact `components`.
#[test]
fn predicates_and_filtered_books_hash_as_vanilla_does() {
let cases: [(&str, i32, &str, Option<i32>); 21] = [
(
"stone_nbt",
14,
"0101020100010a010001610108000162000178000000",
Some(-359417396),
),
(
"nbt_all_types",
14,
"010000010a08000361206200027122020001730007060001654202a05f20000000050001783fc000000600017940000000000000000b0001690000000200000001000000020900054974656d730a000000010800026964000e6d696e6563726166743a64697274010005436f756e7401000700017a0000000201020400016c00000000000000050c00014c0000000100000000000000030500016e00000000000000",
Some(18640168),
),
(
"damage_partial",
14,
"010102010000000101000a0a000664616d6167650300036d696e000000010300036d6178000000050000",
Some(-752850259),
),
(
"custom_data",
14,
"010000000001010408000d7b613a312c623a5b312c325d7d",
Some(-1320400474),
),
(
"twinkle",
14,
"01000000000101070a080005736861706500047374617201000b6861735f7477696e6b6c650100",
Some(-1476889576),
),
(
"trail_false",
14,
"01000000000101070a0100096861735f747261696c0000",
Some(-1034820270),
),
("any_value", 14, "01000000000100020a00", Some(-1904716790)),
(
"villager",
14,
"010000000001010e0800106d696e6563726166743a646573657274",
Some(1625640440),
),
(
"potion_effects",
14,
"01000000000101030a0a000765666665637473090008636f6e7461696e730a000000010a000f6d696e6563726166743a73706565640a00086475726174696f6e0300036d6178000000640001000776697369626c6500030009616d706c696669657200000001010007616d6269656e740100000000",
Some(150754403),
),
(
"container",
14,
"01000000000101050a0a00056974656d73090008636f6e7461696e730a000000010a0005636f756e740300036d696e00000002000800056974656d73000f6d696e6563726166743a73746f6e650003000473697a65000000030000",
Some(-820017514),
),
(
"fireworks",
14,
"01000000000101080a0a000a6578706c6f73696f6e7303000473697a65000000010003000f666c696768745f6475726174696f6e0000000200",
Some(-551561987),
),
(
"attribute_modifiers",
14,
"010000000001010b0a0a00096d6f64696669657273090008636f6e7461696e730a000000010a0006616d6f756e740600036d696e3ff800000000000000080009617474726962757465000f6d696e6563726166743a61726d6f72080004736c6f740004686561640800096f7065726174696f6e00096164645f76616c7565000000",
Some(-233125185),
),
(
"bundle_stored",
14,
"01000000000201060a0a00056974656d73090008636f6e7461696e730a000000010800056974656d73000f6d696e6563726166743a73746f6e650000000102090a000000010300066c6576656c730000000300",
Some(1901599730),
),
(
"writable_book",
14,
"01000000000101090a0a00057061676573090008636f6e7461696e7308000000010001780000",
Some(-883070685),
),
(
"written_book",
14,
"010000000001010a0a03000a67656e65726174696f6e000000020a00057061676573090008636f6e7461696e730a000000010800047465787400026869010004626f6c64010000080006617574686f720001410100087265736f6c7665640000",
Some(1470044104),
),
(
"all_fields",
14,
"010000010a0300016100000001000101050301000a03000a6475726162696c697479000000030001040800027b7d00020a00",
Some(808431166),
),
(
"container_components",
14,
"01000000000101050a0a00056974656d73090008636f6e7461696e730a000000010a000a636f6d706f6e656e74730100246d696e6563726166743a656e6368616e746d656e745f676c696e745f6f7665727269646501000800056974656d73000f6d696e6563726166743a73746f6e65000000",
None,
),
(
"can_break_two",
15,
"0201020100010a000000000000000100020a00",
Some(2146318348),
),
(
"written_filtered",
57,
"01540101460161010208000170010a08000474657874000171010004626f6c640100080005706c61696e0001",
Some(1884774986),
),
(
"written_plain",
57,
"01540001610001080001700000",
Some(100213250),
),
(
"writable_filtered",
56,
"03036f6e6501022a2a0374776f000133010133",
Some(1192237218),
),
];
for (name, kind, hex, expected) in cases {
let bytes = unhex(hex);
let mut r = Reader::new(&bytes);
let c = Component::read(kind, &mut r).unwrap();
assert_eq!(r.remaining(), 0, "{name}");
assert_eq!(c.hash(kind, &Pinned), expected, "{name} {hex}");
}
}
}
+1
View File
@@ -109,6 +109,7 @@ pub mod registry_fluid;
pub mod retrogen;
pub mod server_player_combat;
pub mod server_player_damage;
pub mod snbt_text;
pub mod stored_consume;
pub mod stored_effect_attributes;
pub mod suffocation;
+177
View File
@@ -0,0 +1,177 @@
//! `StringTagVisitor` (compact SNBT, `Tag.toString`) and Java's number
//! formatting, below the text-component layer so that the protocol can hash
//! a tag's string form.
use crate::nbt::Tag;
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
pub fn string_of(t: &crate::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}