-
Notifications
You must be signed in to change notification settings - Fork 9
feat(ltk_lua): add lua manifest parser and scaffold new crate #160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 } |
| 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"]; |
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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")); | ||
| } | ||
| } | ||
| 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>; |
There was a problem hiding this comment.
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