the addon arc: the AddOns folder opens

- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:31 +07:00
co-authored by Claude Fable 5
parent bfb3e3addc
commit 44ff435304
231 changed files with 31316 additions and 5594 deletions
+100 -2
View File
@@ -6,8 +6,9 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, session, skills,
slider, social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
slider, social, spellbook, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot,
AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest,
UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -18,6 +19,31 @@ use super::{
/// id↔handle bijection, region visuals, and the event/script registrations. Held in `lua.app_data`
/// (interior-mutable) so callbacks reach it; contains **no** mlua handles (the MAXCSTACK discipline).
pub(crate) struct Model {
/// Every discovered addon, in load order — the AddOn API's registry, filled by the host at
/// world entry ([`super::UiScript::register_addons`]). See [`super::addon`].
pub(crate) addons: Vec<super::addon::AddOnInfo>,
/// The AddOns folder, so `LoadAddOn` can read an addon's files from inside a Lua binding.
pub(crate) addons_root: Option<std::path::PathBuf>,
/// Where per-addon saved variables live: the account-scoped folder, then this character's.
/// Both are directories holding one `<Addon>.lua` per declaring addon.
pub(crate) addons_saved_account: Option<std::path::PathBuf>,
pub(crate) addons_saved_character: Option<std::path::PathBuf>,
/// The FrameXML **template registry**, persisted across [`crate::loader::load`] calls — the
/// client's template table is global (`0x6ee500`, rf24), so a file may `inherits=` a template
/// an *earlier file* registered (the real MerchantFrame.xml inherits
/// CharacterFrameTemplates.xml's tab template). Register-before-use in load order, exactly
/// the client's rule; a per-document registry silently dropped every cross-file inherit.
///
/// **Here rather than on `UiScript`** so the loader can run from a bare `&Lua`: that is what
/// lets `LoadAddOn` load an addon from inside a Lua binding, synchronously, the way the
/// reference does (1188 phase 2). `font_objects` below was already here, and these two are
/// the same kind of thing — VM-global registries the loader fills.
pub(crate) framexml_templates:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
/// The FrameXML **font-element registry** (a separate namespace — a font inherits a font,
/// never a frame template), persisted for the same cross-file reason.
pub(crate) framexml_fonts:
std::cell::RefCell<std::collections::HashMap<String, crate::framexml::Element>>,
/// The frame arena (create/destroy + show/hide/strata/level/scale/alpha propagation).
pub(crate) arena: WidgetArena,
/// Per-frame layout input (anchors/size/scale). Every live frame has one (created at
@@ -122,6 +148,29 @@ pub(crate) struct Model {
/// same-frame press+release test in [`UiScript::mouse_button`]. Keyed by button so a `LeftButton`
/// press is not cleared by a `RightButton` release.
pub(crate) mouse_down_on: HashMap<String, FrameHandle>,
/// Per **frame**, the [`UiScript::now`] second at which its last single `OnClick` fired — the
/// double-click detector's entire state, and the faithful stand-in for the client's per-widget
/// timestamp `[CButton+0x334]` (wow-re `ui/scratch/button-doubleclick-law.md`).
///
/// Three properties of that field are load-bearing, and are why this is keyed the way it is.
/// It lives on the **widget**, so two frames can never pair with each other. It carries **no
/// button identity** — the detector is button-agnostic, so a left release followed by a right
/// one on a button registered for both completes a double click (with `arg1` = the *second*
/// button); what normally confines double-clicks to the left button is `RegisterForClicks`'
/// `{"LeftButtonUp"}` default, not the detector. And it is written only when a single click
/// actually fires, then **zeroed** when a double completes — which is what makes four rapid
/// clicks read `Click · DoubleClick · Click · DoubleClick` rather than one click and three
/// doubles. Nothing else clears it: not hide, not disable, not the cursor leaving the frame
/// (`[+0x334]` has exactly three writers binary-wide — ctor, the fired-double zero, and the
/// fired-single stamp).
pub(crate) last_click: HashMap<FrameHandle, f64>,
/// Frames whose resolved SIZE moved in the last [`UiScript::resolve_layout`], as
/// `(frame id, width, height)` — queued there (it holds only a `&mut Model`, so it cannot call
/// Lua) and drained by [`super::event::fire_size_changes`] at the next `&Lua` seam, which turns
/// each into `OnSizeChanged(self, width, height)`. Same compute-under-borrow / fire-outside
/// shape as the pointer path's `OnValueChanged`/`OnDragStart` hand-offs.
pub(crate) pending_size_changed: Vec<(u32, f32, f32)>,
/// Script errors collected from `pcall`'d handlers (never panics, never prints — decision 0068).
pub(crate) errors: Vec<String>,
@@ -151,6 +200,11 @@ pub(crate) struct Model {
/// The app resolves each token to a streamed entity and commits the selection; a token it can't
/// resolve is a no-op, as the real client no-ops `TargetUnit` on a nonexistent unit ([`unit`]).
pub(crate) target_requests: Vec<String>,
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
/// [`Self::target_requests`], which takes unit tokens. The app runs the shared by-name
/// resolver (`crate::target::by_name`, decision 0886) and commits the selection ([`unit`]).
pub(crate) target_by_name_requests: Vec<(String, bool)>,
/// Set when `ClearTarget()` fired with a live target — the ESC chain's LAST leg
/// (`ToggleGameMenu`'s order: the clear runs only when nothing earlier ate the press).
/// Drained by [`super::UiScript::take_target_clear`]; the app commits the deselect ([`unit`]).
@@ -183,6 +237,11 @@ pub(crate) struct Model {
/// [`super::UiScript::take_tell_requests`] drain — the app opens its chat edit box prefilled
/// `/w <name> ` (the unit popup's WHISPER action; [`party`] registers the global).
pub(crate) tell_requests: Vec<String>,
/// The player's default chat language name, app-resolved from `ChrRaces.BaseLanguage` ×
/// `Languages.dbc` ([`super::UiScript::set_default_language`]). `None` = the reference's
/// no-player-object state, where `GetDefaultLanguage()` returns **zero Lua values**
/// ([`chat_send`]).
pub(crate) default_language: Option<String>,
/// Duel intents (`AcceptDuel`/`CancelDuel`/`StartDuel*`) queued since the app's last
/// [`super::UiScript::take_duel_requests`] drain — the outbound seam ([`duel`]). There is no
/// duel *snapshot* beside it: everything the UI reads arrives as event arguments.
@@ -448,6 +507,14 @@ pub(crate) struct Model {
/// `started` once the cursor has moved past the drag-start threshold — `None` between
/// gestures ([`cursor::DragGesture`], decision 0216 §3).
pub(crate) drag: Option<cursor::DragGesture>,
/// The one in-flight `StartMoving()` — the client's single root-side drag slot (`root+0xcfc`
/// and the cursor sample beside it), `None` between moves ([`super::object::FrameMove`]). Held
/// beside [`Model::drag`] because that gesture is what normally opens and closes it: the
/// canonical addon idiom is `OnDragStart → StartMoving`, `OnDragStop → StopMovingOrSizing`.
/// The two remain separate systems, exactly as in the reference — a drag can carry a payload
/// with nothing moving, and a move outlives the mouse button (the reference's mouse-up
/// auto-stop skips the Lua drag type).
pub(crate) moving: Option<super::object::FrameMove>,
/// The in-flight Slider thumb drag: set when a LeftButton press lands on a Slider's thumb, held
/// until release / pointer-leave (decision 0250 §5, the engine's C++-equivalent thumb drag —
/// like a scrollbar dragging in the real client, no Lua involved). `None` between drags
@@ -757,6 +824,11 @@ pub(crate) struct Model {
/// Inventory-slot ids queued by `UseInventoryItem` (decision 0208 phase 1b, `cursor`'s `doll`
/// submodule) — drained by the app into `CMSG_USE_ITEM` against the equipped position.
pub(crate) inventory_uses: Vec<u32>,
/// The two weapons' **temporary** enchantments — `[0]` main hand, `[1]` off hand, in the order
/// `GetWeaponEnchantInfo` pushes them ([`weapon_enchant`]). A separate slot from
/// [`Self::inventory_slots`] on purpose: this one carries a live countdown and is pushed every
/// frame, where the slot snapshot is change-gated and fires an event.
pub(crate) weapon_enchants: [Option<weapon_enchant::WeaponEnchant>; 2],
/// The inspected unit's equipment view, keyed by unit token ([`inspect`], decision 0631) —
/// the *second* source behind the unit-keyed `GetInventoryItem*` family. Unlike
@@ -820,6 +892,17 @@ pub(crate) struct Model {
/// saw (UI space: logical px, y-up — the same frame `resolve` rects live in). Behind Lua's
/// `GetCursorPosition()`; the reference world map polls it every OnUpdate for hover/click math.
pub(crate) cursor_pos: (f32, f32),
/// The realm this session is on, behind `GetRealmName()` (decision 1195). `""` until the app
/// pushes one — the glue screen's own answer, and never `nil`, because the corpus idiom is
/// `db[GetRealmName()] = …` at file scope and a nil index errors one call deeper.
/// Lines an addon queued with `SendChatMessage`, drained by the app into the wire
/// (decision 1199). Deliberately a different queue from the chat box's input: the box's drain
/// runs the slash grammar and this one must not.
pub(crate) chat_sends: Vec<super::chat_send::ChatSend>,
pub(crate) realm_name: String,
/// Frames per second, behind `GetFramerate()`. Pushed per tick by the app, which owns the
/// clock this crate does not have.
pub(crate) framerate: f64,
/// The modifier-key mirror `(shift, ctrl, alt)` behind `IsShiftKeyDown`/`IsControlKeyDown`/
/// `IsAltKeyDown` — pushed by the app's input pass ([`UiScript::set_modifiers`]) BEFORE the
/// frame's mouse events, so a click handler's fork reads the state at click time.
@@ -829,6 +912,12 @@ pub(crate) struct Model {
impl Model {
pub(crate) fn new() -> Model {
Model {
addons: Vec::new(),
addons_root: None,
addons_saved_account: None,
addons_saved_character: None,
framexml_templates: Default::default(),
framexml_fonts: Default::default(),
arena: WidgetArena::new(),
layout_inputs: HashMap::new(),
solver: LayoutSolver::new(),
@@ -856,6 +945,8 @@ impl Model {
focused_editbox: None,
mouseover: None,
mouse_down_on: HashMap::new(),
last_click: HashMap::new(),
pending_size_changed: Vec::new(),
errors: Vec::new(),
warnings: Vec::new(),
// Classic Era's UIParent virtual space is 1024×768-ish; a sensible default the host can
@@ -866,6 +957,7 @@ impl Model {
cancel_aura_requests: Vec::new(),
tracking: None,
target_requests: Vec::new(),
target_by_name_requests: Vec::new(),
drop_item_on_unit: Vec::new(),
target_clear: false,
party: party::PartyState::default(),
@@ -873,6 +965,7 @@ impl Model {
social: social::SocialState::default(),
social_requests: Vec::new(),
tell_requests: Vec::new(),
default_language: None,
duel_requests: Vec::new(),
follow_requests: Vec::new(),
session_requests: Vec::new(),
@@ -934,6 +1027,7 @@ impl Model {
container_autoequips: Vec::new(),
drag_registered: HashMap::new(),
drag: None,
moving: None,
slider_drag: None,
gossip: None,
gossip_selects: Vec::new(),
@@ -1025,6 +1119,9 @@ impl Model {
worldmap: super::worldmap::WorldMapState::default(),
pending_events: Vec::new(),
cursor_pos: (0.0, 0.0),
chat_sends: Vec::new(),
realm_name: String::new(),
framerate: 0.0,
modifiers: (false, false, false),
money: 0,
net_latency_ms: 0,
@@ -1050,6 +1147,7 @@ impl Model {
inventory_alerts: [0; 12],
paperdoll_yaw: 0.0,
inventory_uses: Vec::new(),
weapon_enchants: [None; 2],
inspect: None,
inspect_notifies: Vec::new(),
inspect_clear: false,