Skip to main content

steel_registry/data_components/components/
books.rs

1//! Writable and written book item components.
2
3use std::io::{Cursor, Error, Result, Write};
4
5use simdnbt::owned::{NbtCompound, NbtList, NbtTag};
6use simdnbt::{FromNbtTag, ToNbtTag};
7use steel_utils::codec::VarInt;
8use steel_utils::hash::{ComponentHasher, HashComponent, HashEntry, sort_map_entries};
9use steel_utils::nbt::NbtNumeric as _;
10use steel_utils::serial::{PrefixedRead, PrefixedWrite, ReadFrom, WriteTo};
11use text_components::TextComponent;
12
13const MAX_NETWORK_STRING_LENGTH: usize = 32_767;
14const MAX_NETWORK_STRING_BYTES: usize = MAX_NETWORK_STRING_LENGTH * 3;
15
16/// Raw text paired with the optional server-filtered projection.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Filterable<T> {
19    raw: T,
20    filtered: Option<T>,
21}
22
23impl<T> Filterable<T> {
24    #[must_use]
25    pub const fn new(raw: T, filtered: Option<T>) -> Self {
26        Self { raw, filtered }
27    }
28
29    #[must_use]
30    pub const fn pass_through(raw: T) -> Self {
31        Self::new(raw, None)
32    }
33
34    #[must_use]
35    pub const fn raw(&self) -> &T {
36        &self.raw
37    }
38
39    #[must_use]
40    pub const fn filtered(&self) -> Option<&T> {
41        self.filtered.as_ref()
42    }
43
44    #[must_use]
45    pub fn get(&self, filter_enabled: bool) -> &T {
46        if filter_enabled {
47            self.filtered.as_ref().unwrap_or(&self.raw)
48        } else {
49            &self.raw
50        }
51    }
52}
53
54/// Editable pages in a writable book.
55#[derive(Debug, Default, Clone, PartialEq, Eq)]
56pub struct WritableBookContent {
57    pages: Vec<Filterable<String>>,
58}
59
60impl WritableBookContent {
61    pub const PAGE_EDIT_LENGTH: usize = 1024;
62    pub const MAX_PAGES: usize = 100;
63
64    #[must_use]
65    pub const fn empty() -> Self {
66        Self { pages: Vec::new() }
67    }
68
69    pub fn new(pages: Vec<Filterable<String>>) -> Result<Self> {
70        if pages.len() > Self::MAX_PAGES {
71            return Err(Error::other(format!(
72                "Got {} pages, but maximum is {}",
73                pages.len(),
74                Self::MAX_PAGES
75            )));
76        }
77        if pages.iter().any(|page| {
78            string_too_long(page.raw(), Self::PAGE_EDIT_LENGTH)
79                || page
80                    .filtered()
81                    .is_some_and(|value| string_too_long(value, Self::PAGE_EDIT_LENGTH))
82        }) {
83            return Err(Error::other(
84                "Writable book page is longer than 1024 characters",
85            ));
86        }
87        Ok(Self { pages })
88    }
89
90    #[must_use]
91    pub fn pages(&self) -> &[Filterable<String>] {
92        &self.pages
93    }
94
95    fn to_nbt_tag_ref(&self) -> NbtTag {
96        let mut compound = NbtCompound::new();
97        if !self.pages.is_empty() {
98            compound.insert(
99                "pages",
100                NbtTag::List(NbtList::Compound(
101                    self.pages.iter().map(filterable_string_nbt).collect(),
102                )),
103            );
104        }
105        NbtTag::Compound(compound)
106    }
107}
108
109impl WriteTo for WritableBookContent {
110    fn write(&self, writer: &mut impl Write) -> Result<()> {
111        write_bounded_count(self.pages.len(), Self::MAX_PAGES, writer)?;
112        for page in &self.pages {
113            write_filterable_string(page, Self::PAGE_EDIT_LENGTH, writer)?;
114        }
115        Ok(())
116    }
117}
118
119impl ReadFrom for WritableBookContent {
120    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
121        let count = read_bounded_count(data, Self::MAX_PAGES)?;
122        let mut pages = Vec::with_capacity(count);
123        for _ in 0..count {
124            pages.push(read_filterable_string(data, Self::PAGE_EDIT_LENGTH)?);
125        }
126        Self::new(pages)
127    }
128}
129
130impl ToNbtTag for WritableBookContent {
131    fn to_nbt_tag(self) -> NbtTag {
132        self.to_nbt_tag_ref()
133    }
134}
135
136impl FromNbtTag for WritableBookContent {
137    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
138        let compound = tag.compound()?;
139        let pages = match compound.get("pages") {
140            Some(tag) => {
141                let values = tag.list()?.to_owned().as_nbt_tags();
142                if values.len() > Self::MAX_PAGES {
143                    return None;
144                }
145                values
146                    .iter()
147                    .map(|tag| filterable_string_from_nbt(tag, Self::PAGE_EDIT_LENGTH))
148                    .collect::<Option<Vec<_>>>()?
149            }
150            None => Vec::new(),
151        };
152        Self::new(pages).ok()
153    }
154}
155
156impl HashComponent for WritableBookContent {
157    fn hash_component(&self, hasher: &mut ComponentHasher) {
158        let mut entries = Vec::with_capacity(1);
159        if !self.pages.is_empty() {
160            push_hash_entry(&mut entries, "pages", &FilterableStringList(&self.pages));
161        }
162        hash_entries(hasher, &mut entries);
163    }
164}
165
166/// Signed book metadata and rendered pages.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct WrittenBookContent {
169    title: Filterable<String>,
170    author: String,
171    generation: i32,
172    pages: Vec<Filterable<TextComponent>>,
173    resolved: bool,
174}
175
176impl WrittenBookContent {
177    pub const TITLE_MAX_LENGTH: usize = 32;
178    pub const MAX_GENERATION: i32 = 3;
179    pub const PAGE_LENGTH: usize = 32_767;
180
181    pub fn new(
182        title: Filterable<String>,
183        author: String,
184        generation: i32,
185        pages: Vec<Filterable<TextComponent>>,
186        resolved: bool,
187    ) -> Result<Self> {
188        if string_too_long(title.raw(), Self::TITLE_MAX_LENGTH)
189            || title
190                .filtered()
191                .is_some_and(|value| string_too_long(value, Self::TITLE_MAX_LENGTH))
192        {
193            return Err(Error::other(
194                "Written book title is longer than 32 characters",
195            ));
196        }
197        if !(0..=Self::MAX_GENERATION).contains(&generation) {
198            return Err(Error::other(format!(
199                "Book generation must be in 0..={}, got {generation}",
200                Self::MAX_GENERATION
201            )));
202        }
203        if string_too_long(&author, MAX_NETWORK_STRING_LENGTH)
204            || author.len() > MAX_NETWORK_STRING_BYTES
205        {
206            return Err(Error::other(
207                "Written book author exceeds the network string limit",
208            ));
209        }
210        Ok(Self {
211            title,
212            author,
213            generation,
214            pages,
215            resolved,
216        })
217    }
218
219    #[must_use]
220    pub const fn empty() -> Self {
221        Self {
222            title: Filterable::pass_through(String::new()),
223            author: String::new(),
224            generation: 0,
225            pages: Vec::new(),
226            resolved: true,
227        }
228    }
229
230    #[must_use]
231    pub const fn title(&self) -> &Filterable<String> {
232        &self.title
233    }
234
235    #[must_use]
236    pub fn author(&self) -> &str {
237        &self.author
238    }
239
240    #[must_use]
241    pub const fn generation(&self) -> i32 {
242        self.generation
243    }
244
245    /// Returns the signed-book content created by one crafting copy.
246    #[must_use]
247    pub fn craft_copy(&self) -> Self {
248        Self {
249            title: self.title.clone(),
250            author: self.author.clone(),
251            generation: self.generation + 1,
252            pages: self.pages.clone(),
253            resolved: self.resolved,
254        }
255    }
256
257    #[must_use]
258    pub fn pages(&self) -> &[Filterable<TextComponent>] {
259        &self.pages
260    }
261
262    #[must_use]
263    pub const fn resolved(&self) -> bool {
264        self.resolved
265    }
266
267    fn to_nbt_tag_ref(&self) -> NbtTag {
268        let mut compound = NbtCompound::new();
269        compound.insert(
270            "title",
271            NbtTag::Compound(filterable_string_nbt(&self.title)),
272        );
273        compound.insert("author", self.author.clone());
274        if self.generation != 0 {
275            compound.insert("generation", self.generation);
276        }
277        if !self.pages.is_empty() {
278            compound.insert(
279                "pages",
280                NbtTag::List(NbtList::Compound(
281                    self.pages.iter().map(filterable_component_nbt).collect(),
282                )),
283            );
284        }
285        if self.resolved {
286            compound.insert("resolved", true);
287        }
288        NbtTag::Compound(compound)
289    }
290}
291
292impl WriteTo for WrittenBookContent {
293    fn write(&self, writer: &mut impl Write) -> Result<()> {
294        write_filterable_string(&self.title, Self::TITLE_MAX_LENGTH, writer)?;
295        write_network_string(&self.author, MAX_NETWORK_STRING_LENGTH, writer)?;
296        VarInt(self.generation).write(writer)?;
297        write_count(self.pages.len(), writer)?;
298        for page in &self.pages {
299            write_filterable_component(page, writer)?;
300        }
301        self.resolved.write(writer)
302    }
303}
304
305impl ReadFrom for WrittenBookContent {
306    fn read(data: &mut Cursor<&[u8]>) -> Result<Self> {
307        let title = read_filterable_string(data, Self::TITLE_MAX_LENGTH)?;
308        let author = read_network_string(data, MAX_NETWORK_STRING_LENGTH)?;
309        let generation = VarInt::read(data)?.0;
310        let count = read_count(data)?;
311        let mut pages = Vec::with_capacity(count.min(65_536));
312        for _ in 0..count {
313            pages.push(read_filterable_component(data)?);
314        }
315        Self::new(title, author, generation, pages, bool::read(data)?)
316    }
317}
318
319impl ToNbtTag for WrittenBookContent {
320    fn to_nbt_tag(self) -> NbtTag {
321        self.to_nbt_tag_ref()
322    }
323}
324
325impl FromNbtTag for WrittenBookContent {
326    fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option<Self> {
327        let compound = tag.compound()?;
328        let title =
329            filterable_string_from_nbt(&compound.get("title")?.to_owned(), Self::TITLE_MAX_LENGTH)?;
330        let author = compound.get("author")?.string()?.to_string();
331        let generation = match compound.get("generation") {
332            Some(tag) => tag.codec_i32()?,
333            None => 0,
334        };
335        let pages = match compound.get("pages") {
336            Some(tag) => tag
337                .list()?
338                .to_owned()
339                .as_nbt_tags()
340                .iter()
341                .map(filterable_component_from_nbt)
342                .collect::<Option<Vec<_>>>()?,
343            None => Vec::new(),
344        };
345        let resolved = match compound.get("resolved") {
346            Some(tag) => tag.codec_bool()?,
347            None => false,
348        };
349        Self::new(title, author, generation, pages, resolved).ok()
350    }
351}
352
353impl HashComponent for WrittenBookContent {
354    fn hash_component(&self, hasher: &mut ComponentHasher) {
355        let mut entries = Vec::with_capacity(5);
356        push_hash_entry(&mut entries, "title", &FilterableStringHash(&self.title));
357        push_hash_entry(&mut entries, "author", self.author.as_str());
358        if self.generation != 0 {
359            push_hash_entry(&mut entries, "generation", &self.generation);
360        }
361        if !self.pages.is_empty() {
362            push_hash_entry(&mut entries, "pages", &FilterableComponentList(&self.pages));
363        }
364        if self.resolved {
365            push_hash_entry(&mut entries, "resolved", &true);
366        }
367        hash_entries(hasher, &mut entries);
368    }
369}
370
371fn filterable_string_nbt(value: &Filterable<String>) -> NbtCompound {
372    let mut compound = NbtCompound::new();
373    compound.insert("raw", value.raw.clone());
374    if let Some(filtered) = &value.filtered {
375        compound.insert("filtered", filtered.clone());
376    }
377    compound
378}
379
380fn filterable_string_from_nbt(tag: &NbtTag, max_length: usize) -> Option<Filterable<String>> {
381    if let Some(compound) = tag.compound()
382        && let Some(raw) = compound.get("raw")
383    {
384        let raw = raw.string()?.to_string();
385        let filtered = match compound.get("filtered") {
386            Some(tag) => Some(tag.string()?.to_string()),
387            None => None,
388        };
389        return (!string_too_long(&raw, max_length)
390            && filtered
391                .as_ref()
392                .is_none_or(|value| !string_too_long(value, max_length)))
393        .then_some(Filterable::new(raw, filtered));
394    }
395    let raw = tag.string()?.to_string();
396    (!string_too_long(&raw, max_length)).then_some(Filterable::pass_through(raw))
397}
398
399fn filterable_component_nbt(value: &Filterable<TextComponent>) -> NbtCompound {
400    let mut compound = NbtCompound::new();
401    compound.insert("raw", value.raw.to_codec_nbt());
402    if let Some(filtered) = &value.filtered {
403        compound.insert("filtered", filtered.to_codec_nbt());
404    }
405    compound
406}
407
408fn filterable_component_from_nbt(tag: &NbtTag) -> Option<Filterable<TextComponent>> {
409    if let Some(compound) = tag.compound()
410        && let Some(raw) = compound.get("raw")
411    {
412        let raw = restricted_component_from_nbt(raw)?;
413        let filtered = match compound.get("filtered") {
414            Some(tag) => Some(restricted_component_from_nbt(tag)?),
415            None => None,
416        };
417        return Some(Filterable::new(raw, filtered));
418    }
419    restricted_component_from_nbt(tag).map(Filterable::pass_through)
420}
421
422fn restricted_component_from_nbt(tag: &NbtTag) -> Option<TextComponent> {
423    let component = TextComponent::from_nbt(tag)?;
424    let encoded = serde_json::to_string(&component).ok()?;
425    (encoded.encode_utf16().count() <= WrittenBookContent::PAGE_LENGTH).then_some(component)
426}
427
428fn write_filterable_string(
429    value: &Filterable<String>,
430    max_length: usize,
431    writer: &mut impl Write,
432) -> Result<()> {
433    write_network_string(&value.raw, max_length, writer)?;
434    value.filtered.is_some().write(writer)?;
435    if let Some(filtered) = &value.filtered {
436        write_network_string(filtered, max_length, writer)?;
437    }
438    Ok(())
439}
440
441fn read_filterable_string(
442    data: &mut Cursor<&[u8]>,
443    max_length: usize,
444) -> Result<Filterable<String>> {
445    let raw = read_network_string(data, max_length)?;
446    let filtered = if bool::read(data)? {
447        Some(read_network_string(data, max_length)?)
448    } else {
449        None
450    };
451    Ok(Filterable::new(raw, filtered))
452}
453
454fn write_filterable_component(
455    value: &Filterable<TextComponent>,
456    writer: &mut impl Write,
457) -> Result<()> {
458    write_component_network(&value.raw, writer)?;
459    value.filtered.is_some().write(writer)?;
460    if let Some(filtered) = &value.filtered {
461        write_component_network(filtered, writer)?;
462    }
463    Ok(())
464}
465
466fn read_filterable_component(data: &mut Cursor<&[u8]>) -> Result<Filterable<TextComponent>> {
467    let raw = TextComponent::read(data)?;
468    let filtered = if bool::read(data)? {
469        Some(TextComponent::read(data)?)
470    } else {
471        None
472    };
473    Ok(Filterable::new(raw, filtered))
474}
475
476fn write_component_network(component: &TextComponent, writer: &mut impl Write) -> Result<()> {
477    let mut encoded = Vec::new();
478    component.to_codec_nbt().write(&mut encoded);
479    writer.write_all(&encoded)
480}
481
482fn write_network_string(value: &str, max_length: usize, writer: &mut impl Write) -> Result<()> {
483    if string_too_long(value, max_length) || value.len() > max_length.saturating_mul(3) {
484        return Err(Error::other(format!(
485            "String exceeds the {max_length}-character network limit"
486        )));
487    }
488    value.write_prefixed::<VarInt>(writer)
489}
490
491fn read_network_string(data: &mut Cursor<&[u8]>, max_length: usize) -> Result<String> {
492    let value = String::read_prefixed_bound::<VarInt>(data, max_length.saturating_mul(3))?;
493    if string_too_long(&value, max_length) {
494        return Err(Error::other(format!(
495            "String exceeds the {max_length}-character network limit"
496        )));
497    }
498    Ok(value)
499}
500
501fn string_too_long(value: &str, max_length: usize) -> bool {
502    value.encode_utf16().count() > max_length
503}
504
505fn write_bounded_count(count: usize, max: usize, writer: &mut impl Write) -> Result<()> {
506    if count > max {
507        return Err(Error::other(format!(
508            "Collection size {count} exceeds {max}"
509        )));
510    }
511    write_count(count, writer)
512}
513
514fn write_count(count: usize, writer: &mut impl Write) -> Result<()> {
515    let count = i32::try_from(count).map_err(|_| Error::other("Collection is too large"))?;
516    VarInt(count).write(writer)
517}
518
519fn read_bounded_count(data: &mut Cursor<&[u8]>, max: usize) -> Result<usize> {
520    let count = read_count(data)?;
521    if count > max {
522        return Err(Error::other(format!(
523            "Collection size {count} exceeds {max}"
524        )));
525    }
526    Ok(count)
527}
528
529fn read_count(data: &mut Cursor<&[u8]>) -> Result<usize> {
530    let count = VarInt::read(data)?.0;
531    usize::try_from(count).map_err(|_| Error::other(format!("Negative collection size: {count}")))
532}
533
534struct FilterableStringHash<'a>(&'a Filterable<String>);
535
536impl HashComponent for FilterableStringHash<'_> {
537    fn hash_component(&self, hasher: &mut ComponentHasher) {
538        let mut entries = Vec::with_capacity(2);
539        push_hash_entry(&mut entries, "raw", self.0.raw.as_str());
540        if let Some(filtered) = &self.0.filtered {
541            push_hash_entry(&mut entries, "filtered", filtered.as_str());
542        }
543        hash_entries(hasher, &mut entries);
544    }
545}
546
547struct FilterableComponentHash<'a>(&'a Filterable<TextComponent>);
548
549impl HashComponent for FilterableComponentHash<'_> {
550    fn hash_component(&self, hasher: &mut ComponentHasher) {
551        let mut entries = Vec::with_capacity(2);
552        push_hash_entry(&mut entries, "raw", &self.0.raw);
553        if let Some(filtered) = &self.0.filtered {
554            push_hash_entry(&mut entries, "filtered", filtered);
555        }
556        hash_entries(hasher, &mut entries);
557    }
558}
559
560struct FilterableStringList<'a>(&'a [Filterable<String>]);
561
562impl HashComponent for FilterableStringList<'_> {
563    fn hash_component(&self, hasher: &mut ComponentHasher) {
564        hasher.start_list();
565        for value in self.0 {
566            hasher.put_component_hash(&FilterableStringHash(value));
567        }
568        hasher.end_list();
569    }
570}
571
572struct FilterableComponentList<'a>(&'a [Filterable<TextComponent>]);
573
574impl HashComponent for FilterableComponentList<'_> {
575    fn hash_component(&self, hasher: &mut ComponentHasher) {
576        hasher.start_list();
577        for value in self.0 {
578            hasher.put_component_hash(&FilterableComponentHash(value));
579        }
580        hasher.end_list();
581    }
582}
583
584fn push_hash_entry<T: HashComponent + ?Sized>(entries: &mut Vec<HashEntry>, key: &str, value: &T) {
585    let mut key_hasher = ComponentHasher::new();
586    key_hasher.put_string(key);
587    let mut value_hasher = ComponentHasher::new();
588    value.hash_component(&mut value_hasher);
589    entries.push(HashEntry::new(key_hasher, value_hasher));
590}
591
592fn hash_entries(hasher: &mut ComponentHasher, entries: &mut [HashEntry]) {
593    sort_map_entries(entries);
594    hasher.start_map();
595    for entry in entries {
596        hasher.put_raw_bytes(&entry.key_bytes);
597        hasher.put_raw_bytes(&entry.value_bytes);
598    }
599    hasher.end_map();
600}
601
602#[cfg(test)]
603mod tests {
604    use std::io::Cursor;
605
606    use simdnbt::ToNbtTag as _;
607    use steel_utils::serial::{ReadFrom as _, WriteTo as _};
608    use text_components::TextComponent;
609
610    use super::{Filterable, WritableBookContent, WrittenBookContent};
611    use crate::data_components::vanilla_components::WRITABLE_BOOK_CONTENT;
612    use crate::init_vanilla_registry;
613    use crate::{REGISTRY, RegistryExt};
614
615    fn parse<T: simdnbt::FromNbtTag>(tag: simdnbt::owned::NbtTag) -> Option<T> {
616        let mut bytes = Vec::new();
617        tag.write(&mut bytes);
618        let borrowed = simdnbt::borrow::read_tag(&mut Cursor::new(bytes.as_slice())).ok()?;
619        T::from_nbt_tag(borrowed.as_tag())
620    }
621
622    #[test]
623    fn writable_pages_use_full_filterable_persistence_and_bounded_network() {
624        let value = WritableBookContent::new(vec![Filterable::new(
625            "raw".to_owned(),
626            Some("filtered".to_owned()),
627        )])
628        .expect("valid writable book");
629        let nbt = value.clone().to_nbt_tag();
630        assert_eq!(parse(nbt), Some(value.clone()));
631        let mut network = Vec::new();
632        value.write(&mut network).expect("book should encode");
633        assert_eq!(
634            WritableBookContent::read(&mut Cursor::new(network.as_slice()))
635                .expect("book should decode"),
636            value
637        );
638        assert!(
639            WritableBookContent::new(vec![Filterable::pass_through("x".repeat(1025))]).is_err()
640        );
641    }
642
643    #[test]
644    fn written_book_round_trips_text_pages_and_validates_generation() {
645        let value = WrittenBookContent::new(
646            Filterable::pass_through("Title".to_owned()),
647            "Author".to_owned(),
648            2,
649            vec![Filterable::pass_through(TextComponent::plain("Page"))],
650            true,
651        )
652        .expect("valid written book");
653        let nbt = value.clone().to_nbt_tag();
654        assert_eq!(parse(nbt), Some(value.clone()));
655        let mut network = Vec::new();
656        value.write(&mut network).expect("book should encode");
657        assert_eq!(
658            WrittenBookContent::read(&mut Cursor::new(network.as_slice()))
659                .expect("book should decode"),
660            value
661        );
662        assert!(
663            WrittenBookContent::new(
664                Filterable::pass_through(String::new()),
665                String::new(),
666                4,
667                Vec::new(),
668                false,
669            )
670            .is_err()
671        );
672    }
673
674    #[test]
675    fn extracted_writable_book_starts_with_empty_pages() {
676        init_vanilla_registry();
677        let item = REGISTRY
678            .items
679            .by_key(&steel_utils::Identifier::vanilla_static("writable_book"))
680            .expect("writable book should be registered");
681        assert_eq!(
682            item.components.get(WRITABLE_BOOK_CONTENT),
683            Some(WritableBookContent::empty())
684        );
685    }
686}