Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/league-toolkit/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
3 changes: 3 additions & 0 deletions crates/league-toolkit/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
10 changes: 10 additions & 0 deletions crates/ltk_io_ext/src/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ pub trait WriterExt: Write {
Ok(())
}

/// Writes a string with a `u32` length prefix (writes 4 + str.len() bytes).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we leave a TODO here to destroy this in future

///
/// The inverse of [`ReaderExt::read_sized_string_u32`](crate::ReaderExt::read_sized_string_u32).
fn write_sized_string_u32<T: ByteOrder>(&mut self, str: impl AsRef<str>) -> io::Result<()> {
let str = str.as_ref();
self.write_u32::<T>(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<S: AsRef<str>>(&mut self, str: S) -> io::Result<()> {
self.write_all(str.as_ref().as_bytes())?;
Expand Down
22 changes: 22 additions & 0 deletions crates/ltk_lua/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
138 changes: 138 additions & 0 deletions crates/ltk_lua/src/dir.rs
Original file line number Diff line number Diff line change
@@ -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/<character>` 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<N>`.
///
/// 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"];
145 changes: 145 additions & 0 deletions crates/ltk_lua/src/entry.rs
Original file line number Diff line number Diff line change
@@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this being a free function feels kinda cringe, i think newtype would be nicer (or even just making this not public)

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inner u64 shouldn't be public, should have a from_raw instead


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<SharedScriptDir>) -> 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we add a test to make this cast never break? (e.g enforce DIR_INDEX_BYTES <= 8)

}

/// 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> {
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<u64> for ScriptEntry {
fn from(value: u64) -> Self {
Self(value)
}
}

impl From<ScriptEntry> 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"));
}
}
17 changes: 17 additions & 0 deletions crates/ltk_lua/src/error.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, LuaManifestError>;
Loading
Loading