Skip to content
4 changes: 3 additions & 1 deletion crates/league-toolkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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 }
Expand All @@ -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 }
3 changes: 3 additions & 0 deletions crates/league-toolkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions crates/ltk_hash/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ license = "MIT OR Apache-2.0"
readme = "../../README.md"

[dependencies]
xxhash-rust = { workspace = true }
1 change: 1 addition & 0 deletions crates/ltk_hash/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//! Other utilities (hashing, etc)
pub mod elf;
pub mod fnv1a;
pub mod xxhash;
10 changes: 10 additions & 0 deletions crates/ltk_hash/src/xxhash.rs
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions crates/ltk_rst/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
15 changes: 15 additions & 0 deletions crates/ltk_rst/src/error.rs
Original file line number Diff line number Diff line change
@@ -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),
}
30 changes: 30 additions & 0 deletions crates/ltk_rst/src/hash.rs
Original file line number Diff line number Diff line change
@@ -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)
}
53 changes: 53 additions & 0 deletions crates/ltk_rst/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>>(())
//! ```
//!
//! # 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<dyn std::error::Error>>(())
//! ```
//!
//! # 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::*;
197 changes: 197 additions & 0 deletions crates/ltk_rst/src/rst.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>>(())
/// ```
///
/// # 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<dyn std::error::Error>>(())
/// ```
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stringtable {
/// Hash → string mapping.
pub entries: HashMap<u64, String>,
}

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<Item = (&u64, &String)> {
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<String>) {
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<String>) {
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<Self, RstError> {
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::<LE>()?;
reader.seek(SeekFrom::Current(len as i64))?;
}
}

let count = reader.read_i32::<LE>()? as usize;
let mut pairs: Vec<(u64, u64)> = Vec::with_capacity(count);
for _ in 0..count {
let raw = reader.read_u64::<LE>()?;
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<u64, String> = HashMap::with_capacity(count);
let mut entries: HashMap<u64, String> = 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::<LE>(self.entries.len() as i32)?;

// Build string data blob with deduplication, and collect packed entries
let mut data: Vec<u8> = Vec::new();
let mut text_to_offset: HashMap<&str, u64> = HashMap::with_capacity(self.entries.len());
let mut packed_entries: Vec<u64> = 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::<LE>(*packed)?;
}

writer.write_all(&data)?;

Ok(())
}
}

impl Default for Stringtable {
fn default() -> Self {
Self::new()
}
}
Loading
Loading