diff --git a/crates/league-toolkit/Cargo.toml b/crates/league-toolkit/Cargo.toml index 30506a03..7691a71a 100644 --- a/crates/league-toolkit/Cargo.toml +++ b/crates/league-toolkit/Cargo.toml @@ -21,7 +21,7 @@ default = [ "hash", ] -serde = ["ltk_wad/serde", "ltk_file/serde", "ltk_meta/serde"] +serde = ["ltk_wad/serde", "ltk_file/serde", "ltk_meta/serde", "ltk_rst/serde"] anim = ["dep:ltk_anim"] file = ["dep:ltk_file"] @@ -31,6 +31,7 @@ primitives = ["dep:ltk_primitives"] texture = ["dep:ltk_texture"] wad = ["dep:ltk_wad"] hash = ["dep:ltk_hash"] +rst = ["dep:ltk_rst"] [dependencies] ltk_anim = { version = "0.3.3", path = "../ltk_anim", optional = true } @@ -41,3 +42,4 @@ ltk_primitives = { version = "0.3.3", path = "../ltk_primitives", optional = tru ltk_texture = { version = "0.5.0", path = "../ltk_texture", optional = true } ltk_wad = { version = "0.2.14", path = "../ltk_wad", optional = true } ltk_hash = { version = "0.2.6", path = "../ltk_hash", optional = true } +ltk_rst = { version = "0.1.0", path = "../ltk_rst", optional = true } diff --git a/crates/league-toolkit/src/lib.rs b/crates/league-toolkit/src/lib.rs index 4f577afc..98c567b1 100644 --- a/crates/league-toolkit/src/lib.rs +++ b/crates/league-toolkit/src/lib.rs @@ -21,3 +21,6 @@ pub use ltk_wad as wad; #[cfg(feature = "hash")] pub use ltk_hash as hash; + +#[cfg(feature = "rst")] +pub use ltk_rst as rst; diff --git a/crates/ltk_hash/Cargo.toml b/crates/ltk_hash/Cargo.toml index 70a9e0be..0125cd80 100644 --- a/crates/ltk_hash/Cargo.toml +++ b/crates/ltk_hash/Cargo.toml @@ -7,3 +7,4 @@ license = "MIT OR Apache-2.0" readme = "../../README.md" [dependencies] +xxhash-rust = { workspace = true } diff --git a/crates/ltk_hash/src/lib.rs b/crates/ltk_hash/src/lib.rs index bbb2f0e2..4b330549 100644 --- a/crates/ltk_hash/src/lib.rs +++ b/crates/ltk_hash/src/lib.rs @@ -1,3 +1,4 @@ //! Other utilities (hashing, etc) pub mod elf; pub mod fnv1a; +pub mod xxhash; diff --git a/crates/ltk_hash/src/xxhash.rs b/crates/ltk_hash/src/xxhash.rs new file mode 100644 index 00000000..b0b1e36c --- /dev/null +++ b/crates/ltk_hash/src/xxhash.rs @@ -0,0 +1,10 @@ +use xxhash_rust::xxh64::xxh64; + +/// Computes the XXHash64 of `input` bytes with the given `seed`. +/// +/// This is a thin wrapper around [`xxhash_rust::xxh64::xxh64`] that is +/// re-exported so downstream crates can depend on a single hashing crate. +#[inline] +pub fn xxhash64(input: &[u8], seed: u64) -> u64 { + xxh64(input, seed) +} diff --git a/crates/ltk_rst/Cargo.toml b/crates/ltk_rst/Cargo.toml new file mode 100644 index 00000000..8c412490 --- /dev/null +++ b/crates/ltk_rst/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ltk_rst" +version = "0.1.0" +edition = "2021" +description = "RST (Riot String Table) reading/writing for League Toolkit" +license = "MIT OR Apache-2.0" +readme = "../../README.md" + +[features] +serde = ["dep:serde"] + +[dependencies] +thiserror = { workspace = true } +byteorder = { workspace = true } +ltk_hash = { version = "0.2.6", path = "../ltk_hash" } +ltk_io_ext = { version = "0.4.2", path = "../ltk_io_ext" } + +serde = { workspace = true, optional = true } diff --git a/crates/ltk_rst/src/error.rs b/crates/ltk_rst/src/error.rs new file mode 100644 index 00000000..666a52bd --- /dev/null +++ b/crates/ltk_rst/src/error.rs @@ -0,0 +1,15 @@ +use std::io; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum RstError { + #[error("invalid magic code (expected [0x52, 0x53, 0x54], got {actual:?})")] + InvalidMagic { actual: [u8; 3] }, + + #[error("unsupported RST version: {version:#04x}")] + UnsupportedVersion { version: u8 }, + + #[error("io error")] + IoError(#[from] io::Error), +} diff --git a/crates/ltk_rst/src/hash.rs b/crates/ltk_rst/src/hash.rs new file mode 100644 index 00000000..635c3d69 --- /dev/null +++ b/crates/ltk_rst/src/hash.rs @@ -0,0 +1,30 @@ +use ltk_hash::xxhash::xxhash64; + +use crate::version::RstHashType; + +/// Computes the masked XXHash64 of `key` lowercased as UTF-8, suitable for use +/// as an RST entry hash (without the string-offset component). +/// +/// The result is masked to the bit-width defined by `hash_type`: +/// - [`RstHashType::Complex`] → lower 40 bits +/// - [`RstHashType::Simple`] → lower 39 bits +pub fn compute_hash(key: &str, hash_type: RstHashType) -> u64 { + let lowered = key.to_lowercase(); + let raw = xxhash64(lowered.as_bytes(), 0); + raw & hash_type.hash_mask() +} + +/// Packs a pre-computed masked `hash` together with a string `offset` into the +/// single `u64` value written into the RST hash table. +#[inline] +pub fn pack_entry(hash: u64, offset: u64, hash_type: RstHashType) -> u64 { + hash | (offset << hash_type.offset_shift()) +} + +/// Unpacks a raw RST hash-table entry into `(hash, offset)`. +#[inline] +pub fn unpack_entry(entry: u64, hash_type: RstHashType) -> (u64, u64) { + let hash = entry & hash_type.hash_mask(); + let offset = entry >> hash_type.offset_shift(); + (hash, offset) +} diff --git a/crates/ltk_rst/src/lib.rs b/crates/ltk_rst/src/lib.rs new file mode 100644 index 00000000..2f249b85 --- /dev/null +++ b/crates/ltk_rst/src/lib.rs @@ -0,0 +1,53 @@ +//! Reading and writing League of Legends RST (Riot String Table) files. +//! +//! RST files are localisation tables that map XXHash64-based keys to UTF-8 +//! strings. They are typically found at `DATA/Menu/*.stringtable` or +//! `DATA/Menu/fontconfig_*.txt` inside WAD archives. +//! +//! # Reading +//! +//! ```no_run +//! use std::fs::File; +//! use ltk_rst::Stringtable; +//! +//! let mut file = File::open("fontconfig_en_us.stringtable")?; +//! let table = Stringtable::from_rst_reader(&mut file)?; +//! +//! for (hash, text) in &table.entries { +//! println!("{hash:#018x} = {text}"); +//! } +//! # Ok::<(), Box>(()) +//! ``` +//! +//! # Writing +//! +//! ```no_run +//! use std::fs::File; +//! use ltk_rst::Stringtable; +//! +//! let mut table = Stringtable::new(); +//! table.insert_str("game_client_quit", "Quit"); +//! +//! let mut out = File::create("out.stringtable")?; +//! table.to_rst_writer(&mut out)?; +//! # Ok::<(), Box>(()) +//! ``` +//! +//! # Hashing keys manually +//! +//! ``` +//! use ltk_rst::{RstHashType, compute_hash}; +//! +//! let hash = compute_hash("game_client_quit", RstHashType::Simple); +//! println!("{hash:#018x}"); +//! ``` + +mod error; +mod hash; +mod rst; +mod version; + +pub use error::*; +pub use hash::*; +pub use rst::*; +pub use version::*; diff --git a/crates/ltk_rst/src/rst.rs b/crates/ltk_rst/src/rst.rs new file mode 100644 index 00000000..88ae86aa --- /dev/null +++ b/crates/ltk_rst/src/rst.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom, Write}; + +use byteorder::{ReadBytesExt as _, WriteBytesExt as _, LE}; +use ltk_io_ext::ReaderExt as _; + +use crate::error::RstError; +use crate::hash::{compute_hash, pack_entry, unpack_entry}; +use crate::version::RstVersion; + +/// Magic bytes at the start of every RST file: `"RST"`. +pub const MAGIC: &[u8; 3] = b"RST"; + +/// A parsed string table. +/// +/// String tables are League of Legends localisation tables that map +/// XXHash64-based keys to UTF-8 strings. The hash table entries pack both +/// the string hash and the offset of its null-terminated UTF-8 data into a +/// single `u64`. +/// +/// # Reading +/// +/// ```no_run +/// use std::fs::File; +/// use ltk_rst::Stringtable; +/// +/// let mut file = File::open("fontconfig_en_us.stringtable")?; +/// let table = Stringtable::from_rst_reader(&mut file)?; +/// +/// if let Some(text) = table.get(0x1234_5678_9abc_def0) { +/// println!("{text}"); +/// } +/// # Ok::<(), Box>(()) +/// ``` +/// +/// # Writing +/// +/// ```no_run +/// use std::fs::File; +/// use ltk_rst::Stringtable; +/// +/// let mut table = Stringtable::new(); +/// table.insert_str("game_client_quit", "Quit"); +/// +/// let mut out = File::create("out.stringtable")?; +/// table.to_rst_writer(&mut out)?; +/// # Ok::<(), Box>(()) +/// ``` +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stringtable { + /// Hash → string mapping. + pub entries: HashMap, +} + +impl Stringtable { + /// Creates an empty [`Stringtable`]. + pub fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + /// Returns the number of entries in the table. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns `true` if the table contains no entries. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Returns an iterator over the entries in the table. + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + /// Returns the string associated with `hash`, if any. + pub fn get(&self, hash: u64) -> Option<&str> { + self.entries.get(&hash).map(|s| s.as_str()) + } + + /// Inserts an entry by pre-computed hash. + /// + /// The hash must already be masked to the bit-width of the desired + /// [`RstHashType`] — use [`compute_hash`] to produce it. + pub fn insert(&mut self, hash: u64, value: impl Into) { + self.entries.insert(hash, value.into()); + } + + /// Hashes `key` using the latest version's hash type and inserts the entry. + pub fn insert_str(&mut self, key: &str, value: impl Into) { + let hash = compute_hash(key, RstVersion::V5.hash_type()); + self.insert(hash, value); + } + + /// Parses a [`Stringtable`] from any [`Read`] + [`Seek`] source containing + /// RST data. + /// + /// Seeking is required because string data offsets stored in the hash + /// table are relative to the start of the string section, which is only + /// known after the entire hash table has been read. + pub fn from_rst_reader(reader: &mut (impl Read + Seek)) -> Result { + let mut magic = [0u8; 3]; + reader.read_exact(&mut magic)?; + if magic != *MAGIC { + return Err(RstError::InvalidMagic { actual: magic }); + } + + let version = RstVersion::try_from_u8(reader.read_u8()?)?; + let hash_type = version.hash_type(); + + // V2 has an optional font-config string (read and discard). + if version == RstVersion::V2 { + let has_config = reader.read_u8()? != 0; + if has_config { + let len = reader.read_i32::()?; + reader.seek(SeekFrom::Current(len as i64))?; + } + } + + let count = reader.read_i32::()? as usize; + let mut pairs: Vec<(u64, u64)> = Vec::with_capacity(count); + for _ in 0..count { + let raw = reader.read_u64::()?; + pairs.push(unpack_entry(raw, hash_type)); + } + + // V2–V4 have a mode byte (read and discard). + if version.has_mode_byte() { + let _ = reader.read_u8()?; + } + + let data_start = reader.stream_position()?; + let mut offset_cache: HashMap = HashMap::with_capacity(count); + let mut entries: HashMap = HashMap::with_capacity(count); + + for (hash, offset) in pairs { + let text = if let Some(cached) = offset_cache.get(&offset) { + cached.clone() + } else { + reader.seek(SeekFrom::Start(data_start + offset))?; + let text = reader.read_str_until_nul()?; + offset_cache.insert(offset, text.clone()); + text + }; + entries.insert(hash, text); + } + + Ok(Self { entries }) + } + + /// Serialises this [`Stringtable`] to any [`Write`] sink as RST V5. + pub fn to_rst_writer(&self, writer: &mut impl Write) -> Result<(), RstError> { + use ltk_io_ext::WriterExt as _; + let hash_type = RstVersion::V5.hash_type(); + + writer.write_all(MAGIC)?; + writer.write_u8(RstVersion::V5.to_u8())?; + + writer.write_i32::(self.entries.len() as i32)?; + + // Build string data blob with deduplication, and collect packed entries + let mut data: Vec = Vec::new(); + let mut text_to_offset: HashMap<&str, u64> = HashMap::with_capacity(self.entries.len()); + let mut packed_entries: Vec = Vec::with_capacity(self.entries.len()); + + for (hash, text) in &self.entries { + let offset = if let Some(&off) = text_to_offset.get(text.as_str()) { + off + } else { + let off = data.len() as u64; + data.write_terminated_string(text)?; + text_to_offset.insert(text.as_str(), off); + off + }; + + let packed = pack_entry(*hash, offset, hash_type); + packed_entries.push(packed); + } + + for packed in &packed_entries { + writer.write_u64::(*packed)?; + } + + writer.write_all(&data)?; + + Ok(()) + } +} + +impl Default for Stringtable { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/ltk_rst/src/version.rs b/crates/ltk_rst/src/version.rs new file mode 100644 index 00000000..62c48c30 --- /dev/null +++ b/crates/ltk_rst/src/version.rs @@ -0,0 +1,79 @@ +use crate::error::RstError; + +/// RST file version. +/// +/// - **V2** — complex (40-bit) hashing, optional font config, mode byte. +/// - **V3** — complex (40-bit) hashing, mode byte. +/// - **V4** — simple (39-bit) hashing, mode byte. +/// - **V5** — simple (39-bit) hashing, no mode byte. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RstVersion { + /// Version 2 — uses complex (40-bit) hashing; supports optional font config and mode byte. + V2 = 2, + /// Version 3 — uses complex (40-bit) hashing; has mode byte. + V3 = 3, + /// Version 4 — uses simple (39-bit) hashing; has mode byte. + V4 = 4, + /// Version 5 — uses simple (39-bit) hashing; mode byte removed. + V5 = 5, +} + +impl RstVersion { + /// Returns the raw version number as a `u8`. + #[inline] + pub fn to_u8(self) -> u8 { + self as u8 + } + + /// Returns the [`RstHashType`] that corresponds to this version. + pub fn hash_type(self) -> RstHashType { + match self { + RstVersion::V2 | RstVersion::V3 => RstHashType::Complex, + RstVersion::V4 | RstVersion::V5 => RstHashType::Simple, + } + } + + /// Returns `true` if this version stores a mode byte in the file. + pub fn has_mode_byte(self) -> bool { + !matches!(self, RstVersion::V5) + } + + pub(crate) fn try_from_u8(value: u8) -> Result { + match value { + 0x02 => Ok(RstVersion::V2), + 0x03 => Ok(RstVersion::V3), + 0x04 => Ok(RstVersion::V4), + 0x05 => Ok(RstVersion::V5), + _ => Err(RstError::UnsupportedVersion { version: value }), + } + } +} + +/// Determines the hash bit-width used when packing a hash+offset pair into a +/// single `u64` entry in the RST hash table. +/// +/// - [`Complex`](RstHashType::Complex): used by v2/v3 — 40-bit hash, offset in upper 24 bits. +/// - [`Simple`](RstHashType::Simple): used by v4/v5 — 39-bit hash, offset in upper 25 bits. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RstHashType { + /// 40-bit hash key (`(1 << 40) - 1`). Used by RST v2 and v3. + Complex = 40, + /// 39-bit hash key (`(1 << 39) - 1`). Used by RST v4 and v5. + Simple = 39, +} + +impl RstHashType { + /// Returns the bitmask for the hash portion of a packed entry. + #[inline] + pub fn hash_mask(self) -> u64 { + (1u64 << (self as u8)) - 1 + } + + /// Returns the bit-shift used when packing or unpacking the string offset. + #[inline] + pub fn offset_shift(self) -> u8 { + self as u8 + } +} diff --git a/crates/ltk_rst/tests/parse_files.rs b/crates/ltk_rst/tests/parse_files.rs new file mode 100644 index 00000000..b2b99b9d --- /dev/null +++ b/crates/ltk_rst/tests/parse_files.rs @@ -0,0 +1,220 @@ +//! Integration tests for RST parsing using real game files. +//! +//! Test files live at `/../test-files/data/menu/`. +//! Tests that reference missing files are skipped rather than failing, so the +//! suite can run in CI environments that do not include game assets. + +use std::fs::File; +use std::io::{BufReader, Cursor}; +use std::path::Path; + +use ltk_rst::{compute_hash, RstError, RstHashType, Stringtable}; + +const TEST_FILES_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../test-files/data/menu"); + +fn open(relative: &str) -> Option> { + let path = Path::new(TEST_FILES_ROOT).join(relative); + if !path.exists() { + println!("skipping missing file: {}", path.display()); + return None; + } + Some(BufReader::new(File::open(&path).unwrap_or_else(|e| { + panic!("failed to open {}: {e}", path.display()) + }))) +} + +/// Parses every locale's bootstrap.stringtable to ensure the reader handles +/// all regional encodings (CJK, Arabic, Cyrillic, …) without error. +#[test] +fn parse_all_bootstrap_locales() { + let locales = [ + "ar_ae", "cs_cz", "de_de", "el_gr", "en_au", "en_gb", "en_ph", "en_sg", "en_us", "es_ar", + "es_es", "es_mx", "fr_fr", "hu_hu", "id_id", "it_it", "ja_jp", "ko_kr", "pl_pl", "pt_br", + "ro_ro", "ru_ru", "th_th", "tr_tr", "vi_vn", "zh_cn", "zh_my", "zh_tw", + ]; + + for locale in locales { + let Some(mut reader) = open(&format!("{locale}/bootstrap.stringtable")) else { + continue; + }; + + let table = Stringtable::from_rst_reader(&mut reader) + .unwrap_or_else(|e| panic!("failed to parse {locale}/bootstrap.stringtable: {e}")); + + assert!( + !table.entries.is_empty(), + "{locale}: expected at least one entry" + ); + + println!( + "{locale}/bootstrap.stringtable: {} entries", + table.entries.len() + ); + } +} + +/// Parses the large LoL and TFT string tables and checks their entry counts. +#[test] +fn parse_lol_and_tft_stringtables() { + for (name, expected_count) in [("lol", 115310usize), ("tft", 94652usize)] { + let Some(mut reader) = open(&format!("en_us/{name}.stringtable")) else { + continue; + }; + + let table = Stringtable::from_rst_reader(&mut reader) + .unwrap_or_else(|e| panic!("failed to parse en_us/{name}.stringtable: {e}")); + + assert_eq!( + table.entries.len(), + expected_count, + "{name}.stringtable entry count mismatch" + ); + + println!("en_us/{name}.stringtable: {} entries", table.entries.len()); + } +} + +/// Verifies known hash→string mappings in en_us/bootstrap.stringtable. +#[test] +fn parse_bootstrap_known_entries() { + let Some(mut reader) = open("en_us/bootstrap.stringtable") else { + return; + }; + + let table = Stringtable::from_rst_reader(&mut reader) + .expect("failed to parse en_us/bootstrap.stringtable"); + + assert_eq!(table.entries.len(), 201); + + // Known stable entries confirmed from the file. + assert_eq!(table.get(0x000000008818cc3c), Some("Ignore")); + assert_eq!(table.get(0x0000004732dbee5e), Some("Cancel")); +} + +/// Parses en_us/bootstrap.stringtable, serialises it back to bytes, parses +/// those bytes again, and asserts the two parsed representations are equal. +#[test] +fn round_trip_bootstrap() { + let Some(mut reader) = open("en_us/bootstrap.stringtable") else { + return; + }; + + let original = Stringtable::from_rst_reader(&mut reader) + .expect("failed to parse en_us/bootstrap.stringtable"); + + let mut buf = Vec::new(); + original + .to_rst_writer(&mut buf) + .expect("failed to serialise bootstrap.stringtable"); + + let mut cursor = Cursor::new(&buf); + let reloaded = + Stringtable::from_rst_reader(&mut cursor).expect("failed to re-parse serialised bootstrap"); + + assert_eq!( + original.entries.len(), + reloaded.entries.len(), + "entry count mismatch after round-trip" + ); + for (hash, text) in &original.entries { + assert_eq!( + reloaded.get(*hash), + Some(text.as_str()), + "entry {hash:#018x} missing or changed after round-trip" + ); + } +} + +/// compute_hash lowercases before hashing, so both cases must produce the same +/// result. +#[test] +fn compute_hash_is_case_insensitive() { + let lower = compute_hash("game_client_quit", RstHashType::Simple); + let upper = compute_hash("GAME_CLIENT_QUIT", RstHashType::Simple); + let mixed = compute_hash("Game_Client_Quit", RstHashType::Simple); + + assert_eq!(lower, upper); + assert_eq!(lower, mixed); +} + +/// The Simple (v4/v5) mask is 39 bits; the Complex (v2/v3) mask is 40 bits. +/// Each hash must fit within its own mask. +#[test] +fn compute_hash_respects_bit_width() { + let simple_mask = (1u64 << 39) - 1; + let complex_mask = (1u64 << 40) - 1; + + let simple_hash = compute_hash("some_key", RstHashType::Simple); + let complex_hash = compute_hash("some_key", RstHashType::Complex); + + assert_eq!(simple_hash & simple_mask, simple_hash); + assert_eq!(complex_hash & complex_mask, complex_hash); +} + +#[test] +fn invalid_magic_returns_error() { + let bad = b"\x00\x00\x00\x05"; + let mut cursor = Cursor::new(bad); + let err = Stringtable::from_rst_reader(&mut cursor).unwrap_err(); + assert!( + matches!(err, RstError::InvalidMagic { .. }), + "expected InvalidMagic, got {err:?}" + ); +} + +#[test] +fn unsupported_version_returns_error() { + let bad = b"RST\x01"; + let mut cursor = Cursor::new(bad); + let err = Stringtable::from_rst_reader(&mut cursor).unwrap_err(); + assert!( + matches!(err, RstError::UnsupportedVersion { version: 0x01 }), + "expected UnsupportedVersion(0x01), got {err:?}" + ); +} + +/// Verifies that insert_str hashes the key and stores the value, and that the +/// resulting file can be written and re-read with no data loss. +#[test] +fn insert_str_round_trips() { + let mut table = Stringtable::new(); + table.insert_str("game_client_quit", "Quit"); + table.insert_str("game_client_play", "Play"); + + let mut buf = Vec::new(); + table.to_rst_writer(&mut buf).expect("serialise failed"); + + let mut cursor = Cursor::new(&buf); + let loaded = Stringtable::from_rst_reader(&mut cursor).expect("re-parse failed"); + + let quit_hash = compute_hash("game_client_quit", RstHashType::Simple); + let play_hash = compute_hash("game_client_play", RstHashType::Simple); + + assert_eq!(loaded.get(quit_hash), Some("Quit")); + assert_eq!(loaded.get(play_hash), Some("Play")); +} + +/// Entries with identical string values must share a single copy in the +/// serialised byte stream. +#[test] +fn to_writer_deduplicates_strings() { + let mut table = Stringtable::new(); + let shared_value = "Shared string value"; + + for i in 0u64..10 { + table.insert(i, shared_value); + } + + let mut buf = Vec::new(); + table.to_rst_writer(&mut buf).expect("serialise failed"); + + let occurrences = buf + .windows(shared_value.len()) + .filter(|w| *w == shared_value.as_bytes()) + .count(); + + assert_eq!( + occurrences, 1, + "string should appear exactly once in output" + ); +}