- addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178) - GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175) - SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176) - a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176) - a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5136 lines
253 KiB
Rust
5136 lines
253 KiB
Rust
//! **The addon corpus harness** (1188 phase 6) — load every addon in a folder, one at a time, and
|
||
//! report what happened as numbers that can be re-read on any day.
|
||
//!
|
||
//! 1188 asks for a harness rather than a one-off, and the reason is in its own closing line:
|
||
//! *"that harness plus phase 0's coverage script are this arc's instruments, and they are what
|
||
//! make the remaining work a list instead of an argument."* `scripts/api-coverage.sh` answers
|
||
//! *what surface do we present*; this answers *what does that surface actually carry*.
|
||
//!
|
||
//! ## One VM per addon, deliberately
|
||
//!
|
||
//! Every addon is surveyed in a **fresh** [`UiScript`] with the whole in-game interface loaded
|
||
//! underneath it — the stock 1.12 FrameXML off the player's chain plus the few files still ours
|
||
//! (1751), exactly what a live session seats.
|
||
//! That costs a full UI load per addon and buys the only property that makes the report readable:
|
||
//! one addon's failure cannot be another's. Loading them all into one VM means the first addon to
|
||
//! leave a global in a bad state gets blamed for the next twenty, and the distribution 1188 asks
|
||
//! for stops meaning anything.
|
||
//!
|
||
//! **What one VM per addon costs, stated because it bounds every number here.** The real client
|
||
//! loads every addon into ONE Lua state, so a library embedded in *any* addon's `Libs\` is global
|
||
//! for every addon that loads after it. Here it is not. An addon that ships no libraries and relies
|
||
//! on a sibling's copy — `FuBar_CustomMenuFu` ships one Lua file and calls
|
||
//! `AceLibrary("Tablet-2.0")`, which no addon it can reach provides — fails in this survey and
|
||
//! would work in a real session. **So the headline is a floor, not an estimate**, and a
|
||
//! `Cannot find a library instance of X` row is this limitation before it is a gap of ours. The
|
||
//! isolation is still the right trade (see above: one addon's failure must not be another's); it
|
||
//! is the reporting that has to say so. Pinned by
|
||
//! [`dependency_tests::a_sibling_addons_embedded_library_is_invisible`].
|
||
//!
|
||
//! The FrameXML underneath is not optional either — an addon calls `UIDropDownMenu_Initialize` and
|
||
//! `GameTooltip_SetDefaultAnchor` as readily as it calls `UnitName`, and roughly half of what looks
|
||
//! like "the WoW API" is Lua the client ships (decision 1190: 1,100 engine functions vs 1,075
|
||
//! FrameXML ones). Surveying against a bare VM would report most of FrameXML as missing.
|
||
//!
|
||
//! ## What "missing" means here, and what it does not
|
||
//!
|
||
//! [`AddonReport::missing_globals`] is a **static** read: the names the addon's own source calls
|
||
//! like functions, minus everything the loaded VM has, minus what the addon defines itself. It is
|
||
//! deliberately not a runtime trace, because an addon's API calls overwhelmingly happen in
|
||
//! handlers that only fire in a live session — a load-time trace would report almost nothing and
|
||
//! read as success.
|
||
//!
|
||
//! The cost of that choice, stated rather than hidden: it over-reports. A name reached only on a
|
||
//! path the addon never takes still counts, and a name built at runtime (`getglobal("Unit"..verb)`)
|
||
//! is invisible to it. So the list is a **prioritisation signal**, exactly like
|
||
//! `api-coverage.sh`'s — read the ranked aggregate, not any single row, and never quote it as a
|
||
//! pass rate.
|
||
//!
|
||
//! There are **three** such lists and they rank three different queues, never merged (1207):
|
||
//! [`AddonReport::missing_globals`] is functions to write in Rust, [`AddonReport::missing_tables`]
|
||
//! is FrameXML to transcribe, and [`AddonReport::missing_methods`] is widget bindings. The third
|
||
//! arrived last and had been invisible the longest — a method is not a global, so nothing here
|
||
//! could rank one, and the error row that carried them collapsed the name away.
|
||
//!
|
||
//! The method queue then turned out to be **two** questions, not one (decision 1228):
|
||
//! `missing_methods` asks whether *any* widget answers a name, which is blind to a verb wired to
|
||
//! one class and forgotten on its sibling — `MessageFrame:AddMessage` scored zero on it while three
|
||
//! corpus addons had exactly that call as their first load error.
|
||
//! [`AddonReport::kind_missing_methods`] asks the question the addon asked, by typing the receiver
|
||
//! of the call site; [`AddonReport::ambiguous_methods`] is what the typing could not reach, printed
|
||
//! rather than swallowed.
|
||
//!
|
||
//! ## Running it
|
||
//!
|
||
//! ```text
|
||
//! cargo run -q -p benilla-app --example addon_harness -- <folder of addons>
|
||
//! ```
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use benilla_ui::script::UiScript;
|
||
use benilla_ui::toc::Toc;
|
||
|
||
/// The read-back — *why is this global nil?* One addon, loaded the way the survey loads it, then
|
||
/// arbitrary Lua evaluated against the VM that is left standing. A debugger, not a column; its
|
||
/// header says what it is worth and where it deliberately stops.
|
||
pub mod probe;
|
||
#[cfg(test)]
|
||
mod probe_tests;
|
||
/// The render column — the one question every column here was blind to: *did this addon put
|
||
/// anything on screen?* Its own module because it is its own concern (and this file is well over
|
||
/// the size budget); its header is the design and the honest bounds.
|
||
pub mod render;
|
||
#[cfg(test)]
|
||
mod render_tests;
|
||
/// The control for this module's one-VM-per-addon bound — the whole folder in ONE VM, the way a
|
||
/// real client runs it. Its header says what it can and cannot answer.
|
||
pub mod together;
|
||
/// The use column — *does the thing it drew survive being **touched**?* Its own module for the
|
||
/// same two reasons `render` is; its header is the design, the bounds and the four-deep history
|
||
/// that made it necessary.
|
||
mod use_probe;
|
||
#[cfg(test)]
|
||
mod use_probe_tests;
|
||
|
||
use render::{measure_render, RenderBaseline};
|
||
pub use render::{Drew, RenderReport};
|
||
use use_probe::measure_use;
|
||
pub use use_probe::{UseReport, Used, MAX_USE_TARGETS};
|
||
|
||
/// What one addon did.
|
||
///
|
||
/// `Default` is derived so the two error returns in [`survey_one`] and the unit tests can name only
|
||
/// the fields they mean — a report grew from nine fields to fourteen over this arc, and a
|
||
/// positional wall of `Vec::new()`s is where a new column silently gets filled with the wrong one.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct AddonReport {
|
||
pub name: String,
|
||
/// `## Interface`, as written. 1.12 is `11200`; the corpus is full of older values, and we
|
||
/// deliberately do not refuse them (decision 1191 §6).
|
||
pub interface: Vec<u32>,
|
||
/// **Did anything in its manifest RAISE, fail to parse, or drop a frame?**
|
||
///
|
||
/// Not "did every entry resolve" — that is [`Self::absent_own_files`] and
|
||
/// [`Self::absent_foreign_files`], and it is a different question because the reference
|
||
/// answers it differently: a manifest line naming no file is `"Couldn't open %s"` in
|
||
/// FrameXML.log and the walk carries straight on (wow-re
|
||
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED). An addon shipping an incomplete zip
|
||
/// *works* on a real client, so counting it here made the survey say the opposite of the truth
|
||
/// about 24 addons — FuBar itself among them, on two locale files it does not ship.
|
||
///
|
||
/// **[`Self::errors`] is unchanged and still carries those rows verbatim** (1213), so every
|
||
/// number in every past record is still readable off it; what changed, deliberately and with
|
||
/// decision 2155 behind it, is which of them this flag counts. The report prints both.
|
||
pub loaded: bool,
|
||
/// Load errors, verbatim, tagged by file.
|
||
pub errors: Vec<String>,
|
||
/// **Manifest entries the addon's own package does not contain.** Already counted in
|
||
/// [`Self::errors`], and deliberately not removed from it — 1213's rule for the fifth time:
|
||
/// a new question gets a new column, because silently shrinking `errors` would move `loaded`
|
||
/// and make every number in every past decision record incomparable.
|
||
///
|
||
/// The question it asks that no other column can: **whose fault is this row?** Five of the
|
||
/// corpus's failures are one addon family shipping a `.toc` that lists files it does not
|
||
/// contain — `DPSMate_CureDisease.toc` names eight files and the folder holds six, because the
|
||
/// three `*Received*` ones were split into a sibling addon and the manifest was never updated.
|
||
/// The client is behaving correctly there (the reference logs `Couldn't open %s` and carries
|
||
/// on, which is what our loader does), and a headline that cannot say so invites a session to
|
||
/// go hunting for a bug that is not ours. The director's own AtlasLoot copy is the same shape.
|
||
pub absent_own_files: Vec<String>,
|
||
/// **Manifest entries resolving OUTSIDE the addon's folder, into one that is not installed** —
|
||
/// resolved paths, not the raw entry, because the `..` collapse is the interesting half.
|
||
///
|
||
/// A different question from [`Self::absent_own_files`] and kept apart from it: this addon's
|
||
/// package is fine, it wants a neighbour. The corpus's two are Auctioneer and BeanCounter
|
||
/// reaching `..\Blizzard_AuctionUI\Blizzard_AuctionUITemplates.xml` — which wow-re records as
|
||
/// **RESOLVING** in the real client (`ui/scratch/xml-toc-path-resolution.md` §5 case 2, by
|
||
/// name), because there the file layer can see `Blizzard_AuctionUI` inside `patch.MPQ`. Ours
|
||
/// reads the AddOns directory only, so it misses. That one IS ours, and the split is what
|
||
/// makes it visible instead of averaging with the row above.
|
||
pub absent_foreign_files: Vec<String>,
|
||
/// Names it calls that the VM does not have — see the module doc on what this is worth.
|
||
pub missing_globals: Vec<String>,
|
||
/// Dependencies named in its `.toc` that are not in the folder.
|
||
pub missing_deps: Vec<String>,
|
||
/// Templates it names in `CreateFrame(kind, name, parent, "Template")` that the VM has never
|
||
/// declared (decision 1203).
|
||
///
|
||
/// **A blind spot by construction until it was measured.** `CreateFrame`'s fourth argument was
|
||
/// ignored outright until today; now it is honoured, and the survey's headline did not move at
|
||
/// all — because an unresolved template produces *no load error*, so `loaded` cannot see it and
|
||
/// `missing_globals` does not either (a template is not a global). An addon gets a bare frame,
|
||
/// loads clean, and paints nothing.
|
||
///
|
||
/// Static, like `missing_globals`, and with the same caveat: read the ranked aggregate.
|
||
pub missing_templates: Vec<String>,
|
||
/// Templates it names in an XML `inherits=` that the VM has never declared.
|
||
///
|
||
/// [`Self::missing_templates`]'s twin, added because the first was measuring the wrong axis.
|
||
/// 1203 built `CreateFrame`'s fourth argument and ranked what it could not resolve; the
|
||
/// transcription that followed then moved the headline by twelve addons, and **not one of
|
||
/// those twelve came through `CreateFrame`** — every one failed on an `inherits=` in its own
|
||
/// XML. The two are ranked separately rather than merged because they behave differently:
|
||
/// an unresolved `CreateFrame` template is silent (1203 §2), while an unresolved `inherits=`
|
||
/// is usually loud, because the very next line of the element's `<OnLoad>` is
|
||
/// `getglobal(this:GetName().."Text")` and the loader fires that `<OnLoad>` immediately.
|
||
///
|
||
/// Same static caveat, plus one of its own: `inherits=` spans two namespaces — a `<FontString
|
||
/// inherits="GameFontNormal">` names a FONT — so a name registered as either is resolved.
|
||
pub missing_inherits: Vec<String>,
|
||
/// Errors raised **after** this addon's own files loaded, while the session start was driven —
|
||
/// its own `ADDON_LOADED` → `VARIABLES_LOADED` → `PLAYER_LOGIN` → `PLAYER_ENTERING_WORLD`,
|
||
/// then a few ticks to drain `OnUpdate` and anything scheduled. Every DEPENDENCY's
|
||
/// `ADDON_LOADED` is already behind this point: each fires beside its own addon's files, where
|
||
/// `0x51f5ad` fires it ([`load_dependencies`], 2166).
|
||
///
|
||
/// **This is the survey's answer to its own oldest blind spot.** Every number beside it is
|
||
/// load-time, and this arc has now written the same sentence into four decision records —
|
||
/// 1203, 1205, 1211 and the state-texture pass all end with *"the headline cannot see this"*,
|
||
/// because an addon whose file scope runs clean scores as a pass no matter what its handlers
|
||
/// do. The handlers are where addons actually live: an `OnEvent` that fires on `PLAYER_LOGIN`
|
||
/// is the single most common shape in the corpus.
|
||
///
|
||
/// Names it INDEXES (`Foo.bar`, `Foo:baz()`) that the VM does not have — frames and tables,
|
||
/// where [`Self::missing_globals`] is functions.
|
||
///
|
||
/// Two lists because the two queues go to different places: a missing function is a verb to
|
||
/// write in Rust, a missing frame or table is FrameXML to transcribe. And ranking them together
|
||
/// would mis-rank, which is 1207's lesson.
|
||
///
|
||
/// **The scan was blind to this shape until 2026-08-11.** `ColorPickerFrame` — a window
|
||
/// **86 corpus addons reach** — scored exactly 0 on the most-wanted list, because the corpus
|
||
/// spells it `ColorPickerFrame.func` and `ColorPickerFrame:SetColorRGB`, never `ColorPickerFrame(`.
|
||
/// The same blindness hid `GameTooltip`, `WorldFrame`, `ChatFrame1` and every other FrameXML
|
||
/// frame global. Third instrument correction of this arc, after 1209 and 1210.
|
||
pub missing_tables: Vec<String>,
|
||
/// Names it calls as `obj:Name(...)` that **no widget we ship provides** — the widget-method
|
||
/// class, which nothing else here could see.
|
||
///
|
||
/// [`Self::missing_globals`] is functions and [`Self::missing_tables`] is frames; **a method is
|
||
/// neither, and never lands in either.** `BuffCheck2/BuffCheck2.lua:448` died on
|
||
/// `attempt to call method 'GetBackdrop' (a nil value)` while `GetBackdrop` scored exactly zero
|
||
/// on every ranking this harness printed — and the arc was working precisely this class by hand
|
||
/// at the time (`GetTexture`, `SetShadowColor`, `SetNonSpaceWrap`, `GetBackdrop` all landed
|
||
/// within hours of each other). Six corpus addons' FIRST error is this shape today, and the
|
||
/// ranked row that carries them reads `attempt to call method 'X' (a nil value)`: the name —
|
||
/// the only part anyone can act on — is exactly what the normalisation deletes.
|
||
///
|
||
/// **Resolved by ASKING THE VM, never by a name list.** One instance of every `CreateFrame`
|
||
/// kind plus a Texture and a FontString are stood up and each wanted name is looked up through
|
||
/// the real `__index` dispatcher — the same path the addon's own call takes — so this cannot
|
||
/// drift from what we actually implement the way a hand-kept list would.
|
||
///
|
||
/// **It resolves against ANY probe, so it is blind to a method on the WRONG kind** (decision
|
||
/// 1228), and it stays that way **on purpose**: this is the number four decision records quote,
|
||
/// and redefining it would make every one of them incomparable (1209). A name survives here
|
||
/// only if *no* widget answers it; a verb we implemented on one class and forgot on its sibling
|
||
/// resolves and scores zero. This doc once claimed the opposite — that an unwired
|
||
/// `<MessageFrame>` was exactly what the list would catch, with `AddMessage` topping the
|
||
/// ranking because of it — and the run underneath it never agreed: `AddMessage` sat at zero all
|
||
/// along, answered by `ScrollingMessageFrame`, while three addons died on
|
||
/// `UIErrorsFrame:AddMessage`.
|
||
///
|
||
/// So this list ranks **verbs nobody has**; [`Self::kind_missing_methods`] ranks **kinds nobody
|
||
/// wired**, by typing the receiver of the call site instead of asking the probe set at large,
|
||
/// and [`Self::ambiguous_methods`] carries the residue it cannot type. Read all three; they are
|
||
/// three different jobs, and merging them would lose exactly the distinction that cost this
|
||
/// instrument a whole class.
|
||
///
|
||
/// **It OVER-reports, deliberately**, and this is the field where that trade bites hardest: a
|
||
/// scanner cannot know the receiver of a `:` call, so every OO library method whose definition
|
||
/// is not in the addon's own source counts here too (the whole Ace2/FuBar `self:Foo()` family
|
||
/// when it is reached through a *dependency* rather than an embedded copy). What is subtracted
|
||
/// is what can be seen: names the addon's own files bind as `function T:N`, `function T.N`, and
|
||
/// `T.N = …`, and the same three read off every dependency the VM loaded under it. A method
|
||
/// bound as `T["N"] = …` cannot be subtracted at all — `strip_lua_noise` blanks the string
|
||
/// before the scan sees it — and that is stated rather than hidden.
|
||
///
|
||
/// **The residue in the ranking's head is the one-VM limitation, not a gap of ours** — the same
|
||
/// bound the module doc already puts on `Cannot find a library instance of X`, showing up on a
|
||
/// new axis. `FuBar:RegisterPlugin` sits at 8 because eight addons call a library they never
|
||
/// declare, so nothing loads it for them; on the real client a sibling's copy is global. And
|
||
/// like every list here it counts a name on a branch the addon never takes:
|
||
/// `instance:RegisterTabCompletion` is 51 addons' copy of one AceConsole line guarded by
|
||
/// `elseif major == "AceTab-2.0"`, for a library no corpus addon ships. Read the ranked
|
||
/// aggregate, and read the row's call sites before building it (1214).
|
||
pub missing_methods: Vec<String>,
|
||
/// Methods it calls **and feature-tests first** (`if f.SetTopLevel then f:SetTopLevel(1) end`)
|
||
/// that no widget we ship provides — [`Self::missing_methods`]'s other half, printed beside it
|
||
/// rather than folded into it.
|
||
///
|
||
/// These are not blockers: the addon has already written the branch it takes without them, so
|
||
/// counting them in the ranking a session builds from would put a name nobody is stuck on above
|
||
/// one somebody is. But they are not nothing either — **`SetTopLevel` is a real 1.12 widget
|
||
/// method 60 corpus addons work around**, and implementing it silently improves every one of
|
||
/// them. Dropping the class outright is exactly the silent under-report this instrument keeps
|
||
/// being caught in; it gets its own row instead.
|
||
pub optional_methods: Vec<String>,
|
||
/// `Kind:Name (on …)` — a method called on a receiver this survey could **type**, that that
|
||
/// kind does not answer. [`Self::missing_methods`]'s answer to the bound 1228 recorded.
|
||
///
|
||
/// `missing_methods` asks the probe set *"does ANY widget answer this name"*, and a verb we
|
||
/// implemented on one class and never wired to its sibling answers yes. That is not a corner
|
||
/// case: `MessageFrame:AddMessage` scored **zero** on that list for as long as it existed —
|
||
/// answered by the ScrollingMessageFrame probe — while `EasyCopy`, `QuestHistory` and
|
||
/// `QuestItem` all had `UIErrorsFrame:AddMessage` as their FIRST load error. This list asks the
|
||
/// question the addon actually asked: *does the kind it called it on answer it*.
|
||
///
|
||
/// The row says which kinds do answer, because that is the difference between two jobs: `(on no
|
||
/// kind)` is a verb to write, `(on ScrollingMessageFrame)` is a verb we already wrote and
|
||
/// forgot to wire.
|
||
///
|
||
/// **Bounded by the attributor, and only the attributor.** A receiver is typed when the file
|
||
/// binds it from a widget factory (`local f = CreateFrame("MessageFrame", …)`,
|
||
/// `f:CreateTexture()`) or when it is a **published name** whose kind the live arena knows
|
||
/// (`UIErrorsFrame` → `MessageFrame`, via [`UiScript::widget_kind`]). `self:Foo()`,
|
||
/// `this:Foo()`, `a.b:Foo()` and `getglobal(n):Foo()` are not typable and are reported in
|
||
/// [`Self::ambiguous_methods`] instead of being silently counted as fine.
|
||
pub kind_missing_methods: Vec<String>,
|
||
/// `Name (only on …)` — a name the addon calls on a receiver nothing could type, where the
|
||
/// answer **depends on the kind**: some widgets we ship answer it and some do not.
|
||
///
|
||
/// The row this whole per-kind pass exists to stop swallowing. Before it, such a name resolved
|
||
/// against the probe set and vanished; now the reader is told the answer is conditional and on
|
||
/// what. It is an **upper bound on purpose** — the call may well be on one of the kinds that
|
||
/// does answer — and the honest way to read it is as a measure of how much the *attributor*
|
||
/// still cannot see: every receiver shape [`scan_receivers`] learns to type moves rows out of
|
||
/// here and into a real answer.
|
||
///
|
||
/// A name **no** kind answers is not here — it is a plain miss and it is in
|
||
/// [`Self::missing_methods`].
|
||
pub ambiguous_methods: Vec<String>,
|
||
/// Kept **separate from [`Self::loaded`] on purpose.** Folding these in would silently change
|
||
/// what the headline means and make every number in every past decision record incomparable
|
||
/// (1209's whole subject). `loaded` still means exactly "no LOAD errors"; this is a second,
|
||
/// stricter column beside it.
|
||
pub session_errors: Vec<String>,
|
||
/// What the addon's own UI OVERRIDES raised when actually invoked — see [`drive_ui_probe`].
|
||
///
|
||
/// A NEW column beside `session_errors`, never folded into it (1213's rule): this asks a
|
||
/// different question, and redefining an existing number would make every past run
|
||
/// incomparable.
|
||
pub probe_errors: Vec<String>,
|
||
/// **Warnings raised while this addon loaded and ran** — the engine's own non-fatal channel
|
||
/// (decision 2135), which nothing in this survey could see until it had one.
|
||
///
|
||
/// A warning is the class of failure that *does not announce itself*: an `inherits=` argument
|
||
/// that was not a template name and got dropped, a `SetPoint` whose `relativeTo` did not
|
||
/// resolve and silently re-anchored, a `SetCVar` on a name nothing registered, a saved
|
||
/// variable that would not serialise, a loader note about a template of the wrong kind.
|
||
/// Nothing raises, so `errors`, `session_errors` and `probe_errors` are all blind to it, and
|
||
/// the addon scores a clean pass while doing the wrong thing. That is exactly the shape
|
||
/// `render` was added for — and this reaches the cases `render` cannot, because a warning
|
||
/// usually fires long before anything would have been drawn.
|
||
///
|
||
/// A NEW column, never folded into another (1213's rule, again).
|
||
///
|
||
/// **Read off the retained diagnostic log by `seq`, not off the per-frame drain**, so it
|
||
/// carries the host's loader warnings (which are recorded with their `<file>` prefix and never
|
||
/// touch the drain) as well as the engine's. One consequence of the log's dedupe is worth
|
||
/// stating: a warning whose exact text the FrameXML underneath already produced collapses onto
|
||
/// that older row and does not appear here. The corpus's warnings name the addon's own frames
|
||
/// and files, so this is rare rather than theoretical — but it means the column, like every
|
||
/// other one here, is a floor.
|
||
pub warnings: Vec<String>,
|
||
/// **What it actually PUT ON SCREEN** — see [`render`], whose header is the design.
|
||
///
|
||
/// The column every other one here was blind to. `loaded`, `session_errors` and `probe_errors`
|
||
/// all ask whether something *raised*; the director's Bagnon report raised nothing at all and
|
||
/// drew nothing either, and scored a clean pass on all three. A new column beside them,
|
||
/// never folded in — 1213's rule, for the third time.
|
||
pub render: RenderReport,
|
||
/// **What happened when the UI it drew was actually USED** — see [`use_probe`], whose header is
|
||
/// the design and the four-deep history behind it.
|
||
///
|
||
/// [`Self::render`] closed "did anything appear"; this closes "is what appeared alive". The
|
||
/// director drew the line themselves: Bagnon draws sixteen bag slots and they are dead to the
|
||
/// touch — hovering one raised `attempt to call global 'ContainerFrameItemButton_OnEnter'` —
|
||
/// while `render` scored it a full pass, because a frame that paints and does nothing paints
|
||
/// exactly like one that works.
|
||
///
|
||
/// A new column beside the others, never folded in — 1213's rule, for the fourth time. Read
|
||
/// [`UseReport::driven`] with every verdict: a clean row that touched nothing is
|
||
/// [`Used::Untouched`], which is not a pass.
|
||
pub used: UseReport,
|
||
}
|
||
|
||
/// Survey every addon folder under `root`.
|
||
///
|
||
/// `root` is an AddOns folder — one subfolder per addon, each with a `<Name>.toc`. Anything else
|
||
/// is skipped in silence, exactly as discovery does, because a stray `Backup/` is the common case.
|
||
pub fn survey(root: &Path) -> Vec<AddonReport> {
|
||
let (names, installed, registry) = corpus(root);
|
||
|
||
// Folder → the method names its source DEFINES, memoised across the whole survey.
|
||
//
|
||
// A dependency's definitions have to be read once per *folder*, not once per dependent: the
|
||
// corpus's library folders are declared by dozens of addons each (83 declare `FuBar`), and
|
||
// re-scanning `FuBar`'s source 83 times is the survey's runtime for nothing.
|
||
let mut defined_methods: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||
names
|
||
.iter()
|
||
.map(|n| survey_one(root, n, &installed, ®istry, &mut defined_methods))
|
||
.collect()
|
||
}
|
||
|
||
/// **The corpus as every VM must see it** — the folders that carry a manifest, the case-folded
|
||
/// installed set dependency resolution consults, and the AddOn registry each VM is seated with.
|
||
///
|
||
/// Its own function because [`probe`] needs the IDENTICAL environment for a single addon, and a
|
||
/// probe built against a registry of one would answer a different question from the row it exists
|
||
/// to explain (see [`probe`]'s header). The installed set is a property of the FOLDER, never of
|
||
/// the selection.
|
||
fn corpus(
|
||
root: &Path,
|
||
) -> (
|
||
Vec<String>,
|
||
BTreeSet<String>,
|
||
Vec<benilla_ui::script::AddOnInfo>,
|
||
) {
|
||
let mut names: Vec<String> = std::fs::read_dir(root)
|
||
.into_iter()
|
||
.flatten()
|
||
.flatten()
|
||
.filter(|e| e.path().is_dir())
|
||
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
|
||
.filter(|n| manifest_path(root, n).is_some())
|
||
.collect();
|
||
// **The live walk's order, not a second opinion.** This used to be a bare `names.sort()`,
|
||
// a private copy of the ordering `ui_script::addons` decides — so the instrument could
|
||
// measure a walk the client does not perform. It calls the one definition now, which is what
|
||
// makes a `--together` row about shared-library provenance mean anything.
|
||
crate::ui_script::addons::sort_by_directory_order(&mut names);
|
||
|
||
let installed: BTreeSet<String> = names.iter().map(|n| n.to_ascii_lowercase()).collect();
|
||
|
||
// THE ADDON REGISTRY, built once and seated into every VM below.
|
||
//
|
||
// Until this existed the survey never called `register_addons` at all, so every VM ran with an
|
||
// EMPTY registry: `GetNumAddOns()` answered 0 and `GetAddOnInfo(...)` answered nothing, for all
|
||
// 218. That is a state the real client cannot produce — the same fault as 1193 (dependencies
|
||
// unloaded) and 1212 (`OptionalDeps` unwalked), and it hid the whole AddOn API from the survey.
|
||
//
|
||
// The cost was not theoretical. AceAddon and AceLibrary — the two most replicated files in the
|
||
// corpus — find their dependencies with `local name, _, _, enabled, loadable =
|
||
// GetAddOnInfo(major)`, and against an empty registry every one of those took the "nothing is
|
||
// installed" path. It is also why fixing `GetAddOnInfo`'s slot 4 (`4666b708`) moved the
|
||
// headline by exactly zero: the instrument could not see the verb at all.
|
||
//
|
||
// Every addon is registered ENABLED, which is the survey's own premise — it asks what an addon
|
||
// does when the player has it on. `loaded` stays false: the flag means "already loaded in this
|
||
// session", and each VM loads exactly one addon plus its declared dependencies.
|
||
let registry: Vec<benilla_ui::script::AddOnInfo> = names
|
||
.iter()
|
||
.filter_map(|n| {
|
||
let toc = Toc::parse(&benilla_ui::source::decode(
|
||
&std::fs::read(manifest_path(root, n)?).unwrap_or_default(),
|
||
));
|
||
Some(crate::ui_script::addons::info_from_toc(n, &toc))
|
||
})
|
||
.collect();
|
||
|
||
(names, installed, registry)
|
||
}
|
||
|
||
/// The method names one addon folder's source **defines** — `function T:N`, `function T.N`,
|
||
/// `T.N = …` — memoised by folder.
|
||
///
|
||
/// This is the method-side answer to a question `known` already answers for globals. `known` is
|
||
/// read off the VM *after* the dependency chain has run, so a global a dependency defines is
|
||
/// correctly not missing; there is no equivalent read for methods, because a method lives on an
|
||
/// object the survey cannot name, so the source is the only available proxy.
|
||
///
|
||
/// **Skipping this made the first widget-method ranking useless**, and in the most instructive way:
|
||
/// seven of its top eleven rows were `FuBar:RegisterPlugin`, `FuBar:GetNumPanels`,
|
||
/// `FuBar:IsChangingProfile` and their siblings, at 75-78 addons each. Every one is
|
||
/// `function FuBar:<Name>` in the **`FuBar` addon's own source** — a dependency 83 corpus addons
|
||
/// declare, which `load_dependencies` duly loads into their VM. The methods exist at runtime; only
|
||
/// the scanner could not see them, because it read the dependent's folder and stopped. Under them,
|
||
/// at 61 and 60, sat `SetDesaturated` and `SetTopLevel` — two widget methods we genuinely do not
|
||
/// have. That is 1210's lesson a third time: a ranking built by grepping source is worth exactly
|
||
/// what its subtractions are worth.
|
||
fn methods_defined_by<'a>(
|
||
root: &Path,
|
||
folder: &str,
|
||
memo: &'a mut BTreeMap<String, BTreeSet<String>>,
|
||
) -> &'a BTreeSet<String> {
|
||
if !memo.contains_key(folder) {
|
||
let mut scan = Scan::default();
|
||
if let Some(toc) = manifest_path(root, folder)
|
||
.and_then(|p| std::fs::read(p).ok())
|
||
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
|
||
{
|
||
for path in source_files(root, folder, &toc) {
|
||
if let Some(text) = read_text(root, &path) {
|
||
scan_source(&path, &text, &mut scan);
|
||
}
|
||
}
|
||
}
|
||
memo.insert(folder.to_string(), scan.defined_methods);
|
||
}
|
||
&memo[folder]
|
||
}
|
||
|
||
/// `<root>/<name>/<name>.toc`, matched case-insensitively — a real 1.12 addon may ship
|
||
/// `MyAddon/myaddon.toc`, and on a case-sensitive filesystem an exact probe would not find it.
|
||
fn manifest_path(root: &Path, name: &str) -> Option<PathBuf> {
|
||
let want = format!("{name}.toc");
|
||
std::fs::read_dir(root.join(name))
|
||
.ok()?
|
||
.flatten()
|
||
.find(|e| {
|
||
e.file_name()
|
||
.to_str()
|
||
.is_some_and(|f| f.eq_ignore_ascii_case(&want))
|
||
})
|
||
.map(|e| e.path())
|
||
}
|
||
|
||
/// The per-addon VM-instruction bound (see its use in [`survey_one`]) — the ONE number, shared
|
||
/// with the live client's world-entry arming (decision 1306) so the corpus measurement behind it
|
||
/// (heaviest legitimate addon 4M, 214 of 218 under 1M) cannot drift apart from the bound players
|
||
/// actually run under.
|
||
const ADDON_INSTRUCTION_BUDGET: u64 = crate::ui_script::addons::LOAD_INSTRUCTION_BUDGET;
|
||
|
||
fn survey_one(
|
||
root: &Path,
|
||
name: &str,
|
||
installed: &BTreeSet<String>,
|
||
registry: &[benilla_ui::script::AddOnInfo],
|
||
defined_methods: &mut BTreeMap<String, BTreeSet<String>>,
|
||
) -> AddonReport {
|
||
let Some(toc_path) = manifest_path(root, name) else {
|
||
return AddonReport {
|
||
name: name.to_string(),
|
||
errors: vec!["no manifest".into()],
|
||
..Default::default()
|
||
};
|
||
};
|
||
// Decoded, not `read_to_string`'d: five of the corpus's 218 manifests are cp1252 and would
|
||
// otherwise parse as an empty `.toc` — an addon with no files and no dependencies, which reads
|
||
// as a clean pass (decision 1193).
|
||
let toc = Toc::parse(&benilla_ui::source::decode(
|
||
&std::fs::read(&toc_path).unwrap_or_default(),
|
||
));
|
||
let missing_deps: Vec<String> = toc
|
||
.dependencies()
|
||
.into_iter()
|
||
.filter(|d| !installed.contains(&d.to_ascii_lowercase()))
|
||
.map(str::to_owned)
|
||
.collect();
|
||
|
||
// A VM with our whole interface under it — see the module doc on why per-addon and why loaded.
|
||
let mut script = match UiScript::new() {
|
||
Ok(s) => s,
|
||
Err(e) => {
|
||
return AddonReport {
|
||
name: name.to_string(),
|
||
interface: toc.interface_versions(),
|
||
errors: vec![format!("VM: {e}")],
|
||
missing_deps,
|
||
..Default::default()
|
||
}
|
||
}
|
||
};
|
||
// **A time bound, so one non-terminating addon cannot take the whole survey with it.**
|
||
//
|
||
// 1247 met the failure this exists for: `date("*t")` returned a string where Lua returns a
|
||
// table, so an addon's `while` never ended and the 218-addon run simply stopped finishing —
|
||
// no roster to diff, no column to compare, no error row to read, and the cause found by
|
||
// bisecting the corpus BY HAND. A wrong number is recoverable; an instrument that does not
|
||
// return is not.
|
||
//
|
||
// The budget is MEASURED, not guessed. Across the corpus at the time of writing, 214 of 218
|
||
// addons execute fewer than 1M VM instructions in a whole survey (the counter's resolution);
|
||
// the heaviest legitimate one is Enchantrix at 4M, then FuBar_CTRaid and BigWigs at 1M. So
|
||
// this is ~50x the heaviest real addon — far enough out that a fixture growing new seats
|
||
// cannot drift into it, and near enough that a runaway reports in about a second.
|
||
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
|
||
script.set_screen_size(1024.0, 768.0);
|
||
// Before anything runs: the AddOn API must answer for the whole installed set, not for nothing.
|
||
// The **saved-variable** roots are `None` — a survey must never read or write the director's
|
||
// real ones (the same reason `drive_session_start` is not `finish_ui_load`, 1213 §4). The
|
||
// ADDONS root is not one of those and is passed in full (decision 2102): without it every
|
||
// `LoadAddOn("<a folder addon>")` answered `MISSING`, which is a state the real client cannot
|
||
// produce and is exactly the 1193/1212 fault one level up — `/msbt`'s
|
||
// `UIParentLoadAddOn("MikScrollingBattleTextOptions")` is the shape it hides.
|
||
script.register_addons(registry.to_vec(), Some(root.to_path_buf()), None, None);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
// The addon's DEPENDENCIES, first and recursively — `AddOn_Load 0x51f240`'s own first two
|
||
// steps (1191 §2, byte-verified). Surveying an addon without them is surveying a state the
|
||
// real client never presents: `FuBar_Aspect` declares `## Dependencies: Ace, FuBar` and opens
|
||
// with `ace:LoadTranslation(...)`, so in isolation it fails on a global its dependency was
|
||
// always going to define. Fifteen corpus addons failed that way, on us rather than on
|
||
// themselves.
|
||
//
|
||
// Loaded BEFORE `globals_of` below, so a name a dependency provides does not also count as a
|
||
// missing global — the same double-count the FrameXML-underneath decision avoided.
|
||
let mut dep_loads: Vec<LoadedDep> = Vec::new();
|
||
load_dependencies(
|
||
&mut script,
|
||
root,
|
||
&toc,
|
||
installed,
|
||
&mut BTreeSet::new(),
|
||
&mut dep_loads,
|
||
);
|
||
let dep_order: Vec<String> = dep_loads.into_iter().map(|d| d.name).collect();
|
||
let known = globals_of(&script);
|
||
// The METHODS the dependency chain defines, gathered from the same folders whose files were
|
||
// just loaded above. `known` covers their globals because the VM actually ran them; a method
|
||
// has no such read (it lives on an object the survey cannot name), so its source stands in.
|
||
let dep_methods: BTreeSet<String> = dep_order
|
||
.iter()
|
||
.flat_map(|d| methods_defined_by(root, d, defined_methods).clone())
|
||
.collect();
|
||
|
||
// THE RENDER BASELINE — every widget that exists before this addon has run a single line.
|
||
// Everything that appears after it was created by this addon (or by a handler of its own that
|
||
// it installed), which is what makes the render column an attribution rather than a guess.
|
||
// Taken here, after the dependency chain, so a library's frames are not charged to its
|
||
// consumer — the same rule `load_dependencies` applies to errors.
|
||
let baseline = RenderBaseline::of(&script);
|
||
// THE WARNING MARK, taken at the same seam and for the same reason: everything the FrameXML
|
||
// underneath and this addon's dependency chain warned about is already behind us, so what
|
||
// follows is this addon's (decision 2135). `seq` is monotonic and never reused, which is what
|
||
// makes a high-water mark a valid cut of a log that also evicts and dedupes.
|
||
let warn_mark = script.diagnostics().last().map_or(0, |d| d.seq);
|
||
|
||
let files = load_addon_files(&script, root, name, &toc);
|
||
// The registry has to agree with the VM about what has loaded: the live walk stamps each
|
||
// addon at `0x51f5ad`'s position (`ui_script::addons`'s `mark_addon_loaded`, just before
|
||
// `ADDON_LOADED`), and without it here `IsAddOnLoaded` answered nil for an addon whose files
|
||
// had just run — and a LoadOnDemand dependent of it got `DEP_NOT_DEMAND_LOADED` rather than
|
||
// loading (decision 2102). The dependency chain stamped itself as it loaded, each one beside
|
||
// its own `ADDON_LOADED` (`load_dependencies`, 2166); only the surveyed addon is left.
|
||
script.mark_addon_loaded(name);
|
||
let wants = missing_calls(root, name, &toc, &known, &dep_methods);
|
||
// AFTER the addon's files: a template it declares in its OWN XML is registered by then, so it
|
||
// is not missing. The check asks the VM's live registry, not a name list.
|
||
let missing_templates = missing_templates(&script, root, name, &toc);
|
||
let missing_inherits = missing_inherits(&script, root, name, &toc);
|
||
let session_errors = drive_session_start(&mut script, name, installed);
|
||
let probe_errors = drive_ui_probe(&mut script);
|
||
// Everything filed since the mark, warnings only — the errors have their own columns and are
|
||
// retained under their own kinds.
|
||
let warnings: Vec<String> = script
|
||
.diagnostics()
|
||
.into_iter()
|
||
.filter(|d| {
|
||
d.seq > warn_mark && d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Warning
|
||
})
|
||
.map(|d| {
|
||
if d.count > 1 {
|
||
format!("{} (x{})", d.message, d.count)
|
||
} else {
|
||
d.message
|
||
}
|
||
})
|
||
.collect();
|
||
// AFTER the UI probe and BEFORE the method oracle, and both halves of that are load-bearing.
|
||
// After, because the probe leaves the addon fully driven — and this pass re-OPENS what the
|
||
// probe's second toggle closed, which is why it is a separate probe rather than a read at the
|
||
// end of that one. Before, because `unresolved_widget_methods` stands up one frame of every
|
||
// kind: measuring after it would charge sixteen widgets of the harness's own to the addon and
|
||
// score every single row as having drawn.
|
||
let (render, painted) = measure_render(&mut script, &baseline);
|
||
// ...and then USE what it drew. Same seam, same two reasons: after every other column so no
|
||
// number beside it can be perturbed by input this probe invented, and before the method oracle
|
||
// so the sixteen widgets that pass stands up can never become an addon's input targets.
|
||
//
|
||
// It consumes `render`'s own attribution rather than re-deriving it — "which pixels are this
|
||
// addon's" is one question with one answer, and two instruments computing it separately is how
|
||
// they start disagreeing about the same addon.
|
||
let used = measure_use(&mut script, &baseline, &painted);
|
||
// LAST, and the ordering is deliberate: standing up the probe widgets writes frames and one
|
||
// global into the VM, and this is the only point at which no addon code will ever observe
|
||
// them. Every other number above is therefore provably unperturbed by this list existing —
|
||
// which is what an instrument addition has to be able to claim (1225's measurement rule).
|
||
//
|
||
// ONE oracle call for both question sets and for the per-kind pass: it stood the whole probe
|
||
// set up twice for the same answer before, and the per-kind pass would have made that three.
|
||
let asked: BTreeSet<String> = wants
|
||
.wanted_methods
|
||
.union(&wants.tested_methods)
|
||
.cloned()
|
||
.collect();
|
||
let oracle = widget_method_kinds(&script, &asked);
|
||
let missing_methods = unresolved_from(&oracle, &wants.wanted_methods);
|
||
let optional_methods = unresolved_from(&oracle, &wants.tested_methods);
|
||
// The receiver-typed half. It reads `widget_kind` off the same VM, which is the point: the kind
|
||
// an addon's call actually lands on is the kind OUR object graph publishes, not the one the
|
||
// reference documents.
|
||
let (kind_missing_methods, ambiguous_methods) = per_kind_rows(&script, &oracle, &wants);
|
||
|
||
AddonReport {
|
||
name: name.to_string(),
|
||
interface: toc.interface_versions(),
|
||
loaded: !files.raised,
|
||
errors: files.errors,
|
||
absent_own_files: files.absent.own,
|
||
absent_foreign_files: files.absent.foreign,
|
||
missing_globals: wants.missing_globals,
|
||
missing_deps,
|
||
missing_templates,
|
||
missing_inherits,
|
||
missing_tables: wants.missing_tables,
|
||
missing_methods,
|
||
optional_methods,
|
||
kind_missing_methods,
|
||
ambiguous_methods,
|
||
session_errors,
|
||
probe_errors,
|
||
warnings,
|
||
render,
|
||
used,
|
||
}
|
||
}
|
||
|
||
/// **Which widget KINDS answer each wanted name** — the oracle every method table here is read off.
|
||
/// `None` means the oracle could not run at all.
|
||
///
|
||
/// **It asks the live `__index` dispatcher, one probe per widget kind, rather than consulting a
|
||
/// list of names.** The method tables live in the Lua registry behind a Rust `__index` and are not
|
||
/// enumerable from Lua at all, so the alternative was a hand-kept mirror of every
|
||
/// `REG_*_METHODS` table — a list that would go stale the first time a kind was added and would
|
||
/// then report a whole widget class as missing, or (worse) as present. Standing up one of each
|
||
/// kind and indexing it is the *same path the addon's own call takes*, which is the only oracle
|
||
/// that cannot disagree with what we ship.
|
||
///
|
||
/// **The answer is kept PER KIND, and that is the change decision 1228 asked for.** It used to be
|
||
/// collapsed to "did anything answer", which is why `MessageFrame:AddMessage` was unfindable while
|
||
/// three addons died on it: the ScrollingMessageFrame probe answered the name, so the name was not
|
||
/// missing, so there was no row anywhere in this report.
|
||
///
|
||
/// A kind `CreateFrame` refuses is skipped rather than fatal (`pcall`), so this survives a kind
|
||
/// being renamed. But **an oracle that finds no probes at all reports everything as missing**, not
|
||
/// nothing: a silent empty answer would read as "this addon calls no method we lack", which is the
|
||
/// one thing this instrument exists to stop being invisible. `None` is how that travels; every
|
||
/// reader of it ([`unresolved_from`], [`per_kind_rows`]) turns it into the loudest answer it can.
|
||
/// Pinned by `the_method_oracle_fails_loudly_when_it_cannot_probe` and
|
||
/// `the_per_kind_census_fails_loudly_when_it_cannot_probe`.
|
||
///
|
||
/// One call per addon serves all three tables. It used to be two (`wanted`, then `tested`), which
|
||
/// stood the whole probe set up twice for the same answer.
|
||
fn widget_method_kinds(script: &UiScript, wanted: &BTreeSet<String>) -> Option<MethodOracle> {
|
||
if wanted.is_empty() {
|
||
return Some(MethodOracle::default());
|
||
}
|
||
// Safe to interpolate unquoted: these names came out of the scanner's identifier reader, so
|
||
// they are letters, digits and underscores — a quote or a backslash cannot be in one.
|
||
let names = wanted
|
||
.iter()
|
||
.map(|n| format!("\"{n}\""))
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let kinds = PROBE_FRAME_KINDS
|
||
.iter()
|
||
.map(|k| format!("\"{k}\""))
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
let chunk = format!(
|
||
r#"
|
||
local kinds, probes = {{}}, {{}}
|
||
-- Every kind `frame_kind_from_str` accepts. A kind that is refused is simply skipped: this
|
||
-- list going stale must never turn into a wrong ANSWER, only into a narrower probe set.
|
||
for _, kind in ipairs({{{kinds}}}) do
|
||
local ok, f = pcall(CreateFrame, kind)
|
||
if ok and type(f) == "table" then
|
||
table.insert(kinds, kind)
|
||
table.insert(probes, f)
|
||
end
|
||
end
|
||
-- The two REGION leaves. They carry their own metatable (the "tag" table), so a
|
||
-- FontString's SetFont and a Texture's SetTexCoord are unreachable from any frame probe —
|
||
-- and region methods are a large slice of what the corpus calls.
|
||
if probes[1] then
|
||
local okt, tex = pcall(function() return probes[1]:CreateTexture() end)
|
||
if okt and type(tex) == "table" then
|
||
table.insert(kinds, "Texture") table.insert(probes, tex)
|
||
end
|
||
local okf, fs = pcall(function() return probes[1]:CreateFontString() end)
|
||
if okf and type(fs) == "table" then
|
||
table.insert(kinds, "FontString") table.insert(probes, fs)
|
||
end
|
||
end
|
||
if table.getn(probes) == 0 then error("no widget probes could be created") end
|
||
-- Row 1 is the ROSTER: which kinds actually stood up. Without it the caller has to infer
|
||
-- the probe count from the answers, and "how many kinds exist" then depends on whether
|
||
-- some name happened to be answered by all of them — a number that silently shrinks with
|
||
-- the question set.
|
||
local out = {{"*=" .. table.concat(kinds, ",")}}
|
||
for _, name in ipairs({{{names}}}) do
|
||
local have = ""
|
||
for i = 1, table.getn(probes) do
|
||
if type(probes[i][name]) == "function" then
|
||
if have == "" then have = kinds[i] else have = have .. "," .. kinds[i] end
|
||
end
|
||
end
|
||
table.insert(out, name .. "=" .. have)
|
||
end
|
||
return out
|
||
"#
|
||
);
|
||
let rows = script.eval::<Vec<String>>(&chunk).ok()?;
|
||
let mut oracle = MethodOracle::default();
|
||
for (name, have) in rows.iter().filter_map(|row| row.split_once('=')) {
|
||
let list: Vec<String> = have
|
||
.split(',')
|
||
.filter(|k| !k.is_empty())
|
||
.map(str::to_owned)
|
||
.collect();
|
||
if name == "*" {
|
||
oracle.probes = list;
|
||
} else {
|
||
oracle.answered.insert(name.to_string(), list);
|
||
}
|
||
}
|
||
Some(oracle)
|
||
}
|
||
|
||
/// What the live `__index` dispatcher answered, per kind — [`widget_method_kinds`]'s result.
|
||
#[derive(Default)]
|
||
struct MethodOracle {
|
||
/// The kinds that actually stood up, in probe order. Carried explicitly so "how many kinds are
|
||
/// there" never has to be inferred from the answers.
|
||
probes: Vec<String>,
|
||
/// Wanted name → the kinds that answer it (empty = no widget we ship has it).
|
||
answered: BTreeMap<String, Vec<String>>,
|
||
}
|
||
|
||
/// The names in `wanted` that **no** kind answers — [`AddonReport::missing_methods`]'s rule,
|
||
/// unchanged since 1227 so its numbers stay comparable (1209).
|
||
///
|
||
/// "Found nothing" must stay distinguishable from "ran nothing": an oracle that could not run
|
||
/// reports the whole wanted set, which is loud and wrong in the direction that gets noticed, rather
|
||
/// than empty and wrong in the direction that does not. A name the oracle somehow did not answer
|
||
/// for falls the same way.
|
||
fn unresolved_from(oracle: &Option<MethodOracle>, wanted: &BTreeSet<String>) -> Vec<String> {
|
||
let Some(oracle) = oracle else {
|
||
return wanted.iter().cloned().collect();
|
||
};
|
||
wanted
|
||
.iter()
|
||
.filter(|n| oracle.answered.get(*n).is_none_or(Vec::is_empty))
|
||
.cloned()
|
||
.collect()
|
||
}
|
||
|
||
/// The two per-kind lists: what the addon calls **on a kind that cannot answer it**, and what it
|
||
/// calls on a receiver nothing could type where the answer *depends* on the kind.
|
||
///
|
||
/// This is 1228's bound, closed as far as a static scan honestly can — and the residue is printed
|
||
/// rather than swallowed. Three outcomes for a wanted name:
|
||
///
|
||
/// | the call site | the verdict |
|
||
/// |---|---|
|
||
/// | receiver typed, the kind answers | silent — this is the ordinary case |
|
||
/// | receiver typed, the kind does not | `Kind:Name` — a real blocker, whoever else answers it |
|
||
/// | receiver untypable, some kinds answer and some do not | `Name (only on …)` — an upper bound |
|
||
///
|
||
/// **It over-reports and never under-reports, deliberately.** The ambiguous list counts a name once
|
||
/// per addon that calls it on *any* untypable receiver, which is a call that might be perfectly
|
||
/// fine — `self:SetTexture()` inside a texture wrapper class is not a bug. The alternative is to
|
||
/// swallow it as present because *something* answers it, which is precisely how `AddMessage` scored
|
||
/// zero for a fortnight while three addons died on it. Its size is therefore a reading of the
|
||
/// **attributor's** reach, not of ours: every shape [`scan_receivers`] learns to type moves rows out
|
||
/// of it.
|
||
///
|
||
/// The blocking/feature-tested split is honoured here as it is above (a guarded call is not a
|
||
/// blocker), and so is the definition subtraction — `f.Update = function … f:Update()` on a frame
|
||
/// the addon made is the corpus's most common idiom, and its receiver is genuinely a widget, so
|
||
/// without the subtraction every one of those would be a `Frame:Update` row.
|
||
fn per_kind_rows(
|
||
script: &UiScript,
|
||
oracle: &Option<MethodOracle>,
|
||
wants: &Wants,
|
||
) -> (Vec<String>, Vec<String>) {
|
||
let empty = Vec::new();
|
||
let answered = |name: &str| -> &[String] {
|
||
oracle
|
||
.as_ref()
|
||
.and_then(|o| o.answered.get(name))
|
||
.unwrap_or(&empty)
|
||
.as_slice()
|
||
};
|
||
// Every typed call site, as `name → the kinds it was called on`.
|
||
let mut typed: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
|
||
for (kind, name) in &wants.kind_calls {
|
||
if wants.wanted_methods.contains(name) {
|
||
typed.entry(name).or_default().insert(kind);
|
||
}
|
||
}
|
||
// ...plus the published names, resolved against the live arena rather than guessed.
|
||
let mut untyped_by_name: BTreeSet<&str> = BTreeSet::new();
|
||
for (receiver, name) in &wants.global_calls {
|
||
if !wants.wanted_methods.contains(name) {
|
||
continue;
|
||
}
|
||
match script.widget_kind(receiver) {
|
||
Some(kind) => {
|
||
typed.entry(name).or_default().insert(kind);
|
||
}
|
||
// Nothing publishes that name, so the receiver is a runtime value this scan cannot
|
||
// follow — the same standing as `self:Foo()`.
|
||
None => {
|
||
untyped_by_name.insert(name);
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut kind_missing: Vec<String> = Vec::new();
|
||
for (name, called_on) in &typed {
|
||
let have = answered(name);
|
||
for kind in called_on {
|
||
if !have.iter().any(|k| k == kind) {
|
||
kind_missing.push(format!("{kind}:{name} ({})", elsewhere(have)));
|
||
}
|
||
}
|
||
}
|
||
|
||
// A name is ambiguous only if it has an UNTYPED call site in this addon (a name every one of
|
||
// whose call sites was typed has already been answered above) and the answer actually depends
|
||
// on the kind. A name no kind answers is not ambiguous — it is missing, and it is already in
|
||
// `missing_methods`.
|
||
let probes: &[String] = oracle.as_ref().map_or(&[], |o| o.probes.as_slice());
|
||
let mut ambiguous: Vec<String> = Vec::new();
|
||
for name in wants
|
||
.wanted_methods
|
||
.iter()
|
||
.filter(|n| wants.loose_methods.contains(*n) || untyped_by_name.contains(n.as_str()))
|
||
{
|
||
let have = answered(name);
|
||
if !have.is_empty() && have.len() < probes.len() {
|
||
ambiguous.push(format!("{name} ({})", only_on(have, probes)));
|
||
}
|
||
}
|
||
kind_missing.sort();
|
||
(kind_missing, ambiguous)
|
||
}
|
||
|
||
/// `(on ScrollingMessageFrame)` / `(on no kind)` — who *does* answer a name the called kind does not.
|
||
///
|
||
/// The distinction is the whole point of the row: "on no kind" is a verb to write, "on
|
||
/// ScrollingMessageFrame" is a verb we already wrote and forgot to wire to its sibling, and those
|
||
/// are different jobs.
|
||
fn elsewhere(have: &[String]) -> String {
|
||
match have.len() {
|
||
0 => "on no kind".to_string(),
|
||
1..=4 => format!("on {}", have.join(", ")),
|
||
n => format!("on {} +{} more", have[..3].join(", "), n - 3),
|
||
}
|
||
}
|
||
|
||
/// `(only on Texture)` / `(missing on Texture, FontString)` — whichever side of the split is
|
||
/// shorter, so a reader can triage the row at a glance. Deterministic, because it is part of a
|
||
/// ranking key.
|
||
fn only_on(have: &[String], probes: &[String]) -> String {
|
||
if have.len() * 2 <= probes.len() {
|
||
// Never truncated, unlike the other side: the length of this list IS the row's rank
|
||
// ([`ambiguous_method_demand`] counts its separators), and it is bounded by construction —
|
||
// this branch only runs when at most half the kinds answer.
|
||
format!("only on {}", have.join(", "))
|
||
} else {
|
||
let lack: Vec<&str> = probes
|
||
.iter()
|
||
.map(String::as_str)
|
||
.filter(|k| !have.iter().any(|h| h == k))
|
||
.collect();
|
||
match lack.len() {
|
||
0..=4 => format!("missing on {}", lack.join(", ")),
|
||
n => format!("missing on {} +{} more", lack[..3].join(", "), n - 3),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Drive the client's own session-start sequence over a loaded addon and report what its HANDLERS
|
||
/// raised — the errors no other number here can see.
|
||
///
|
||
/// The order is the reference's, byte-verified inside `UI_Init 0x48fbf0` and already pinned by
|
||
/// `ui_script::addons`' own test: every addon's `ADDON_LOADED`, then `VARIABLES_LOADED`, then
|
||
/// `PLAYER_LOGIN`; `PLAYER_ENTERING_WORLD` follows in the cascade. Then a few ticks, because a
|
||
/// great deal of addon code runs from `OnUpdate` or from something scheduled on the first one.
|
||
///
|
||
/// **Only the surveyed addon's `ADDON_LOADED` is fired here** (2166). The client fires each one
|
||
/// inside that addon's own `AddOn_Load` (`0x51f5ad`), not in a batch afterwards, so every
|
||
/// dependency's event has already happened in [`load_dependencies`] — before the surveyed addon's
|
||
/// first line ran, which is what the real client presents to it.
|
||
///
|
||
/// **Deliberately not `ui_script::finish_ui_load`**, which is the production path: that also runs
|
||
/// `load_saved_variables`, which reads the machine's real `BENILLA_HOME`. A survey must not depend
|
||
/// on — or write to — the director's own saved variables, so the events are fired directly.
|
||
///
|
||
/// **Ten ticks of 0.1 s, and the bound is the point.** An addon with a busy `OnUpdate` would
|
||
/// otherwise run for as long as we let it, and 218 VMs multiply whatever that is. One simulated
|
||
/// second reaches the common `ScheduleEvent(..., 0)`/`(..., 0.05)` shapes and Ace's own one-second
|
||
/// `AceEvent_FullyInitialized` timer; it does not reach a ten-second self-heal, and that
|
||
/// under-report is stated rather than hidden.
|
||
fn drive_session_start(
|
||
script: &mut UiScript,
|
||
name: &str,
|
||
installed: &BTreeSet<String>,
|
||
) -> Vec<String> {
|
||
// **The dependencies' `ADDON_LOADED`s are already behind us** — each fired beside its own
|
||
// addon's files, at `0x51f5ad`'s position, inside `load_dependencies` (2166). What is left of
|
||
// the client's session start is the SURVEYED addon's own event and the three that follow it
|
||
// once every addon has loaded: `VARIABLES_LOADED`, `PLAYER_LOGIN`, `PLAYER_ENTERING_WORLD`.
|
||
//
|
||
// A dependency's initialiser is almost always gated on its own name arriving in `arg1`:
|
||
//
|
||
// -- Atlas.lua:326
|
||
// if (event == "ADDON_LOADED" and arg1 == "Atlas") then Atlas_Init(); end
|
||
//
|
||
// `Atlas_Init` is what assigns `AtlasOptions` (l.199). Firing only the SURVEYED addon's name
|
||
// meant that guard never passed for a dependency, so `FuBar_AtlasFu` met an Atlas that had
|
||
// loaded its files and never initialised — and died at `AtlasButton.lua:30` on a nil
|
||
// `AtlasOptions`, a fault the survey then recorded against FuBar_AtlasFu. That was fixed by
|
||
// firing every dependency's event here; 2166 moved each one to where the client fires it.
|
||
//
|
||
// **Attribution is by the RAISING CHUNK, not by which event window the raise fell in**
|
||
// (decision 1226 — recorded when this was still window-based, fixed since). The window proxy
|
||
// broke on the shape it was written for: AceAddon drains its ENTIRE `nextAddon` queue on any
|
||
// `ADDON_LOADED` it sees (`AceAddon-2.0.lua:104-105`) and calls each consumer's
|
||
// `OnInitialize` there (`:230`). So the SURVEYED addon's own code can run inside a
|
||
// DEPENDENCY's window, and a mark that only covered its own window charged those raises to
|
||
// nobody — the whole FuBar/Ace family was silently OVER-reported as surviving.
|
||
//
|
||
// The mark opens BEFORE the surveyed addon's own event for exactly that reason, and the
|
||
// dependency exemption is applied afterwards by asking WHOSE FILE raised. Chunk names have
|
||
// been truthful since 1217 (`@Interface\AddOns\<Folder>\<File>`), which is what makes the
|
||
// honest key available at all.
|
||
let before = script.errors().len();
|
||
script.fire_event(
|
||
"ADDON_LOADED",
|
||
vec![benilla_ui::script::ScriptValue::Str(name.to_string())],
|
||
);
|
||
for event in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
|
||
script.fire_event(event, Vec::new());
|
||
}
|
||
for _ in 0..10 {
|
||
script.tick(0.1);
|
||
}
|
||
let raised = script.errors().split_off(before);
|
||
raised
|
||
.into_iter()
|
||
.filter(|e| !raised_inside_another_addons_own_file(e, name, installed))
|
||
.collect()
|
||
}
|
||
|
||
/// Does this raise belong to **another installed addon's** file rather than the surveyed addon's?
|
||
///
|
||
/// The rule `load_dependencies` already states for load errors, enforceable for session errors
|
||
/// since 1226: *a library that fails is its own row; blaming its consumers would count one fault
|
||
/// once per addon that embeds it.* Only the FIRST line is consulted — that is the raise site; the
|
||
/// frames below it are the call path, and a consumer calling into a library that then raises is
|
||
/// still the library's fault, exactly as it is at load time.
|
||
///
|
||
/// **The exemption was "a DECLARED DEPENDENCY's file" and that was too narrow** (decision 2107).
|
||
/// Once the harness's VMs got a real AddOns root, `LoadAddOn` began doing what it does in a real
|
||
/// session, and the corpus's largest family drives it deliberately: `FuBar.lua:1034`'s
|
||
/// `LoadLoadOnDemandPlugins` demand-loads **every** installed `FuBar_*` plugin the moment any one
|
||
/// of them seats. So surveying `FuBar_MoneyFu` runs fifty *siblings*' file scope, and
|
||
/// `FuBar_BattlegroundFu`'s embedded `Glory-2.0` raises `Glory-2.0 requires Deformat-2.0` —
|
||
/// a library `FuBar_BattlegroundFu` does not ship and that a dozen of its siblings do, which is
|
||
/// this module's own stated one-VM-per-addon limitation and not a gap of ours. It is not
|
||
/// `FuBar_MoneyFu`'s row either: it is `FuBar_BattlegroundFu`'s, where the survey already counts it
|
||
/// once. **49 of the corpus's 219 addons were failing on somebody else's file.**
|
||
///
|
||
/// A demand-loaded sibling is the same category as a declared dependency — code the surveyed addon
|
||
/// did not write — so the test is now "an installed addon that is not this one", which subsumes
|
||
/// `deps` (every dependency is installed) and adds the siblings.
|
||
///
|
||
/// **Conservative on purpose, unchanged.** A chunk that names no addon folder at all — our own
|
||
/// FrameXML, an `[string "Frame:OnEvent"]` handler — is KEPT, because the surveyed addon is what
|
||
/// drove it. And the surveyed addon's own folder is tested FIRST, so a raise inside its own
|
||
/// `Libs\Ace\…` is never handed to an installed addon that happens to be called `Ace`.
|
||
fn raised_inside_another_addons_own_file(
|
||
err: &str,
|
||
name: &str,
|
||
installed: &BTreeSet<String>,
|
||
) -> bool {
|
||
let Some(first) = err.lines().next().map(str::to_ascii_lowercase) else {
|
||
return false;
|
||
};
|
||
let owns = |line: &str, folder: &str| {
|
||
line.contains(&format!("\\{folder}\\"))
|
||
|| line.contains(&format!("{folder}/"))
|
||
|| line.starts_with(&format!("{folder}\\"))
|
||
};
|
||
let mine = name.to_ascii_lowercase();
|
||
// The surveyed addon's own folder always wins, even when another addon's name is a substring
|
||
// of a path inside it.
|
||
if owns(&first, &mine) {
|
||
return false;
|
||
}
|
||
if installed.iter().any(|d| owns(&first, d)) {
|
||
return true;
|
||
}
|
||
raised_inside_a_demand_load(err, &mine)
|
||
}
|
||
|
||
/// The raise site names **no file at all** — did it happen inside a `LoadAddOn` this addon made?
|
||
///
|
||
/// An inline `<OnEvent>`/`<OnLoad>` body is compiled under the FRAME's name
|
||
/// (`[string "AuctioneerFrame:OnEvent"]`), not the file's, so the folder test above cannot see
|
||
/// whose code it is — and 1226's conservative half then KEEPS it, charged to whoever drove it.
|
||
/// That is right for a handler the surveyed addon's own events reached, and wrong for one reached
|
||
/// through a demand load, which is somebody else's addon loading its own frames.
|
||
///
|
||
/// The traceback says which. `Stubby.lua:581`'s `inspectAddOn` calls `LoadAddOn` on every addon it
|
||
/// finds, Auctioneer's `AuctioneerFrame:OnEvent` raises inside that call, and the frame between
|
||
/// them is `[C]: in function 'LoadAddOn'`. So: walk the traceback from the raise site down, and if
|
||
/// a `LoadAddOn` frame comes **before** any frame naming the surveyed addon's own folder, the code
|
||
/// that raised belongs to the addon being loaded. It cost six addons their row — Stubby and the
|
||
/// five that pull it in — for one raise in Auctioneer, which has its own row already.
|
||
///
|
||
/// **The false negative, stated:** the surveyed addon's OWN inline handler, fired by an event that
|
||
/// happened to be dispatched inside a demand load it made, is exempted too. That is the same
|
||
/// direction the dependency exemption has always erred in, and the alternative — charging every
|
||
/// addon that calls `LoadAddOn` for its neighbours' code — is what this replaces.
|
||
fn raised_inside_a_demand_load(err: &str, mine: &str) -> bool {
|
||
for line in err.lines().skip(1).map(str::to_ascii_lowercase) {
|
||
if line.contains(&format!("\\{mine}\\")) || line.contains(&format!("{mine}/")) {
|
||
return false; // our own frame is above the boundary: our raise
|
||
}
|
||
if line.contains("in function 'loadaddon'") {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Invoke the reference's own UI entry points, so an addon's OVERRIDES actually execute.
|
||
///
|
||
/// **The blind spot this closes was found by the director, not by any number here.** They installed
|
||
/// Bagnon, saw it listed in the AddOns window, and still got the stock bags: it had replaced
|
||
/// `ToggleBackpack` and nothing in the client ever called that name. Every column in this survey
|
||
/// said the addon was fine, because the survey loads addons and fires events and **never clicks
|
||
/// anything**. An override that is installed and never invoked is indistinguishable here from one
|
||
/// that works.
|
||
///
|
||
/// So this drives a small set of entry points that real corpus addons are known to replace:
|
||
///
|
||
/// | entry point | who replaces it |
|
||
/// |---|---|
|
||
/// | `ToggleBackpack` | Bagnon (`Bagnon_Core/core/Overrides.lua`) |
|
||
/// | `UnitFrame_OnEnter`/`OnLeave` | TipBuddy (`TipBuddy.lua:2770`) |
|
||
/// | `ActionButton_Update` | zBar, zBarEx, CT_BarMod |
|
||
///
|
||
/// **Bounded on purpose, like the ten ticks above.** These three families are the ones with a
|
||
/// measured corpus hooker; driving the whole UI would be a different program, and an unbounded
|
||
/// probe over 218 VMs is how a survey stops finishing. Each call is guarded on the global existing
|
||
/// and wrapped so a raise is RECORDED rather than aborting the probe — the error is the finding.
|
||
///
|
||
/// What it still cannot see: whether the override produced the right *result*. It proves the body
|
||
/// ran, not that the bags look right. That remains the director's eye.
|
||
fn drive_ui_probe(script: &mut UiScript) -> Vec<String> {
|
||
let before = script.errors().len();
|
||
// `this` is set explicitly for the `this`-shaped entry points, because the reference's contract
|
||
// reads it and an addon's replacement will too.
|
||
let _ = script.run(
|
||
r#"
|
||
-- The pcall CAPTURES the error; it does not discard it. Written the obvious way first
|
||
-- (`pcall(fn)` alone) this probe could never report anything at all — it drove the
|
||
-- overrides correctly and swallowed every raise, so the corpus showed a spotless column
|
||
-- forever. `the_ui_probe_records_what_an_override_raises` is the test that caught it, and
|
||
-- it exists because a silent instrument is worse than no instrument.
|
||
BENILLA_PROBE_ERRORS = {}
|
||
local function try(fn)
|
||
if type(fn) == "function" then
|
||
local ok, err = pcall(fn)
|
||
if not ok then
|
||
table.insert(BENILLA_PROBE_ERRORS, tostring(err))
|
||
end
|
||
end
|
||
end
|
||
-- Open, then close: a toggle left open would leak state into nothing here, but the second
|
||
-- call is what exercises an override's close path.
|
||
try(ToggleBackpack); try(ToggleBackpack)
|
||
if PlayerFrame then
|
||
this = PlayerFrame
|
||
try(UnitFrame_OnEnter); try(UnitFrame_OnLeave)
|
||
end
|
||
if ActionButton1 then
|
||
this = ActionButton1
|
||
try(ActionButton_Update)
|
||
end
|
||
-- HOVER. Nothing above puts a tooltip on screen, and a whole class of addon only runs
|
||
-- there: hooks on GameTooltip's OnShow/OnHide, the FrameXML globals an addon replaces
|
||
-- (GameTooltip_SetDefaultAnchor), and the ones that SCRAPE the line regions
|
||
-- (`GameTooltipTextLeft1:GetText()`). Decision 1220 fixed a raise squarely in that class
|
||
-- and this column could not see it, which is the reason this block exists.
|
||
--
|
||
-- Each call is guarded on the global being a FUNCTION, not merely non-nil: an unguarded
|
||
-- call to a missing FrameXML global would land in every addon's probe column as that
|
||
-- addon's fault, which is exactly the mis-attribution 1209 was written about.
|
||
if GameTooltip and UIParent then
|
||
if type(GameTooltip_SetDefaultAnchor) == "function" then
|
||
try(function() GameTooltip_SetDefaultAnchor(GameTooltip, UIParent) end)
|
||
end
|
||
try(function()
|
||
GameTooltip:SetOwner(UIParent, "ANCHOR_NONE")
|
||
GameTooltip:SetText("benilla probe")
|
||
GameTooltip:Show()
|
||
end)
|
||
try(function() GameTooltip:Hide() end)
|
||
end
|
||
this = nil
|
||
"#,
|
||
);
|
||
let mut out = script.errors().split_off(before);
|
||
// The captured raises, in the order they happened.
|
||
if let Ok(n) = script.eval::<i64>("return table.getn(BENILLA_PROBE_ERRORS)") {
|
||
for i in 1..=n {
|
||
if let Ok(e) = script.eval::<String>(&format!("return BENILLA_PROBE_ERRORS[{i}]")) {
|
||
out.push(e);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Load an addon's declared dependencies into the VM, depth-first, each at most once.
|
||
///
|
||
/// The reference's own order (`AddOn_Load 0x51f240`, 1191 §2): **OptionalDeps first, failures
|
||
/// ignored; RequiredDeps next, a failure aborts the dependent's load; then its own files.** Both
|
||
/// halves are walked, in that order; the harness does not reproduce the *abort*, only the **state**
|
||
/// a dependent's file scope actually meets, and it reports a missing required dependency separately
|
||
/// ([`AddonReport::missing_deps`]) while an absent optional one is silent, as the client's is.
|
||
///
|
||
/// This doc claimed the same thing from 1193 onward while the code read `dependencies()` only —
|
||
/// the third doc-vs-code lie this arc has found in its own instruments (the others: a module doc
|
||
/// claiming it printed which mode a run was in, and a comment claiming the leading-capital filter
|
||
/// covered Lua locals). **A sentence describing behaviour is a claim, and an unverified claim in a
|
||
/// comment is worse than none, because it stops the next reader checking.**
|
||
///
|
||
/// Errors inside a dependency are **not** attributed to the dependent. A library that fails is its
|
||
/// own row in this survey; blaming its consumers would count one fault N times, which is the
|
||
/// mistake the one-VM-per-addon rule exists to prevent.
|
||
fn load_dependencies(
|
||
script: &mut UiScript,
|
||
root: &Path,
|
||
toc: &Toc,
|
||
installed: &BTreeSet<String>,
|
||
seen: &mut BTreeSet<String>,
|
||
loaded: &mut Vec<LoadedDep>,
|
||
) {
|
||
// OPTIONAL first, then required — the reference's own order, and the half this walk was
|
||
// missing. The doc above has claimed since 1193 that the two are "folded together"; only
|
||
// `dependencies()` was ever read, so `## OptionalDeps: FuBar, Ace2` did nothing and the survey
|
||
// met a state the real client never produces. That is the exact bug `load_dependencies` exists
|
||
// to prevent, in the half nobody checked. **130 corpus addons declare optional deps**, and it
|
||
// is how the whole FuBar family gets AceLibrary: `FuBar_BagFu`'s `.toc` lists
|
||
// `FuBarPlugin-2.0.lua` BEFORE `AceLibrary.lua`, and FuBarPlugin raises
|
||
// "FuBarPlugin-2.0 requires AceLibrary." unless the `Ace2` addon loaded first.
|
||
let deps: Vec<&str> = toc
|
||
.optional_dependencies()
|
||
.into_iter()
|
||
.chain(toc.dependencies())
|
||
.collect();
|
||
for dep in deps {
|
||
let key = dep.to_ascii_lowercase();
|
||
if !installed.contains(&key) || !seen.insert(key) {
|
||
continue; // not installed (already reported), or already in this VM
|
||
}
|
||
let Some(dep_toc) = manifest_path(root, dep)
|
||
.and_then(|p| std::fs::read(p).ok())
|
||
.map(|b| Toc::parse(&benilla_ui::source::decode(&b)))
|
||
else {
|
||
continue;
|
||
};
|
||
load_dependencies(script, root, &dep_toc, installed, seen, loaded);
|
||
let files = load_addon_files(script, root, dep, &dep_toc);
|
||
// **`ADDON_LOADED` fires HERE, at the reference's own position** — not later, with every
|
||
// other one, which is what this harness did until 2166. `AddOn_Load 0x51f240` emits, per
|
||
// addon: its dependencies → its own `.toc` files (`0x51f3fa`) → `Bindings.xml` → the two
|
||
// SavedVariables chunks → **`ADDON_LOADED` (event 429) at `0x51f5ad`** — and only then
|
||
// returns to load the next addon. So a dependency's event lands BEFORE its dependent's
|
||
// first line runs, and the loaded flag is set before the event (`[rec+0x18]=1` at
|
||
// `0x51f313`, the loop preamble) so a handler asking `IsAddOnLoaded` gets the truth.
|
||
//
|
||
// Deferring them all to `drive_session_start` put the corpus's largest family in a state
|
||
// the client never produces. AceAddon-2.0 drains its **whole** `nextAddon` queue on the
|
||
// first `ADDON_LOADED` it sees, whoever it names (`AceAddon-2.0.lua:104-105`), and calls
|
||
// each consumer's `OnInitialize` there (`:230`) — so with the events deferred, every Ace2
|
||
// addon in the VM initialised under its FIRST DEPENDENCY's name, after every file in the
|
||
// chain had already run. ~87 corpus addons are Ace2.
|
||
//
|
||
// Fired OUTSIDE the surveyed addon's error mark, deliberately and for the same reason the
|
||
// dependency's load errors are: a library that raises in its own initialiser is its own
|
||
// row in this survey, where it is the surveyed addon and this same event fires inside the
|
||
// mark. Charging it here would count one fault once per addon that embeds the library.
|
||
script.mark_addon_loaded(dep);
|
||
script.fire_event(
|
||
"ADDON_LOADED",
|
||
vec![benilla_ui::script::ScriptValue::Str(dep.to_string())],
|
||
);
|
||
loaded.push(LoadedDep {
|
||
name: dep.to_string(),
|
||
files,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// One dependency the walk pulled in, and what its own files did.
|
||
///
|
||
/// The name alone was enough while every consumer only wanted the load ORDER. The shared-VM
|
||
/// control ([`together`]) wants the failures too, and it cannot recover them any other way: a
|
||
/// library folder loads exactly once in that VM, under whichever dependent reached it first, so
|
||
/// it never gets a turn of its own to be measured in.
|
||
struct LoadedDep {
|
||
name: String,
|
||
files: FileLoad,
|
||
}
|
||
|
||
/// Template names the addon passes to `CreateFrame` that the VM cannot resolve.
|
||
///
|
||
/// Scanned rather than traced, for `missing_globals`' reason: the calls overwhelmingly happen in
|
||
/// handlers a load-time survey never fires. It is not comment-stripped: a `CreateFrame` inside a
|
||
/// comment counts, and that over-report is the cheaper error of the two.
|
||
///
|
||
/// **A string literal in the fourth argument, OR a local bound to one in the same file.** The
|
||
/// literal-only form was the honest under-report this doc used to name — and then a decision was
|
||
/// made on the number it produced. `Interface\FrameXML\ItemButtonTemplate.xml` declined to build
|
||
/// `ItemButtonTemplate` citing "the harness's template demand ranks it at zero on both axes"; the
|
||
/// zero was real, and pfUI wanted `ContainerFrameItemButtonTemplate` (which inherits it) the whole
|
||
/// time, through
|
||
///
|
||
/// ```lua
|
||
/// local tpl = "ContainerFrameItemButtonTemplate"
|
||
/// if bag == -1 then tpl = "BankItemButtonGenericTemplate" end
|
||
/// CreateFrame("Button", "pfBag" .. bag .. "item" .. slot, pfUI.bags[bag], tpl)
|
||
/// ```
|
||
///
|
||
/// The instrument was not lying — it said what it could not see, right here. But a bound that is
|
||
/// stated at the function and forgotten at the call site is only half a guard, so the cheap half
|
||
/// of the gap is closed: one pass collects `x = "literal"` bindings per file, and a bare
|
||
/// identifier in the fourth argument resolves through it.
|
||
///
|
||
/// Still invisible, and still stated: a name built by concatenation, one passed in as a function
|
||
/// argument, one read from a table, and one bound in another file. Those are real and this does
|
||
/// not pretend otherwise — what it fixes is the single commonest shape, measured.
|
||
fn missing_templates(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Vec<String> {
|
||
let mut wanted: BTreeSet<String> = BTreeSet::new();
|
||
for path in source_files(root, name, toc) {
|
||
let Some(text) = read_text(root, &path) else {
|
||
continue;
|
||
};
|
||
// `ident = "literal"` bindings in this file, for the fourth-argument lookup below. A name
|
||
// rebound to several literals keeps ALL of them (pfUI's `tpl` is two), because the census
|
||
// asks "could this call want that template", and both branches genuinely can.
|
||
let mut bound: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||
for (i, _) in text.match_indices('=') {
|
||
// Not `==`, `~=`, `<=`, `>=`.
|
||
if text[i + 1..].starts_with('=')
|
||
|| text[..i].ends_with(['=', '~', '<', '>'])
|
||
|| text[i + 1..].trim_start().is_empty()
|
||
{
|
||
continue;
|
||
}
|
||
let lhs = text[..i].trim_end();
|
||
let lhs = lhs.rsplit(['\n', ';', ' ', '\t']).next().unwrap_or("");
|
||
if !is_ident(lhs) {
|
||
continue;
|
||
}
|
||
let rhs = text[i + 1..].trim_start();
|
||
let Some(q) = rhs.chars().next().filter(|c| *c == '"' || *c == '\'') else {
|
||
continue;
|
||
};
|
||
let Some(end) = rhs[1..].find(q) else {
|
||
continue;
|
||
};
|
||
let lit = &rhs[1..end + 1];
|
||
// **Disqualify on an OPERATOR, not on a terminator.** The defect this closes is a
|
||
// fragment: `local built = "A" .. "B"` binding `built` to `"A"` — a name nobody asked
|
||
// for entering the demand ranking, which is not an under-report but the fiction this
|
||
// table has opened with three times (1210/1218/1227).
|
||
//
|
||
// Written first as "the tail must be a statement end", which is the wrong shape: Lua's
|
||
// terminators are open-ended (`end`, `then`, `else`, `until`, …) and chasing that list
|
||
// is endless — `tpl = "X" end` was missed for exactly that reason. What actually makes
|
||
// a literal part of a larger expression is a binary operator after it, and on a STRING
|
||
// in Lua that is `..` almost exclusively. So the test names the operators and lets
|
||
// everything else through, which also keeps this scanner's standing posture: over-
|
||
// report rather than under-report, the cheaper error of the two.
|
||
let tail = rhs[end + 2..].trim_start_matches([' ', '\t']);
|
||
let part_of_expression = tail.starts_with("..")
|
||
|| tail.starts_with(['+', '-', '*', '/', '%', '^', '<', '>'])
|
||
|| tail.starts_with("==")
|
||
|| tail.starts_with("~=")
|
||
|| tail.starts_with("and ")
|
||
|| tail.starts_with("or ");
|
||
let whole = !part_of_expression;
|
||
if !lit.is_empty() && whole {
|
||
bound.entry(lhs).or_default().push(lit);
|
||
}
|
||
}
|
||
for (i, _) in text.match_indices("CreateFrame") {
|
||
let rest = &text[i + "CreateFrame".len()..];
|
||
let Some(open) = rest.find('(') else { continue };
|
||
let Some(close) = rest[open..].find(')') else {
|
||
continue;
|
||
};
|
||
// Split the ARGUMENT LIST at depth 0 only. A plain `split(',')` cuts inside nested
|
||
// calls and strings, and it produced a phantom: AceGUI's
|
||
//
|
||
// CreateFrame("ScrollFrame", format("%s@%s@%s", Type, "ScrollFrame", …), backdrop, …)
|
||
//
|
||
// has its 4th comma-separated chunk INSIDE `format(...)`, so the census reported a
|
||
// missing template literally named `ScrollFrame` — which is a frame KIND, not a
|
||
// template, and named nothing that was ever missing. One of four rows in that table was
|
||
// fiction (1210/1218/1227, the fourth time a ranking here has opened with one).
|
||
let args = &rest[open + 1..open + close];
|
||
let mut depth = 0i32;
|
||
let mut quote: Option<char> = None;
|
||
let mut fields: Vec<&str> = Vec::new();
|
||
let mut start = 0usize;
|
||
for (bi, c) in args.char_indices() {
|
||
match (quote, c) {
|
||
(Some(q), c) if c == q => quote = None,
|
||
(Some(_), _) => {}
|
||
(None, '"' | '\'') => quote = Some(c),
|
||
(None, '(' | '[' | '{') => depth += 1,
|
||
(None, ')' | ']' | '}') => depth -= 1,
|
||
(None, ',') if depth == 0 => {
|
||
fields.push(&args[start..bi]);
|
||
start = bi + 1;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
fields.push(&args[start..]);
|
||
let Some(fourth) = fields.get(3) else {
|
||
continue;
|
||
};
|
||
let f = fourth.trim();
|
||
// A quoted literal, either quote style, taken WHOLE. This used to split the literal on
|
||
// commas, on the belief that the fourth argument is a list; it is not — 1.12 looks up
|
||
// the string it was handed, once.
|
||
let lit = f
|
||
.strip_prefix('"')
|
||
.and_then(|s| s.strip_suffix('"'))
|
||
.or_else(|| f.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')));
|
||
if let Some(lit) = lit.filter(|s| !s.is_empty()) {
|
||
wanted.insert(lit.to_string());
|
||
} else if let Some(lits) = bound.get(f) {
|
||
// A bare identifier bound to a literal in this file — pfUI's `tpl`.
|
||
wanted.extend(lits.iter().map(|s| (*s).to_string()));
|
||
}
|
||
}
|
||
}
|
||
wanted
|
||
.into_iter()
|
||
.filter(|name| !script.has_framexml_template(name))
|
||
.collect()
|
||
}
|
||
|
||
/// Template names the addon names in an XML `inherits=` that the VM cannot resolve.
|
||
///
|
||
/// [`missing_templates`]'s twin over the *other* way a template is asked for, and the one that
|
||
/// turned out to carry the weight (see [`AddonReport::missing_inherits`]). Same discipline as
|
||
/// every scanner here: a plain attribute read, not comment-stripped, over the addon's XML only.
|
||
///
|
||
/// Two deliberate rules, both of which the CreateFrame scanner does not need:
|
||
///
|
||
/// - **A name registered as a FONT resolves.** `inherits=` is one attribute over two namespaces,
|
||
/// and `<FontString inherits="GameFontNormal">` is the single most common use of it in any
|
||
/// corpus. Asking only the template registry would bury the real answer under font names.
|
||
/// - **`virtual="true"` elements the addon declares itself are already in the registry**, because
|
||
/// this runs after its files have loaded — the same ordering `missing_templates` relies on.
|
||
fn missing_inherits(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> Vec<String> {
|
||
let mut wanted: BTreeSet<String> = BTreeSet::new();
|
||
for path in source_files(root, name, toc) {
|
||
if !path.to_ascii_lowercase().ends_with(".xml") {
|
||
continue;
|
||
}
|
||
let Some(text) = read_text(root, &path) else {
|
||
continue;
|
||
};
|
||
for (i, _) in text.match_indices("inherits=") {
|
||
let rest = &text[i + "inherits=".len()..];
|
||
let quote = match rest.chars().next() {
|
||
Some(q @ ('"' | '\'')) => q,
|
||
_ => continue,
|
||
};
|
||
let Some(end) = rest[1..].find(quote) else {
|
||
continue;
|
||
};
|
||
// ONE name, verbatim — the attribute value is what the loader looks up, commas and
|
||
// spaces included. Splitting here made the census ask about names the loader never
|
||
// asks for: a comma list would be reported as two missing templates when the real
|
||
// lookup misses once, on the whole string.
|
||
wanted.extend(
|
||
[rest[1..1 + end].to_string()]
|
||
.into_iter()
|
||
.filter(|s| !s.is_empty()),
|
||
);
|
||
}
|
||
}
|
||
wanted
|
||
.into_iter()
|
||
.filter(|n| !script.has_framexml_template(n) && !script.has_font_object(n))
|
||
.collect()
|
||
}
|
||
|
||
/// Every source file an addon reaches — its manifest entries plus the `<Script file=>`/`<Include>`
|
||
/// tree hanging off them. An addon's real Lua often hangs off its XML rather than its `.toc`, the
|
||
/// same trap the 1.12 corpus set in decision 1190.
|
||
fn source_files(root: &Path, name: &str, toc: &Toc) -> Vec<String> {
|
||
let base = addon_base(name);
|
||
let mut pending: Vec<String> = toc
|
||
.files
|
||
.iter()
|
||
.map(|f| benilla_ui::loader::join_ref(&base, f))
|
||
.collect();
|
||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||
let mut out = Vec::new();
|
||
while let Some(path) = pending.pop() {
|
||
if !seen.insert(path.clone()) {
|
||
continue;
|
||
}
|
||
if let Some(text) = read_text(root, &path) {
|
||
let base = path.rfind('/').map_or("", |i| &path[..i]);
|
||
for m in refs_in_xml(&text) {
|
||
pending.push(benilla_ui::loader::join_ref(base, &m));
|
||
}
|
||
}
|
||
out.push(path);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The FrameXML digest of the interface this survey loaded (`crate::ui_script::framexml_digest`).
|
||
///
|
||
/// **Print it beside every number.** A survey run is only comparable to another survey run that
|
||
/// loaded the same interface, and in a dev build the manifest and the files still ours are read
|
||
/// from the source tree — so an edit by anything sharing the checkout moves the headline with no
|
||
/// rebuild and no announcement. A chain entry contributes its name only (its bytes are the
|
||
/// player's install), so migrating a window changes the digest and playing does not.
|
||
pub fn framexml_digest() -> String {
|
||
crate::ui_script::framexml_digest()
|
||
}
|
||
|
||
/// Every name our VM publishes into `_G`, with its Lua type — **no addons loaded**.
|
||
///
|
||
/// The other half of decision 1189's diff. 1189 established that the authoritative 1.12 surface is
|
||
/// already captured (the running client's in-world `_G`, 19,572 entries, vendored here as
|
||
/// `reference/1.12-globals.tsv`) and compared our table against it *once*, by hand. Nothing has
|
||
/// re-run it since, so the comparison has been drifting ever since — which is the failure mode this
|
||
/// project keeps writing records about: a measurement taken once and then cited as though it were
|
||
/// current.
|
||
///
|
||
/// Deliberately addon-free. The question is what OUR interface publishes, and an addon's own globals
|
||
/// would inflate both directions of the diff with names that belong to nobody but that addon.
|
||
///
|
||
/// The dump runs in Lua rather than reaching into the registry from Rust, because `_G` is the thing
|
||
/// an addon actually sees — the same reasoning that made the reference side a live capture instead
|
||
/// of the binary's registration table.
|
||
pub fn surface() -> Vec<(String, String)> {
|
||
let Ok(mut script) = UiScript::new() else {
|
||
return Vec::new();
|
||
};
|
||
script.set_instruction_budget(ADDON_INSTRUCTION_BUDGET);
|
||
script.set_screen_size(1024.0, 768.0);
|
||
script.register_addons(Vec::new(), None, None, None);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
|
||
let dump: String = script
|
||
.eval(
|
||
r#"
|
||
local out = {}
|
||
for k, v in pairs(_G) do
|
||
if type(k) == "string" then
|
||
out[table.getn(out) + 1] = k .. "\t" .. type(v)
|
||
end
|
||
end
|
||
table.sort(out)
|
||
return table.concat(out, "\n")
|
||
"#,
|
||
)
|
||
.unwrap_or_default();
|
||
|
||
dump.lines()
|
||
.filter_map(|l| l.split_once('\t'))
|
||
.map(|(n, t)| (n.to_string(), t.to_string()))
|
||
.collect()
|
||
}
|
||
|
||
/// The real `GlobalStrings.lua`, read once off the install's patch chain.
|
||
///
|
||
/// **~5,000 globals the surveyed VM would otherwise not have**, and the difference between an
|
||
/// instrument that models a session and one that models a blank slate. `FACTION_ALLIANCE`,
|
||
/// `PLAYER_OF_REALM`, `LEVEL`, every `ERR_*` — an addon reads them at file scope constantly, and
|
||
/// AceDB-2.0 alone builds its per-realm key out of `FACTION_ALLIANCE` before anything else runs.
|
||
///
|
||
/// Read once per process rather than per addon: it is a megabyte of Lua and the survey stands up
|
||
/// 218 VMs. `None` when there is no install, in which case the survey still runs and the numbers
|
||
/// are simply worse — and this says which mode a run was in, so two numbers taken on different
|
||
/// machines are never quietly compared.
|
||
pub fn seated_with_global_strings() -> bool {
|
||
global_strings().is_some()
|
||
}
|
||
|
||
/// The real `GlobalStrings.lua`, or `None` with no install to read it from.
|
||
fn global_strings() -> Option<&'static str> {
|
||
use std::sync::OnceLock;
|
||
static SRC: OnceLock<Option<String>> = OnceLock::new();
|
||
SRC.get_or_init(|| {
|
||
let data = benilla_formats::wow_data()?;
|
||
let mut chain = benilla_formats::open_chain(&data).ok()?;
|
||
let bytes = chain
|
||
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||
.ok()?;
|
||
Some(benilla_ui::source::decode(&bytes).into_owned())
|
||
})
|
||
.as_deref()
|
||
}
|
||
|
||
/// Put a **player and a realm** in the VM before the addon loads (decision 1195).
|
||
///
|
||
/// Not decoration, and not optimism: the reference runs `AddOn_Load` from inside `UI_Init`, which
|
||
/// is *after* the world is entered, so an addon's file scope always sees a real character. A bare
|
||
/// VM answers `UnitName("player")` with nil, and the corpus's single most common opening line is
|
||
///
|
||
/// ```lua
|
||
/// local charID = string.format(PLAYER_OF_REALM, UnitName("player"), GetRealmName())
|
||
/// ```
|
||
///
|
||
/// — AceDB-2.0's, embedded in a large slice of the ecosystem. Without a seated session that is 24
|
||
/// addons failing on a condition that cannot occur in a real client, which would make the harness
|
||
/// pessimistic in exactly the way §4 of 1193 caught it being optimistic. The numbers are only
|
||
/// worth quoting if the VM is shaped like the session an addon will actually meet.
|
||
///
|
||
/// Deliberately minimal — a name, a realm, a level, a class — because everything beyond that is
|
||
/// state an addon reads *in handlers*, which this survey never fires.
|
||
fn seat_a_session(script: &mut UiScript) {
|
||
// The reference boots FrameXML with this file FIRST; so does our app. Before the survey did,
|
||
// an addon's `FACTION_ALLIANCE` was nil and the failure looked like our bug.
|
||
if let Some(src) = global_strings() {
|
||
let _ = script.run(src);
|
||
}
|
||
// The shipped CVar table, before the realm below writes into it — the survey never registered
|
||
// any, so `GetCVar` answered nil for every name the client actually ships. Same class as the
|
||
// empty addon registry: a state the real client cannot be in.
|
||
script.register_cvars(crate::cvars::registered_pairs());
|
||
script.set_realm_name("Harness");
|
||
// THE BIND POINT. `GetBindLocation()` answered `""` in every VM, and a logged-in character with
|
||
// no hearth location is not a state one is in — the server sends `SMSG_BINDPOINTUPDATE` at
|
||
// login, before any addon runs. Same argument as the purse and the nil faction group.
|
||
//
|
||
// Three corpus addons read it, in three separate files (FuBar_TransporterFu, Necrosis,
|
||
// _LazyPig), and one of them CONCATENATES the result — so an empty seat is the difference
|
||
// between exercising their path and not.
|
||
script.set_bind_location("Stormwind City");
|
||
script.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Harness".into()),
|
||
health: 100,
|
||
max_health: 100,
|
||
level: 60,
|
||
power_type: 0,
|
||
power: 100,
|
||
max_power: 100,
|
||
race: Some("Human".into()),
|
||
race_file: Some("Human".into()),
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
sex: 2,
|
||
is_player: true,
|
||
// `UnitFactionGroup("player")` — nil here is not "no faction", it is a state a real
|
||
// player character cannot be in. AceDB-2.0 builds its per-realm key as
|
||
// `realm .. " - " .. faction` at file scope, so a nil faction is 24 addons stopping on
|
||
// `attempt to concatenate local 'faction'`. Every playable race has a side.
|
||
faction_group: Some("Alliance".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
|
||
// ── A POPULATED world, not merely an inhabited one ──────────────────────────────────────
|
||
//
|
||
// The columns above answer "did it raise"; the render column answers "did it draw". Until
|
||
// now the survey seated a player into an EMPTY world — no buffs, no target, no live
|
||
// cooldown — so a buff bar, a target frame or a cooldown-text addon drew nothing and landed
|
||
// in the drew-nothing list beside the pure libraries. `CT_BuffMod` declares 29 frames with
|
||
// none hidden at birth and `ElkBuffBar` 3: neither was failing to build, both had nothing to
|
||
// put in them.
|
||
//
|
||
// That made "nothing to draw" and "failed to draw" share a row, and the second is the one
|
||
// worth finding. Seating content separates them — and, more usefully, exposes the addons
|
||
// that only fail WHEN there is something to draw, which no empty-world column can reach.
|
||
//
|
||
// Deliberately minimal and deliberately ordinary: one buff, one target, one occupied action
|
||
// with a running cooldown. Not a stress fixture — a Tuesday.
|
||
script.set_auras(
|
||
"player",
|
||
Some(vec![benilla_ui::script::AuraState {
|
||
spell_id: 1243,
|
||
name: Some("Power Word: Fortitude".into()),
|
||
icon: Some("Interface\\Icons\\Spell_Holy_WordFortitude".into()),
|
||
count: 1,
|
||
helpful: true,
|
||
cancelable: true,
|
||
..Default::default()
|
||
}]),
|
||
);
|
||
script.set_unit(
|
||
"target",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Target Dummy".into()),
|
||
health: 80,
|
||
max_health: 100,
|
||
level: 60,
|
||
power_type: 0,
|
||
power: 50,
|
||
max_power: 100,
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
script.set_action(
|
||
1,
|
||
Some(benilla_ui::script::ActionSlot {
|
||
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
|
||
kind: 0x00,
|
||
action: 100,
|
||
count: 0,
|
||
consumable: false,
|
||
}),
|
||
);
|
||
script.set_action_state(
|
||
1,
|
||
Some(benilla_ui::script::ActionState {
|
||
usable: true,
|
||
// (startTime ms, duration ms, enable) — a cooldown with time left on it, which is
|
||
// what a cooldown-text addon (OmniCC, CooldownCount) needs before it draws anything.
|
||
cooldown: Some((1, 30_000, true)),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
// A SPELLBOOK. A level-60 warrior with an empty one is a state no real character is in — the
|
||
// same argument this fixture already makes for a nil `UnitFactionGroup` and an empty CVar
|
||
// table, and it costs an addon the same way: `TheoryCraftEngine.lua:306` is
|
||
// `name, texture, offset, numSpells = GetSpellTabInfo(1)` then `for i=1, numSpells`, so an
|
||
// empty book is `'for' limit must be a number` and the addon dies at session start. The verb
|
||
// was never the problem — `GetSpellTabInfo` already returns all four values — the FIXTURE was.
|
||
//
|
||
// Deliberately modest, and the reason is 1209's: a fixture that seats everything stops
|
||
// resembling a session anyone has, and every row it lights up is one nobody can attribute. Two
|
||
// tabs and four spells is a Tuesday. `Attack` is slot 1 because it is on every warrior's, and
|
||
// three corpus addons look for exactly that name.
|
||
// THE QUEST LOG. `GetNumQuestLogEntries()` answered `0, 0`, so every quest addon's walk ran
|
||
// zero times — the same shape as the 0-copper purse below and the empty spellbook: not a bug
|
||
// anywhere, just a path nothing ever entered. Five corpus addons walk this API (AtlasQuest,
|
||
// EQL3, FuBar_QuestsFu, QuestHistory, QuestItem).
|
||
//
|
||
// A HEADER AND TWO QUESTS, and each piece is here to be a shape the API distinguishes rather
|
||
// than to be plausible scenery:
|
||
// · a zone HEADER row, because `GetQuestLogTitle`'s `isHeader` is a different row kind and an
|
||
// addon that indexes rows without checking it walks straight into one;
|
||
// · an IN-PROGRESS quest with two objectives, one finished and one not — so a leaderboard walk
|
||
// sees both states, and `%d/%d` progress is mid-way rather than 0 or complete;
|
||
// · a COMPLETE quest (`complete = 1`), the other end of `isComplete`'s 1/-1/nil.
|
||
// Not seated: a FAILED quest (-1) and a TIMED one. Both are real states, and both are states a
|
||
// character is only briefly in — 1209's rule that a row nobody can attribute is worth less than
|
||
// one nobody lit applies to fixtures too.
|
||
{
|
||
use benilla_ui::script::{QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
|
||
let objective = |text: &str, cur: u32, req: u32| QuestLogObjectiveView {
|
||
text: text.into(),
|
||
kind: "monster".into(),
|
||
finished: cur >= req,
|
||
cur,
|
||
req,
|
||
};
|
||
let header = QuestLogEntryView {
|
||
quest_id: 0,
|
||
title: "Elwynn Forest".into(),
|
||
is_header: true,
|
||
..Default::default()
|
||
};
|
||
let in_progress = QuestLogEntryView {
|
||
quest_id: 62,
|
||
title: "The Fargodeep Mine".into(),
|
||
level: 10,
|
||
objectives: vec![
|
||
objective("Kobold Miner slain: 8/12", 8, 12),
|
||
objective("Kobold Vermin slain: 6/6", 6, 6),
|
||
],
|
||
..Default::default()
|
||
};
|
||
let done = QuestLogEntryView {
|
||
quest_id: 176,
|
||
title: "Kobold Candles".into(),
|
||
level: 8,
|
||
complete: 1,
|
||
..Default::default()
|
||
};
|
||
script.set_quest_log(QuestLogState {
|
||
entries: vec![header, in_progress, done],
|
||
num_quests: 2,
|
||
detail: None,
|
||
});
|
||
}
|
||
|
||
// THE PURSE. `GetMoney()` answered **0 copper** for every addon in every VM — and a level-60
|
||
// with literally no money is not a state a character is in; it is the same "state the real
|
||
// client cannot be in" the nil `UnitFactionGroup` below is seated for. Money addons are a whole
|
||
// genre in this corpus (Accountant, the CT_* expense trackers, every auction and vendor addon),
|
||
// and they all compute with this number.
|
||
//
|
||
// **12_345_678 copper — 1234g 56s 78c — and the digits are the point.** A round figure is what
|
||
// a fixture reaches for and it is exactly the value that hides bugs: any of `gold`, `silver`
|
||
// and `copper` being zero lets a broken coin-format or a `mod`/`floor` slip read as correct.
|
||
// All three non-zero means a formatter that drops a field, or divides in the wrong order, has
|
||
// nowhere to hide. Non-zero also matters on its own: an addon that guards `if GetMoney() > 0`
|
||
// never ran its body here.
|
||
script.set_money(12_345_678);
|
||
|
||
// EQUIPPED GEAR. Every `GetInventoryItem*("player", slot)` answered the empty shape, and a
|
||
// level-60 with no equipment at all is a state no character reaches — the same argument the
|
||
// spellbook and the backpack landed on.
|
||
//
|
||
// Three slots, not nineteen: head, chest and main hand. A fully-geared doll would be a state a
|
||
// real session presents, but it would also light up every path at once, and 1209's rule is
|
||
// that a row nobody can attribute is worth less than one nobody lit. Three is enough for an
|
||
// addon that WALKS the slots to reach real items, empty ones, and the ammo slot's absence.
|
||
{
|
||
let mut slots: benilla_ui::script::InventorySlots = Default::default();
|
||
slots[1] = Some(equip_slot(12640, "Lionheart Helm"));
|
||
slots[5] = Some(equip_slot(11726, "Bloodmail Hauberk"));
|
||
slots[16] = Some(equip_slot(871, "Flurry Axe"));
|
||
script.set_inventory_slots(slots);
|
||
}
|
||
// THE BACKPACK. `GetContainerNumSlots(0)` answered 0 — and a character with no backpack has
|
||
// never existed: bag 0 is 16 slots from level 1, before a single bag is bought. Same argument
|
||
// as the spellbook above and the nil `UnitFactionGroup` below it, and the same expectation:
|
||
// seating a state every character is in should LOSE rows, because a path nothing walked is a
|
||
// path nothing tested.
|
||
//
|
||
// Backpack only, and deliberately: bags 1..4 are equipped bags, which a fresh character does
|
||
// NOT have, so seating them would manufacture a state rather than expose one. Two items in
|
||
// sixteen slots — the rest empty, which is itself a shape bag addons must handle.
|
||
{
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(1, bag_slot(6948, "Hearthstone", 1));
|
||
slots.insert(5, bag_slot(2589, "Linen Cloth", 12));
|
||
script.set_container(
|
||
0,
|
||
Some(benilla_ui::script::ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots,
|
||
}),
|
||
);
|
||
}
|
||
script.set_spellbook(benilla_ui::script::SpellBookState {
|
||
tabs: vec![
|
||
benilla_ui::script::SpellTabView {
|
||
name: "General".into(),
|
||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||
offset: 0,
|
||
num_spells: 2,
|
||
},
|
||
benilla_ui::script::SpellTabView {
|
||
name: "Arms".into(),
|
||
texture: Some("Interface\\Icons\\Ability_Rogue_Eviscerate".into()),
|
||
offset: 2,
|
||
num_spells: 2,
|
||
},
|
||
],
|
||
slots: vec![
|
||
spell_slot(6603, "Attack", None),
|
||
spell_slot(78, "Heroic Strike", Some("Rank 1")),
|
||
spell_slot(100, "Charge", Some("Rank 1")),
|
||
spell_slot(772, "Rend", Some("Rank 1")),
|
||
],
|
||
});
|
||
|
||
// ── THE TWO LOGIN-SCOPED CATALOGUES (2167) ─────────────────────────────────────────────
|
||
//
|
||
// Both of these are answered by the client with **no session behind them at all** — they are
|
||
// DBC reads, live from the moment the VM exists — so a VM that answers them empty is in a
|
||
// state the reference cannot be in, exactly like the nil faction group and the empty CVar
|
||
// table above. Both are read off the PLAYER'S OWN CHAIN, never shipped: we seat the
|
||
// structure, the install supplies the words (`ui_auction`'s own rule).
|
||
//
|
||
// **The auction class tree.** `GetAuctionItemClasses()` is `ItemClass.dbc` in the browse
|
||
// menu's order (1971), and the stock `AuctionFrameBrowse_OnLoad` reads it at the addon's
|
||
// LOAD. `Auctioneer`'s `AucCore.lua:105` is
|
||
//
|
||
// local classes = pack(GetAuctionItemClasses());
|
||
// local bidBasedCategories = {[classes[1]]=true, ..., [classes[10]]=true}
|
||
//
|
||
// — ten subscripts into a list that answered nothing, so `AucCore.lua:106` died on
|
||
// `table index is nil` and took `BeanCounter`, `Informant`, `Enchantrix` and `FonzAppraiser`
|
||
// with it. Five corpus addons read the tree; four of them at file scope.
|
||
// **The `SMSG_ADDON_INFO` reply, hiding nothing** (2175) — the same class of state correction
|
||
// as the two catalogues below and the empty spellbook above. The Lua index space
|
||
// (`GetNumAddOns`, and the index form of every AddOn verb) is built only when the server
|
||
// answers, and a surveyed VM is an IN-WORLD VM: the reference cannot be in the world without
|
||
// having had that reply. A survey that left it unanswered would measure a client whose
|
||
// `GetNumAddOns()` is 0, which is what AceAddon and AceLibrary scan.
|
||
//
|
||
// Empty, not the twelve: the corpus registry holds no `## Secure:` addon, so a real server
|
||
// would have nothing to hide here.
|
||
script.note_addon_info_reply(&[]);
|
||
script.set_auction_item_classes(auction_item_classes());
|
||
|
||
// **The talent tree.** `GetTalentInfo(tab, i)` answers off `Talent.dbc` × `TalentTab.dbc`;
|
||
// ten corpus addons read it, and `KLHThreatMeter` reads it by POSITION at file scope
|
||
// (`KTM_Data.lua:376`, `_, _, _, _, rank = GetTalentInfo(info[1], info[2])`) and then does
|
||
// `-rank` arithmetic on the answer (`KTM_My.lua:721`). An unseated tree is nil, not zero.
|
||
//
|
||
// **Zero ranks, the full unspent pool** — a character who has just reached 60 and not spent a
|
||
// point. Deliberately, and for 1209's reason: a specific build would put arbitrary constants
|
||
// into every threat and damage calculation in the corpus, and a row nobody can attribute is
|
||
// worse than a row that is merely quiet. Rank 0 is a NUMBER, which is the whole difference
|
||
// this seat makes. 51 points is `level - 9` at 60, and the class is the seated warrior's.
|
||
if let Some(state) = talent_snapshot(script) {
|
||
script.set_talents(state);
|
||
}
|
||
}
|
||
|
||
/// The reference's own auction browse tree, read once off the player's chain.
|
||
///
|
||
/// The same builder the live client feeds from (`ui_auction::categories`) over the same two DBCs,
|
||
/// so the survey cannot drift from the window: a second copy of the ten-class set would be a
|
||
/// second thing to keep right. Empty with no install, in which case the survey simply measures a
|
||
/// worse VM and says so through [`seated_with_global_strings`].
|
||
fn auction_item_classes() -> Vec<benilla_ui::script::AuctionCategory> {
|
||
use std::sync::OnceLock;
|
||
static TREE: OnceLock<Vec<benilla_ui::script::AuctionCategory>> = OnceLock::new();
|
||
TREE.get_or_init(|| {
|
||
let Some(data) = benilla_formats::wow_data() else {
|
||
return Vec::new();
|
||
};
|
||
let Ok(mut chain) = benilla_formats::open_chain(&data) else {
|
||
return Vec::new();
|
||
};
|
||
let classes = benilla_formats::load_item_classes(&mut chain)
|
||
.ok()
|
||
.map(crate::ui_items::ItemClasses);
|
||
let subclasses = benilla_formats::load_item_sub_classes(&mut chain)
|
||
.ok()
|
||
.map(crate::ui_items::ItemSubClasses);
|
||
crate::ui_auction::categories(classes.as_ref(), subclasses.as_ref())
|
||
})
|
||
.clone()
|
||
}
|
||
|
||
/// The seated character's talent pages, built by the live client's own `build_pages`.
|
||
///
|
||
/// The catalogues are read once per process (they are two DBC loads plus `Spell.dbc`, and the
|
||
/// survey stands up 219 VMs); the PAGES are built per VM, because the red requirement lines are
|
||
/// filled from that VM's own `GlobalStrings.lua` — the same rule the live feed follows (2045).
|
||
/// `None` with no install.
|
||
fn talent_snapshot(script: &UiScript) -> Option<benilla_ui::script::TalentUiState> {
|
||
use std::sync::OnceLock;
|
||
#[allow(clippy::type_complexity)]
|
||
static DBCS: OnceLock<
|
||
Option<(
|
||
benilla_formats::TalentCatalog,
|
||
benilla_formats::SpellCatalog,
|
||
)>,
|
||
> = OnceLock::new();
|
||
let (talents, spells) = DBCS
|
||
.get_or_init(|| {
|
||
let data = benilla_formats::wow_data()?;
|
||
let mut chain = benilla_formats::open_chain(&data).ok()?;
|
||
let talents = benilla_formats::load_talent_catalog(&mut chain).ok()?;
|
||
let spells = benilla_formats::load_spell_catalog(&mut chain).ok()?;
|
||
Some((talents, spells))
|
||
})
|
||
.as_ref()?;
|
||
let get = |key: &str| {
|
||
script
|
||
.lua()
|
||
.globals()
|
||
.get::<String>(key)
|
||
.ok()
|
||
.filter(|t| !t.is_empty())
|
||
};
|
||
// Human (`ChrRaces.dbc` 1) warrior (`ChrClasses.dbc` 1) — the character `seat_a_session`
|
||
// seats above, so the pages are the ones that character's talent window would show.
|
||
Some(crate::ui_talent::build_pages(
|
||
talents,
|
||
&BTreeSet::new(),
|
||
spells,
|
||
1,
|
||
1,
|
||
(51, 0),
|
||
&get,
|
||
))
|
||
}
|
||
|
||
/// One seated equipment slot — full durability, so no alert region lights up.
|
||
fn equip_slot(item_id: u32, name: &str) -> benilla_ui::script::InvSlotView {
|
||
benilla_ui::script::InvSlotView {
|
||
item_id,
|
||
icon: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||
count: 1,
|
||
quality: 2,
|
||
name: Some(name.to_string()),
|
||
// FULL durability on purpose. The setter recomputes the eleven alert statuses and fires
|
||
// UPDATE_INVENTORY_ALERTS, so a worn item would light DurabilityFrame's regions — a
|
||
// VISIBLE change, and this fixture's job is to present a plausible session, not to drive
|
||
// the alert law. Undamaged gear is the ordinary case anyway.
|
||
durability: Some((100, 100)),
|
||
link: Some(format!("|cff1eff00|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// One seated backpack slot.
|
||
fn bag_slot(item_id: u32, name: &str, count: u32) -> benilla_ui::script::ContainerSlot {
|
||
benilla_ui::script::ContainerSlot {
|
||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||
count,
|
||
quality: Some(1),
|
||
item_id,
|
||
link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// One seated spellbook slot — the fields a book reader actually reads, defaulted otherwise.
|
||
fn spell_slot(spell_id: u32, name: &str, rank: Option<&str>) -> benilla_ui::script::SpellSlotView {
|
||
benilla_ui::script::SpellSlotView {
|
||
spell_id,
|
||
name: name.to_string(),
|
||
rank: rank.map(str::to_string),
|
||
texture: Some("Interface\\Icons\\INV_Misc_QuestionMark".into()),
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Every string key in the VM's `_G`.
|
||
fn globals_of(script: &UiScript) -> BTreeSet<String> {
|
||
script
|
||
.eval::<Vec<String>>(
|
||
"local out = {} \
|
||
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
|
||
return out",
|
||
)
|
||
.unwrap_or_default()
|
||
.into_iter()
|
||
.collect()
|
||
}
|
||
|
||
/// What a manifest entry that does not resolve actually is — see [`AddonReport::absent_own_files`].
|
||
#[derive(Debug, Default)]
|
||
pub struct AbsentFiles {
|
||
/// Entries under the addon's **own** folder that the package does not contain.
|
||
pub own: Vec<String>,
|
||
/// Entries resolving **outside** it, into a folder that is not installed.
|
||
pub foreign: Vec<String>,
|
||
}
|
||
|
||
/// What one addon's manifest did: every load failure verbatim, which of them were files that
|
||
/// simply are not there, and whether anything actually **raised**.
|
||
struct FileLoad {
|
||
/// Every load failure, in order, unchanged in content and meaning (1213: `errors` is never
|
||
/// quietly shrunk — every past record's number is still readable off this list).
|
||
errors: Vec<String>,
|
||
/// The subset of [`Self::errors`] that is a named file the provider does not have, split by
|
||
/// whose package is incomplete.
|
||
absent: AbsentFiles,
|
||
/// **Did anything raise, fail to parse, or drop a frame** — the question
|
||
/// [`AddonReport::loaded`] asks (decision 2155).
|
||
///
|
||
/// It is `false` for an addon whose only failures are absent files, because on the reference
|
||
/// that addon *loads*: `0x6edaa0` logs `"Couldn't open %s"` and returns null, and the walk
|
||
/// carries on to the next manifest entry with nothing raised (wow-re
|
||
/// `ui/scratch/xml-toc-path-resolution.md` §4, VERIFIED — the same rule 2107 unified for the
|
||
/// two loaders that reach it from Lua). Until this existed the survey scored 24 corpus addons
|
||
/// as not surviving a session start on nothing but a locale file their own zip omits, FuBar
|
||
/// and 22 of its plugins among them.
|
||
raised: bool,
|
||
}
|
||
|
||
/// Run the addon's manifest through the same two arms the real loader uses — `.lua` as a chunk,
|
||
/// anything else as FrameXML — in the client's **install-relative** path space (decision 2155:
|
||
/// `Interface/AddOns/<Folder>`, exactly what `ui_script::addons::Addon::prefix` hands its own
|
||
/// loader, so a chunk name here and a chunk name in a live session are the same string).
|
||
fn load_addon_files(script: &UiScript, root: &Path, name: &str, toc: &Toc) -> FileLoad {
|
||
let provider = |req: &str| -> Option<Vec<u8>> { read_under(root, req) };
|
||
let base = addon_base(name);
|
||
let mut errors = Vec::new();
|
||
let mut absent = AbsentFiles::default();
|
||
let mut raised = false;
|
||
for file in &toc.files {
|
||
let path = benilla_ui::loader::join_ref(&base, file);
|
||
let Some(bytes) = read_under(root, &path) else {
|
||
errors.push(format!("{file}: not found"));
|
||
// WHOSE package is incomplete. `join_ref` has already collapsed the `..`s the way the
|
||
// client does (wow-re `ui/scratch/xml-toc-path-resolution.md` §2), so the resolved
|
||
// path is what the real file layer would be handed — and an entry that still points
|
||
// inside the addon's own folder is the addon shipping a manifest it does not satisfy,
|
||
// while one pointing out of it wants a neighbour that is not installed.
|
||
let own = path
|
||
.strip_prefix(base.as_str())
|
||
.is_some_and(|rest| rest.starts_with('/'));
|
||
if own {
|
||
absent.own.push(file.clone());
|
||
} else {
|
||
absent.foreign.push(path.clone());
|
||
}
|
||
continue;
|
||
};
|
||
if is_lua(file) {
|
||
// Named as the client names it, so the survey sees what a player would: an addon
|
||
// that PARSES a traceback for its own folder (the whole FuBar family) needs the real
|
||
// `Interface\AddOns\<Folder>\<File>` shape, not mlua's Rust-caller default. Built
|
||
// from the RESOLVED path, like the loader's own `<Script file=>` naming — one rule,
|
||
// so the two doors into a VM cannot disagree about what a file is called (2155).
|
||
if let Err(e) = script.run_chunk_named(&bytes, &format!("@{}", path.replace('/', "\\")))
|
||
{
|
||
errors.push(format!("{file}: {e}"));
|
||
raised = true;
|
||
}
|
||
continue;
|
||
}
|
||
match benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes)) {
|
||
Ok(doc) => {
|
||
let report = benilla_ui::loader::load_in(script, &doc, &path, &provider);
|
||
// Retained with the file prefix so the survey's `warnings` column can see them
|
||
// (2135); they were dropped with the report before that, which is why an
|
||
// unresolved `inherits=` scored as a clean load.
|
||
for w in report.warnings {
|
||
script.report_warning(&format!("{file}: {w}"));
|
||
}
|
||
// A file the DOCUMENT named and the provider does not have — the `.toc` arm's rule
|
||
// one level down (decision 2155). Recorded as absent, under the same own/foreign
|
||
// split, and never as something that raised: the reference logs `Couldn't open %s`
|
||
// for an `<Include>` and `Error loading %s` for a `<Script file=>`, and carries on.
|
||
for m in report.missing_files {
|
||
let own = m
|
||
.split_once("no provider hit for \"")
|
||
.and_then(|(_, rest)| rest.split_once('"'))
|
||
.is_some_and(|(p, _)| {
|
||
p.strip_prefix(base.as_str())
|
||
.is_some_and(|r| r.starts_with('/'))
|
||
});
|
||
errors.push(format!("{file}: {m}"));
|
||
if own {
|
||
absent.own.push(format!("{file}: {m}"));
|
||
} else {
|
||
absent.foreign.push(format!("{file}: {m}"));
|
||
}
|
||
}
|
||
raised |= !report.errors.is_empty();
|
||
errors.extend(report.errors.into_iter().map(|e| format!("{file}: {e}")));
|
||
}
|
||
Err(e) => {
|
||
errors.push(format!("{file}: {e}"));
|
||
raised = true;
|
||
}
|
||
}
|
||
}
|
||
FileLoad {
|
||
errors,
|
||
absent,
|
||
raised,
|
||
}
|
||
}
|
||
|
||
fn is_lua(entry: &str) -> bool {
|
||
entry
|
||
.rsplit(['/', '\\'])
|
||
.next()
|
||
.unwrap_or(entry)
|
||
.rsplit_once('.')
|
||
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
|
||
}
|
||
|
||
/// One addon file, read **through the client's own reader** — the loose AddOns tree, then the
|
||
/// player's patch chain ([`crate::ui_script::addons::read_addon_file`], decision 2155).
|
||
///
|
||
/// It delegates rather than re-implementing, and that is the whole point of it still existing as a
|
||
/// name: this was a private copy of the loader's `read_under`, and a copy of a resolution rule is
|
||
/// a copy that drifts. It had already drifted — the client resolves `..\Blizzard_AuctionUI\…`
|
||
/// and `..\..\FrameXML\…` off the chain and this could not, so the survey reported two addon
|
||
/// families as broken packages when the miss was the instrument's.
|
||
///
|
||
/// **Bytes, like the loader's** (decision 1193). Until then this function carried a private
|
||
/// lossy-UTF-8 + BOM-strip of its own, so the harness could survey files the *client* refused to
|
||
/// load — an instrument reporting on a world its host could not reach, which is the wrong way
|
||
/// round. The client reads bytes now, so the harness can simply read bytes too, and the one place
|
||
/// that still needs text ([`read_text`]) says so.
|
||
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
|
||
crate::ui_script::addons::read_addon_file(root, rel)
|
||
}
|
||
|
||
/// Where one addon's files sit in the install's path space — `Interface/AddOns/<Folder>`.
|
||
///
|
||
/// The survey's copy of [`crate::ui_script::addons::Addon::prefix`], and it must stay its copy:
|
||
/// every path this module builds is handed to the same reader and named by the same rule, so a
|
||
/// divergence here is a survey measuring a client nobody runs.
|
||
fn addon_base(name: &str) -> String {
|
||
format!("Interface/AddOns/{name}")
|
||
}
|
||
|
||
/// [`read_under`] for the **source scanner**, which greps text rather than running it.
|
||
fn read_text(root: &Path, rel: &str) -> Option<String> {
|
||
read_under(root, rel).map(|b| benilla_ui::source::decode(&b).into_owned())
|
||
}
|
||
|
||
/// Names the addon calls like functions that the VM does not have.
|
||
///
|
||
/// Two subtractions matter and both are easy to get wrong: the addon's **own** definitions (a
|
||
/// helper it declares and calls is not a missing API), and Lua's **locals** — `local Foo = function`
|
||
/// then `Foo()`. The leading-capital filter alone does not cover the second, because a local can be
|
||
/// capitalised, and for a long time this list's top five rows were exactly that mistake (see
|
||
/// [`scan_lua`]'s assignment pass). Both `local function Foo` and `local Foo = …` are credited now.
|
||
fn missing_calls(
|
||
root: &Path,
|
||
name: &str,
|
||
toc: &Toc,
|
||
known: &BTreeSet<String>,
|
||
dep_methods: &BTreeSet<String>,
|
||
) -> Wants {
|
||
let mut scan = Scan::default();
|
||
for path in source_files(root, name, toc) {
|
||
if let Some(text) = read_text(root, &path) {
|
||
scan_source(&path, &text, &mut scan);
|
||
}
|
||
}
|
||
let Scan {
|
||
called,
|
||
indexed,
|
||
methods,
|
||
defined,
|
||
defined_methods,
|
||
tested_fields,
|
||
kind_calls,
|
||
global_calls,
|
||
loose_methods,
|
||
} = scan;
|
||
let absent = |set: BTreeSet<String>| -> Vec<String> {
|
||
set.into_iter()
|
||
.filter(|n| !known.contains(n) && !defined.contains(n))
|
||
.collect()
|
||
};
|
||
// The METHOD candidates are returned raw rather than resolved here: `known` is a set of
|
||
// globals and a method is not one, so the only honest oracle is the VM itself
|
||
// ([`widget_method_kinds`]). Subtracted here is the half a *scanner* can answer — the
|
||
// names this addon's own source binds as fields on its own tables.
|
||
// Split, not filtered away: a name the addon feature-tests is not a blocker, but losing it
|
||
// entirely would be the silent under-report this instrument keeps getting caught in — 60
|
||
// corpus addons guard `SetTopLevel`, which is a real widget method we do not have.
|
||
let (wanted_methods, tested_methods): (BTreeSet<String>, BTreeSet<String>) = methods
|
||
.into_iter()
|
||
.filter(|n| !defined_methods.contains(n) && !dep_methods.contains(n))
|
||
.partition(|n| !tested_fields.contains(n));
|
||
// Kept as separate lists, not one, for 1207's reason: shapes ranked together mis-rank. A
|
||
// missing FUNCTION is a verb to write in Rust; a missing FRAME or TABLE is FrameXML to
|
||
// transcribe; a missing METHOD is a widget binding — three queues, three places.
|
||
Wants {
|
||
missing_globals: absent(called),
|
||
missing_tables: absent(indexed),
|
||
wanted_methods,
|
||
tested_methods,
|
||
kind_calls,
|
||
global_calls,
|
||
loose_methods,
|
||
}
|
||
}
|
||
|
||
/// What one addon's source scan wants — [`missing_calls`]' result.
|
||
///
|
||
/// A struct rather than the tuple this was: the per-kind census took it from four members to seven,
|
||
/// and four of those are `BTreeSet<String>`-shaped, which is how the wrong one gets passed at a
|
||
/// call site and nobody sees it. Same reasoning as [`Scan`]'s own.
|
||
struct Wants {
|
||
missing_globals: Vec<String>,
|
||
missing_tables: Vec<String>,
|
||
/// Method names the addon calls and does not define — the census's question set.
|
||
wanted_methods: BTreeSet<String>,
|
||
/// ...and the half it feature-tests first, which is not a blocker.
|
||
tested_methods: BTreeSet<String>,
|
||
kind_calls: BTreeSet<(String, String)>,
|
||
global_calls: BTreeSet<(String, String)>,
|
||
loose_methods: BTreeSet<String>,
|
||
}
|
||
|
||
/// `<Script file=>` / `<Include file=>` targets.
|
||
fn refs_in_xml(text: &str) -> Vec<String> {
|
||
let mut out = Vec::new();
|
||
for (i, _) in text.match_indices("file=\"") {
|
||
let rest = &text[i + 6..];
|
||
if let Some(end) = rest.find('"') {
|
||
out.push(rest[..end].to_string());
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Blank out `<!-- … -->`, preserving line structure — run over an XML file **before**
|
||
/// [`strip_lua_noise`], which cannot see them.
|
||
///
|
||
/// An unterminated `<!--` swallows the rest of the file, which is what a real XML parser does with
|
||
/// it too: the document is malformed and there is no honest way to guess where the comment meant
|
||
/// to stop.
|
||
fn strip_xml_comments(text: &str) -> String {
|
||
let mut out = String::with_capacity(text.len());
|
||
let mut rest = text;
|
||
while let Some(open) = rest.find("<!--") {
|
||
out.push_str(&rest[..open]);
|
||
let after = &rest[open + 4..];
|
||
match after.find("-->") {
|
||
Some(close) => {
|
||
// Keep the newlines so anything reporting a line still reports a real one.
|
||
out.extend(after[..close].chars().filter(|c| *c == '\n'));
|
||
rest = &after[close + 3..];
|
||
}
|
||
None => {
|
||
out.extend(after.chars().filter(|c| *c == '\n'));
|
||
return out;
|
||
}
|
||
}
|
||
}
|
||
out.push_str(rest);
|
||
out
|
||
}
|
||
|
||
/// Blank out Lua comments and string literals, preserving line structure.
|
||
///
|
||
/// **This is not tidying — it is the difference between a signal and a list of names.** Without
|
||
/// it the ranked demand is topped by `Author`, `Iriel`, `Tekkub` and `Knight`: words inside
|
||
/// `-- credits` comments and `"..."` messages that happen to sit before a `(`. Measured on the
|
||
/// vanilla corpus, stripping removes over a thousand phantom call targets, and every one of the
|
||
/// top four was one.
|
||
fn strip_lua_noise(text: &str) -> String {
|
||
strip_lua(text, false)
|
||
}
|
||
|
||
/// [`strip_lua_noise`] with the string **contents kept** — comments still go.
|
||
///
|
||
/// Exactly one question needs this, and it is the one the per-kind census is built on: the widget
|
||
/// kind in `CreateFrame("MessageFrame", …)` lives *inside* a string literal, which the ordinary
|
||
/// stripper blanks before anything can read it. Reading the raw file instead would count a
|
||
/// commented-out `CreateFrame` — the mistake 1218 is about, where five words of GPL boilerplate
|
||
/// topped a ranking — so the comments go and only the literals stay.
|
||
///
|
||
/// It is never used for the *call-site* scan ([`scan_lua`], [`attribute_calls`]): a name inside a
|
||
/// string would read as an identifier there, which is the same fault the other way round.
|
||
fn strip_lua_comments_only(text: &str) -> String {
|
||
strip_lua(text, true)
|
||
}
|
||
|
||
fn strip_lua(text: &str, keep_strings: bool) -> String {
|
||
let src: Vec<char> = text.chars().collect();
|
||
let mut out = String::with_capacity(text.len());
|
||
let mut i = 0;
|
||
while i < src.len() {
|
||
let c = src[i];
|
||
// Long bracket `[[ … ]]` (a string, or a `--[[ … ]]` comment): both end the same way.
|
||
let long_open = c == '[' && i + 1 < src.len() && src[i + 1] == '[';
|
||
let line_comment = c == '-' && i + 1 < src.len() && src[i + 1] == '-';
|
||
if line_comment && i + 3 < src.len() && src[i + 2] == '[' && src[i + 3] == '[' {
|
||
i += 4;
|
||
while i + 1 < src.len() && !(src[i] == ']' && src[i + 1] == ']') {
|
||
if src[i] == '\n' {
|
||
out.push('\n');
|
||
}
|
||
i += 1;
|
||
}
|
||
i = (i + 2).min(src.len());
|
||
continue;
|
||
}
|
||
if line_comment {
|
||
while i < src.len() && src[i] != '\n' {
|
||
i += 1;
|
||
}
|
||
continue;
|
||
}
|
||
if long_open {
|
||
i += 2;
|
||
if keep_strings {
|
||
out.push_str("[[");
|
||
}
|
||
while i + 1 < src.len() && !(src[i] == ']' && src[i + 1] == ']') {
|
||
if keep_strings || src[i] == '\n' {
|
||
out.push(src[i]);
|
||
}
|
||
i += 1;
|
||
}
|
||
i = (i + 2).min(src.len());
|
||
if keep_strings {
|
||
out.push_str("]]");
|
||
}
|
||
continue;
|
||
}
|
||
if c == '"' || c == '\'' {
|
||
let quote = c;
|
||
i += 1;
|
||
let start = i;
|
||
while i < src.len() && src[i] != quote {
|
||
if src[i] == '\\' {
|
||
i += 1; // an escaped quote does not close the literal
|
||
}
|
||
i += 1;
|
||
}
|
||
if keep_strings {
|
||
out.push(quote);
|
||
out.extend(src[start..i.min(src.len())].iter());
|
||
out.push(quote);
|
||
} else {
|
||
out.push_str("\"\""); // keep it an expression, drop its contents
|
||
}
|
||
i = (i + 1).min(src.len());
|
||
continue;
|
||
}
|
||
out.push(c);
|
||
i += 1;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// [`scan_lua`] over one source file, stripped according to what kind of file it is.
|
||
///
|
||
/// An XML file is scanned as Lua on purpose — its `<Script>` CDATA and its `<OnLoad>` handler
|
||
/// bodies ARE Lua, and skipping the file would lose them. But `strip_lua_noise` only knows `--` and
|
||
/// `[[ ]]`, so an `<!-- … -->` header survived it whole. XML attribute values happen to be blanked
|
||
/// already (they are `"…"`, which the Lua string rule eats) and tag names are never followed by
|
||
/// `(`/`.`/`:` — the comment was the entire hole, and it was enough to put five license-boilerplate
|
||
/// words at the top of the frames/tables ranking (decision 1218).
|
||
fn scan_source(path: &str, text: &str, scan: &mut Scan) {
|
||
let text: std::borrow::Cow<'_, str> = if is_lua(path) {
|
||
std::borrow::Cow::Borrowed(text)
|
||
} else {
|
||
std::borrow::Cow::Owned(strip_xml_comments(text))
|
||
};
|
||
scan_lua(&text, scan);
|
||
// The receiver pass is per FILE, never per addon: a `local f = CreateFrame("MessageFrame")` in
|
||
// one file says nothing about an `f` in the next, and pretending otherwise is how an
|
||
// attribution becomes fiction.
|
||
scan_receivers(&text, scan);
|
||
}
|
||
|
||
/// Every kind `CreateFrame` accepts, in `frame_kind_from_str`'s own spelling — the probe set of the
|
||
/// widget-method census, and the vocabulary [`UiScript::widget_kind`] answers in.
|
||
///
|
||
/// A kind that is refused is simply skipped by the oracle, so this list going stale narrows the
|
||
/// probe set rather than producing a wrong answer.
|
||
const PROBE_FRAME_KINDS: &[&str] = &[
|
||
"Frame",
|
||
"Button",
|
||
"CheckButton",
|
||
"EditBox",
|
||
"StatusBar",
|
||
"Slider",
|
||
"ScrollFrame",
|
||
"Model",
|
||
"PlayerModel",
|
||
"MessageFrame",
|
||
"ScrollingMessageFrame",
|
||
"ColorSelect",
|
||
"SimpleHTML",
|
||
"MovieFrame",
|
||
"GameTooltip",
|
||
"Minimap",
|
||
];
|
||
|
||
/// **Type the receiver of a `:` call where the file says what it is** — the whole basis of the
|
||
/// per-kind census.
|
||
///
|
||
/// The census's oracle used to ask "does *any* widget answer this name", and decision 1228 is the
|
||
/// record of what that cost: `MessageFrame:AddMessage` scored **zero** for as long as it existed,
|
||
/// because the ScrollingMessageFrame probe answered the name, while three corpus addons had
|
||
/// `UIErrorsFrame:AddMessage` as their first load error. A verb wired to one class and forgotten on
|
||
/// its sibling is exactly the shape an ANY-kind answer cannot see. The only fix is to ask the
|
||
/// question per kind, and that needs a receiver type.
|
||
///
|
||
/// A static scan cannot type an arbitrary expression, so this types the two shapes that carry the
|
||
/// corpus and says nothing about the rest:
|
||
///
|
||
/// - a **local (or plain global) bound from a widget factory** — `local f = CreateFrame("Frame")`,
|
||
/// `local t = f:CreateTexture()`, `local fs = f:CreateFontString()`;
|
||
/// - a **published name**, resolved later against the live arena by [`UiScript::widget_kind`] —
|
||
/// `UIErrorsFrame:AddMessage(…)` is a MessageFrame call because our `UIErrorsFrame` *is* one.
|
||
/// That is deliberately our object graph, not the reference's: if we declared it a plain
|
||
/// `<Frame>`, the addon's call really would land on a plain Frame, and the census should say so.
|
||
///
|
||
/// Everything else — `self:Foo()`, `this:Foo()`, `a.b:Foo()`, `getglobal(n):Foo()` — is left
|
||
/// **untyped on purpose** and reported as such ([`AddonReport::ambiguous_methods`]), because
|
||
/// swallowing it as "some kind has it, so fine" is the exact blindness this is here to end.
|
||
fn scan_receivers(text: &str, scan: &mut Scan) {
|
||
// Pass 1 needs the string literal inside `CreateFrame("MessageFrame")`, which the ordinary
|
||
// stripper blanks; pass 2 must NOT see identifiers inside strings. Two texts, one file.
|
||
let typed = local_widget_kinds(&strip_lua_comments_only(text));
|
||
attribute_calls(&strip_lua_noise(text), &typed, scan);
|
||
}
|
||
|
||
/// Identifiers this file binds to a widget of a known kind — `Some(kind)`, or `None` for a name the
|
||
/// file also binds to something else (and therefore cannot be typed).
|
||
///
|
||
/// **Every binding of a name must agree, and a name that is ever a loop variable or a function
|
||
/// parameter is out.** Without that, one `local frame = CreateFrame("Frame")` at file top types
|
||
/// every `frame:Method()` in the file — including the `frame` that is a *parameter* of a helper
|
||
/// three functions down, holding whatever the caller passed. That is how an attribution pass
|
||
/// invents rows, and the corpus is full of exactly that naming.
|
||
fn local_widget_kinds(text: &str) -> BTreeMap<String, Option<&'static str>> {
|
||
let mut out: BTreeMap<String, Option<&'static str>> = BTreeMap::new();
|
||
let mut bind = |name: &str, kind: Option<&'static str>| match out.get(name) {
|
||
Some(prev) if *prev == kind => {}
|
||
Some(_) => {
|
||
out.insert(name.to_string(), None);
|
||
}
|
||
None => {
|
||
out.insert(name.to_string(), kind);
|
||
}
|
||
};
|
||
for line in text.lines() {
|
||
let Some(eq) = assignment_at(line) else {
|
||
continue;
|
||
};
|
||
let lhs = line[..eq].trim();
|
||
let lhs = lhs.strip_prefix("local ").unwrap_or(lhs).trim();
|
||
if !is_ident(lhs) {
|
||
continue; // a comma list, a dotted field, an index — not a name we can follow
|
||
}
|
||
bind(lhs, widget_kind_of_expression(&line[eq + 1..]));
|
||
}
|
||
// The POISON pass, over the whole file rather than line by line: every name that is ever a
|
||
// `for` variable or a function parameter is struck out, wherever it was bound. Without it one
|
||
// `local frame = CreateFrame("Frame")` at file top types every `frame:Method()` below —
|
||
// including the `frame` that is a *parameter* of a helper three functions down, holding
|
||
// whatever its caller passed. An untyped name costs a row in the ambiguous table; a wrongly
|
||
// typed one is fiction in the ranking, which is the error this whole arc keeps paying for.
|
||
for name in binder_names(text) {
|
||
bind(&name, None);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The byte offset of the line's first real `=` (never `==`, `<=`, `>=`, `~=`), or `None`.
|
||
fn assignment_at(line: &str) -> Option<usize> {
|
||
let b = line.as_bytes();
|
||
(0..b.len()).find(|&i| {
|
||
b[i] == b'='
|
||
&& b.get(i + 1) != Some(&b'=')
|
||
&& !matches!(
|
||
i.checked_sub(1).map(|p| b[p]),
|
||
Some(b'=' | b'<' | b'>' | b'~')
|
||
)
|
||
})
|
||
}
|
||
|
||
/// Every identifier the file binds through a `for` header or a `function` parameter list.
|
||
fn binder_names(text: &str) -> BTreeSet<String> {
|
||
let mut out = BTreeSet::new();
|
||
let b: Vec<char> = text.chars().collect();
|
||
let word_at = |i: usize, w: &str| -> bool {
|
||
let n = w.chars().count();
|
||
i + n <= b.len()
|
||
&& b[i..i + n].iter().copied().eq(w.chars())
|
||
&& (i == 0 || !(b[i - 1].is_alphanumeric() || b[i - 1] == '_'))
|
||
&& b.get(i + n)
|
||
.is_none_or(|c| !(c.is_alphanumeric() || *c == '_'))
|
||
};
|
||
let take = |out: &mut BTreeSet<String>, s: &str| {
|
||
for name in s.split(|c: char| !c.is_alphanumeric() && c != '_') {
|
||
if is_ident(name) {
|
||
out.insert(name.to_string());
|
||
}
|
||
}
|
||
};
|
||
for i in 0..b.len() {
|
||
if word_at(i, "for") {
|
||
// `for i = 1, n do` and `for k, v in pairs(t) do` — the header ends at the `=`/`in`.
|
||
let rest: String = b[i + 3..].iter().take(200).collect();
|
||
let head = rest
|
||
.find(" in ")
|
||
.map(|p| &rest[..p])
|
||
.or_else(|| assignment_at(&rest).map(|p| &rest[..p]))
|
||
.unwrap_or(&rest);
|
||
take(&mut out, head);
|
||
}
|
||
if word_at(i, "function") {
|
||
let rest: String = b[i + 8..].iter().take(400).collect();
|
||
if let Some(open) = rest.find('(') {
|
||
if let Some(close) = rest[open..].find(')') {
|
||
take(&mut out, &rest[open + 1..open + close]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The widget kind a right-hand side **provably** produces, or `None`.
|
||
///
|
||
/// Only the factory calls, and only with a literal kind: `CreateFrame(kind, …)` with a variable
|
||
/// first argument is exactly as untypable as `getglobal(n)` and is treated the same.
|
||
fn widget_kind_of_expression(rhs: &str) -> Option<&'static str> {
|
||
// This is the ONE pass that reads un-blanked string literals, so it is also the one pass that
|
||
// has to check it is not standing *inside* one: `local x = "CreateFrame('Button')"` types
|
||
// nothing. An odd number of quotes before the match is the cheap, deterministic test.
|
||
let outside_string =
|
||
|i: usize| rhs[..i].chars().filter(|c| *c == '"' || *c == '\'').count() % 2 == 0;
|
||
if let Some(i) = rhs
|
||
.match_indices("CreateFrame")
|
||
.find(|(i, _)| outside_string(*i))
|
||
{
|
||
let i = i.0;
|
||
// Not `lib.CreateFrame(...)` — a qualified call is some other function of the same name.
|
||
let qualified = rhs[..i].trim_end().ends_with(['.', ':']);
|
||
let after = &rhs[i + "CreateFrame".len()..];
|
||
if !qualified && after.trim_start().starts_with('(') {
|
||
let arg = after[after.find('(')? + 1..].trim_start();
|
||
let quote = arg.chars().next().filter(|c| *c == '"' || *c == '\'')?;
|
||
let lit = &arg[1..arg[1..].find(quote)? + 1];
|
||
return PROBE_FRAME_KINDS
|
||
.iter()
|
||
.find(|k| k.eq_ignore_ascii_case(lit))
|
||
.copied();
|
||
}
|
||
return None;
|
||
}
|
||
// The two region leaves have factories of their own, and they are worth typing: a region method
|
||
// called on a region is the single biggest source of "present on some kinds only" noise, and
|
||
// every one of these moves a row OUT of the ambiguous table by answering it properly.
|
||
for (call, kind) in [
|
||
(":CreateTexture", "Texture"),
|
||
(":CreateFontString", "FontString"),
|
||
] {
|
||
if rhs.match_indices(call).any(|(i, _)| outside_string(i)) {
|
||
return Some(kind);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn is_ident(s: &str) -> bool {
|
||
!s.is_empty()
|
||
&& s.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||
&& !s.starts_with(|c: char| c.is_ascii_digit())
|
||
}
|
||
|
||
/// Walk `obj:Name(` call sites and file each under the receiver we can (or cannot) type.
|
||
fn attribute_calls(text: &str, typed: &BTreeMap<String, Option<&'static str>>, scan: &mut Scan) {
|
||
let src: Vec<char> = text.chars().collect();
|
||
let is_word = |c: char| c.is_alphanumeric() || c == '_';
|
||
let mut i = 0;
|
||
while i < src.len() {
|
||
if src[i] != ':' || (i + 1 < src.len() && src[i + 1] == ':') {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
// The METHOD name, right of the colon.
|
||
let mut m = i + 1;
|
||
while m < src.len() && src[m] == ' ' {
|
||
m += 1;
|
||
}
|
||
let start = m;
|
||
while m < src.len() && is_word(src[m]) {
|
||
m += 1;
|
||
}
|
||
let name: String = src[start..m].iter().collect();
|
||
let mut p = m;
|
||
while p < src.len() && src[p] == ' ' {
|
||
p += 1;
|
||
}
|
||
// Same guards as `scan_lua`'s method arm, so the two lists cannot disagree about what a
|
||
// method call is: it must be called, and it must be capitalised.
|
||
if name.is_empty()
|
||
|| p >= src.len()
|
||
|| src[p] != '('
|
||
|| !name.starts_with(|c: char| c.is_uppercase())
|
||
{
|
||
i += 1;
|
||
continue;
|
||
}
|
||
// The RECEIVER, left of the colon: a bare identifier and nothing else. `a.b:C()`,
|
||
// `getglobal(n):C()` and `t[1]:C()` are all real corpus shapes and none of them is typable.
|
||
let mut r = i;
|
||
while r > 0 && src[r - 1] == ' ' {
|
||
r -= 1;
|
||
}
|
||
let end = r;
|
||
while r > 0 && is_word(src[r - 1]) {
|
||
r -= 1;
|
||
}
|
||
let receiver: String = src[r..end].iter().collect();
|
||
let plain =
|
||
!receiver.is_empty() && (r == 0 || !matches!(src[r - 1], '.' | ':' | ']' | ')'));
|
||
if !plain {
|
||
scan.loose_methods.insert(name);
|
||
} else {
|
||
match typed.get(&receiver) {
|
||
Some(Some(kind)) => {
|
||
scan.kind_calls.insert(((*kind).to_string(), name));
|
||
}
|
||
// The file demonstrably rebinds this name, so it is untypable HERE — and it must
|
||
// not fall through to the published-name lookup either, or a local called `Minimap`
|
||
// would be answered by the global of that name.
|
||
Some(None) => {
|
||
scan.loose_methods.insert(name);
|
||
}
|
||
// The receiver might still be a PUBLISHED widget — but only the live arena knows,
|
||
// so the name travels to Rust rather than being guessed here.
|
||
None => {
|
||
scan.global_calls.insert((receiver, name));
|
||
}
|
||
}
|
||
}
|
||
i = p;
|
||
}
|
||
}
|
||
|
||
/// What one pass of [`scan_lua`] found.
|
||
///
|
||
/// **A struct rather than a row of `&mut BTreeSet` out-parameters**, which is what this was: the
|
||
/// scan grew a fourth and fifth set when the widget-method class was made rankable, and five
|
||
/// positionally-identical sets in one signature is how the wrong one gets passed and nobody sees it
|
||
/// — the exact class of silent instrument fault this arc keeps writing records about.
|
||
#[derive(Default)]
|
||
struct Scan {
|
||
/// `Foo(` — API-shaped call sites. Missing ones become [`AddonReport::missing_globals`].
|
||
called: BTreeSet<String>,
|
||
/// `Foo.bar` / `Foo:baz` — names the addon *indexes*. → [`AddonReport::missing_tables`].
|
||
indexed: BTreeSet<String>,
|
||
/// `obj:Name(` — method calls, receiver unknowable. → [`AddonReport::missing_methods`].
|
||
methods: BTreeSet<String>,
|
||
/// Globals the addon binds itself, in any of the shapes below.
|
||
defined: BTreeSet<String>,
|
||
/// FIELDS the addon binds itself — `function T:N`, `function T.N`, `T.N = …`. These are the
|
||
/// method definitions a scanner *can* see, and subtracting them is what keeps an embedded OO
|
||
/// library's own `self:Foo()` calls out of the widget-method ranking.
|
||
defined_methods: BTreeSet<String>,
|
||
/// Fields the addon READS without calling — `if self.OnMouseUp then`. A feature-tested method
|
||
/// is one the addon has already written a fallback for, so it is not a blocker; see the arm
|
||
/// that fills this for the two library idioms that made the rule necessary.
|
||
tested_fields: BTreeSet<String>,
|
||
/// `(kind, method)` — call sites [`scan_receivers`] could TYPE from the file itself, because
|
||
/// the receiver was bound by a widget factory: `local f = CreateFrame("MessageFrame")` then
|
||
/// `f:AddMessage(…)`. → [`AddonReport::kind_missing_methods`].
|
||
kind_calls: BTreeSet<(String, String)>,
|
||
/// `(receiver name, method)` — the receiver is a bare identifier the file does not bind, so it
|
||
/// may be a **published** widget. Only the live arena can say ([`UiScript::widget_kind`]), so
|
||
/// the pair travels to Rust unresolved.
|
||
global_calls: BTreeSet<(String, String)>,
|
||
/// Methods called on a receiver nothing can type — `self:Foo()`, `a.b:Foo()`,
|
||
/// `getglobal(n):Foo()`. The size of this set is the honest measure of how much the per-kind
|
||
/// census is *guessing about*, and it is why [`AddonReport::ambiguous_methods`] exists.
|
||
loose_methods: BTreeSet<String>,
|
||
}
|
||
|
||
/// Collect API-shaped call sites and the file's own top-level definitions.
|
||
fn scan_lua(text: &str, scan: &mut Scan) {
|
||
let Scan {
|
||
called,
|
||
indexed,
|
||
methods,
|
||
defined,
|
||
defined_methods,
|
||
tested_fields,
|
||
..
|
||
} = scan;
|
||
let text = &strip_lua_noise(text);
|
||
let bytes: Vec<char> = text.chars().collect();
|
||
let ident = |start: usize| -> (String, usize) {
|
||
let mut i = start;
|
||
while i < bytes.len() && (bytes[i].is_alphanumeric() || bytes[i] == '_') {
|
||
i += 1;
|
||
}
|
||
(bytes[start..i].iter().collect(), i)
|
||
};
|
||
let mut i = 0;
|
||
while i < bytes.len() {
|
||
if bytes[i].is_alphabetic() || bytes[i] == '_' {
|
||
// A qualified name (`self.foo`, `string.format`) is not a global call site — skip the
|
||
// whole chain, except the `C_Thing.Verb` shape which IS one.
|
||
let prev = if i > 0 { bytes[i - 1] } else { '\0' };
|
||
let qualified = prev == '.' || prev == ':';
|
||
let (word, next) = ident(i);
|
||
let mut j = next;
|
||
while j < bytes.len() && bytes[j] == ' ' {
|
||
j += 1;
|
||
}
|
||
let capitalised = word.chars().next().is_some_and(char::is_uppercase);
|
||
if !qualified && j < bytes.len() && bytes[j] == '(' && capitalised {
|
||
called.insert(word.clone());
|
||
}
|
||
// **`obj:Name(` — the widget-method class**, and the third thing an addon can want
|
||
// that we might not have. It is neither a global nor an indexed table, so neither arm
|
||
// above can see it, and until this existed the only trace a missing method left was a
|
||
// ranked error row reading `attempt to call method 'X' (a nil value)` — with the name
|
||
// collapsed away by the normalisation.
|
||
//
|
||
// The receiver is deliberately ignored: a static scan cannot type `obj`. The same
|
||
// leading-capital guard as the other two arms is the only filter, and it is doing the
|
||
// same work — every widget method in the 1.12 surface is capitalised, while an
|
||
// addon's private `obj:doThing()` helpers usually are not.
|
||
if prev == ':' && j < bytes.len() && bytes[j] == '(' && capitalised {
|
||
methods.insert(word.clone());
|
||
}
|
||
if prev == '.' {
|
||
let assigned = bytes.get(j) == Some(&'=') && bytes.get(j + 1) != Some(&'=');
|
||
if assigned {
|
||
// `T.Name = …` — a field the addon binds on a table it already has. `f.Update
|
||
// = function` is the corpus's other way of writing a method, and without
|
||
// crediting it every subsequent `f:Update()` would read as a widget binding we
|
||
// lack.
|
||
defined_methods.insert(word.clone());
|
||
} else if bytes.get(j) != Some(&'(') {
|
||
// **A field the source READS rather than calls is a feature test, and a
|
||
// feature-tested method is by definition not a blocker** — the addon has
|
||
// already written the branch it takes when the method is absent.
|
||
//
|
||
// This is not a heuristic dressed as a rule; it is the corpus's dominant idiom,
|
||
// verbatim, in the two libraries that between them owned ten of this ranking's
|
||
// top sixteen rows:
|
||
//
|
||
// if type(self.OnMouseUp) == "function" then self:OnMouseUp(arg1) end
|
||
// -- FuBarPlugin-2.0.lua:768
|
||
// elseif type(self) == "table" and self.CompareTo then
|
||
// return self:CompareTo(other) == 0
|
||
// -- AceOO-2.0.lua:353
|
||
//
|
||
// Neither `OnMouseUp` nor `CompareTo` is defined anywhere in the corpus: they
|
||
// are optional callbacks a *plugin author* may supply, dispatched by hand.
|
||
// They are not widget methods, they are not missing, and no amount of
|
||
// definition-scanning could ever have subtracted them — the definition does not
|
||
// exist. What does exist, one line up, is the addon saying it can live without.
|
||
//
|
||
// A dotted CALL (`MyLib.Helper()`) is neither read nor test and lands in
|
||
// neither set: it is a plain function call through a table, already the
|
||
// `indexed` arm's business.
|
||
tested_fields.insert(word.clone());
|
||
}
|
||
}
|
||
// **A name the addon INDEXES is a surface it expects too**, and this scan was blind to
|
||
// every one of them. `ColorPickerFrame.func = …`, `GameTooltip:AddLine(…)`,
|
||
// `ChatFrame1:AddMessage(…)` — a FRAME or a TABLE global, never followed by `(`, so the
|
||
// call arm above cannot see it. A window **86 corpus addons reach** scored exactly 0 on
|
||
// the most-wanted list until this arm existed, and the same blindness hid every other
|
||
// FrameXML frame global.
|
||
//
|
||
// Same two guards as the call arm (unqualified, capitalised) and the same `defined`
|
||
// subtraction, so an addon's own `MyAddon = {}` namespace stays its own.
|
||
if !qualified && j < bytes.len() && (bytes[j] == '.' || bytes[j] == ':') && capitalised
|
||
{
|
||
indexed.insert(word.clone());
|
||
}
|
||
if word == "function" {
|
||
let mut k = next;
|
||
while k < bytes.len() && bytes[k] == ' ' {
|
||
k += 1;
|
||
}
|
||
if k < bytes.len() && (bytes[k].is_alphabetic() || bytes[k] == '_') {
|
||
let (fname, mut end) = ident(k);
|
||
defined.insert(fname);
|
||
// `function T:N(`, `function T.N(`, `function A.B.C:D(` — every name after the
|
||
// first is a FIELD bound on a table the addon already has, not a global. This
|
||
// is how an addon and every embedded Ace/FuBar library declares its methods,
|
||
// and it is the single biggest subtraction keeping the widget-method ranking
|
||
// from being a list of `AceEvent-2.0`'s verbs.
|
||
while end < bytes.len() && (bytes[end] == '.' || bytes[end] == ':') {
|
||
let (part, next_end) = ident(end + 1);
|
||
if part.is_empty() {
|
||
break;
|
||
}
|
||
defined_methods.insert(part);
|
||
end = next_end;
|
||
}
|
||
}
|
||
}
|
||
i = next;
|
||
continue;
|
||
}
|
||
i += 1;
|
||
}
|
||
// Assignments — `Foo = …`, the other way an addon defines a global it later calls, AND
|
||
// `local Foo = …`, which is not a global at all and is the reason this scan exists in the
|
||
// shape it does.
|
||
//
|
||
// **The local arm is a correction, and it was worth five wrong rows at the top of `demand`.**
|
||
// The doc above claims locals are handled by only counting leading-capital names; they are
|
||
// not, because a local can be capitalised. `local CheckShow = function(self, panelId)` in
|
||
// `FuBarPlugin-2.0.lua` is called four lines later as `CheckShow(...)`, and the scanner read
|
||
// that as a missing API in **74 addons** — the corpus's most-wanted global, ahead of anything
|
||
// real. `DropDownList1_Show`, `WorldFrame_OnMouseDown`, `WorldFrame_OnMouseUp` (60 each) and
|
||
// `ColorPickerOkayButton_OnClick` (51) are the same `local X = <expr>` shape in Dewdrop-2.0
|
||
// and AceConsole-2.0. Five rows, 305 addon-mentions, all phantom; the first true row was
|
||
// `SendAddonMessage` at 24.
|
||
//
|
||
// **The trade, stated:** `defined` is addon-wide while a `local` is file-scoped, so an addon
|
||
// that shadows a real API name in one file now suppresses that name in all of them. That is an
|
||
// under-report, which is the error this instrument's module doc already chooses to prefer —
|
||
// and it is bounded by the addon, where the over-report was unbounded.
|
||
for line in text.lines() {
|
||
let t = line.trim_start();
|
||
let body = t.strip_prefix("local ").unwrap_or(t);
|
||
let Some(eq) = body.find('=') else { continue };
|
||
let (lhs, rhs) = body.split_at(eq);
|
||
for name in lhs.split(',') {
|
||
let name = name.trim();
|
||
if name.is_empty()
|
||
|| !name.chars().all(|c| c.is_alphanumeric() || c == '_')
|
||
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||
{
|
||
continue;
|
||
}
|
||
// **A self-localisation is not a definition.** `local GetTime = GetTime` — and its
|
||
// `local a, b = a, b` and `local X = X or {}` cousins — binds the local from the
|
||
// GLOBAL of the same name, so the global really is demanded and hiding it is exactly
|
||
// the under-report this pass was supposed to avoid. **143 corpus sites, 41 distinct
|
||
// names**, and the shape is the performance idiom every library writes at file top, so
|
||
// it is concentrated on precisely the APIs an addon uses most.
|
||
if rhs
|
||
.split(|c: char| !c.is_alphanumeric() && c != '_')
|
||
.any(|tok| tok == name)
|
||
{
|
||
continue;
|
||
}
|
||
defined.insert(name.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The aggregate 1188 asks for: how many addons want each missing name, most-wanted first.
|
||
///
|
||
/// **This is the number phase 5 is prioritised by** — one addon wanting a verb is a curiosity,
|
||
/// forty wanting it is the next thing to build.
|
||
pub fn demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.missing_globals)
|
||
}
|
||
|
||
/// **How many ADDONS want each name**, most-wanted first, ties broken alphabetically — the one
|
||
/// shape every ranking here has, factored out when the fifth arrived.
|
||
///
|
||
/// The unit is deliberately the addon, never the call site: a library file replicated across sixty
|
||
/// folders would otherwise decide the whole ranking by itself (1207), and the queue this feeds is
|
||
/// "how many players notice if we build it", which counts addons.
|
||
fn rank(
|
||
reports: &[AddonReport],
|
||
pick: impl Fn(&AddonReport) -> &Vec<String>,
|
||
) -> Vec<(String, usize)> {
|
||
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
|
||
for r in reports {
|
||
for n in pick(r) {
|
||
*counts.entry(n.as_str()).or_default() += 1;
|
||
}
|
||
}
|
||
let mut out: Vec<(String, usize)> = counts
|
||
.into_iter()
|
||
.map(|(k, v)| (k.to_string(), v))
|
||
.collect();
|
||
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||
out
|
||
}
|
||
|
||
/// [`demand`]'s twin for **templates** (decision 1203) — how many addons name each template we
|
||
/// have never declared, most-wanted first.
|
||
///
|
||
/// The list `CreateFrame`'s fourth argument working made measurable: honouring it moved the
|
||
/// headline by exactly zero, because an unresolved template was never a load error in the first
|
||
/// place. This is what actually stands between an addon and a painted window.
|
||
pub fn template_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.missing_templates)
|
||
}
|
||
|
||
/// [`demand`] over [`AddonReport::missing_tables`] — the frames and tables an addon indexes and we
|
||
/// do not have. Its own list because a missing frame is FrameXML to transcribe, not a Rust verb.
|
||
pub fn table_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.missing_tables)
|
||
}
|
||
|
||
/// [`demand`] over [`AddonReport::missing_methods`] — **the widget-method queue**, and the axis
|
||
/// this survey was blind to while the arc spent a day building exactly these.
|
||
///
|
||
/// Its own list for the same reason as the other three (1207): the three queues go to different
|
||
/// places. A missing global is a Rust verb to write; a missing frame is FrameXML to transcribe; a
|
||
/// missing method is a widget **binding** — and sometimes not a verb at all but a whole *kind* left
|
||
/// unwired, which is what the head of this list said the first time it was run.
|
||
///
|
||
/// Read [`AddonReport::missing_methods`] before quoting a row: this one over-reports by
|
||
/// construction and the doc there says exactly how far.
|
||
pub fn method_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.missing_methods)
|
||
}
|
||
|
||
/// [`method_demand`] over [`AddonReport::optional_methods`] — the methods addons **work around**.
|
||
///
|
||
/// Ranked apart from the blocking list on purpose, and never merged into it: nobody is stuck on
|
||
/// these, so mixing them in would rank a name nobody needs above one somebody does. Read as
|
||
/// "implementing this improves N addons silently", not as "N addons are broken".
|
||
pub fn optional_method_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.optional_methods)
|
||
}
|
||
|
||
/// [`demand`] over [`AddonReport::kind_missing_methods`] — **the per-kind widget-method queue**,
|
||
/// and the one row shape this whole survey was structurally unable to print until it existed.
|
||
///
|
||
/// Read it before [`method_demand`], not after: a row here names a kind an addon's call actually
|
||
/// lands on, so it is a blocker whether or not some *other* widget answers the same verb. The
|
||
/// `(on …)` tail says which job it is — `(on no kind)` is a verb to write, `(on
|
||
/// ScrollingMessageFrame)` is a verb we already wrote and forgot to wire to its sibling, which is
|
||
/// the `MessageFrame:AddMessage` shape decision 1228 is about.
|
||
pub fn kind_method_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.kind_missing_methods)
|
||
}
|
||
|
||
/// [`demand`] over [`AddonReport::ambiguous_methods`] — the names whose answer **depends on the
|
||
/// kind** at a call site nothing could type.
|
||
///
|
||
/// An **upper bound**, and it says so in every row: the receiver may well be one of the kinds that
|
||
/// answers. It is printed anyway because the alternative — resolving it against the whole probe set
|
||
/// and calling it present — is exactly how `AddMessage` scored zero while three addons died on it.
|
||
/// Its length is a reading of the *attributor's* reach, not of ours.
|
||
///
|
||
/// **NARROWEST first, then by demand** — the only table here that is not ranked by count alone, and
|
||
/// the ordering is the row's whole usefulness rather than a preference.
|
||
///
|
||
/// Measured on the corpus, demand order puts `StopMovingOrSizing` (122 addons, absent only on the
|
||
/// two region leaves) at the top: an untyped receiver being a Texture *there* is not a thing anyone
|
||
/// writes, so the row is noise by construction — and with 139 rows and a printed head, noise at the
|
||
/// top is the same as hiding the rest. The fewer kinds answer a name, the likelier an untyped
|
||
/// receiver is one of the kinds that does not, and two-kind rows are literally the
|
||
/// `MessageFrame:AddMessage` shape (`AddMessage` is answered by exactly the two message frames).
|
||
/// So the key is the size of the answering set, ascending, then demand.
|
||
///
|
||
/// Nothing is filtered — the wide rows follow in their own demand order, and the printed header
|
||
/// says which half is which.
|
||
pub fn ambiguous_method_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
let mut rows = rank(reports, |r| &r.ambiguous_methods);
|
||
// The row TEXT is the sort key, so the ordering and the printed row can never disagree about
|
||
// how narrow a name is: `only on` is [`only_on`]'s own word for "at most half the kinds", and
|
||
// that branch never truncates its list, so counting separators recovers the set size exactly.
|
||
rows.sort_by_key(|(row, count)| {
|
||
let narrow = row.contains("(only on ");
|
||
(
|
||
!narrow,
|
||
if narrow { row.matches(", ").count() } else { 0 },
|
||
std::cmp::Reverse(*count),
|
||
)
|
||
});
|
||
rows
|
||
}
|
||
|
||
/// [`template_demand`] over [`AddonReport::missing_inherits`] — the same ranking, the other axis.
|
||
pub fn inherits_demand(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
rank(reports, |r| &r.missing_inherits)
|
||
}
|
||
|
||
/// The **first** error of every addon that failed to load, normalised and ranked (decision 1193).
|
||
///
|
||
/// [`demand`]'s twin, and the more useful of the two for a while. `demand` answers *what would an
|
||
/// addon like to call*; this answers **what actually stopped it**, and those are different
|
||
/// questions with different top entries. The first error is the load-bearing one because a chunk
|
||
/// stops at its first raise: everything after it in that file never ran, so ranking all errors
|
||
/// would count the same root cause once per victim.
|
||
///
|
||
/// Normalisation is deliberately crude and deliberately stated: quoted names (`'setn'`,
|
||
/// `'GetMouseFocus'`) collapse to `'X'`, source positions are dropped, and the `<file>: ` prefix
|
||
/// goes. That turns 60 different-looking lines into one row reading `runtime error: 'X' is
|
||
/// obsolete`, which is what made the Lua 5.0/5.1 dialect gap visible at all — before this the
|
||
/// only view was per-addon, where it read as sixty unrelated failures.
|
||
pub fn blockers(reports: &[AddonReport]) -> Vec<(String, usize)> {
|
||
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
||
for r in reports.iter().filter(|r| !r.loaded) {
|
||
if let Some(e) = r.errors.first() {
|
||
*counts.entry(normalise_error(e)).or_default() += 1;
|
||
}
|
||
}
|
||
let mut out: Vec<(String, usize)> = counts.into_iter().collect();
|
||
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||
out
|
||
}
|
||
|
||
/// The addons **behind** one [`blockers`] row, with their verbatim first errors.
|
||
///
|
||
/// The ranked table collapses every quoted name to `'X'` on purpose (1193) — that collapse is what
|
||
/// made the Lua-dialect gap readable as one wall instead of sixty. The cost is that reading back
|
||
/// *through* it has been a manual grep every time, and twice this arc that manual step was where
|
||
/// the finding actually was: decision **1206** came from noticing that eleven of fifteen
|
||
/// `bad argument #1 to 'X' (table expected, got nil)` rows were one missing table, and **1210** came
|
||
/// from asking which addons were behind a 74-count row (none: it was a Lua local).
|
||
///
|
||
/// `pattern` is a plain substring match against **the normalised row *or* the raw error text**, and
|
||
/// it is checked against **every** error, not only the ranked one.
|
||
///
|
||
/// **Both halves are corrections, and the first one is why this read-back was blind to an entire
|
||
/// class.** It used to match the normalised row alone — and step 2 of [`normalise_error`] replaces
|
||
/// every quoted name with `'X'`. So the one substring a reader would ever type was the one
|
||
/// substring the matcher deleted before comparing. `--why GetBackdrop` answered `(none)` while
|
||
/// `BuffCheck2/BuffCheck2.lua:448` was dying on `attempt to call method 'GetBackdrop' (a nil
|
||
/// value)`; `--why AddMessage` answered `(none)` with three addons behind it. The instrument even
|
||
/// printed *"matched against the NORMALISED row"* underneath, which reads as an explanation and is
|
||
/// exactly why nobody chased it (1212: a comment that says why a known problem is already handled
|
||
/// is the dangerous kind).
|
||
///
|
||
/// This is not a widget-method bug. **No name of any kind was findable** — not `setn`, not
|
||
/// `SendAddonMessage`, not a file path. The method class is simply the one that is *only* ever
|
||
/// identified by its quoted name, so it is where the hole became impossible to miss.
|
||
///
|
||
/// The second half: a chunk stops at its first raise, but a session start does not — an addon's
|
||
/// handlers keep firing after one of them dies, so the error naming the method can be the fourth in
|
||
/// the list. Searching only the first is the same blindness one level down.
|
||
///
|
||
/// **The ranked correspondence is preserved, which is what makes the read-back trustworthy.** A hit
|
||
/// on the error the tables actually counted (a failed addon's first LOAD error; a loaded addon's
|
||
/// first SESSION error) is labelled `[load]`/`[session]` with no index — so counting the
|
||
/// index-less rows reproduces the ranked row's count exactly. Anything else is `[load #3]`,
|
||
/// `[session #2]`: real, findable, and visibly not part of that count.
|
||
pub fn blocked_by(reports: &[AddonReport], pattern: &str) -> Vec<(String, String)> {
|
||
let mut out = Vec::new();
|
||
for r in reports {
|
||
// A failed addon is ranked through its LOAD error; one that loaded clean is ranked through
|
||
// what its handlers raised at session start. Both lists are searched for every addon,
|
||
// because a reader asking "which addons?" means whichever table they were looking at — and
|
||
// because an addon that failed to load still runs a session start.
|
||
//
|
||
// The USE column is searched too, and its first error IS ranked (its table counts every
|
||
// addon's first, with no `loaded` filter) — without this, `--why` could not read back the
|
||
// one column whose rows a human most wants the text of. The UI probe ranks nothing, so its
|
||
// rows are always indexed.
|
||
let ranked_kind = if r.loaded { "session" } else { "load" };
|
||
for (list, kind) in [
|
||
(&r.errors, "load"),
|
||
(&r.session_errors, "session"),
|
||
(&r.probe_errors, "probe"),
|
||
(&r.used.errors, "used"),
|
||
] {
|
||
for (i, e) in list.iter().enumerate() {
|
||
if !(e.contains(pattern) || normalise_error(e).contains(pattern)) {
|
||
continue;
|
||
}
|
||
let ranked = i == 0 && (kind == ranked_kind || kind == "used");
|
||
let label = if ranked {
|
||
kind.to_string()
|
||
} else {
|
||
format!("{kind} #{}", i + 1)
|
||
};
|
||
out.push((format!("{} [{label}]", r.name), e.clone()));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The addons behind one **method-table** row — [`blocked_by`]'s twin for the rankings that are
|
||
/// built by scanning rather than by catching an error.
|
||
///
|
||
/// `blocked_by` reads back through error text; nothing read back through a demand row, and the cost
|
||
/// of that was paid immediately: verifying the per-kind table's own top row (`EditBox:SetFontObject`,
|
||
/// 63 addons) meant patching a debug print into the example and re-running the corpus, because
|
||
/// there was no way to ask *which* 63. "Read the line before ranking" (1207, 1210, 1214, 1227 — four
|
||
/// records in one arc, three of which found fiction at the top of a ranking) is the standing rule
|
||
/// here, and a rule whose only tool is a temporary code edit gets skipped.
|
||
///
|
||
/// A plain substring match against the row as printed, over all four method tables, each hit
|
||
/// labelled with the table it came from — so `--why "EditBox:SetFontObject"` names the addons and
|
||
/// `--why SetFontObject` finds every table that mentions it.
|
||
/// Which addons carry `pattern` in one of the three demand lists — the read-back those rankings
|
||
/// never had.
|
||
///
|
||
/// Every ranked table above is a **count**, and until this existed nothing could ask which addons a
|
||
/// count was made of. That is not a theoretical gap: `GetChannelList` ranked 4 while exactly one
|
||
/// corpus addon's source names it, and establishing that took a hand-rolled grep across a corpus
|
||
/// whose entries are symlinks (so `grep -r` silently reads none of them). A number you cannot open
|
||
/// is a claim rather than a measurement — the same lesson `--why`'s error read-back learned in
|
||
/// 1210/1218/1227, applied to the other three tables.
|
||
///
|
||
/// Matched case-insensitively on a substring, exactly like the error read-back, so a half-remembered
|
||
/// name still finds its row.
|
||
pub fn wanters(
|
||
reports: &[AddonReport],
|
||
pattern: &str,
|
||
pick: impl Fn(&AddonReport) -> &Vec<String>,
|
||
) -> Vec<(String, String)> {
|
||
let needle = pattern.to_ascii_lowercase();
|
||
let mut out = Vec::new();
|
||
for r in reports {
|
||
for n in pick(r) {
|
||
if n.to_ascii_lowercase().contains(&needle) {
|
||
out.push((r.name.clone(), n.clone()));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
pub fn method_rows_matching(reports: &[AddonReport], pattern: &str) -> Vec<(String, String)> {
|
||
let mut out = Vec::new();
|
||
for r in reports {
|
||
for (list, table) in [
|
||
(&r.kind_missing_methods, "by-kind"),
|
||
(&r.missing_methods, "missing"),
|
||
(&r.ambiguous_methods, "ambiguous"),
|
||
(&r.optional_methods, "feature-tested"),
|
||
] {
|
||
for row in list.iter().filter(|row| row.contains(pattern)) {
|
||
out.push((format!("{} [{table}]", r.name), row.clone()));
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// [`normalise_error`], public so the report can rank SESSION-start errors with the same collapse
|
||
/// the load-time table uses — one wall must read as one row on both sides of the load boundary.
|
||
pub fn normalise(raw: &str) -> String {
|
||
normalise_error(raw)
|
||
}
|
||
|
||
/// The byte index of the next `'` that actually OPENS a quote — one with a non-alphanumeric on at
|
||
/// least one side. See [`normalise_error`] step 2.
|
||
fn quote_open(s: &str) -> Option<usize> {
|
||
let b = s.as_bytes();
|
||
let mut from = 0usize;
|
||
while let Some(rel) = s[from..].find('\'') {
|
||
let i = from + rel;
|
||
let prev_word = i > 0 && b[i - 1].is_ascii_alphanumeric();
|
||
let next_word = b.get(i + 1).is_some_and(u8::is_ascii_alphanumeric);
|
||
if !(prev_word && next_word) {
|
||
return Some(i);
|
||
}
|
||
from = i + 1;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// One load error with everything addon-specific removed, so two addons hitting the same wall
|
||
/// produce the same string. See [`blockers`] for why the crudeness is the point.
|
||
fn normalise_error(raw: &str) -> String {
|
||
// 1 · Keep from the LAST `error: ` on, which drops every `<file>: <Script file="…">: ` prefix
|
||
// without having to know their shapes — and cut mlua's `stack traceback:` tail, which is
|
||
// per-addon detail that would split one wall into a dozen rows.
|
||
let core = raw.rfind("error: ").map_or(raw, |i| &raw[i..]);
|
||
let core = core
|
||
.split_once("stack traceback:")
|
||
.map_or(core, |(head, _)| head)
|
||
.trim();
|
||
|
||
// 2 · Every quoted name becomes `'X'` — the name is what varies between two addons that hit
|
||
// the same wall, and it is already ranked by `demand`. Both quote kinds, because mlua
|
||
// writes a chunk name as `[string "MyFrame:OnLoad"]`.
|
||
//
|
||
// **An APOSTROPHE INSIDE A WORD is not a quote.** The reference's own diagnostics are
|
||
// written in English — `Couldn't find region named '%s'`, `Couldn't find relative frame:
|
||
// %s` — and the `'` in `Couldn't` opened a quote that then swallowed the rest of the line,
|
||
// so the row read `Couldn'X'` and named nothing. Letters on both sides is the whole test:
|
||
// a real opening quote is preceded by a space, a bracket or the line start.
|
||
let squashed = core.replace('"', "'");
|
||
let mut collapsed = String::with_capacity(squashed.len());
|
||
let mut rest = squashed.as_str();
|
||
while let Some(open) = quote_open(rest) {
|
||
collapsed.push_str(&rest[..open]);
|
||
collapsed.push_str("'X'");
|
||
match rest[open + 1..].find('\'') {
|
||
Some(close) => rest = &rest[open + 1 + close + 1..],
|
||
None => {
|
||
rest = "";
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
collapsed.push_str(rest);
|
||
|
||
// 3 · Source positions carry no information once the name is gone.
|
||
collapsed
|
||
.split_whitespace()
|
||
.filter(|t| !is_position(t))
|
||
.collect::<Vec<_>>()
|
||
.join(" ")
|
||
}
|
||
|
||
/// Is this whitespace-separated token a source position rather than words?
|
||
///
|
||
/// `crates/benilla-ui/src/script/mod.rs:406:305:`, `[string "X"]:2:`, `MyAddon.lua:12:` — all of
|
||
/// them a `<where>:<line>[:<col>]:` tail, which is the only shape mlua emits.
|
||
fn is_position(tok: &str) -> bool {
|
||
if tok == "[string" {
|
||
return true; // the opening half of mlua's `[string "…"]:N:` chunk name
|
||
}
|
||
let t = tok.strip_suffix(':').unwrap_or(tok);
|
||
let mut tail = t.rsplit(':');
|
||
let last = tail.next().unwrap_or("");
|
||
if last.is_empty() || !last.chars().all(|c| c.is_ascii_digit()) {
|
||
return false;
|
||
}
|
||
// `<where>:<line>` is enough; a third field just means a column was included.
|
||
tail.next().is_some()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Two addons hitting one wall must produce **one** row — that collapse is the whole value of
|
||
/// [`blockers`], and it is what made the Lua-dialect gap readable as `61` rather than as sixty
|
||
/// unrelated-looking lines (decision 1193).
|
||
#[test]
|
||
fn one_wall_is_one_row_however_it_was_reported() {
|
||
let same = [
|
||
"libs\\AceLibrary\\AceLibrary.lua: runtime error: crates/benilla-ui/src/script/mod.rs:406:305: 'setn' is obsolete",
|
||
"Libs/AceLibrary.lua: runtime error: crates/benilla-ui/src/script/mod.rs:406:301: 'setn' is obsolete",
|
||
"embeds.xml: <Script file=\"AceLibrary.lua\">: runtime error: crates/benilla-ui/src/loader/mod.rs:218:13: 'setn' is obsolete",
|
||
];
|
||
let normalised: BTreeSet<String> = same.iter().map(|e| normalise_error(e)).collect();
|
||
assert_eq!(
|
||
normalised.into_iter().collect::<Vec<_>>(),
|
||
vec!["error: 'X' is obsolete"],
|
||
"the file, the source position and the quoted name are all per-addon noise"
|
||
);
|
||
}
|
||
|
||
/// **A raise in a DEMAND-LOADED SIBLING is that sibling's row, not the surveyed addon's**
|
||
/// (decision 2107) — the FuBar shape, verbatim from the corpus.
|
||
///
|
||
/// `FuBar.lua:1034`'s `LoadLoadOnDemandPlugins` demand-loads every installed `FuBar_*` the
|
||
/// moment any one of them seats, so surveying `FuBar_MoneyFu` runs `FuBar_BattlegroundFu`'s
|
||
/// file scope. Its embedded `Glory-2.0` wants a `Deformat-2.0` a dozen of its siblings ship and
|
||
/// it does not — this module's one-VM-per-addon limitation, and `FuBar_BattlegroundFu`'s own
|
||
/// row, where the survey counts it once. It cost 49 addons their session-start row.
|
||
#[test]
|
||
fn a_raise_in_another_installed_addons_file_is_that_addons_row() {
|
||
let installed: BTreeSet<String> = ["fubar", "fubar_moneyfu", "fubar_battlegroundfu", "ace"]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect();
|
||
let sibling = "LoadAddOn: FuBar_BattlegroundFu/lib\\Glory-2.0\\Glory-2.0.lua: runtime \
|
||
error: ...Ons\\FuBar_BattlegroundFu\\lib\\Glory-2.0\\Glory-2.0.lua:23: \
|
||
Glory-2.0 requires Deformat-2.0";
|
||
assert!(
|
||
raised_inside_another_addons_own_file(sibling, "FuBar_MoneyFu", &installed),
|
||
"a sibling's own file is the sibling's row"
|
||
);
|
||
// The surveyed addon's OWN file is always its own row — including a library it embeds
|
||
// whose folder name is itself an installed addon.
|
||
let mine = "runtime error: Interface\\AddOns\\FuBar_MoneyFu\\Libs\\Ace\\Ace.lua:8: boom";
|
||
assert!(!raised_inside_another_addons_own_file(
|
||
mine,
|
||
"FuBar_MoneyFu",
|
||
&installed
|
||
));
|
||
// A chunk naming no addon folder at all is KEPT — the surveyed addon drove it.
|
||
let ours = "runtime error: [string \"Frame:OnEvent\"]:2: attempt to index a nil value";
|
||
assert!(!raised_inside_another_addons_own_file(
|
||
ours,
|
||
"FuBar_MoneyFu",
|
||
&installed
|
||
));
|
||
// A folder that is not installed is not somebody else's row either.
|
||
let stranger = "runtime error: Interface\\AddOns\\NotInstalled\\x.lua:1: boom";
|
||
assert!(!raised_inside_another_addons_own_file(
|
||
stranger,
|
||
"FuBar_MoneyFu",
|
||
&installed
|
||
));
|
||
}
|
||
|
||
/// **A raise inside a `LoadAddOn` the surveyed addon made is the LOADED addon's row** — even
|
||
/// when the raise site names no file at all (decision 2107).
|
||
///
|
||
/// An inline `<OnEvent>` body is compiled under the frame's name, so the folder test is blind
|
||
/// to it. `Stubby.lua:581` demand-loads every addon it finds; Auctioneer's
|
||
/// `AuctioneerFrame:OnEvent` raises inside that call. Six addons — Stubby and the five that
|
||
/// pull it in — were failing on that one raise, which Auctioneer's own row already carries.
|
||
#[test]
|
||
fn a_raise_inside_a_demand_load_belongs_to_the_addon_being_loaded() {
|
||
let installed: BTreeSet<String> = ["stubby", "auctioneer", "enchantrix"]
|
||
.into_iter()
|
||
.map(str::to_string)
|
||
.collect();
|
||
let through_loadaddon =
|
||
"runtime error: [string \"AuctioneerFrame:OnEvent\"]:4: attempt to \
|
||
index field 'Core' (a nil value)\n\
|
||
stack traceback:\n\
|
||
\t[C]: in ?\n\
|
||
\t[string \"AuctioneerFrame:OnEvent\"]:4: in function <...>\n\
|
||
\t[C]: in function 'LoadAddOn'\n\
|
||
\tInterface\\AddOns\\Stubby\\Stubby.lua:581: in upvalue 'inspectAddOn'";
|
||
assert!(
|
||
raised_inside_another_addons_own_file(through_loadaddon, "Stubby", &installed),
|
||
"the LoadAddOn frame sits between the raise and Stubby's own code"
|
||
);
|
||
assert!(
|
||
raised_inside_another_addons_own_file(through_loadaddon, "Enchantrix", &installed),
|
||
"…and the same for an addon that only pulled Stubby in"
|
||
);
|
||
// The surveyed addon's OWN frame ABOVE the boundary keeps the raise: it drove it itself.
|
||
let ours_first = "runtime error: [string \"StubbyFrame:OnEvent\"]:2: boom\n\
|
||
stack traceback:\n\
|
||
\tInterface\\AddOns\\Stubby\\Stubby.lua:12: in function 'Stubby.Thing'\n\
|
||
\t[C]: in function 'LoadAddOn'";
|
||
assert!(!raised_inside_another_addons_own_file(
|
||
ours_first, "Stubby", &installed
|
||
));
|
||
// No LoadAddOn anywhere: 1226's conservative KEEP is untouched.
|
||
let plain = "runtime error: [string \"StubbyFrame:OnEvent\"]:2: boom\n\
|
||
stack traceback:\n\
|
||
\t[C]: in ?";
|
||
assert!(!raised_inside_another_addons_own_file(
|
||
plain, "Stubby", &installed
|
||
));
|
||
}
|
||
|
||
/// mlua's `[string "Frame:OnLoad"]:2:` chunk name is a position, not words.
|
||
#[test]
|
||
fn a_chunk_name_position_is_not_mistaken_for_the_message() {
|
||
assert_eq!(
|
||
normalise_error(
|
||
"Outfitter: OnLoad: runtime error: [string \"OutfitterShowMinimapButton:OnLoad\"]:2: attempt to index a nil value"
|
||
),
|
||
"error: attempt to index a nil value"
|
||
);
|
||
}
|
||
|
||
/// **An XML license header is not a list of frames**, and for a while it was the top of one.
|
||
///
|
||
/// `.xml` files are scanned as Lua deliberately — `<Script>` CDATA and `<OnLoad>` bodies are
|
||
/// Lua and skipping the file loses them. But `strip_lua_noise` only knows `--` and `[[ ]]`, so
|
||
/// a GPL header in an `<!-- … -->` came through whole, and `PURPOSE. See the` reads exactly
|
||
/// like a table index. Five of the frames/tables ranking's top rows were this boilerplate
|
||
/// (decision 1218).
|
||
///
|
||
/// The other half of the claim matters as much: the Lua INSIDE the file must survive, or the
|
||
/// fix trades a wrong ranking for a blind one.
|
||
#[test]
|
||
fn an_xml_comment_is_not_scanned_but_the_script_body_is() {
|
||
let xml = "<Ui>\n\
|
||
<!-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n\
|
||
GNU General Public License. $Id: Thing.xml 1 2006 $ -->\n\
|
||
<Script><![CDATA[\n\
|
||
GameTooltip:AddLine(\"x\")\n\
|
||
MyHelper()\n\
|
||
]]></Script>\n\
|
||
</Ui>";
|
||
let mut s = Scan::default();
|
||
scan_lua(&strip_xml_comments(xml), &mut s);
|
||
for noise in ["PURPOSE", "License", "Id"] {
|
||
assert!(
|
||
!s.indexed.contains(noise) && !s.called.contains(noise),
|
||
"{noise} is license boilerplate, not a surface the addon expects"
|
||
);
|
||
}
|
||
assert!(
|
||
s.indexed.contains("GameTooltip"),
|
||
"the script body must still be scanned: {:?}",
|
||
s.indexed
|
||
);
|
||
assert!(
|
||
s.called.contains("MyHelper"),
|
||
"the script body must still be scanned: {:?}",
|
||
s.called
|
||
);
|
||
// ...and the METHOD arm obeys the same stripper. `See the` in that header is a capitalised
|
||
// word after a word — harmless — but `GameTooltip:AddLine(` inside the CDATA is the shape
|
||
// the method scan lives on, so both halves are asserted on the same file (1218's rule: a
|
||
// noise fix that goes blind is worse than the noise).
|
||
assert!(
|
||
s.methods.contains("AddLine"),
|
||
"the script body's method call must be scanned: {:?}",
|
||
s.methods
|
||
);
|
||
}
|
||
|
||
/// An unterminated `<!--` takes the rest of the file, exactly as a real XML parser does — the
|
||
/// document is malformed and guessing where the comment meant to stop would invent structure.
|
||
#[test]
|
||
fn an_unterminated_xml_comment_swallows_the_rest() {
|
||
let out = strip_xml_comments("Kept.Alpha\n<!-- Dropped.Beta\nDropped.Gamma\n");
|
||
assert!(out.contains("Kept"));
|
||
assert!(
|
||
!out.contains("Beta") && !out.contains("Gamma"),
|
||
"got: {out:?}"
|
||
);
|
||
// Line structure survives, so anything counting lines still counts real ones.
|
||
assert_eq!(out.matches('\n').count(), 3);
|
||
}
|
||
|
||
/// **A capitalised Lua local is not a missing API**, and for a long time this instrument said
|
||
/// it was — loudly, at the top of its own most-wanted list.
|
||
///
|
||
/// The shape is `FuBarPlugin-2.0.lua`'s, verbatim: a `local` bound to a function expression and
|
||
/// called a few lines down. `local function Foo` was already credited; `local Foo = function`
|
||
/// was not, and the leading-capital filter that was supposed to cover it cannot, because
|
||
/// nothing stops a local from being capitalised. Five rows and 305 addon-mentions of the
|
||
/// vanilla corpus's `demand` table were this.
|
||
#[test]
|
||
fn a_capitalised_local_is_not_a_missing_global() {
|
||
let mut s = Scan::default();
|
||
scan_lua(
|
||
"local CheckShow = function(self, panelId) end\n\
|
||
local DropDownList1_Show = DropDownList1.Show\n\
|
||
local A, B = 1, 2\n\
|
||
local function Direct() end\n\
|
||
Global = function() end\n\
|
||
CheckShow(self, 1)\n\
|
||
DropDownList1_Show(DropDownList1)\n\
|
||
A() B() Direct() Global()\n\
|
||
UnitName(\"player\")\n",
|
||
&mut s,
|
||
);
|
||
let missing: Vec<&str> = s
|
||
.called
|
||
.iter()
|
||
.filter(|n| !s.defined.contains(*n))
|
||
.map(String::as_str)
|
||
.collect();
|
||
assert_eq!(
|
||
missing,
|
||
vec!["UnitName"],
|
||
"every capitalised name the file binds itself is the file's, however it binds it"
|
||
);
|
||
}
|
||
|
||
/// ...**but a self-localisation is not a definition.** `local GetTime = GetTime` is the
|
||
/// performance idiom every library writes at file top, and it binds the local from the GLOBAL,
|
||
/// so the global is still demanded. 143 corpus sites over 41 names — concentrated, by its
|
||
/// nature, on the APIs an addon leans on hardest.
|
||
///
|
||
/// This is a correction to the fix one commit up: crediting every `local X =` line hid exactly
|
||
/// the names most worth ranking.
|
||
#[test]
|
||
fn localising_a_global_still_demands_it() {
|
||
let mut s = Scan::default();
|
||
scan_lua(
|
||
"local GetTime = GetTime\n\
|
||
local UnitName, UnitClass = UnitName, UnitClass\n\
|
||
local MyCache = MyCache or {}\n\
|
||
local Helper = function() end\n\
|
||
GetTime() UnitName('player') UnitClass('player') MyCache() Helper()\n",
|
||
&mut s,
|
||
);
|
||
let mut missing: Vec<&str> = s
|
||
.called
|
||
.iter()
|
||
.filter(|n| !s.defined.contains(*n))
|
||
.map(String::as_str)
|
||
.collect();
|
||
missing.sort_unstable();
|
||
assert_eq!(
|
||
missing,
|
||
vec!["GetTime", "MyCache", "UnitClass", "UnitName"],
|
||
"self-localisation in every shape — single, comma list, and the `or` form — keeps \
|
||
the demand; only the genuinely-new `Helper` is the file's own"
|
||
);
|
||
}
|
||
|
||
/// A "not found" has no `error: ` marker and must survive whole — it is a *different* wall
|
||
/// (a missing file) and collapsing it into the runtime errors would hide it.
|
||
#[test]
|
||
fn a_missing_file_stays_its_own_row() {
|
||
assert_eq!(
|
||
normalise_error("..\\..\\FrameXML\\Fonts.xml: not found"),
|
||
"..\\..\\FrameXML\\Fonts.xml: not found"
|
||
);
|
||
}
|
||
|
||
/// Only the FIRST error counts, and only from addons that failed — a chunk stops at its first
|
||
/// raise, so everything after it is a consequence rather than a cause.
|
||
#[test]
|
||
fn only_the_first_error_of_a_failed_addon_is_counted() {
|
||
let report = |name: &str, loaded: bool, errors: Vec<String>| AddonReport {
|
||
name: name.into(),
|
||
loaded,
|
||
errors,
|
||
..Default::default()
|
||
};
|
||
let ranked = blockers(&[
|
||
report(
|
||
"A",
|
||
false,
|
||
vec![
|
||
"x.lua: runtime error: 'setn' is obsolete".into(),
|
||
"y.lua: runtime error: 'other' is obsolete".into(),
|
||
],
|
||
),
|
||
report(
|
||
"B",
|
||
false,
|
||
vec!["z.lua: runtime error: 'setn' is obsolete".into()],
|
||
),
|
||
report("C", true, vec![]),
|
||
]);
|
||
assert_eq!(ranked, vec![("error: 'X' is obsolete".to_string(), 2)]);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod dependency_tests {
|
||
use super::*;
|
||
|
||
/// **A dependency is loaded before its dependent** — `AddOn_Load 0x51f240`'s own first step,
|
||
/// and the difference between surveying a real session and surveying a state that cannot occur.
|
||
///
|
||
/// The corpus case this is drawn from: `FuBar_Aspect` declares `## Dependencies: Ace, FuBar`
|
||
/// and its very first line is `ace:LoadTranslation("FuBar_Aspect")`. Surveyed alone it fails
|
||
/// on a global its dependency was always going to define — fifteen addons failed that way, on
|
||
/// us rather than on themselves.
|
||
#[test]
|
||
fn a_dependency_runs_before_the_addon_that_declares_it() {
|
||
let tmp = std::env::temp_dir().join(format!("benilla-harness-deps-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, file: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
std::fs::write(dir.join(file), body).unwrap();
|
||
};
|
||
// A library, a middle layer that depends on it, and a leaf that depends on the middle —
|
||
// so the walk has to be depth-first, not one level.
|
||
write(
|
||
"Lib",
|
||
"## Interface: 11200\nlib.lua\n",
|
||
"lib.lua",
|
||
"LibReady = 1\n",
|
||
);
|
||
write(
|
||
"Mid",
|
||
"## Interface: 11200\n## Dependencies: Lib\nmid.lua\n",
|
||
"mid.lua",
|
||
"MidReady = LibReady + 1\n",
|
||
);
|
||
write(
|
||
"Leaf",
|
||
"## Interface: 11200\n## Dependencies: Mid\nleaf.lua\n",
|
||
"leaf.lua",
|
||
"LeafReady = MidReady + 1\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let leaf = reports.iter().find(|r| r.name == "Leaf").unwrap();
|
||
assert!(
|
||
leaf.loaded,
|
||
"the leaf loaded because its chain ran first: {:?}",
|
||
leaf.errors
|
||
);
|
||
assert!(leaf.missing_deps.is_empty());
|
||
|
||
// ...and an addon whose dependency is NOT installed still reports it, unchanged.
|
||
write(
|
||
"Orphan",
|
||
"## Interface: 11200\n## Dependencies: Nowhere\norphan.lua\n",
|
||
"orphan.lua",
|
||
"OrphanReady = 1\n",
|
||
);
|
||
let reports = survey(&tmp);
|
||
let orphan = reports.iter().find(|r| r.name == "Orphan").unwrap();
|
||
assert_eq!(orphan.missing_deps, vec!["Nowhere".to_string()]);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **A frame is a surface too**, and the scan could not see one until this arm existed.
|
||
///
|
||
/// The corpus spells the colour picker `ColorPickerFrame.func` and
|
||
/// `ColorPickerFrame:SetColorRGB` — never `ColorPickerFrame(` — so a window **86 addons reach**
|
||
/// scored exactly 0 on the most-wanted list. Same blindness for `GameTooltip`, `WorldFrame`,
|
||
/// `ChatFrame1` and every other FrameXML frame global.
|
||
///
|
||
/// The two lists stay separate (1207): a missing function is a Rust verb, a missing frame is
|
||
/// FrameXML to transcribe.
|
||
#[test]
|
||
fn an_indexed_frame_is_a_missing_surface_not_a_missing_function() {
|
||
let mut s = Scan::default();
|
||
scan_lua(
|
||
"ColorPickerFrame.func = function() end\n\
|
||
ColorPickerFrame:SetColorRGB(1, 0, 0)\n\
|
||
GameTooltip:AddLine('hi')\n\
|
||
MyAddon = {}\n\
|
||
MyAddon.thing = 1\n\
|
||
local Cache = {}\n\
|
||
Cache.x = 1\n\
|
||
UnitName('player')\n\
|
||
self.wrong = 1\n",
|
||
&mut s,
|
||
);
|
||
let live: Vec<&str> = s
|
||
.indexed
|
||
.iter()
|
||
.filter(|n| !s.defined.contains(*n))
|
||
.map(String::as_str)
|
||
.collect();
|
||
assert_eq!(
|
||
live,
|
||
vec!["ColorPickerFrame", "GameTooltip"],
|
||
"the addon's own MyAddon and its local Cache are its own; `self` is lowercase and \
|
||
qualified reads never count"
|
||
);
|
||
assert_eq!(
|
||
s.called.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
vec!["UnitName"],
|
||
"and the lists do not bleed into each other"
|
||
);
|
||
// `ColorPickerFrame.func = function` is a FIELD the file binds, so `func` is credited as a
|
||
// definition — while `SetColorRGB` and `AddLine`, which it only calls, are demanded.
|
||
assert_eq!(
|
||
s.methods.iter().map(String::as_str).collect::<Vec<_>>(),
|
||
vec!["AddLine", "SetColorRGB"],
|
||
"a `:` call is a method demand and never a global one"
|
||
);
|
||
assert!(
|
||
s.defined_methods.contains("func"),
|
||
"a dotted assignment is the addon binding its own field: {:?}",
|
||
s.defined_methods
|
||
);
|
||
}
|
||
|
||
/// `blocked_by` matches the row **as printed** *and* the raw error text — and the second half
|
||
/// is a correction to what this test used to assert.
|
||
///
|
||
/// It pinned `blocked_by(&reports, "tinsert").is_empty()` as *correct*, on the reasoning that
|
||
/// "the quoted name is already `'X'` in the row the reader is holding". That reasoning describes
|
||
/// the mechanism accurately and draws the wrong conclusion from it: the normalisation exists so
|
||
/// sixty lines rank as one **row**, not so a reader is forbidden from searching by **name**.
|
||
/// Searching by name is in fact the only thing anyone ever does with this — and it silently
|
||
/// returned nothing every time. `--why GetBackdrop` said `(none)` while an addon was dying on
|
||
/// `attempt to call method 'GetBackdrop' (a nil value)`.
|
||
///
|
||
/// A test can encode a bug as an invariant, and this one did, which is why the assertion below
|
||
/// is inverted rather than deleted.
|
||
#[test]
|
||
fn blocked_by_reads_back_by_row_and_by_name() {
|
||
let report = |name: &str, loaded: bool, first: &str| AddonReport {
|
||
name: name.into(),
|
||
loaded,
|
||
errors: vec![first.into()],
|
||
..Default::default()
|
||
};
|
||
let reports = [
|
||
report(
|
||
"A",
|
||
false,
|
||
"a.lua: runtime error: bad argument #1 to 'tinsert' (table expected, got nil)",
|
||
),
|
||
report(
|
||
"B",
|
||
false,
|
||
"b.xml: runtime error: bad argument #1 to 'tremove' (table expected, got nil)",
|
||
),
|
||
report(
|
||
"C",
|
||
false,
|
||
"c.lua: runtime error: attempt to call a table value",
|
||
),
|
||
report("D", true, ""),
|
||
];
|
||
let wall = blocked_by(&reports, "table expected");
|
||
let hits: Vec<&str> = wall.iter().map(|(n, _)| n.as_str()).collect();
|
||
// The `[load]`/`[session]` tag is part of the row (c91cd11a: "the label says which") —
|
||
// both of these failed to LOAD, so both read back through the load table.
|
||
assert_eq!(
|
||
hits,
|
||
vec!["A [load]", "B [load]"],
|
||
"two different verbs, one wall"
|
||
);
|
||
// The half that was inverted: the NAME the normalisation collapsed is findable again, and
|
||
// finds exactly the addon that used it — not the whole wall.
|
||
let by_name: Vec<String> = blocked_by(&reports, "tinsert")
|
||
.into_iter()
|
||
.map(|(n, _)| n)
|
||
.collect();
|
||
assert_eq!(
|
||
by_name,
|
||
vec!["A [load]"],
|
||
"a quoted name is what a reader types, and it must reach the addon behind it"
|
||
);
|
||
// ...and the verbatim error comes back, which is the reason to run it at all.
|
||
assert!(wall[0].1.contains("tinsert"));
|
||
}
|
||
|
||
/// **A later error is findable, and says so** — the second half of the read-back's blindness.
|
||
///
|
||
/// A chunk stops at its first raise, but a session start does not: handlers keep firing, so the
|
||
/// error naming the method can be the third in the list. Searching only the first is the same
|
||
/// hole one level down.
|
||
///
|
||
/// The labelling is the load-bearing part. A hit on the error the tables actually **ranked**
|
||
/// carries no index, so counting index-less rows reproduces the ranked row's count exactly; a
|
||
/// later one is `#N` and visibly outside that count. Without that, making the search complete
|
||
/// would have made the read-back disagree with the table above it, and a reader would have no
|
||
/// way to tell which was wrong.
|
||
#[test]
|
||
fn a_later_error_is_found_and_labelled_as_not_the_ranked_one() {
|
||
let reports = [AddonReport {
|
||
name: "Late".into(),
|
||
loaded: true,
|
||
session_errors: vec![
|
||
"runtime error: attempt to call global 'Foo' (a nil value)".into(),
|
||
"runtime error: attempt to index a nil value".into(),
|
||
"runtime error: attempt to call method 'GetBackdrop' (a nil value)".into(),
|
||
],
|
||
..Default::default()
|
||
}];
|
||
let ranked: Vec<String> = blocked_by(&reports, "attempt to call global")
|
||
.into_iter()
|
||
.map(|(n, _)| n)
|
||
.collect();
|
||
assert_eq!(
|
||
ranked,
|
||
vec!["Late [session]"],
|
||
"no index: this is the row the table counted"
|
||
);
|
||
|
||
let late: Vec<String> = blocked_by(&reports, "GetBackdrop")
|
||
.into_iter()
|
||
.map(|(n, _)| n)
|
||
.collect();
|
||
assert_eq!(
|
||
late,
|
||
vec!["Late [session #3]"],
|
||
"the method that killed a handler was the addon's THIRD error, and it must still be \
|
||
reachable by the only name anyone would search for"
|
||
);
|
||
}
|
||
|
||
/// **A clean load is not a working addon**, and this is the column that can tell the
|
||
/// difference — the survey's answer to the blind spot four decision records end on.
|
||
///
|
||
/// Three addons, one shape each: one that raises only from its `PLAYER_LOGIN` handler, one that
|
||
/// raises only from `OnUpdate` (so it needs a tick, not just an event), and one that is clean
|
||
/// throughout. All three must report `loaded == true`, because **`loaded` still means exactly
|
||
/// "no LOAD errors"** — changing that would make every number in every past decision record
|
||
/// incomparable, which is 1209's whole subject.
|
||
#[test]
|
||
fn a_clean_load_is_not_a_working_addon() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-session-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(
|
||
dir.join(format!("{name}.toc")),
|
||
"## Interface: 11200\na.lua\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(dir.join("a.lua"), body).unwrap();
|
||
};
|
||
write(
|
||
"LoginBreaker",
|
||
"local f = CreateFrame('Frame')\n\
|
||
f:RegisterEvent('PLAYER_LOGIN')\n\
|
||
f:SetScript('OnEvent', function() error('boom at login') end)\n",
|
||
);
|
||
write(
|
||
"TickBreaker",
|
||
"local f = CreateFrame('Frame')\n\
|
||
f:SetScript('OnUpdate', function() error('boom on update') end)\n",
|
||
);
|
||
write(
|
||
"Fine",
|
||
"local f = CreateFrame('Frame')\n\
|
||
f:RegisterEvent('PLAYER_LOGIN')\n\
|
||
f:SetScript('OnEvent', function() FineRan = 1 end)\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let get = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
|
||
|
||
for n in ["LoginBreaker", "TickBreaker", "Fine"] {
|
||
assert!(
|
||
get(n).loaded,
|
||
"{n}: `loaded` is LOAD errors only and must not change meaning: {:?}",
|
||
get(n).errors
|
||
);
|
||
}
|
||
assert!(
|
||
!get("LoginBreaker").session_errors.is_empty(),
|
||
"a handler that raises on PLAYER_LOGIN is exactly what no other column can see"
|
||
);
|
||
assert!(
|
||
!get("TickBreaker").session_errors.is_empty(),
|
||
"and an OnUpdate needs the ticks, not just the events"
|
||
);
|
||
assert!(
|
||
get("Fine").session_errors.is_empty(),
|
||
"{:?}",
|
||
get("Fine").session_errors
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **A library another addon embeds is invisible here, and that is the isolation's price.**
|
||
///
|
||
/// The real client loads everything into one Lua state; this harness stands up one VM per
|
||
/// addon so a failure cannot be attributed to the wrong party (the module doc's first
|
||
/// section). The cost is exactly this: an addon that ships no libraries and leans on a
|
||
/// sibling's copy fails here and would work in a real session.
|
||
///
|
||
/// Drawn from `FuBar_CustomMenuFu`, which ships one Lua file, declares
|
||
/// `## OptionalDeps: Ace2, FuBar`, and calls `AceLibrary("Tablet-2.0")` — a library neither
|
||
/// installed addon provides. Five corpus addons sit behind that row, and none of them is a gap
|
||
/// of ours.
|
||
/// **Every addon in the VM gets its own `ADDON_LOADED`, with its OWN name in `arg1`.**
|
||
///
|
||
/// The survey fired exactly one, carrying the SURVEYED addon's folder. A dependency's
|
||
/// initialiser is almost always gated on its own name — `Atlas.lua:326` is
|
||
/// `if (event == "ADDON_LOADED" and arg1 == "Atlas") then Atlas_Init(); end`, and `Atlas_Init`
|
||
/// is what assigns `AtlasOptions` (l.199) — so a dependency loaded its files and never
|
||
/// initialised. `FuBar_AtlasFu` then died at `AtlasButton.lua:30` on a nil `AtlasOptions`, and
|
||
/// the survey recorded that against FuBar_AtlasFu. **The state was one the real client never
|
||
/// produces**, and it cost 11 addons of the session-start column.
|
||
///
|
||
/// The second assertion is the half that keeps the rule this module already states: a
|
||
/// dependency's OWN handler raising is the dependency's row, never its consumers'. Those
|
||
/// events fire outside the error-capture window, so one library's fault cannot be counted once
|
||
/// per addon that embeds it.
|
||
/// **A consumer's OWN code raising inside a DEPENDENCY's window is the consumer's row.**
|
||
///
|
||
/// 1226's finding, now enforced. `AceAddon-2.0.lua:104-105` drains its entire `nextAddon` queue
|
||
/// on ANY `ADDON_LOADED` it sees and calls each consumer's `OnInitialize` there (`:230`). So
|
||
/// the surveyed addon's own file runs inside a dependency's window — and while attribution was
|
||
/// by window, those raises were charged to nobody and the whole FuBar/Ace family read as
|
||
/// surviving when it was not.
|
||
///
|
||
/// Attribution is by the raising CHUNK now, which 1217 made truthful. The fixture is AceAddon's
|
||
/// shape reduced: the library drains a queue on the first ADDON_LOADED it sees, whoever it
|
||
/// names, and the consumer's callback raises from the consumer's own file.
|
||
///
|
||
/// The second half is the half that must not regress: the LIBRARY's own raise, in the same
|
||
/// window, still belongs to the library.
|
||
#[test]
|
||
fn a_consumers_raise_inside_a_dependency_window_is_the_consumers_row() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-attrib-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, file: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
std::fs::write(dir.join(file), body).unwrap();
|
||
};
|
||
// AceAddon's shape: drain every queued consumer on the FIRST ADDON_LOADED, whoever it names.
|
||
write(
|
||
"QueueLib",
|
||
"## Interface: 11200\nlib.lua\n",
|
||
"lib.lua",
|
||
"QueueLibQueue = {}\n\
|
||
QueueLibFrame = CreateFrame(\"Frame\")\n\
|
||
QueueLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\
|
||
QueueLibFrame:SetScript(\"OnEvent\", function()\n\
|
||
while table.getn(QueueLibQueue) > 0 do\n\
|
||
local f = table.remove(QueueLibQueue, 1) f()\n\
|
||
end\n\
|
||
end)\n",
|
||
);
|
||
// The consumer queues a callback that raises from ITS OWN file.
|
||
write(
|
||
"QueueUser",
|
||
"## Interface: 11200\n## Dependencies: QueueLib\nuse.lua\n",
|
||
"use.lua",
|
||
"table.insert(QueueLibQueue, function() error(\"consumer init blew up\") end)\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
|
||
|
||
assert!(
|
||
of("QueueUser")
|
||
.session_errors
|
||
.iter()
|
||
.any(|e| e.contains("consumer init blew up")),
|
||
"the consumer's own file raised — window or not, it is the consumer's row: {:?}",
|
||
of("QueueUser").session_errors
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
#[test]
|
||
fn each_loaded_addon_gets_its_own_addon_loaded_event() {
|
||
let tmp = std::env::temp_dir().join(format!(
|
||
"benilla-harness-addonloaded-{}",
|
||
std::process::id()
|
||
));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, file: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
std::fs::write(dir.join(file), body).unwrap();
|
||
};
|
||
// Atlas's shape, reduced: a library that initialises ONLY on its own ADDON_LOADED.
|
||
write(
|
||
"TheLib",
|
||
"## Interface: 11200\nlib.lua\n",
|
||
"lib.lua",
|
||
"TheLibFrame = CreateFrame(\"Frame\")\n\
|
||
TheLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\
|
||
TheLibFrame:SetScript(\"OnEvent\", function()\n\
|
||
if event == \"ADDON_LOADED\" and arg1 == \"TheLib\" then TheLibOptions = {} end\n\
|
||
end)\n",
|
||
);
|
||
// The consumer reads the library's initialised state on a LATER event, as Atlas's button does.
|
||
write(
|
||
"TheUser",
|
||
"## Interface: 11200\n## Dependencies: TheLib\nuse.lua\n",
|
||
"use.lua",
|
||
"TheUserFrame = CreateFrame(\"Frame\")\n\
|
||
TheUserFrame:RegisterEvent(\"PLAYER_LOGIN\")\n\
|
||
TheUserFrame:SetScript(\"OnEvent\", function() local _ = TheLibOptions.anything end)\n",
|
||
);
|
||
// A library whose own handler blows up — its consumers must not wear it.
|
||
write(
|
||
"BadLib",
|
||
"## Interface: 11200\nbad.lua\n",
|
||
"bad.lua",
|
||
"BadLibFrame = CreateFrame(\"Frame\")\n\
|
||
BadLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\
|
||
BadLibFrame:SetScript(\"OnEvent\", function()\n\
|
||
if event == \"ADDON_LOADED\" and arg1 == \"BadLib\" then error(\"lib init blew up\") end\n\
|
||
end)\n",
|
||
);
|
||
write(
|
||
"BadUser",
|
||
"## Interface: 11200\n## Dependencies: BadLib\nquiet.lua\n",
|
||
"quiet.lua",
|
||
"QuietGlobal = 1\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
|
||
|
||
assert!(
|
||
of("TheUser").session_errors.is_empty(),
|
||
"the dependency must have received its OWN ADDON_LOADED and initialised: {:?}",
|
||
of("TheUser").session_errors
|
||
);
|
||
assert!(
|
||
of("BadUser").session_errors.is_empty(),
|
||
"a dependency's own handler raising is ITS row, not its consumer's: {:?}",
|
||
of("BadUser").session_errors
|
||
);
|
||
assert!(
|
||
of("BadLib")
|
||
.session_errors
|
||
.iter()
|
||
.any(|e| e.contains("lib init blew up")),
|
||
"...and it must still be reported against the library itself: {:?}",
|
||
of("BadLib").session_errors
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **A dependency's `ADDON_LOADED` lands before the dependent's FIRST LINE runs** (2166).
|
||
///
|
||
/// The reference emits, per addon: its dependencies → its own `.toc` files (`0x51f3fa`) →
|
||
/// `Bindings.xml` → the SavedVariables chunks → **`ADDON_LOADED` at `0x51f5ad`** — and only
|
||
/// then moves to the next addon. So by the time a dependent's file scope runs, every
|
||
/// dependency has already had its own event.
|
||
///
|
||
/// This harness deferred all of them to `drive_session_start` instead, which is a state the
|
||
/// client never produces and which no earlier test could see: the one above reads the
|
||
/// dependency's state from a `PLAYER_LOGIN` handler, and that passes either way. **The
|
||
/// discriminating read is at FILE SCOPE**, and it is the corpus's own shape — `KTMAutoHider`'s
|
||
/// `<OnLoad>` calls into `KLHThreatMeter`'s GUI, which is nil until KLHThreatMeter's own
|
||
/// `ADDON_LOADED` builds it.
|
||
///
|
||
/// The control below is what stops this test passing for the wrong reason: `EagerLib` must
|
||
/// initialise **on its own event and nowhere else**, so a consumer that sees it has genuinely
|
||
/// received the event and not merely run a file.
|
||
#[test]
|
||
fn a_dependencys_addon_loaded_precedes_the_dependents_file_scope() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-eager-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, file: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
std::fs::write(dir.join(file), body).unwrap();
|
||
};
|
||
write(
|
||
"EagerLib",
|
||
"## Interface: 11200\nlib.lua\n",
|
||
"lib.lua",
|
||
"EagerLibFrame = CreateFrame(\"Frame\")\n\
|
||
EagerLibFrame:RegisterEvent(\"ADDON_LOADED\")\n\
|
||
EagerLibFrame:SetScript(\"OnEvent\", function()\n\
|
||
if event == \"ADDON_LOADED\" and arg1 == \"EagerLib\" then EagerLibReady = {} end\n\
|
||
end)\n",
|
||
);
|
||
// The consumer reads it AT FILE SCOPE — no handler, no later event.
|
||
write(
|
||
"EagerUser",
|
||
"## Interface: 11200\n## Dependencies: EagerLib\nuse.lua\n",
|
||
"use.lua",
|
||
"EagerUserSaw = EagerLibReady.anything\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
|
||
|
||
assert!(
|
||
of("EagerUser").errors.is_empty(),
|
||
"the dependency's ADDON_LOADED must precede the dependent's file scope: {:?}",
|
||
of("EagerUser").errors
|
||
);
|
||
assert!(
|
||
of("EagerUser").loaded && of("EagerUser").session_errors.is_empty(),
|
||
"...and the row is clean end to end: {:?}",
|
||
of("EagerUser").session_errors
|
||
);
|
||
// The control: EagerLib's own row still gets its own event, and nothing raised there.
|
||
assert!(
|
||
of("EagerLib").loaded && of("EagerLib").session_errors.is_empty(),
|
||
"the library's own row is unaffected: {:?}",
|
||
of("EagerLib").session_errors
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **A manifest entry with no file is split by WHOSE package is short** — the column that
|
||
/// stops a session hunting for a client bug that is not one.
|
||
///
|
||
/// Both rows below are load failures and stay load failures: nothing is subtracted from
|
||
/// `loaded` or from `errors` (1213's rule, for the fifth time). What the split adds is the
|
||
/// attribution, and the two cases genuinely differ:
|
||
///
|
||
/// - **`own`** — the addon ships a `.toc` listing a file it does not contain. Five corpus rows
|
||
/// are this, all one family: `DPSMate_CureDisease.toc` names eight files and the folder
|
||
/// holds six, because the three `*Received*` ones were split into a sibling addon and the
|
||
/// manifest was never updated. Our loader is behaving correctly (the reference logs
|
||
/// `Couldn't open %s` and carries on — wow-re `ui/scratch/xml-toc-path-resolution.md` §4).
|
||
/// - **`foreign`** — the entry escapes the addon's folder with `..`, which the client supports
|
||
/// and `join_ref` reproduces, into a folder that is not installed. The corpus's two are
|
||
/// Auctioneer and BeanCounter reaching `..\Blizzard_AuctionUI\...`, which wow-re records as
|
||
/// **RESOLVING** in the real client (§5 case 2, by name) because its file layer can see that
|
||
/// folder inside `patch.MPQ`. That one IS ours.
|
||
///
|
||
/// The assertion that matters is that the two never merge: a single "files not found" count
|
||
/// would average a broken package with a real client gap and read as one number.
|
||
/// **A template named through a local resolves; the shapes that still cannot are asserted too.**
|
||
///
|
||
/// The literal-only scan was an honest under-report — stated at `missing_templates` — and a
|
||
/// decision was then made on the number it produced:
|
||
/// `Interface\FrameXML\ItemButtonTemplate.xml` declined to build `ItemButtonTemplate` citing a
|
||
/// demand of zero "on both axes". The zero was real and the demand was not: pfUI binds `local
|
||
/// tpl = "ContainerFrameItemButtonTemplate"` and passes the variable, so the one addon that wanted it was invisible to the ranking.
|
||
///
|
||
/// Both halves are asserted, because a scanner that quietly widened would be the worse fix:
|
||
/// the shape it now sees, AND the shapes it still does not, so the next decision quoting this
|
||
/// number can read its bound from a test rather than from a comment.
|
||
///
|
||
/// **The exact-set form earned itself immediately.** Written first as a few absence checks it
|
||
/// PASSED, while the collector was binding `built` to `"Unseen"` out of
|
||
/// `local built = "Unseen" .. "ByConcat"` — a fragment entering the demand ranking as a
|
||
/// template name nobody asked for. That is not an under-report, it is the fiction this table
|
||
/// has opened with three times before (1210/1218/1227). Hence the whole-right-hand-side rule
|
||
/// in `missing_templates`, and hence asserting the set rather than sampling it.
|
||
#[test]
|
||
fn the_template_census_resolves_a_local_and_says_what_it_still_cannot_see() {
|
||
let tmp = std::env::temp_dir().join(format!("benilla-harness-tpl-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let dir = tmp.join("TplUser");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("TplUser.toc"), "## Interface: 11200\nuse.lua\n").unwrap();
|
||
// Every shape in one file. Only the first three are meant to be seen.
|
||
std::fs::write(
|
||
dir.join("use.lua"),
|
||
r#"
|
||
local tpl = "SeenViaLocal"
|
||
if bag == -1 then tpl = "SeenViaRebind" end
|
||
CreateFrame("Button", "a", nil, tpl)
|
||
CreateFrame("Button", "b", nil, "SeenAsLiteral")
|
||
|
||
-- Still invisible, deliberately: built by concatenation, read from a table, and
|
||
-- passed in as a parameter. Naming them here is the bound.
|
||
local built = "Unseen" .. "ByConcat"
|
||
CreateFrame("Button", "c", nil, built)
|
||
CreateFrame("Button", "d", nil, cfg.template)
|
||
function f(passed) CreateFrame("Button", "e", nil, passed) end
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let reports = survey(&tmp);
|
||
let r = reports.iter().find(|r| r.name == "TplUser").unwrap();
|
||
let got: BTreeSet<&str> = r.missing_templates.iter().map(String::as_str).collect();
|
||
|
||
for want in ["SeenViaLocal", "SeenViaRebind", "SeenAsLiteral"] {
|
||
assert!(got.contains(want), "{want} must be seen — got {got:?}");
|
||
}
|
||
// A name rebound to two literals keeps BOTH: the census asks "could this call want that
|
||
// template", and each branch genuinely can.
|
||
assert!(
|
||
got.contains("SeenViaLocal") && got.contains("SeenViaRebind"),
|
||
"a rebound local keeps every literal it was bound to"
|
||
);
|
||
// **The stated bound, asserted as an EXACT set** so it cannot rot into a claim of
|
||
// completeness. The file also asks for a concatenated name, a table field
|
||
// (`cfg.template`) and a parameter (`passed`); none may appear, and pinning the whole set
|
||
// rather than a few absences is what makes a future widening announce itself here.
|
||
let want: BTreeSet<&str> = ["SeenViaLocal", "SeenViaRebind", "SeenAsLiteral"]
|
||
.into_iter()
|
||
.collect();
|
||
assert_eq!(
|
||
got, want,
|
||
"the census sees exactly these three shapes — concatenation, a table field and a \
|
||
parameter stay invisible, and that bound lives here rather than only in a doc comment"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
#[test]
|
||
fn an_absent_manifest_entry_is_attributed_to_whose_package_is_short() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-absent-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, files: &[(&str, &str)]| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
for (f, body) in files {
|
||
std::fs::write(dir.join(f), body).unwrap();
|
||
}
|
||
};
|
||
// Ships one of the two files its own manifest lists — DPSMate_CureDisease's exact shape.
|
||
write(
|
||
"ShortPackage",
|
||
"## Interface: 11200\nhere.lua\ngone.lua\n",
|
||
&[("here.lua", "ShortPackageRan = 1\n")],
|
||
);
|
||
// Reaches a neighbour that is not installed — Auctioneer's exact shape, `..` and all.
|
||
write(
|
||
"WantsNeighbour",
|
||
"## Interface: 11200\nown.lua\n..\\NotInstalled\\templates.xml\n",
|
||
&[("own.lua", "WantsNeighbourRan = 1\n")],
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let of = |n: &str| reports.iter().find(|r| r.name == n).unwrap();
|
||
|
||
let short = of("ShortPackage");
|
||
assert_eq!(
|
||
short.absent_own_files,
|
||
vec!["gone.lua".to_string()],
|
||
"the entry inside its own folder is the addon's own package being short"
|
||
);
|
||
assert!(
|
||
short.absent_foreign_files.is_empty(),
|
||
"and it is NOT a missing neighbour: {:?}",
|
||
short.absent_foreign_files
|
||
);
|
||
|
||
let wants = of("WantsNeighbour");
|
||
assert_eq!(
|
||
wants.absent_foreign_files,
|
||
vec!["Interface/AddOns/NotInstalled/templates.xml".to_string()],
|
||
"`..` is collapsed the way the client collapses it, and the RESOLVED path is what is \
|
||
reported — the collapse is the interesting half. The path is INSTALL-relative since \
|
||
2155, which is the space the reference's file layer is actually handed: this exact \
|
||
shape is Auctioneer's, and its real neighbour resolves off the chain now."
|
||
);
|
||
assert!(
|
||
wants.absent_own_files.is_empty(),
|
||
"its own package is complete: {:?}",
|
||
wants.absent_own_files
|
||
);
|
||
|
||
// **NOTHING is subtracted from `errors`** (1213) — both rows are still there, verbatim, so
|
||
// every figure any past record quoted is still readable off this list. What changed in
|
||
// 2155 is which of them `loaded` counts: on the reference these addons LOAD, because
|
||
// `0x6edaa0` logs `Couldn't open %s` and the walk carries on with nothing raised
|
||
// (`ui/scratch/xml-toc-path-resolution.md` §4). Both halves are asserted together,
|
||
// because either one alone is a rule that has already been got wrong in both directions.
|
||
for r in [short, wants] {
|
||
assert!(
|
||
r.loaded,
|
||
"{}: a file the package does not contain is not something that RAISED",
|
||
r.name
|
||
);
|
||
assert!(
|
||
r.errors.iter().any(|e| e.contains("not found")),
|
||
"{}: …and the row is still there verbatim: {:?}",
|
||
r.name,
|
||
r.errors
|
||
);
|
||
}
|
||
// ...and the file that DID exist ran, because a missing entry does not abort the manifest.
|
||
assert!(
|
||
!short.errors.iter().any(|e| e.contains("here.lua")),
|
||
"the surviving file loads: {:?}",
|
||
short.errors
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
#[test]
|
||
fn a_sibling_addons_embedded_library_is_invisible() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-sibling-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, file: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
std::fs::write(dir.join(file), body).unwrap();
|
||
};
|
||
// One addon embeds a library. Another uses it, and declares no relationship at all —
|
||
// which on the real client is fine, because they share a Lua state.
|
||
write(
|
||
"Embedder",
|
||
"## Interface: 11200\nembedded.lua\n",
|
||
"embedded.lua",
|
||
"SharedLibGlobal = 1\n",
|
||
);
|
||
write(
|
||
"Freeloader",
|
||
"## Interface: 11200\nuse.lua\n",
|
||
"use.lua",
|
||
"if not SharedLibGlobal then error('needs the sibling library') end\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
assert!(
|
||
reports
|
||
.iter()
|
||
.find(|r| r.name == "Embedder")
|
||
.unwrap()
|
||
.loaded,
|
||
"the addon that ships it is fine"
|
||
);
|
||
let free = reports.iter().find(|r| r.name == "Freeloader").unwrap();
|
||
assert!(
|
||
!free.loaded,
|
||
"and the one that borrows it fails HERE while working on the real client — the \
|
||
isolation's price, not a gap in the API surface"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **An OPTIONAL dependency loads too, and first** — `AddOn_Load`'s own order (1191 §2).
|
||
///
|
||
/// Drawn from the shape that exposed it: `FuBar_BagFu` declares `## OptionalDeps: FuBar, Ace2`
|
||
/// and then lists `FuBarPlugin-2.0.lua` in its manifest BEFORE `AceLibrary.lua`. That only
|
||
/// works because the `Ace2` addon went first and left `AceLibrary` global; surveyed without it,
|
||
/// FuBarPlugin raises "requires AceLibrary" and the addon dies on a state the real client never
|
||
/// produces. Ten corpus addons sat behind that row.
|
||
///
|
||
/// Also asserts the half that must NOT change: an optional dependency that is not installed is
|
||
/// skipped in silence and never appears in `missing_deps`, which is the required-only list.
|
||
#[test]
|
||
fn an_optional_dependency_loads_first_and_a_missing_one_is_silent() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-optdeps-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let write = |name: &str, toc: &str, file: &str, body: &str| {
|
||
let dir = tmp.join(name);
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join(format!("{name}.toc")), toc).unwrap();
|
||
std::fs::write(dir.join(file), body).unwrap();
|
||
};
|
||
// The library addon, and a dependent whose OWN file order needs it to have gone first.
|
||
write(
|
||
"TheLib",
|
||
"## Interface: 11200\nlib.lua\n",
|
||
"lib.lua",
|
||
"TheLibGlobal = 1\n",
|
||
);
|
||
write(
|
||
"Dependent",
|
||
"## Interface: 11200\n## OptionalDeps: TheLib, NotInstalled\nuse.lua\n",
|
||
"use.lua",
|
||
"if not TheLibGlobal then error('Dependent requires TheLib') end\nDependentReady = 1\n",
|
||
);
|
||
|
||
let reports = survey(&tmp);
|
||
let r = reports.iter().find(|r| r.name == "Dependent").unwrap();
|
||
assert!(
|
||
r.loaded,
|
||
"the optional dependency ran first: {:?}",
|
||
r.errors
|
||
);
|
||
assert!(
|
||
r.missing_deps.is_empty(),
|
||
"an uninstalled OPTIONAL dep is silent — missing_deps is the required-only list: {:?}",
|
||
r.missing_deps
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **The `inherits=` census sees the axis `template_demand` cannot**, and its two exclusions
|
||
/// hold: a FONT name is not a missing template, and neither is a virtual the addon declares
|
||
/// itself.
|
||
///
|
||
/// Written from the shape that produced the finding — twelve corpus addons whose whole failure
|
||
/// was an `inherits=` in their own XML, none of which appeared in the `CreateFrame` ranking.
|
||
#[test]
|
||
fn the_inherits_census_counts_templates_and_not_fonts() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-inherits-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let dir = tmp.join("Inheritor");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("Inheritor.toc"), "## Interface: 11200\nui.xml\n").unwrap();
|
||
std::fs::write(
|
||
dir.join("ui.xml"),
|
||
r#"<Ui>
|
||
<Button name="InheritorOwnTemplate" virtual="true"/>
|
||
<Frame name="InheritorRoot">
|
||
<Layers><Layer level="ARTWORK">
|
||
<FontString name="$parentLabel" inherits="GameFontNormal" text="hi"/>
|
||
</Layer></Layers>
|
||
<Frames>
|
||
<Button name="$parentMine" inherits="InheritorOwnTemplate"/>
|
||
<Button name="$parentReal" inherits="UIPanelButtonTemplate"/>
|
||
<Button name="$parentGone" inherits="NoSuchTemplate"/>
|
||
</Frames>
|
||
</Frame>
|
||
</Ui>
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
let reports = survey(&tmp);
|
||
let r = reports.iter().find(|r| r.name == "Inheritor").unwrap();
|
||
assert_eq!(
|
||
r.missing_inherits,
|
||
vec!["NoSuchTemplate".to_string()],
|
||
"GameFontNormal is a font, InheritorOwnTemplate is the addon's own, and \
|
||
UIPanelButtonTemplate is one we now ship"
|
||
);
|
||
assert!(
|
||
r.missing_templates.is_empty(),
|
||
"and none of it is visible to the CreateFrame scanner — the point of the twin"
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
/// **The widget-method census finds a method that does not exist** — the can-it-fail proof, and
|
||
/// the reason this test is written against `survey` rather than against the scanner.
|
||
///
|
||
/// A census that returns an empty list is indistinguishable from one that never ran, and this
|
||
/// instrument has already shipped once in the second state (`drive_ui_probe`'s own comment).
|
||
/// So the fixture calls an **invented** method that no widget can ever provide, and it must
|
||
/// come back — while four shapes that are *not* gaps must not:
|
||
///
|
||
/// | called | why it must not appear |
|
||
/// |---|---|
|
||
/// | `f:SetWidth` | a frame method we ship — if this appeared, the oracle answered "everything" |
|
||
/// | `f:CreateTexture` | ditto, and it is what produces the region probe |
|
||
/// | `t:SetTexCoord` | a **region** method: reachable only from the Texture probe, never a frame |
|
||
/// | `Probe:OwnMethod` | the addon declares it — `function T:N` |
|
||
/// | `f:Hooked` | the addon declares it the other way — `T.N = function` |
|
||
/// | `MethodicalLib:LibOnly` | a **dependency** declares it, and the VM loaded that dependency |
|
||
/// | `f:OptionalHook` | the addon **feature-tests** it one line up, so it is not a blocker |
|
||
///
|
||
/// Both failure directions are therefore pinned by one assertion pair: a silent-empty oracle
|
||
/// loses `NoSuchWidgetMethod`, a blanket-report oracle gains `SetWidth`.
|
||
///
|
||
/// The last two rows are the ones that were learned from the corpus rather than reasoned out,
|
||
/// and each was worth most of a screen of fiction:
|
||
///
|
||
/// - without the dependency subtraction the top seven rows were `FuBar:RegisterPlugin` and its
|
||
/// siblings at 75-78 addons each — every one `function FuBar:<Name>` in a dependency 83
|
||
/// corpus addons declare and the VM duly loads;
|
||
/// - without the feature-test subtraction the next ten were FuBarPlugin's optional callbacks
|
||
/// (`OnMouseUp`, `OnReceiveDrag`) and AceOO's duck-typed operators (`CompareTo`, `Equals`,
|
||
/// `Divide`), none of which is *defined* anywhere in the corpus at all.
|
||
#[test]
|
||
fn the_method_census_finds_a_method_no_widget_provides() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-methods-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let lib = tmp.join("MethodicalLib");
|
||
std::fs::create_dir_all(&lib).unwrap();
|
||
std::fs::write(
|
||
lib.join("MethodicalLib.toc"),
|
||
"## Interface: 11200\nlib.lua\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
lib.join("lib.lua"),
|
||
"MethodicalLib = {}\nfunction MethodicalLib:LibOnly() end\n",
|
||
)
|
||
.unwrap();
|
||
let dir = tmp.join("Methodical");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(
|
||
dir.join("Methodical.toc"),
|
||
"## Interface: 11200\n## Dependencies: MethodicalLib\na.lua\n",
|
||
)
|
||
.unwrap();
|
||
// Wrapped in a function that is never called: the census is STATIC, so it sees these
|
||
// whether or not the path runs — which is the whole reason it is a scan and not a trace
|
||
// (the module doc's first section). Keeping it unrun also keeps `loaded` clean, so a
|
||
// failure here can only be the census's.
|
||
std::fs::write(
|
||
dir.join("a.lua"),
|
||
"local f = CreateFrame(\"Frame\")\n\
|
||
Probe = {}\n\
|
||
function Probe:OwnMethod() end\n\
|
||
f.Hooked = function() end\n\
|
||
function Methodical_Never()\n\
|
||
f:SetWidth(10)\n\
|
||
local t = f:CreateTexture()\n\
|
||
t:SetTexCoord(0, 1, 0, 1)\n\
|
||
f:NoSuchWidgetMethod(1)\n\
|
||
Probe:OwnMethod()\n\
|
||
f:Hooked()\n\
|
||
MethodicalLib:LibOnly()\n\
|
||
if type(f.OptionalHook) == \"function\" then f:OptionalHook() end\n\
|
||
end\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let reports = survey(&tmp);
|
||
let r = reports.iter().find(|r| r.name == "Methodical").unwrap();
|
||
assert!(r.loaded, "the fixture must load clean: {:?}", r.errors);
|
||
assert_eq!(
|
||
r.missing_methods,
|
||
vec!["NoSuchWidgetMethod".to_string()],
|
||
"the invented method must be found and nothing else may be"
|
||
);
|
||
// The feature-tested one is not a blocker — and it is not lost either. Dropping the class
|
||
// outright would have taken `SetTopLevel` (60 corpus addons, a real widget method) with it.
|
||
assert_eq!(
|
||
r.optional_methods,
|
||
vec!["OptionalHook".to_string()],
|
||
"a guarded call is its own row, never a silent omission"
|
||
);
|
||
// ...and it reaches the ranking a session actually reads. The library's own row is empty:
|
||
// it calls nothing.
|
||
assert_eq!(
|
||
method_demand(&reports),
|
||
vec![("NoSuchWidgetMethod".to_string(), 1)]
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **The per-kind census, end to end over a real addon folder** — the can-it-fail proof for the
|
||
/// whole path, not just the oracle.
|
||
///
|
||
/// The fixture is 1228's own case, both ways round, so a pass here cannot be a pass by
|
||
/// accident:
|
||
///
|
||
/// | the call | what must happen |
|
||
/// |---|---|
|
||
/// | `smf:SetInsertMode()` on a `CreateFrame("ScrollingMessageFrame")` local | a row — the verb is real, the sibling has it, this kind does not |
|
||
/// | `mf:SetInsertMode()` on a `CreateFrame("MessageFrame")` local | **no** row — the control that stops "report everything" passing |
|
||
/// | `UIParent:AddMessage()` | a row via the PUBLISHED-name path: `UIParent` is a plain Frame in our arena |
|
||
/// | `UIErrorsFrame:AddMessage()` | **no** row — ours really is a `<MessageFrame>` (1228) |
|
||
/// | `self:SetInsertMode()` | not a row, an AMBIGUOUS one: nothing can type `self` |
|
||
///
|
||
/// And the two blindness guards this arc keeps paying for, both written so they can only pass
|
||
/// for the right reason: `ghost` is typed **only** inside a `--` comment and `faker` **only**
|
||
/// inside a string literal, both as `Button` — a kind nothing else in the fixture uses — so a
|
||
/// stripper that leaked would mint a `Button:AddMessage` row that is impossible otherwise
|
||
/// (1218: five words of GPL boilerplate once topped a ranking, and this pass is the first here
|
||
/// that reads un-blanked string literals at all).
|
||
///
|
||
/// `missing_methods` must stay **empty** throughout: every name here is one some widget answers,
|
||
/// which is exactly why the old table could not see any of it.
|
||
#[test]
|
||
fn the_per_kind_census_finds_a_verb_wired_to_the_wrong_kind() {
|
||
let tmp =
|
||
std::env::temp_dir().join(format!("benilla-harness-perkind-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
let dir = tmp.join("PerKind");
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("PerKind.toc"), "## Interface: 11200\na.lua\n").unwrap();
|
||
// Never executed, like the census fixture above: the scan is static, and an unrun body
|
||
// keeps `loaded` clean so a failure here can only be the census's.
|
||
std::fs::write(
|
||
dir.join("a.lua"),
|
||
"local smf = CreateFrame(\"ScrollingMessageFrame\")\n\
|
||
local mf = CreateFrame(\"MessageFrame\")\n\
|
||
-- local ghost = CreateFrame(\"Button\")\n\
|
||
local faker = \"CreateFrame('Button')\"\n\
|
||
function PerKind_Never(self)\n\
|
||
smf:SetInsertMode(\"TOP\")\n\
|
||
mf:SetInsertMode(\"TOP\")\n\
|
||
UIParent:AddMessage(\"x\")\n\
|
||
UIErrorsFrame:AddMessage(\"x\")\n\
|
||
self:SetInsertMode(\"TOP\")\n\
|
||
ghost:AddMessage(\"x\")\n\
|
||
faker:AddMessage(\"x\")\n\
|
||
end\n",
|
||
)
|
||
.unwrap();
|
||
|
||
let reports = survey(&tmp);
|
||
let r = reports.iter().find(|r| r.name == "PerKind").unwrap();
|
||
assert!(r.loaded, "the fixture must load clean: {:?}", r.errors);
|
||
assert!(
|
||
r.missing_methods.is_empty(),
|
||
"every name here IS answered by some widget — which is the whole reason the ANY-kind \
|
||
table cannot see this class: {:?}",
|
||
r.missing_methods
|
||
);
|
||
assert_eq!(
|
||
r.kind_missing_methods,
|
||
vec![
|
||
"Frame:AddMessage (on MessageFrame, ScrollingMessageFrame)".to_string(),
|
||
"ScrollingMessageFrame:SetInsertMode (on MessageFrame)".to_string(),
|
||
],
|
||
"the typed call sites, and only the ones whose kind cannot answer — no Button row, so \
|
||
neither the comment nor the string literal was read as code"
|
||
);
|
||
// `self`, `ghost` and `faker` are all untypable, so the NAME is reported as conditional
|
||
// rather than swallowed as present.
|
||
assert_eq!(
|
||
r.ambiguous_methods,
|
||
vec![
|
||
"AddMessage (only on MessageFrame, ScrollingMessageFrame)".to_string(),
|
||
"SetInsertMode (only on MessageFrame)".to_string(),
|
||
],
|
||
"an untypable receiver is a stated unknown, never a silent pass"
|
||
);
|
||
// ...and it reaches the rankings a session reads.
|
||
assert_eq!(
|
||
kind_method_demand(&reports),
|
||
vec![
|
||
(
|
||
"Frame:AddMessage (on MessageFrame, ScrollingMessageFrame)".to_string(),
|
||
1
|
||
),
|
||
(
|
||
"ScrollingMessageFrame:SetInsertMode (on MessageFrame)".to_string(),
|
||
1
|
||
),
|
||
]
|
||
);
|
||
|
||
let _ = std::fs::remove_dir_all(&tmp);
|
||
}
|
||
|
||
/// **A name the file rebinds cannot be typed at all** — the rule that stops the attributor
|
||
/// inventing rows, which is the failure mode every demand ranking in this arc has already had.
|
||
///
|
||
/// One `local frame = CreateFrame("MessageFrame")` at file top would otherwise type every
|
||
/// `frame:Method()` below it, including the `frame` that is a **parameter** of a helper, or a
|
||
/// `for` variable, holding whatever the caller passed. Three shapes, one assertion: the typed
|
||
/// receiver keeps its kind, and each rebound one loses it.
|
||
#[test]
|
||
fn a_rebound_name_is_not_typed() {
|
||
let kinds = local_widget_kinds(
|
||
"local kept = CreateFrame(\"MessageFrame\")\n\
|
||
local shadowed = CreateFrame(\"MessageFrame\")\n\
|
||
local shadowed = SomethingElse()\n\
|
||
local looped = CreateFrame(\"MessageFrame\")\n\
|
||
for _, looped in ipairs(t) do end\n\
|
||
local passed = CreateFrame(\"MessageFrame\")\n\
|
||
local function helper(passed) end\n",
|
||
);
|
||
assert_eq!(kinds.get("kept"), Some(&Some("MessageFrame")));
|
||
for rebound in ["shadowed", "looped", "passed"] {
|
||
assert_eq!(
|
||
kinds.get(rebound),
|
||
Some(&None),
|
||
"{rebound} is bound twice, so nothing here can say what it holds"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A VM shaped like the survey's, for the oracle tests below.
|
||
fn seated_vm() -> UiScript {
|
||
let mut script = UiScript::new().unwrap();
|
||
script.set_screen_size(1024.0, 768.0);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
script
|
||
}
|
||
|
||
/// **The session fixture is LIVE**, asserted through the same Lua surface an addon sees.
|
||
///
|
||
/// A fixture that silently fails to seat reads EXACTLY like a fixture that exposed nothing —
|
||
/// both are "net +0" — and this arc has already been fooled once by a measurement that
|
||
/// returned an empty answer for a tooling reason (1242's note on `grep`). The backpack seat
|
||
/// produced no corpus delta at all; this is what makes that a finding rather than a
|
||
/// possibility.
|
||
///
|
||
/// It is also a guard: delete a seat and this fails, instead of the columns quietly shifting
|
||
/// and the next A/B attributing the move to whatever landed beside it.
|
||
#[test]
|
||
fn the_seated_session_is_visible_from_lua() {
|
||
let s = seated_vm();
|
||
|
||
// The backpack — 16 slots from level 1, two of them filled. Exposed nothing, verifiably.
|
||
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 16);
|
||
assert_eq!(
|
||
s.eval::<String>("return GetBagName(0)").unwrap(),
|
||
"Backpack"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("local _, c = GetContainerItemInfo(0, 5) return c")
|
||
.unwrap(),
|
||
12,
|
||
"the Linen Cloth stack"
|
||
);
|
||
// Bags 1..4 are deliberately NOT seated: a fresh character has no equipped bags, and
|
||
// seating them would manufacture a state rather than expose one.
|
||
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(1)").unwrap(), 0);
|
||
|
||
// The quest log — asserted through the API an addon actually walks, not the state struct.
|
||
// `GetNumQuestLogEntries` returns (rows, quests): THREE rows but TWO quests, because the
|
||
// header is a row and not a quest. An addon that conflates the two indexes off the end.
|
||
assert_eq!(
|
||
s.eval::<(i64, i64)>("return GetNumQuestLogEntries()")
|
||
.unwrap(),
|
||
(3, 2),
|
||
"three rows, two quests — the header is a row"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("local t = GetQuestLogTitle(1) return t")
|
||
.unwrap(),
|
||
"Elwynn Forest"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("local _,_,_,h = GetQuestLogTitle(1) return h and true or false")
|
||
.unwrap(),
|
||
"row 1 is the header"
|
||
);
|
||
// Both ends of isComplete: nil for in-progress, 1 for complete.
|
||
assert!(s
|
||
.eval::<Option<i64>>("local _,_,_,_,_,c = GetQuestLogTitle(2) return c")
|
||
.unwrap()
|
||
.is_none());
|
||
assert_eq!(
|
||
s.eval::<i64>("local _,_,_,_,_,c = GetQuestLogTitle(3) return c")
|
||
.unwrap(),
|
||
1
|
||
);
|
||
// A leaderboard walk sees a finished objective AND an unfinished one — the pair a
|
||
// one-objective fixture cannot show.
|
||
s.run("SelectQuestLogEntry(2)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<i64>("return GetNumQuestLeaderBoards()").unwrap(),
|
||
2
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(bool, bool)>(
|
||
"local _,_,f1 = GetQuestLogLeaderBoard(1) local _,_,f2 = GetQuestLogLeaderBoard(2) return f1, f2"
|
||
)
|
||
.unwrap(),
|
||
(false, true),
|
||
"one objective outstanding, one done"
|
||
);
|
||
|
||
// The bind point — a plain string, and the reason it is seated is that an addon
|
||
// CONCATENATES it (`Necrosis.lua:1089`), where an empty answer reads as no hearth at all.
|
||
assert_eq!(
|
||
s.eval::<String>("return GetBindLocation()").unwrap(),
|
||
"Stormwind City"
|
||
);
|
||
|
||
// The purse — and asserted as its three coin fields, not as one number, because that is the
|
||
// whole reason the value is 1234g 56s 78c rather than something round. A seat that reads
|
||
// back as 12345678 but breaks down wrong is the bug this shape exists to catch.
|
||
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 12_345_678);
|
||
assert_eq!(
|
||
s.eval::<(i64, i64, i64)>(
|
||
"local m = GetMoney() return floor(m / 10000), mod(floor(m / 100), 100), mod(m, 100)"
|
||
)
|
||
.unwrap(),
|
||
(1234, 56, 78),
|
||
"gold/silver/copper are each non-zero, so no field can be dropped unnoticed"
|
||
);
|
||
|
||
// Equipped gear — head, chest, main hand, with the rest empty. Also exposed nothing.
|
||
assert_eq!(
|
||
s.eval::<String>(r#"return GetInventoryItemTexture("player", 16)"#)
|
||
.unwrap(),
|
||
"Interface\\Icons\\INV_Misc_QuestionMark"
|
||
);
|
||
assert!(
|
||
s.eval::<String>(r#"return GetInventoryItemLink("player", 5)"#)
|
||
.unwrap()
|
||
.contains("Bloodmail Hauberk"),
|
||
"the chest slot's link"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return GetInventoryItemQuality("player", 1)"#)
|
||
.unwrap(),
|
||
2
|
||
);
|
||
// An EMPTY slot still answers the absent shape — the case an addon walking 1..19 hits most.
|
||
assert!(s
|
||
.eval::<bool>(r#"return GetInventoryItemLink("player", 10) == nil"#)
|
||
.unwrap());
|
||
// The slot ids come from the same table `GetInventorySlotInfo` serves, so a walk that
|
||
// resolves names to ids lands on the seated items rather than past them.
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return GetInventorySlotInfo("MainHandSlot")"#)
|
||
.unwrap(),
|
||
16
|
||
);
|
||
|
||
// The spellbook, as the positive control — this seat DID move the columns (+2).
|
||
assert_eq!(
|
||
s.eval::<String>(r#"return GetSpellName(1, "spell")"#)
|
||
.unwrap(),
|
||
"Attack"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>(r#"local _, r = GetSpellName(1, "spell") return r"#)
|
||
.unwrap(),
|
||
"",
|
||
"and its rank is the empty string, not nil"
|
||
);
|
||
}
|
||
|
||
fn wanted(names: &[&str]) -> BTreeSet<String> {
|
||
names.iter().map(|n| (*n).to_string()).collect()
|
||
}
|
||
|
||
/// **An oracle that cannot probe reports EVERYTHING missing, never nothing.**
|
||
///
|
||
/// [`widget_method_kinds`] asks the live `__index` dispatcher, so it depends on
|
||
/// `CreateFrame` still being callable — and an addon is perfectly free to replace it. The
|
||
/// failure has to land in the direction that gets noticed: a blanket over-report is loud and
|
||
/// obviously wrong, while a silent empty answer reads as "this addon calls no method we lack",
|
||
/// which is the exact sentence this whole census exists to stop being invisible.
|
||
///
|
||
/// The first assertion is what stops the fix being trivially satisfied by always over-reporting.
|
||
#[test]
|
||
fn the_method_oracle_fails_loudly_when_it_cannot_probe() {
|
||
let script = seated_vm();
|
||
let want = wanted(&["SetWidth", "SetTexCoord"]);
|
||
assert!(
|
||
unresolved_from(&widget_method_kinds(&script, &want), &want).is_empty(),
|
||
"a frame method and a region method we both ship must resolve"
|
||
);
|
||
|
||
script
|
||
.run("CreateFrame = function() error('no frames for you') end")
|
||
.unwrap();
|
||
assert_eq!(
|
||
unresolved_from(&widget_method_kinds(&script, &want), &want),
|
||
vec!["SetTexCoord".to_string(), "SetWidth".to_string()],
|
||
"with no probes there is no answer, and the honest report of no answer is the whole \
|
||
wanted set"
|
||
);
|
||
}
|
||
|
||
/// **The PER-KIND pass fails loudly too** — the same rule one level down, and it needs its own
|
||
/// test because the per-kind pass has a second way to answer nothing: no *attributed* call
|
||
/// sites.
|
||
///
|
||
/// With the oracle dead, every typed call site is reported against its kind and the ambiguous
|
||
/// table is empty (a name whose answer we cannot compute is not "conditional", it is unknown,
|
||
/// and it is already in the loudest bucket — `missing_methods`).
|
||
#[test]
|
||
fn the_per_kind_census_fails_loudly_when_it_cannot_probe() {
|
||
let script = seated_vm();
|
||
let wants = Wants {
|
||
missing_globals: Vec::new(),
|
||
missing_tables: Vec::new(),
|
||
wanted_methods: wanted(&["SetWidth", "SetInsertMode"]),
|
||
tested_methods: BTreeSet::new(),
|
||
kind_calls: [("Frame".to_string(), "SetWidth".to_string())]
|
||
.into_iter()
|
||
.collect(),
|
||
global_calls: BTreeSet::new(),
|
||
loose_methods: wanted(&["SetInsertMode"]),
|
||
};
|
||
let (missing, ambiguous) = per_kind_rows(
|
||
&script,
|
||
&widget_method_kinds(&script, &wanted(&["SetWidth", "SetInsertMode"])),
|
||
&wants,
|
||
);
|
||
assert!(
|
||
missing.is_empty(),
|
||
"a live oracle answers SetWidth on a Frame: {missing:?}"
|
||
);
|
||
assert_eq!(
|
||
ambiguous,
|
||
vec!["SetInsertMode (only on MessageFrame)".to_string()],
|
||
"...and the untypable one is reported as conditional, with the kind named"
|
||
);
|
||
|
||
// Now break it. `None` is the oracle saying it could not run.
|
||
let (missing, ambiguous) = per_kind_rows(&script, &None, &wants);
|
||
assert_eq!(
|
||
missing,
|
||
vec!["Frame:SetWidth (on no kind)".to_string()],
|
||
"with no answer at all, every typed call site is reported — loud, not silent"
|
||
);
|
||
assert!(
|
||
ambiguous.is_empty(),
|
||
"and nothing is called merely conditional when nothing is known: {ambiguous:?}"
|
||
);
|
||
}
|
||
|
||
/// **A method on a sibling kind is invisible to the ANY-kind oracle, and the PER-KIND pass
|
||
/// finds it** — decision 1228's bound, and the fix for it, asserted together.
|
||
///
|
||
/// 1228 landed this test to pin a bound it deliberately left open: the census resolved a name
|
||
/// against every probe and stopped at the first `function`, so a name survived only if *no*
|
||
/// widget answered it. `MessageFrame:AddMessage` therefore scored zero for as long as it
|
||
/// existed — the ScrollingMessageFrame probe answered it — while `EasyCopy`, `QuestHistory`
|
||
/// and `QuestItem` all died on `UIErrorsFrame:AddMessage`.
|
||
///
|
||
/// Both halves are still true, and both are asserted here, because they are now *different
|
||
/// tables*: `missing_methods` still answers ANY-kind and still cannot see this (its numbers
|
||
/// have to stay comparable — 1209), while `kind_missing_methods` asks the kind the addon
|
||
/// actually called it on. `SetInsertMode` is the probe: a MessageFrame binding and only a
|
||
/// MessageFrame binding (1228 §3's table), so a ScrollingMessageFrame calling it is exactly the
|
||
/// shape that used to be unfindable.
|
||
///
|
||
/// The control below it is what stops the pass being satisfied by reporting everything: the
|
||
/// same verb on the kind that *does* answer it produces no row at all.
|
||
#[test]
|
||
fn a_method_on_a_sibling_kind_is_invisible_to_any_and_found_per_kind() {
|
||
let script = seated_vm();
|
||
let want = wanted(&["SetInsertMode"]);
|
||
let oracle = widget_method_kinds(&script, &want);
|
||
assert!(
|
||
unresolved_from(&oracle, &want).is_empty(),
|
||
"the ANY-kind table answers `some widget has it`, so a per-kind gap is still not what \
|
||
IT finds — and that number must not move"
|
||
);
|
||
|
||
let called_on = |kind: &str| {
|
||
per_kind_rows(
|
||
&script,
|
||
&oracle,
|
||
&Wants {
|
||
missing_globals: Vec::new(),
|
||
missing_tables: Vec::new(),
|
||
wanted_methods: want.clone(),
|
||
tested_methods: BTreeSet::new(),
|
||
kind_calls: [(kind.to_string(), "SetInsertMode".to_string())]
|
||
.into_iter()
|
||
.collect(),
|
||
global_calls: BTreeSet::new(),
|
||
loose_methods: BTreeSet::new(),
|
||
},
|
||
)
|
||
.0
|
||
};
|
||
assert_eq!(
|
||
called_on("ScrollingMessageFrame"),
|
||
vec!["ScrollingMessageFrame:SetInsertMode (on MessageFrame)".to_string()],
|
||
"the row 1228 could not print: the sibling that DOES answer it is named, because \
|
||
`wire the kind` and `write the verb` are different jobs"
|
||
);
|
||
assert!(
|
||
called_on("MessageFrame").is_empty(),
|
||
"...and the kind that answers it produces nothing — the pass is not just over-reporting"
|
||
);
|
||
}
|
||
|
||
/// **The receiver of a published name is typed off the LIVE ARENA**, which is what lets the
|
||
/// census see `UIErrorsFrame:AddMessage` as a MessageFrame call at all.
|
||
///
|
||
/// Both directions are pinned, because the attribution is only worth what its source is worth:
|
||
/// our `UIErrorsFrame` really is a `<MessageFrame>` (1228 converted it), `UIParent` is a plain
|
||
/// `<Frame>`, `GameTooltipTextLeft1` is a **region** — and a name nothing publishes is `None`
|
||
/// rather than a guess.
|
||
#[test]
|
||
fn a_published_name_carries_its_kind() {
|
||
let script = seated_vm();
|
||
assert_eq!(script.widget_kind("UIErrorsFrame"), Some("MessageFrame"));
|
||
assert_eq!(script.widget_kind("UIParent"), Some("Frame"));
|
||
assert_eq!(
|
||
script.widget_kind("ChatFrame1"),
|
||
Some("ScrollingMessageFrame")
|
||
);
|
||
assert_eq!(script.widget_kind("GameTooltip"), Some("GameTooltip"));
|
||
assert_eq!(
|
||
script.widget_kind("GameTooltipTextLeft1"),
|
||
Some("FontString"),
|
||
"the region leaves publish into their own name table, and the corpus scrapes them"
|
||
);
|
||
assert_eq!(script.widget_kind("NoSuchFrameAnywhere"), None);
|
||
}
|
||
|
||
/// **The seated session answers the two login-scoped catalogues** (2167) — the auction browse
|
||
/// tree and the player's talent pages.
|
||
///
|
||
/// Both are DBC reads the client answers with no session behind them, so an empty answer is a
|
||
/// state the reference cannot be in — and both were read AT FILE SCOPE by corpus addons that
|
||
/// then did arithmetic or subscripting on the answer. `Auctioneer` subscripts `classes[10]`
|
||
/// (`AucCore.lua:106`) and `KLHThreatMeter` negates `GetTalentInfo`'s rank
|
||
/// (`KTM_My.lua:721`); nil is what both were getting.
|
||
///
|
||
/// The subscript and the arithmetic are what this asserts, not a count: the ten-class set and
|
||
/// the warrior's three pages come off the player's own DBCs, and pinning their contents here
|
||
/// would be pinning the install rather than the seat.
|
||
#[test]
|
||
fn the_seated_session_answers_the_login_scoped_catalogues() {
|
||
if benilla_formats::wow_data().is_none() {
|
||
eprintln!("skipping: no WoW client data");
|
||
return;
|
||
}
|
||
let mut script = UiScript::new().unwrap();
|
||
script.set_screen_size(1024.0, 768.0);
|
||
seat_a_session(&mut script);
|
||
|
||
// Auctioneer's own line, reduced: ten subscripts into the packed return.
|
||
assert_eq!(
|
||
script
|
||
.eval::<i64>(
|
||
"local c = {GetAuctionItemClasses()} \
|
||
local t = {} for i = 1, 10 do t[c[i]] = true end \
|
||
return table.getn(c)"
|
||
)
|
||
.ok(),
|
||
Some(10),
|
||
"the browse tree is the reference's ten auctionable classes"
|
||
);
|
||
// KLHThreatMeter's own line, reduced: a positional read and arithmetic on the rank.
|
||
assert_eq!(
|
||
script
|
||
.eval::<i64>("local _, _, _, _, rank = GetTalentInfo(3, 13) return -rank")
|
||
.ok(),
|
||
Some(0),
|
||
"a talent read by POSITION answers a number — nil is what the corpus was getting"
|
||
);
|
||
assert!(
|
||
script
|
||
.eval::<bool>("return GetNumTalentTabs() == 3 and GetNumTalents(1) > 0")
|
||
.unwrap_or(false),
|
||
"the seated warrior has his three pages"
|
||
);
|
||
}
|
||
|
||
/// **The UI probe actually INVOKES an addon's override**, which is the only thing that makes it
|
||
/// different from every other column here.
|
||
///
|
||
/// Earned two ways rather than trusted: a hook is installed the way Bagnon installs one, the
|
||
/// probe is driven, and the hook's own counter is read back. A probe that silently found no
|
||
/// entry point would report a clean run forever and be worse than not having it — which is
|
||
/// exactly the failure mode the corpus's first run looked like (zero addons failing it).
|
||
#[test]
|
||
fn the_ui_probe_reaches_an_addon_override() {
|
||
let mut script = UiScript::new().unwrap();
|
||
script.set_screen_size(1024.0, 768.0);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
|
||
// Bagnon's idiom, on the verb Bagnon actually replaces.
|
||
script
|
||
.run("BENILLA_PROBE_HITS = 0 ToggleBackpack = function() BENILLA_PROBE_HITS = BENILLA_PROBE_HITS + 1 end")
|
||
.unwrap();
|
||
|
||
let errs = drive_ui_probe(&mut script);
|
||
assert!(errs.is_empty(), "the probe must not raise here: {errs:?}");
|
||
assert_eq!(
|
||
script.eval::<i64>("return BENILLA_PROBE_HITS").ok(),
|
||
Some(2),
|
||
"the probe drives ToggleBackpack twice (open, then the override's close path)"
|
||
);
|
||
}
|
||
|
||
/// And it RECORDS a raise rather than swallowing it — the error is the finding.
|
||
#[test]
|
||
fn the_ui_probe_records_what_an_override_raises() {
|
||
let mut script = UiScript::new().unwrap();
|
||
script.set_screen_size(1024.0, 768.0);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
script
|
||
.run("ToggleBackpack = function() error('addon blew up') end")
|
||
.unwrap();
|
||
|
||
let errs = drive_ui_probe(&mut script);
|
||
assert!(
|
||
errs.iter().any(|e| e.contains("addon blew up")),
|
||
"a raising override must land in the probe column: {errs:?}"
|
||
);
|
||
}
|
||
|
||
/// **The HOVER arm reaches an addon's tooltip hook, and reports what it raises.**
|
||
///
|
||
/// Nothing else in the probe puts a tooltip on screen, so an addon that hooks `GameTooltip`'s
|
||
/// `OnShow` or scrapes its line regions was invisible to this column — the gap decision 1220
|
||
/// named after fixing a raise squarely inside it.
|
||
///
|
||
/// The assertion is deliberately the RAISE, not the call count. When the hover arm landed the
|
||
/// column did not move at all, and "found nothing" has to be distinguishable from "ran
|
||
/// nothing" — this instrument has already shipped once in the second state
|
||
/// (see `drive_ui_probe`'s own comment), and a probe that cannot fail is not a probe.
|
||
#[test]
|
||
fn the_ui_probe_hovers_and_records_what_a_tooltip_hook_raises() {
|
||
let mut script = UiScript::new().unwrap();
|
||
script.set_screen_size(1024.0, 768.0);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
// The corpus shape: hook the tooltip's own OnShow, which only runs if something shows it.
|
||
script
|
||
.run(
|
||
"GameTooltip:SetScript(\"OnShow\", function() error(\"tooltip hook blew up\") end)",
|
||
)
|
||
.unwrap();
|
||
|
||
let errs = drive_ui_probe(&mut script);
|
||
assert!(
|
||
errs.iter().any(|e| e.contains("tooltip hook blew up")),
|
||
"the hover arm must actually show the tooltip, and report the hook's raise: {errs:?}"
|
||
);
|
||
}
|
||
|
||
/// ...and the hover arm raises NOTHING on a clean VM with no addon loaded.
|
||
///
|
||
/// Without this, a probe arm that is simply broken would charge its own failure to all 218
|
||
/// addons — the mis-attribution 1209 exists about, arriving through the instrument instead of
|
||
/// through a claim.
|
||
///
|
||
/// **"Silent" has to mean "ran and said nothing", never "had nothing to run"** — the same
|
||
/// distinction the hover-arm test above is built around ("found nothing" vs "ran nothing"),
|
||
/// and decision 1751 put this test one step from the wrong side of it: `ToggleBackpack`, the
|
||
/// probe's first and most-hooked entry point, is defined in the reference's own
|
||
/// `ContainerFrame.lua` now, which `load_default_ui` reads off the PLAYER'S INSTALL. On a
|
||
/// machine without one the global is simply absent, `drive_ui_probe`'s `try` guard skips it,
|
||
/// and an empty error list would mean the probe drove nothing at all. So the entry points are
|
||
/// asserted to EXIST before the silence is asserted, and the test gates on client data like
|
||
/// every other reader of the chain.
|
||
///
|
||
/// What is deliberately NOT asserted here is `load_default_ui`'s own failure list, and the
|
||
/// reason is a policy rather than a symptom: it is a different measurement from this one, and
|
||
/// pinning it here would make the probe's gate hostage to whatever else the chain is mid-way
|
||
/// through. (The concrete case that prompted the note has since gone with its file: our
|
||
/// `UnitFrames.xml` had `TargetFrame`'s OnLoad reach a `BenillaTargetAuras_Update` that indexed
|
||
/// `TargetofTargetFrame` ~1150 lines before the frame was declared, so a seated TARGET raised
|
||
/// at load. `TargetFrame.xml` is the reference's own now, 1751, and neither the caller nor the
|
||
/// ordering survives.)
|
||
#[test]
|
||
fn the_ui_probe_is_silent_on_a_clean_vm() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut script = UiScript::new().unwrap();
|
||
script.set_screen_size(1024.0, 768.0);
|
||
seat_a_session(&mut script);
|
||
let _ = crate::ui_script::load_default_ui(&script);
|
||
// The probe's entry points are here to be driven. Each is `try`-guarded inside
|
||
// `drive_ui_probe`, so a missing one is silent — which would make the assertion below
|
||
// pass by having done nothing.
|
||
for entry in [
|
||
"ToggleBackpack",
|
||
"UnitFrame_OnEnter",
|
||
"UnitFrame_OnLeave",
|
||
"ActionButton_Update",
|
||
"GameTooltip_SetDefaultAnchor",
|
||
] {
|
||
assert_eq!(
|
||
script
|
||
.eval::<String>(&format!("return type({entry})"))
|
||
.unwrap(),
|
||
"function",
|
||
"{entry} is not in the VM, so the probe would skip it and this test would pass \
|
||
without driving anything"
|
||
);
|
||
}
|
||
|
||
let errs = drive_ui_probe(&mut script);
|
||
assert!(
|
||
errs.is_empty(),
|
||
"no addon is loaded — every probe error here would be charged to somebody else: {errs:?}"
|
||
);
|
||
}
|
||
}
|