diff --git a/README.md b/README.md index cdef5b1e..f3044e93 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ let tree = BinTree::builder() | [`ltk_meta`](https://crates.io/crates/ltk_meta) | Property bin files | `.bin` | | [`ltk_ritobin`](https://crates.io/crates/ltk_ritobin) | Human-readable bin format | ritobin text | | [`ltk_mapgeo`](https://crates.io/crates/ltk_mapgeo) | Map environment geometry | `.mapgeo` | +| [`ltk_lua`](https://crates.io/crates/ltk_lua) | Lua utilities and tooling | `all_lua_files.manifest` | | [`ltk_file`](https://crates.io/crates/ltk_file) | File type detection | — | | [`ltk_hash`](https://crates.io/crates/ltk_hash) | Hash functions (FNV-1a, ELF) | — | | [`ltk_shader`](https://crates.io/crates/ltk_shader) | Shader path utilities | — | @@ -224,6 +225,7 @@ league-toolkit/ │ ├── ltk_meta/ # Property bins │ ├── ltk_ritobin/ # Ritobin text format │ ├── ltk_mapgeo/ # Map geometry +│ ├── ltk_lua/ # Lua utilities and tooling │ ├── ltk_file/ # File detection │ ├── ltk_hash/ # Hashing │ ├── ltk_shader/ # Shader utilities diff --git a/crates/league-toolkit/Cargo.toml b/crates/league-toolkit/Cargo.toml index a2bbe450..eb573247 100644 --- a/crates/league-toolkit/Cargo.toml +++ b/crates/league-toolkit/Cargo.toml @@ -34,6 +34,7 @@ texture = ["dep:ltk_texture"] wad = ["dep:ltk_wad"] hash = ["dep:ltk_hash"] rst = ["dep:ltk_rst"] +lua = ["dep:ltk_lua"] [dependencies] ltk_anim = { version = "0.3.6", path = "../ltk_anim", optional = true } @@ -45,6 +46,7 @@ ltk_texture = { version = "0.6.0", path = "../ltk_texture", optional = true } ltk_wad = { version = "0.3.1", path = "../ltk_wad", optional = true } ltk_hash = { version = "0.4.0", path = "../ltk_hash", optional = true } ltk_rst = { version = "0.2.1", path = "../ltk_rst", optional = true } +ltk_lua = { version = "0.1.0", path = "../ltk_lua", optional = true } [dev-dependencies] image = { version = "0.25.2", default-features = false } diff --git a/crates/league-toolkit/src/lib.rs b/crates/league-toolkit/src/lib.rs index 98c567b1..a4ce05cb 100644 --- a/crates/league-toolkit/src/lib.rs +++ b/crates/league-toolkit/src/lib.rs @@ -24,3 +24,6 @@ pub use ltk_hash as hash; #[cfg(feature = "rst")] pub use ltk_rst as rst; + +#[cfg(feature = "lua")] +pub use ltk_lua as lua; diff --git a/crates/ltk_io_ext/src/writer.rs b/crates/ltk_io_ext/src/writer.rs index b9d4d0d6..58ba6db0 100644 --- a/crates/ltk_io_ext/src/writer.rs +++ b/crates/ltk_io_ext/src/writer.rs @@ -32,6 +32,16 @@ pub trait WriterExt: Write { Ok(()) } + /// Writes a string with a `u32` length prefix (writes 4 + str.len() bytes). + /// + /// The inverse of [`ReaderExt::read_sized_string_u32`](crate::ReaderExt::read_sized_string_u32). + fn write_sized_string_u32(&mut self, str: impl AsRef) -> io::Result<()> { + let str = str.as_ref(); + self.write_u32::(str.len() as _)?; + self.write_all(str.as_bytes())?; + Ok(()) + } + /// Writes a string with a null terminator (writes sizeof(str) + 1 bytes) fn write_terminated_string>(&mut self, str: S) -> io::Result<()> { self.write_all(str.as_ref().as_bytes())?; diff --git a/crates/ltk_lua/Cargo.toml b/crates/ltk_lua/Cargo.toml new file mode 100644 index 00000000..0d649740 --- /dev/null +++ b/crates/ltk_lua/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ltk_lua" +version = "0.1.0" +edition = "2021" +description = "Lua script utilities" +license = "MIT OR Apache-2.0" +readme = "../../README.md" + +[lints] +workspace = true + +[features] +serde = ["dep:serde"] + +[dependencies] +thiserror = { workspace = true } +byteorder = { workspace = true } +ltk_io_ext = { version = "0.4.4", path = "../ltk_io_ext" } +num_enum = { workspace = true } +xxhash-rust = { workspace = true } + +serde = { workspace = true, optional = true } diff --git a/crates/ltk_lua/src/dir.rs b/crates/ltk_lua/src/dir.rs new file mode 100644 index 00000000..c72abfd2 --- /dev/null +++ b/crates/ltk_lua/src/dir.rs @@ -0,0 +1,138 @@ +use num_enum::{IntoPrimitive, TryFromPrimitive}; + +/// One of the 16 hardcoded directories a *shared* script can live in. +/// +/// The manifest never stores a shared script's directory as text - it stores an +/// index into this table, packed into the low bits of the script's hash entry +/// (see [`ScriptEntry`](crate::ScriptEntry)). The table is baked into the game +/// binary, so the order of these variants is part of the file format and must not change. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, TryFromPrimitive, IntoPrimitive, +)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[repr(u8)] +pub enum SharedScriptDir { + /// `DATA/Spells/` + Spells = 0, + /// `DATA/Spells/Modules/` + SpellModules = 1, + /// `DATA/Scripts/` + Scripts = 2, + /// `DATA/Shared/Scripts/` + SharedScripts = 3, + /// `DATA/Shared/Scripts/AIComponents/` + SharedAiComponents = 4, + /// `DATA/Shared/Spells/` + SharedSpells = 5, + /// `DATA/Shared/NPCScripts/` + SharedNpcScripts = 6, + /// `DATA/Shared/TFT/Common/` + TftCommon = 7, + /// `DATA/Shared/TFT/Items/` + TftItems = 8, + /// `DATA/Shared/TFT/Traits/` + TftTraits = 9, + /// `DATA/Shared/Spells/PracticeTool/` + PracticeToolSpells = 10, + /// `DATA/Items/` + Items = 11, + /// `DATA/Items/Spells/` + ItemSpells = 12, + /// `DATA/Items/Spells/Modules/` + ItemSpellModules = 13, + /// `DATA/BuildingBlocks/` + BuildingBlocks = 14, + /// `DATA/Shared/GameModes/` + GameModes = 15, +} + +impl SharedScriptDir { + /// Every directory, in index order. + pub const ALL: [Self; 16] = [ + Self::Spells, + Self::SpellModules, + Self::Scripts, + Self::SharedScripts, + Self::SharedAiComponents, + Self::SharedSpells, + Self::SharedNpcScripts, + Self::TftCommon, + Self::TftItems, + Self::TftTraits, + Self::PracticeToolSpells, + Self::Items, + Self::ItemSpells, + Self::ItemSpellModules, + Self::BuildingBlocks, + Self::GameModes, + ]; + + /// The path prefix, with a trailing slash - e.g. `"DATA/Shared/Spells/"`. + pub const fn as_str(self) -> &'static str { + match self { + Self::Spells => "DATA/Spells/", + Self::SpellModules => "DATA/Spells/Modules/", + Self::Scripts => "DATA/Scripts/", + Self::SharedScripts => "DATA/Shared/Scripts/", + Self::SharedAiComponents => "DATA/Shared/Scripts/AIComponents/", + Self::SharedSpells => "DATA/Shared/Spells/", + Self::SharedNpcScripts => "DATA/Shared/NPCScripts/", + Self::TftCommon => "DATA/Shared/TFT/Common/", + Self::TftItems => "DATA/Shared/TFT/Items/", + Self::TftTraits => "DATA/Shared/TFT/Traits/", + Self::PracticeToolSpells => "DATA/Shared/Spells/PracticeTool/", + Self::Items => "DATA/Items/", + Self::ItemSpells => "DATA/Items/Spells/", + Self::ItemSpellModules => "DATA/Items/Spells/Modules/", + Self::BuildingBlocks => "DATA/BuildingBlocks/", + Self::GameModes => "DATA/Shared/GameModes/", + } + } +} + +impl std::fmt::Display for SharedScriptDir { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The sub-directories tried, in order, under `DATA/Characters/` when +/// resolving a character-scoped script. +/// +/// The game probes these against the VFS and takes the first hit, so the +/// manifest gives no way to know which one a given script actually uses - +/// see [`LuaManifest::character_paths`](crate::LuaManifest::character_paths). +/// Almost every script resolves in exactly one of them, but a few do ship in +/// two, in which case only the first listed here is the one that loads. +/// +/// Like [`SharedScriptDir`], this table is hardcoded - its contents and order +/// are part of the format. +pub const CHARACTER_SUBDIRS: [&str; 4] = ["/Spells/", "/", "/Scripts/", "/NPCScripts/"]; + +/// The root every character-scoped path is built under. +pub const CHARACTER_ROOT: &str = "DATA/Characters/"; + +/// The sub-directories a map-scoped script can sit in, under `LEVELS/Map`. +/// +/// Unlike the two tables above this one isn't a lookup the format performs - +/// map-scoped scripts carry no index at all, so both are candidates. They are +/// also reached by two different loaders: level scripts by the map context, and +/// mutators by a loader that builds the whole `Mutators/` path itself. +/// +/// Observed in shipped data on maps 11, 12, 21, 22, 30, 33 and 35, and a given +/// script sits in one of the two - never both. +pub const MAP_SCRIPT_SUBDIRS: [&str; 2] = ["/Scripts/", "/Scripts/Mutators/"]; + +/// The only extension map-scoped scripts are shipped with. +/// +/// Both loaders that reach them resolve with `luabin64`; no `LEVELS/…` path in +/// shipped data has a `.preload` sibling, unlike shared and character scripts. +pub const MAP_SCRIPT_EXTENSION: &str = "luabin64"; + +/// The extensions the game appends to a resolved script name: compiled bytecode +/// and its preload sidecar. +/// +/// `luabin64` is always present for a script that ships at all; `preload` is +/// optional and absent for a large minority of them. Crossing a name with both +/// gives candidates, not files. +pub const SCRIPT_EXTENSIONS: [&str; 2] = ["luabin64", "preload"]; diff --git a/crates/ltk_lua/src/entry.rs b/crates/ltk_lua/src/entry.rs new file mode 100644 index 00000000..c3211163 --- /dev/null +++ b/crates/ltk_lua/src/entry.rs @@ -0,0 +1,145 @@ +use xxhash_rust::xxh3::xxh3_64; + +use crate::dir::SharedScriptDir; + +/// Number of low bits of a [`ScriptEntry`] taken up by the directory index. +pub const DIR_INDEX_BITS: u32 = 5; + +/// Mask of the directory-index bits of a [`ScriptEntry`]. +pub const DIR_INDEX_MASK: u64 = (1 << DIR_INDEX_BITS) - 1; + +/// The directory index found on entries that aren't in any of the 16 +/// [`SharedScriptDir`]s. +/// +/// The index field is 5 bits wide, so it can hold 0..=31, but only 0..=15 are +/// table slots — the resolver rejects everything `>= 16`. An entry carrying this +/// value resolves to nothing at all, exactly as if it were absent. +/// +/// 16 is the only out-of-range value seen in shipped files, and it is what +/// [`ScriptEntry::new`] writes; 17..=31 would behave identically, and +/// [`ScriptEntry::dir`] treats them the same way. +/// +/// It is not a marker for map-scoped scripts: those carry no entry whatsoever. +pub const NO_SHARED_DIR: u8 = 16; + +/// `XXH3-64` (default secret, seed 0) of the lowercased script name - the hash +/// the manifest's entries are keyed by. +/// +/// This is *not* the WAD path hash; it hashes the bare script name (no +/// directory, no extension). +/// +/// Lowercasing is ASCII-only, matching the game's `tolower`. Every name in a +/// shipped manifest is ASCII, so the distinction has never mattered in practice. +#[must_use] +pub fn hash_script_name(name: &str) -> u64 { + xxh3_64(name.to_ascii_lowercase().as_bytes()) +} + +/// A packed shared-script table entry: a truncated name hash plus the index of +/// the directory the script lives in. +/// +/// ```text +/// entry = (XXH3_64(lowercase(name)) << 5) | dir_index +/// bits 5..63 bits 0..4 +/// ``` +/// +/// The shift means the top 5 bits of the hash are discarded, so an entry can +/// only be matched against a *candidate name* - it can't be inverted. +#[repr(transparent)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct ScriptEntry(pub u64); + +impl ScriptEntry { + /// Builds the entry for `name`, placed in `dir` ([`None`] for the + /// [`NO_SHARED_DIR`] sentinel). + #[must_use] + pub fn new(name: &str, dir: Option) -> Self { + let index = dir.map_or(NO_SHARED_DIR, u8::from); + Self(Self::key_of(name) | u64::from(index)) + } + + /// The lookup key for `name`: its hash shifted into place, with the + /// directory-index bits zeroed. + #[must_use] + pub fn key_of(name: &str) -> u64 { + hash_script_name(name) << DIR_INDEX_BITS + } + + /// This entry's lookup key - i.e. the entry with its directory index masked + /// off. Two entries with the same key name the same script. + #[must_use] + pub const fn key(self) -> u64 { + self.0 & !DIR_INDEX_MASK + } + + /// The raw directory index, including the [`NO_SHARED_DIR`] sentinel. + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub const fn dir_index(self) -> u8 { + (self.0 & DIR_INDEX_MASK) as u8 + } + + /// The directory this script lives in, or [`None`] if the entry carries the + /// [`NO_SHARED_DIR`] sentinel. + #[must_use] + pub fn dir(self) -> Option { + SharedScriptDir::try_from(self.dir_index()).ok() + } + + /// Whether this entry is the one for `name`. + #[must_use] + pub fn matches(self, name: &str) -> bool { + self.key() == Self::key_of(name) + } +} + +impl From for ScriptEntry { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for u64 { + fn from(value: ScriptEntry) -> Self { + value.0 + } +} + +impl std::fmt::LowerHex for ScriptEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::LowerHex::fmt(&self.0, f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Golden values lifted from a real 16.x `DATA/all_lua_files.manifest`. + #[test] + fn packs_like_the_game() { + assert_eq!(hash_script_name("Buff"), 0x4f8b_a9da_c274_1602); + + let entry = ScriptEntry(0xf175_3b58_4e82_c041); + assert!(entry.matches("Buff")); + assert!(entry.matches("bUfF")); // name hashing is case-insensitive + assert_eq!(entry.dir(), Some(SharedScriptDir::SpellModules)); + assert_eq!( + entry, + ScriptEntry::new("Buff", Some(SharedScriptDir::SpellModules)) + ); + + let entry = ScriptEntry(0x8160_51ba_183d_c3eb); + assert!(entry.matches("1043")); + assert_eq!(entry.dir(), Some(SharedScriptDir::Items)); + } + + #[test] + fn sentinel_has_no_dir() { + let entry = ScriptEntry::new("ARAMCompanionMutator", None); + assert_eq!(entry.dir_index(), NO_SHARED_DIR); + assert_eq!(entry.dir(), None); + assert!(entry.matches("aramcompanionmutator")); + } +} diff --git a/crates/ltk_lua/src/error.rs b/crates/ltk_lua/src/error.rs new file mode 100644 index 00000000..aefb7ec5 --- /dev/null +++ b/crates/ltk_lua/src/error.rs @@ -0,0 +1,17 @@ +use std::io; + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LuaManifestError { + #[error("invalid magic code (expected {expected:?}, got {actual:?})")] + InvalidMagic { expected: [u8; 4], actual: [u8; 4] }, + + #[error("read error - {0}")] + ReaderError(#[from] ltk_io_ext::ReaderError), + + #[error("io error")] + IoError(#[from] io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/ltk_lua/src/lib.rs b/crates/ltk_lua/src/lib.rs new file mode 100644 index 00000000..caf40518 --- /dev/null +++ b/crates/ltk_lua/src/lib.rs @@ -0,0 +1,92 @@ +//! Reading and writing the League of Legends Lua script manifest, +//! `DATA/all_lua_files.manifest` (found in `Scripts.wad.client`). +//! +//! The manifest used to be a plain newline-separated list of paths. Since the +//! `RLM0` revision it stores *bare script names* plus enough metadata for the +//! game to rebuild each path from a table of hardcoded directories - so getting +//! paths back out means replaying that logic. +//! +//! # How a path is rebuilt +//! +//! - **Shared scripts** get an exact directory. Each has a packed +//! [`ScriptEntry`] - `XXH3_64(lowercase(name)) << 5 | dir_index` - whose low 5 +//! bits index the 16 hardcoded [`SharedScriptDir`]s: +//! +//! ```text +//! DATA/Shared/Spells/ + Foo + .luabin64 +//! ``` +//! +//! The extension is still a guess: `.luabin64` always exists, `.preload` only +//! sometimes. +//! +//! - **Character scripts** are not. The game probes the four +//! [`CHARACTER_SUBDIRS`] under `DATA/Characters//` and takes the +//! first file that exists, so [`LuaManifest::character_paths`] yields all four +//! candidates per script - filter them against the WAD's path hashes +//! (`xxh64` of the lowercased path) to find the real one. +//! +//! - **Map-scoped scripts** have no entry at all - which is why the shared list +//! is longer than the entry table. They live under `LEVELS/Map/Scripts/` or +//! `LEVELS/Map/Scripts/Mutators/`, and neither the map id nor the +//! `Mutators/` hop is in the file. See [`LuaManifest::map_scoped_shared`] and +//! [`LuaManifest::map_paths`]. +//! +//! Only the first of those is a lookup the *format* performs. The rest is the +//! game's own probing logic replayed here, so anything this crate calls a +//! candidate should be checked against a WAD's path hashes (`xxh64` of the +//! lowercased path) before being treated as a file. +//! +//! - A few names have an entry carrying the [`NO_SHARED_DIR`] sentinel rather +//! than a directory index: the manifest knows the name but records no home for +//! it, and shipped builds pack no file for them at all. See +//! [`LuaManifest::unplaced_shared`]. +//! +//! # Example +//! +//! ```no_run +//! use ltk_lua::LuaManifest; +//! +//! let mut file = std::fs::File::open("all_lua_files.manifest")?; +//! let manifest = LuaManifest::from_reader(&mut file)?; +//! +//! // exact +//! for path in manifest.shared_paths() { +//! println!("{path}"); +//! } +//! // candidates - verify against a WAD before trusting them +//! for path in manifest.character_paths() { +//! println!("{path}?"); +//! } +//! # Ok::<(), Box>(()) +//! ``` +//! +//! # Format +//! +//! All integers little-endian; `str` is a `u32` byte length followed by that +//! many bytes, *not* NUL-terminated. +//! +//! ```text +//! char magic[4] // "0MLR" ("RLM0" as a LE u32) +//! u32 characterCount +//! repeat characterCount: +//! str characterName // e.g. "aatrox" +//! u32 scriptCount +//! repeat scriptCount: str scriptName +//! u32 sharedCount +//! repeat sharedCount: str sharedName +//! u32 entryCount +//! repeat entryCount: u64 entry // (XXH3_64(lower(name)) << 5) | dirIndex +//! ``` +//! +//! The character list and the entry table are both sorted - the game +//! binary-searches them. + +mod dir; +mod entry; +mod error; +mod manifest; + +pub use dir::*; +pub use entry::*; +pub use error::*; +pub use manifest::*; diff --git a/crates/ltk_lua/src/manifest.rs b/crates/ltk_lua/src/manifest.rs new file mode 100644 index 00000000..e7be3813 --- /dev/null +++ b/crates/ltk_lua/src/manifest.rs @@ -0,0 +1,517 @@ +use std::io::{Read, Write}; + +use byteorder::{ReadBytesExt as _, WriteBytesExt as _, LE}; +use ltk_io_ext::{ReaderExt as _, WriterExt as _}; + +use crate::dir::{ + SharedScriptDir, CHARACTER_ROOT, CHARACTER_SUBDIRS, MAP_SCRIPT_EXTENSION, MAP_SCRIPT_SUBDIRS, + SCRIPT_EXTENSIONS, +}; +use crate::entry::ScriptEntry; +use crate::error::{LuaManifestError, Result}; + +/// Magic bytes at the start of every manifest: `"0MLR"` - i.e. `"RLM0"` stored +/// as a little-endian `u32`, which is how the game compares it. +/// +/// Presumably short for `Riot Lua Manifest 0`; the binary carries no string +/// confirming that, and there is no version field anywhere in the file. +pub const MAGIC: [u8; 4] = *b"0MLR"; + +/// One character and the scripts scoped to it. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CharacterScripts { + /// The character (folder) name, e.g. `"aatrox"`. + pub name: String, + /// Bare script names - no directory, no extension. + pub scripts: Vec, +} + +/// The `DATA/all_lua_files.manifest` file: the index of every Lua script the +/// game may load. +/// +/// It has three sections, and the *only* one that yields exact paths on its own +/// is the shared one: +/// +/// - **Characters** - per-character lists of bare script names. The game finds +/// these by probing [`CHARACTER_SUBDIRS`] under `DATA/Characters/` in +/// order, so the manifest doesn't record which sub-directory each one is in. +/// - **Shared scripts** - a flat list of bare names, paired with… +/// - **Entries** - a sorted table of packed [`ScriptEntry`] values, each one a +/// truncated hash of a shared name plus the index of the directory it lives +/// in. Joining the two gives an exact *directory* - the extension is still a +/// guess, see [`shared_paths`](Self::shared_paths). +/// +/// The two lists are only joined by hash, and every entry in a shipped file +/// matches some shared name - but a name can exist in *both* scopes (28 do in a +/// 16.x file, e.g. `CharScriptEvelynn`), so a lookup on a character-scoped name +/// can hit an unrelated shared entry. [`entry`](Self::entry) and everything +/// built on it answer the shared-scope question only. +/// +/// Two kinds of shared name don't resolve, and they are not the same thing: +/// +/// - **No entry at all** - map-scoped scripts, living under +/// `LEVELS/Map/Scripts/…`. This is why the shared list is longer than the +/// entry table. See [`map_scoped_shared`](Self::map_scoped_shared). +/// - **An entry with the [`NO_SHARED_DIR`](crate::NO_SHARED_DIR) sentinel** - +/// a name the index knows but places nowhere; no map scripts are in this +/// group. See [`unplaced_shared`](Self::unplaced_shared). +/// +/// # Reading +/// +/// ```no_run +/// use ltk_lua::LuaManifest; +/// +/// let mut file = std::fs::File::open("all_lua_files.manifest")?; +/// let manifest = LuaManifest::from_reader(&mut file)?; +/// +/// for path in manifest.shared_paths() { +/// println!("{path}"); // DATA/Shared/Spells/Foo.luabin64 +/// } +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct LuaManifest { + characters: Vec, + shared: Vec, + entries: Vec, +} + +impl LuaManifest { + /// Creates an empty manifest. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Builds a manifest from its three sections. + /// + /// The result is [`sort`](Self::sort)ed - the game binary-searches both the + /// character list and the entry table. + /// + /// Nothing checks that `entries` and `shared` line up; that join is by hash + /// and is the caller's to maintain (see [`ScriptEntry::new`]). + #[must_use] + pub fn from_parts( + characters: Vec, + shared: Vec, + entries: Vec, + ) -> Self { + let mut this = Self { + characters, + shared, + entries, + }; + this.sort(); + this + } + + /// The per-character script lists. + #[must_use] + pub fn characters(&self) -> &[CharacterScripts] { + &self.characters + } + + /// The bare names of every shared (non character-scoped) script. + #[must_use] + pub fn shared_scripts(&self) -> &[String] { + &self.shared + } + + /// The packed hash table, sorted ascending. + #[must_use] + pub fn entries(&self) -> &[ScriptEntry] { + &self.entries + } + + /// Takes the three sections back out, for editing and re-assembling with + /// [`from_parts`](Self::from_parts). + #[must_use] + pub fn into_parts(self) -> (Vec, Vec, Vec) { + (self.characters, self.shared, self.entries) + } + + /// Sorts every section into the order shipped files use. + /// + /// All three are sorted by raw byte order (so `CHERRY` precedes + /// `CHERRY_BlitzQ`, and uppercase sorts ahead of lowercase). The character + /// list and the entry table *must* be sorted - the game binary-searches + /// them. The shared list is observed sorted but is only iterated, and the + /// per-character script lists are **not** sorted in shipped files, so + /// neither is touched beyond the top level. + pub fn sort(&mut self) { + self.characters.sort_by(|a, b| a.name.cmp(&b.name)); + self.shared.sort(); + self.entries.sort_unstable(); + } + + /// Looks up the entry for a *shared* script name. + /// + /// Matching is by truncated hash, so this answers "is there a shared entry + /// whose name hashes like this", not "is `name` in the shared list" - pass a + /// character-scoped name that also exists as a shared one and you get the + /// shared entry back. + /// + /// Requires [`entries`](Self::entries) to be sorted, which every constructor + /// in this crate guarantees. + #[must_use] + pub fn entry(&self, name: &str) -> Option { + let key = ScriptEntry::key_of(name); + self.entries + .binary_search_by_key(&key, |e| e.key()) + .ok() + .map(|i| self.entries[i]) + } + + /// The directory a shared script lives in, or [`None`] if it has no entry + /// or its entry carries the sentinel index. + #[must_use] + pub fn dir_of(&self, name: &str) -> Option { + self.entry(name)?.dir() + } + + /// The full path of a shared script, e.g. + /// `resolve_shared("Buff", "luabin64")` → `DATA/Spells/Modules/Buff.luabin64`. + /// + /// [`None`] for a name that isn't in the table or is map-scoped. + #[must_use] + pub fn resolve_shared(&self, name: &str, extension: &str) -> Option { + Some(format!("{}{name}.{extension}", self.dir_of(name)?)) + } + + /// Every shared script whose directory is known, crossed with both + /// [`SCRIPT_EXTENSIONS`]. + /// + /// The **directory is exact**; the **extension is not**. `.luabin64` always + /// exists, but only some scripts have a `.preload` sidecar (roughly 60% in a + /// 16.x build), so about a fifth of what this yields is not a real file. + /// Filter against a WAD's path hashes, or use + /// [`resolve_shared`](Self::resolve_shared) with a single extension. + /// + /// Names with no entry, or whose entry carries the sentinel, are skipped - + /// see [`map_scoped_shared`](Self::map_scoped_shared) and + /// [`unplaced_shared`](Self::unplaced_shared). + pub fn shared_paths(&self) -> impl Iterator + '_ { + self.shared.iter().flat_map(move |name| { + let dir = self.dir_of(name); + SCRIPT_EXTENSIONS + .iter() + .filter_map(move |ext| Some(format!("{}{name}.{ext}", dir?))) + }) + } + + /// Shared script names with **no entry at all** — the reason the shared list + /// is longer than the entry table. + /// + /// The format says nothing about what these are; what it says is that they + /// have no directory. In shipped builds they are map-scoped - they live + /// under `LEVELS/Map/Scripts/` or `LEVELS/Map/Scripts/Mutators/`, and + /// neither the map id nor the `Mutators/` hop is recorded anywhere in the + /// file - with a couple of names that ship nowhere at all. Cross them with + /// the map ids you care about via [`map_paths`](Self::map_paths) and verify. + pub fn map_scoped_shared(&self) -> impl Iterator { + self.shared + .iter() + .filter(|name| self.entry(name).is_none()) + .map(String::as_str) + } + + /// Shared script names whose entry carries the + /// [`NO_SHARED_DIR`](crate::NO_SHARED_DIR) sentinel instead of a directory + /// index. + /// + /// The manifest knows the name but records no home for it, and the game + /// treats such an entry exactly like a missing one: the lookup is rejected + /// and resolution fails. In a 16.x build all of them are practice-tool + /// `Cheat*` scripts with no file in any WAD, which suggests the sentinel + /// means "the generator found nowhere to put this" - but that reading is an + /// observation, not something the format states. + pub fn unplaced_shared(&self) -> impl Iterator { + self.shared + .iter() + .filter(|name| self.entry(name).is_some_and(|e| e.dir().is_none())) + .map(String::as_str) + } + + /// Every *candidate* path for the [`map_scoped_shared`](Self::map_scoped_shared) + /// names on map `map_id`, across both [`MAP_SCRIPT_SUBDIRS`]. + /// + /// Only [`MAP_SCRIPT_EXTENSION`] is emitted - map scripts ship without a + /// `.preload` sibling. + /// + /// Candidates, not paths: a given script lives on one map (or a handful), + /// and nothing in the manifest says which. Verify against a WAD. + pub fn map_paths(&self, map_id: u32) -> impl Iterator + '_ { + self.map_scoped_shared().flat_map(move |name| { + MAP_SCRIPT_SUBDIRS.iter().map(move |subdir| { + format!("LEVELS/Map{map_id}{subdir}{name}.{MAP_SCRIPT_EXTENSION}") + }) + }) + } + + /// Every *candidate* character-scoped path: each script crossed with all + /// four [`CHARACTER_SUBDIRS`] and both [`SCRIPT_EXTENSIONS`]. + /// + /// The game probes the sub-directories in order and takes the first that + /// exists, so at most one per script is the one actually loaded. Filter the + /// output against a WAD's path hashes to find out which; note a handful of + /// scripts do ship in two of them, and `.preload` sidecars are optional. + /// + /// A character's script list may repeat a name, in which case so does this. + pub fn character_paths(&self) -> impl Iterator + '_ { + self.characters.iter().flat_map(|character| { + character.scripts.iter().flat_map(move |script| { + CHARACTER_SUBDIRS.iter().flat_map(move |subdir| { + SCRIPT_EXTENSIONS.iter().map(move |ext| { + format!( + "{CHARACTER_ROOT}{}{subdir}{script}.{ext}", + character.name.as_str() + ) + }) + }) + }) + }) + } + + /// [`shared_paths`](Self::shared_paths) followed by + /// [`character_paths`](Self::character_paths) - i.e. everything the manifest + /// can place without being told a map id. Map-scoped names are *not* + /// included; get those from [`map_paths`](Self::map_paths). + /// + /// Read the two methods' caveats: neither stream is a list of files that + /// certainly exist. + pub fn paths(&self) -> impl Iterator + '_ { + self.shared_paths().chain(self.character_paths()) + } + + /// Reads a manifest. + /// + /// Sections are kept in the order they appear on disk, so a shipped file + /// re-writes byte-identically. The one exception is the entry table: the + /// game binary-searches it and so does [`entry`](Self::entry), so if a file + /// somehow carries it unsorted it is sorted here rather than left to + /// mis-resolve. + pub fn from_reader(reader: &mut impl Read) -> Result { + let mut magic = [0_u8; 4]; + reader.read_exact(&mut magic)?; + if magic != MAGIC { + return Err(LuaManifestError::InvalidMagic { + expected: MAGIC, + actual: magic, + }); + } + + let characters = read_vec(reader, |reader| { + Ok(CharacterScripts { + name: reader.read_sized_string_u32::()?, + scripts: read_vec(reader, |reader| Ok(reader.read_sized_string_u32::()?))?, + }) + })?; + let shared = read_vec(reader, |reader| Ok(reader.read_sized_string_u32::()?))?; + let mut entries = read_vec(reader, |reader| Ok(ScriptEntry(reader.read_u64::()?)))?; + if !entries.windows(2).all(|w| w[0] <= w[1]) { + entries.sort_unstable(); + } + + Ok(Self { + characters, + shared, + entries, + }) + } + + /// Writes the manifest. + /// + /// Sections are written in their current order - call + /// [`sort`](Self::sort) first if you've edited them. + pub fn to_writer(&self, writer: &mut impl Write) -> Result<()> { + writer.write_all(&MAGIC)?; + + write_len(writer, self.characters.len())?; + for character in &self.characters { + writer.write_sized_string_u32::(&character.name)?; + write_len(writer, character.scripts.len())?; + for script in &character.scripts { + writer.write_sized_string_u32::(script)?; + } + } + + write_len(writer, self.shared.len())?; + for name in &self.shared { + writer.write_sized_string_u32::(name)?; + } + + write_len(writer, self.entries.len())?; + for entry in &self.entries { + writer.write_u64::(entry.0)?; + } + + Ok(()) + } +} + +/// Reads a `u32`-counted list. +fn read_vec( + reader: &mut R, + mut read: impl FnMut(&mut R) -> Result, +) -> Result> { + let count = reader.read_u32::()? as usize; + // The count is attacker-controlled, so grow as we go instead of reserving it. + let mut items = Vec::new(); + for _ in 0..count { + items.push(read(reader)?); + } + Ok(items) +} + +fn write_len(writer: &mut impl Write, len: usize) -> Result<()> { + writer.write_u32::(u32::try_from(len).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "list is longer than u32::MAX", + ) + })?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manifest() -> LuaManifest { + LuaManifest::from_parts( + vec![ + CharacterScripts { + name: "aatrox".into(), + scripts: vec!["AatroxE".into(), "CharScriptAatrox".into()], + }, + CharacterScripts { + name: "ahri".into(), + scripts: vec!["AhriQ".into()], + }, + ], + vec![ + "Buff".into(), + "1043".into(), + "ButchersBridge".into(), // map-scoped: no entry + "CheatAttackMe".into(), // has an entry, but the sentinel index + ], + vec![ + ScriptEntry::new("Buff", Some(SharedScriptDir::SpellModules)), + ScriptEntry::new("1043", Some(SharedScriptDir::Items)), + ScriptEntry::new("CheatAttackMe", None), + ], + ) + } + + #[test] + fn round_trips() { + let manifest = manifest(); + let mut buf = Vec::new(); + manifest.to_writer(&mut buf).unwrap(); + + assert_eq!(&buf[..4], b"0MLR"); + assert_eq!( + LuaManifest::from_reader(&mut buf.as_slice()).unwrap(), + manifest + ); + } + + #[test] + fn into_parts_round_trips() { + let manifest = manifest(); + let (characters, shared, entries) = manifest.clone().into_parts(); + assert_eq!( + LuaManifest::from_parts(characters, shared, entries), + manifest + ); + } + + #[test] + fn sorts_an_unsorted_entry_table_on_read() { + let mut manifest = manifest(); + manifest.entries.reverse(); // `entry` binary-searches; this would break it + let mut buf = Vec::new(); + manifest.to_writer(&mut buf).unwrap(); + + let read = LuaManifest::from_reader(&mut buf.as_slice()).unwrap(); + assert!(read.entries().windows(2).all(|w| w[0] <= w[1])); + assert_eq!(read.dir_of("1043"), Some(SharedScriptDir::Items)); + } + + #[test] + fn rejects_bad_magic() { + let err = LuaManifest::from_reader(&mut b"RLM1\0\0\0\0".as_slice()).unwrap_err(); + assert!(matches!(err, LuaManifestError::InvalidMagic { .. })); + } + + #[test] + fn resolves_shared_scripts() { + let manifest = manifest(); + + assert_eq!( + manifest.resolve_shared("Buff", "luabin64").as_deref(), + Some("DATA/Spells/Modules/Buff.luabin64") + ); + assert_eq!(manifest.dir_of("1043"), Some(SharedScriptDir::Items)); + assert_eq!(manifest.dir_of("NotInTheTable"), None); + + // no entry at all -> map-scoped + assert!(manifest.entry("ButchersBridge").is_none()); + assert_eq!( + manifest.map_scoped_shared().collect::>(), + ["ButchersBridge"] + ); + + // an entry, but the sentinel index -> known name, no home + assert!(manifest.entry("CheatAttackMe").is_some()); + assert_eq!(manifest.dir_of("CheatAttackMe"), None); + assert_eq!( + manifest.unplaced_shared().collect::>(), + ["CheatAttackMe"] + ); + + // emitted in shared-list order, which `sort` puts in byte order + let paths = manifest.shared_paths().collect::>(); + assert_eq!( + paths, + [ + "DATA/Items/1043.luabin64", + "DATA/Items/1043.preload", + "DATA/Spells/Modules/Buff.luabin64", + "DATA/Spells/Modules/Buff.preload", + ] + ); + } + + #[test] + fn enumerates_map_candidates() { + assert_eq!( + manifest().map_paths(12).collect::>(), + [ + "LEVELS/Map12/Scripts/ButchersBridge.luabin64", + "LEVELS/Map12/Scripts/Mutators/ButchersBridge.luabin64", + ] + ); + } + + #[test] + fn enumerates_character_candidates() { + let paths = manifest().character_paths().collect::>(); + + assert_eq!( + paths.len(), + 3 * CHARACTER_SUBDIRS.len() * SCRIPT_EXTENSIONS.len() + ); + assert_eq!(paths[0], "DATA/Characters/aatrox/Spells/AatroxE.luabin64"); + assert_eq!(paths[1], "DATA/Characters/aatrox/Spells/AatroxE.preload"); + assert_eq!(paths[2], "DATA/Characters/aatrox/AatroxE.luabin64"); + assert_eq!(paths[4], "DATA/Characters/aatrox/Scripts/AatroxE.luabin64"); + assert_eq!( + paths[6], + "DATA/Characters/aatrox/NPCScripts/AatroxE.luabin64" + ); + assert!(paths.contains(&"DATA/Characters/ahri/Spells/AhriQ.luabin64".to_string())); + } +}