Files
benilla-octo/crates/benilla-ui/src/toc.rs
T
SamandClaude Opus 5.5 6d5328994b the spell owns its cast ladder, and landing checks the other platforms
- the cast ladder moves under the spell: try, target bind, the usable walk and the refusal rungs live in one place, and the cast reply sends a chained cast itself (2330)
- every server packet decodes through one exhaustive match, and an opcode-coverage script joins the reference's 387 handled opcodes against ours (2331)
- a landing that touches a platform seam compiles Linux and Windows first, and the seven WMO portal-cull audits join the tests (2331)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 12:12:24 +07:00

351 lines
17 KiB
Rust

//! The `.toc` manifest parser — the load list + metadata of every addon, including Blizzard's own
//! (`FrameXML.toc` and the `Blizzard_*` AddOns are ordinary manifests; third-party addons are the
//! identical mechanism — decision 0068).
//!
//! Grammar, shared by 1.12 and Era manifests (Era only adds directive *keys*, not syntax):
//! - `## Key: Value` — a directive. Keys compare case-insensitively; localized variants carry a
//! locale suffix (`## Title-deDE: …`). Era manifests list several client builds in one
//! `## Interface: 11507, 11508`.
//! - `#` (not followed by another `#`) — a comment.
//! - anything else non-blank — a file to load, in order (`.lua`/`.xml`, `\` or `/` separators).
//!
//! The parser is lossless about order (directives keep file order for duplicate keys; files keep
//! load order) and byte-tolerant: UTF-8 BOM, `\r\n`, and stray whitespace are the norm in shipped
//! manifests, not the exception.
/// A parsed `.toc` manifest: ordered directives + the ordered file load list.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Toc {
/// `## Key: Value` directives in file order, keys as written (compare via [`Toc::directive`]).
pub directives: Vec<(String, String)>,
/// Files to load, in order, exactly as written (path separators are normalized at *load* time,
/// not here — the manifest is quoted verbatim in errors and tooling).
pub files: Vec<String>,
}
/// Case-insensitive ASCII prefix test — the reference's `SStrCmpI(line, key, SStrLen(key))`.
fn starts_with_ci(s: &str, prefix: &str) -> bool {
let (s, p) = (s.as_bytes(), prefix.as_bytes());
s.len() >= p.len() && s[..p.len()].eq_ignore_ascii_case(p)
}
impl Toc {
/// Parse manifest text. Never fails: unrecognizable lines are file entries by definition of
/// the grammar, and an empty input is an empty manifest.
pub fn parse(text: &str) -> Self {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut toc = Toc::default();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() {
continue;
}
if let Some(directive) = line.strip_prefix("##") {
// `##` alone (or without a colon) is treated as a comment, matching the client's
// tolerance for malformed directives.
if let Some((key, value)) = directive.split_once(':') {
let key = key.trim();
if !key.is_empty() {
toc.directives
.push((key.to_string(), value.trim().to_string()));
}
}
} else if !line.starts_with('#') {
toc.files.push(line.to_string());
}
}
toc
}
/// The directive whose key matches `key` case-insensitively — **the LAST one, when a manifest
/// writes the key twice**.
///
/// This read the first for as long as the doc here said "shipped manifests don't duplicate
/// keys", which two of them do: `MoveAnything.toc` has `## Notes` on lines 3 and 5, and
/// `AtlasLoot.toc` has `## Author` on lines 6 and 9. The reference hashes each directive into
/// the metadata map at `[rec+0x98]` (`0x51d580`) and, on an existing entry, **frees the old
/// value and stores the new** (`0x51d77b`) — a hash insert, so the last line written wins.
///
/// Note the asymmetry with [`Self::list`] one function down, and that both halves were wrong
/// here in the same direction: a **scalar** replaces, a **list** appends.
pub fn directive(&self, key: &str) -> Option<&str> {
self.directives
.iter()
.rev()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
/// `## Interface:` as client build numbers. Era manifests list several (`11507, 11508`);
/// 1.12's lists one (`11200`). Unparseable entries are skipped, not errors.
pub fn interface_versions(&self) -> Vec<u32> {
self.directive("Interface")
.map(|v| v.split(',').filter_map(|n| n.trim().parse().ok()).collect())
.unwrap_or_default()
}
/// `## Interface:` **as the 1.12 client reads it** (decision 1292): the leading integer of
/// the value, and a manifest with no `## Interface` line (or a non-numeric one) is `0`.
/// Byte-verified (wow-re `addon-version-gate.md` §2.2): `Toc_Parse 0x51c9b0` stores
/// `SStrToInt` of the value at `[rec+0x1c]`, the record ctor leaves it `0`, and the version
/// gate compares that single dword — so an Era manifest's `11507, 11508` reads as `11507`,
/// exactly as the reference would read it. [`Toc::interface_versions`] stays for display;
/// the GATE runs on this.
pub fn interface_version(&self) -> u32 {
self.directive("Interface")
.map(|v| {
let digits: String = v.trim().chars().take_while(char::is_ascii_digit).collect();
digits.parse().unwrap_or(0)
})
.unwrap_or(0)
}
/// A list directive (`SavedVariables`, `OptionalDeps`, `Dependencies`, …), tokenized the way
/// the reference tokenizes it. Missing directive = empty list.
///
/// **Two departures from the obvious read, both byte-derived, both of which were losing real
/// data** (decision 2126):
///
/// * **A repeated directive APPENDS.** The reference `strdup`s each item into a `0x40`-granular
/// array at `[rec+0x80]` with a running count at `[rec+0x7c]` (wow-re
/// `savedvariables-protocol.md` §4), so a second `## SavedVariables:` line continues the
/// first rather than being ignored. Reading only the first line cost `CT_RaidAssist` 18 of
/// its 25 saved globals — it declares them across four lines and got seven, so raid
/// positions, menu state, boss timers, debuff templates, the loot method and the squelch
/// list were never written and never restored.
/// * **A space separates as surely as a comma.** The tokenizer is `SStrTokenize 0x64ae50` over
/// the delimiter SET `" ,"` at `0x8537c4` — readable in the image's own `.rdata`, sitting
/// between the `RequiredDep` and `OptionalDep` literals. `SpecialTalentUI` writes
/// `## SavedVariables: SpecialTalentPlannedSaved SpecialTalentFrameSaved` and we registered
/// one "global" whose name contained a space.
pub fn list(&self, key: &str) -> Vec<&str> {
self.list_where(|k| k.eq_ignore_ascii_case(key))
}
/// [`Self::list`] over every directive whose key satisfies `pick`, in file order.
fn list_where(&self, mut pick: impl FnMut(&str) -> bool) -> Vec<&str> {
self.directives
.iter()
.filter(|(k, _)| pick(k))
.flat_map(|(_, v)| v.split([',', ' ']).map(str::trim))
.filter(|s| !s.is_empty())
.collect()
}
/// Hard dependencies — **every directive whose key STARTS WITH `Dep` or `RequiredDep`**.
///
/// The three dependency keys are the only ones in the reference's directive table that carry
/// **no colon**: the `.rdata` cluster reads
/// `SavedVariablesPerCharacter:\0 SavedVariables:\0 LoadWith:\0 \0\0 Dep\0 RequiredDep\0 " ,"\0\0 OptionalDep\0 Revision:\0`,
/// and a whole-key compare against `"Dep"` could never match a line reading `Dependencies:`.
/// So the compare is a PREFIX compare — `SStrCmpI(line, key, SStrLen(key))` at
/// `0x51cd4e`-`0x51cd5f` — and `## Dependencies`, `## RequiredDependencies` and
/// `## Dependency` all land here (decision 2126).
///
/// Both keys feed the SAME array in the reference (`[rec+0x50]`), so a manifest writing both
/// gets their union rather than whichever this function happened to test first.
pub fn dependencies(&self) -> Vec<&str> {
self.list_where(|k| starts_with_ci(k, "RequiredDep") || starts_with_ci(k, "Dep"))
}
/// Soft dependencies — every directive whose key starts with `OptionalDep`, on the same
/// prefix rule as [`Self::dependencies`].
///
/// **They load FIRST and their failures are ignored** — `AddOn_Load 0x51f240`'s own order,
/// quoted in 1191 §2 — which is what lets `## OptionalDeps: FuBar, Ace2` mean "if Ace2 is
/// installed, its libraries are already global by the time my files run". **130 corpus addons
/// declare them**, and the whole FuBar family leans on exactly that: `FuBar_BagFu`'s `.toc`
/// lists `FuBarPlugin-2.0.lua` BEFORE `AceLibrary.lua`, which only works because the `Ace2`
/// addon went first.
///
/// The long form `## OptionalDependencies:` — which 4 corpus addons write — used to be
/// refused here, on the reasoning that it was "verified for the required half and merely
/// plausible for this one, queued as an RE question". The premise was wrong: the reference
/// matches all three dependency keys by the same colon-less prefix, so the long form was never
/// a separate question.
pub fn optional_dependencies(&self) -> Vec<&str> {
self.list_where(|k| starts_with_ci(k, "OptionalDep"))
}
/// `## LoadOnDemand: 1` (Era loader; absent in 1.12 manifests).
pub fn load_on_demand(&self) -> bool {
self.directive("LoadOnDemand").map(str::trim) == Some("1")
}
/// `## DefaultState:` — what this addon's enable state is for a character who has never
/// expressed one. The reference's `[rec+0x2b]`, stored by `Toc_Parse` at `0x51d204` from the
/// two literals `"enabled"` (`0x853764`) → `1` and `"disabled"` (`0x853758`) → `0`
/// (wow-5875-re `savedvariables-protocol.md`, the directive table).
///
/// It is load-bearing well beyond a manifest that writes it: the enable query `0x51e470`
/// falls back to this byte whenever the characters disagree, and whenever *none* of them has
/// an opinion at all — which is every addon on a fresh install.
///
/// **A manifest that does not write the line is enabled** — which is what makes a folder
/// dropped into `AddOns/` just work, and is the record's initial byte, read at the bytes and
/// not inferred from the two literals' fall-through (wow-re
/// `addon-defaultstate-and-node-set.md`, decision 2316). The ctor `0x520550` seeds this one
/// field to 1 explicitly — `0x5205b9 mov [esi+0x2b],al` with `eax = 1`, where its five
/// neighbours take `bl = 0` — over an allocation that does zero-fill, which is exactly what
/// made "the ctor zeroes it" read as true.
///
/// Two consequences this function depends on, both verified there: a value matching **neither**
/// literal leaves the 1 (`0x51d21a jne`, no store — there is no "unrecognised means
/// disabled"), and a duplicated directive is **last-wins**, which is [`Self::directive`]'s own
/// rule.
pub fn default_state(&self) -> bool {
!self
.directive("DefaultState")
.is_some_and(|v| v.trim().eq_ignore_ascii_case("disabled"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_directives_files_and_comments() {
let toc = Toc::parse(
"\u{feff}## Interface: 11507, 11508\r\n\
## Title: Probe |cff00ff00Addon|r\r\n\
## SavedVariables: ProbeDB, ProbeSettings\r\n\
## X-Custom: anything: with colons\r\n\
# a comment line\r\n\
##\r\n\
## malformed directive without colon\r\n\
\r\n\
Libs\\LibStub\\LibStub.lua\r\n\
Core.lua\r\n\
Frames.xml\r\n",
);
assert_eq!(toc.interface_versions(), vec![11507, 11508]);
assert_eq!(toc.directive("title"), Some("Probe |cff00ff00Addon|r"));
assert_eq!(toc.list("SavedVariables"), vec!["ProbeDB", "ProbeSettings"]);
// Values keep everything after the first colon.
assert_eq!(toc.directive("X-Custom"), Some("anything: with colons"));
assert_eq!(
toc.files,
vec!["Libs\\LibStub\\LibStub.lua", "Core.lua", "Frames.xml"]
);
// Comments and malformed directives contribute nothing.
assert_eq!(toc.directives.len(), 4);
}
/// **`## DefaultState:` and its three non-obvious answers**, all byte-verified (wow-re
/// `addon-defaultstate-and-node-set.md`): an absent line is *enabled* because the record's
/// ctor seeds `[rec+0x2b]` to 1, a value matching neither literal leaves that 1 rather than
/// meaning disabled, and a duplicated line is last-wins.
///
/// Only `disabled` disables, in other words — which is the same asymmetry `AddOns.txt`'s own
/// value grammar has, and worth a falsifier because "unrecognised means off" is the reading
/// anyone would write by hand.
#[test]
fn default_state_is_enabled_unless_the_manifest_says_disabled() {
let d = |body: &str| Toc::parse(body).default_state();
assert!(d("## Interface: 11200\n"), "absent: the ctor's own 1");
assert!(d("## DefaultState: enabled\n"));
assert!(!d("## DefaultState: disabled\n"));
assert!(
!d("## DefaultState: DISABLED \n"),
"trimmed, case-folded"
);
assert!(
d("## DefaultState: maybe\n"),
"neither literal: no store, the 1 stands"
);
assert!(
d("## DefaultState:\n"),
"empty value is not `disabled` either"
);
// Last-wins, the reference's hash insert — not first-wins, and not an append.
assert!(!d("## DefaultState: enabled\n## DefaultState: disabled\n"));
assert!(d("## DefaultState: disabled\n## DefaultState: enabled\n"));
}
#[test]
fn vanilla_1_12_shape() {
let toc = Toc::parse(
"## Interface: 11200\n\
## Title: FrameXML\n\
## Secure: 1\n\
GlobalStrings.lua\n\
Fonts.xml\n",
);
assert_eq!(toc.interface_versions(), vec![11200]);
assert_eq!(toc.directive("Secure"), Some("1"));
assert!(!toc.load_on_demand());
assert_eq!(toc.files.len(), 2);
}
/// **A scalar directive REPLACES on a repeat; a list directive APPENDS** — the reference's two
/// stores, and this file had both backwards (decision 2126). `MoveAnything.toc` writes
/// `## Notes` twice and `AtlasLoot.toc` writes `## Author` twice, so the scalar half is not
/// hypothetical either.
#[test]
fn a_scalar_directive_replaces_and_a_list_directive_appends() {
let toc = Toc::parse(
"## RequiredDeps: LibA, LibB\n\
## Title: first\n\
## Title: second\n\
## LoadOnDemand: 1\n",
);
assert_eq!(toc.dependencies(), vec!["LibA", "LibB"]);
assert_eq!(toc.directive("Title"), Some("second"));
assert!(toc.load_on_demand());
}
/// **`CT_RaidAssist`'s own manifest, in shape** — 25 saved globals across four lines, of which
/// we registered the first seven. The rest (raid positions, menu state, boss timers, debuff
/// templates, the loot method, the squelch list) were never written and never restored.
///
/// Plus `SpecialTalentUI`'s: two names separated by a SPACE, which we read as one global whose
/// name contained a space. The tokenizer is `SStrTokenize 0x64ae50` over the delimiter set
/// `" ,"` at `0x8537c4`.
#[test]
fn a_repeated_list_directive_accumulates_across_lines_and_splits_on_space() {
let toc = Toc::parse(
"## SavedVariables: A, B\n\
## SavedVariables: C, D\n\
## SavedVariablesPerCharacter: E F\n",
);
assert_eq!(toc.list("SavedVariables"), vec!["A", "B", "C", "D"]);
assert_eq!(toc.list("SavedVariablesPerCharacter"), vec!["E", "F"]);
}
/// **The three dependency keys are matched by PREFIX**, because they are the only directives in
/// the reference's table with no colon: the `.rdata` cluster is
/// `… LoadWith:\0 \0\0 Dep\0 RequiredDep\0 " ,"\0\0 OptionalDep\0 Revision:\0 …`, and a
/// whole-key compare against `"Dep"` could never match a line reading `Dependencies:`.
///
/// Four corpus addons write the long optional form (`FuBar_CRDelayFu`, `FuBar_PetInFu`,
/// `FuBar_ToFu`, `FuBar_WeaponRebuffFu`), which this file used to refuse on the reasoning that
/// the long form was "merely plausible" for the optional half. It was never a separate
/// question.
#[test]
fn the_dependency_keys_match_by_prefix_and_both_required_spellings_union() {
let toc = Toc::parse(
"## Dependencies: LibA\n\
## RequiredDependencies: LibB\n\
## OptionalDependencies: LibC, LibD\n\
## OptionalDeps: LibE\n",
);
assert_eq!(toc.dependencies(), vec!["LibA", "LibB"]);
assert_eq!(
toc.optional_dependencies(),
vec!["LibC", "LibD", "LibE"],
"the long and short optional spellings are the same key"
);
// And the prefixes do not bleed into each other: `OptionalDependencies` does not start
// with `Dep`, so it never lands in the required list.
assert!(!toc.dependencies().contains(&"LibC"));
}
#[test]
fn empty_and_degenerate_inputs() {
assert_eq!(Toc::parse(""), Toc::default());
let toc = Toc::parse("# only a comment\n\n");
assert!(toc.directives.is_empty() && toc.files.is_empty());
}
}