1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
//! V1803 (1.13.2 + 172) — schematic-relevant subset of `V1803.java`.
//!
//! VERSION = MCVersions.V1_13_2 (1631) + 172 = 1803.
//!
//! Ported (the entire version is schematic-relevant): the ITEM_STACK structure
//! converter that wraps each plain string in `tag.display.Lore` into a
//! plain-text JSON text component (V1803.java:16-42).
//!
//! No helper exists for `ComponentUtils.createPlainTextComponent`, so it is
//! implemented inline. Java builds `{"text": <value>}` with Gson and serialises
//! via `GsonHelper.toStableString`, whose `JsonWriter` is HTML-safe by default.
//! `create_plain_text_component` reproduces Gson's exact HTML-safe string
//! escaping (escaping `<`, `>`, `&`, `=`, `'`, the control set, and the line/para
//! separators U+2028/U+2029) so the produced string is byte-for-byte identical to
//! vanilla's. Since the object has a single key, the stable key sort is a no-op.
use crate::nbt::NbtValue;
use super::super::registry::RegistryBuilder;
use super::super::types::{MapExt, ValueExt};
const VERSION: i32 = 1803;
/// `ComponentUtils.createPlainTextComponent(text)` -> `GsonHelper.toStableString`
/// of `{"text": text}`. Reproduces Gson's default (HTML-safe) `JsonWriter` string
/// escaping exactly.
fn create_plain_text_component(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 12);
out.push_str("{\"text\":");
write_gson_string(&mut out, text);
out.push('}');
out
}
/// Port of Gson `JsonWriter.string(value)` with `htmlSafe = true` (the default).
fn write_gson_string(out: &mut String, value: &str) {
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\t' => out.push_str("\\t"),
'\u{0008}' => out.push_str("\\b"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\u{000C}' => out.push_str("\\f"),
// HTML-safe escapes (Gson's default JsonWriter).
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
'=' => out.push_str("\\u003d"),
'\'' => out.push_str("\\u0027"),
// Line/paragraph separators, which Gson always escapes.
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
// Remaining control characters below 0x20.
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
}
pub fn register(reg: &mut RegistryBuilder) {
reg.item_stack.add_structure_converter(
VERSION,
0,
Box::new(|data, _from, _to| {
let display = match data
.get_map_mut("tag")
.and_then(|tag| tag.get_map_mut("display"))
{
Some(d) => d,
None => return,
};
// Java fetches the Lore list filtered to STRING entries; non-string
// entries are skipped (left untouched).
let lore = match display.get_list_mut("Lore") {
Some(l) => l,
None => return,
};
for el in lore.iter_mut() {
if let Some(s) = el.as_str() {
let wrapped = create_plain_text_component(s);
*el = NbtValue::String(wrapped);
}
}
}),
);
// Reverse of the ITEM_STACK converter: unwrap each `{"text": s}` plain-text
// component in `tag.display.Lore` back to its raw legacy string `s`.
//
// The forward (`create_plain_text_component`) only ever produced a single-key
// `{"text": <raw string>}` from each legacy Lore string. That mapping is
// injective — the `"text"` value is exactly the original raw string (even if
// that string itself looked like JSON, it was stored verbatim as the string
// value) — so this unwrap is an exact, lossless inverse for any real downgrade
// (rule 11). Non-string entries (the forward left them untouched) and any
// value that is not a single-key `{"text":…}` string component are foreign to
// the forward output and are left as-is.
reg.item_stack.add_reverse_converter(
VERSION,
0,
Box::new(|data, _from, _to| {
let lore = match data
.get_map_mut("tag")
.and_then(|tag| tag.get_map_mut("display"))
.and_then(|display| display.get_list_mut("Lore"))
{
Some(l) => l,
None => return,
};
for el in lore.iter_mut() {
let Some(text) = el.as_str() else { continue };
let parsed: serde_json::Value = match serde_json::from_str(text) {
Ok(v) => v,
// Forward only ever emits JSON here; non-JSON is foreign — leave it.
Err(_) => continue,
};
if let serde_json::Value::Object(obj) = &parsed {
if obj.len() == 1 {
if let Some(serde_json::Value::String(s)) = obj.get("text") {
*el = NbtValue::String(s.clone());
}
}
}
}
}),
);
}