diff --git a/crates/benilla-app/assets/ui/AddonList.xml b/crates/benilla-app/assets/ui/AddonList.xml deleted file mode 100644 index af5345b0..00000000 --- a/crates/benilla-app/assets/ui/AddonList.xml +++ /dev/null @@ -1,408 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - AddonListFrameTitle:SetText(ADDONS) - AddonListFrameNote:SetText(ADDON_LIST_APPLY_NOTE) - - AddonList_Update() - AddonList_Scroll(-arg1) - - - diff --git a/crates/benilla-app/assets/ui/ColorPickerFrame.xml b/crates/benilla-app/assets/ui/ColorPickerFrame.xml index c2b1ba27..0d2fa4c2 100644 --- a/crates/benilla-app/assets/ui/ColorPickerFrame.xml +++ b/crates/benilla-app/assets/ui/ColorPickerFrame.xml @@ -74,8 +74,8 @@ OTHER DEVIATIONS, each forced: · **`` (ref l.177-184) is not transcribed** — this engine gives plain frames no - raw key events (SCRIPT_KINDS has no `OnKeyDown`), the same wall AddonList.xml hit - (decision 1197). Its ESCAPE arm is `HideUIPanel(this) + cancelFunc(previousValues)`, i.e. + raw key events (SCRIPT_KINDS has no `OnKeyDown`), the same wall StackSplit.xml hit + (its own header note). Its ESCAPE arm is `HideUIPanel(this) + cancelFunc(previousValues)`, i.e. exactly what the Cancel button does, so the arm is carried in UiPanels.xml's ESC chain as `ColorPickerCancelButton:Click()` — the reference's own idiom for this (its game-menu chain cancel-clicks `OptionsFrameCancel` the same way). ESC therefore CANCELS, it does not diff --git a/crates/benilla-app/assets/ui/GameMenuFrame.xml b/crates/benilla-app/assets/ui/GameMenuFrame.xml index 7a33f396..d7811207 100644 --- a/crates/benilla-app/assets/ui/GameMenuFrame.xml +++ b/crates/benilla-app/assets/ui/GameMenuFrame.xml @@ -65,18 +65,23 @@ Rewards / What's New / Ratings are structurally off in Era — store disabled, no account store, IsStandard() false, koKR only.) · The layout math those constants produce, precomputed here (this engine has no layout - frames): width 28+144+28 = 200; button tops 32/73/94/115/136/177/198/239; height - 239+21+28 = 288. Anchored as a TOPLEFT chain so the gaps read in the XML. + frames) — for OUR seven rungs, the era's eight minus AddOns (next bullet): width + 28+144+28 = 200; button tops 32/73/94/115/156/177/218; height 218+21+28 = 267. + Anchored as a TOPLEFT chain so the gaps read in the XML. · No Key Bindings rung — the era menu's own shape: 1.15 ceded the entry to the Settings panel, and since decision 1008 ours lives there too (the Options window's Keybindings category, KeyBindingsPage.xml). 0997 had carved the 1.13/1.14 seat back in for its standalone window; that seat left with the window. + · No AddOns rung — the director's call: the char-select AddOns screen + (`char_select::addons`, the reference's own glue-side home for this list) is the only + addon UI. The in-game panel 1197 put behind this rung (AddonList.xml) is retired, + and the rung left with it — 1.12's own ESC menu has no AddOns row either. · The header — Classic/Dialog/DialogTemplates.xml l.11-43 (ClassicDialogHeaderTemplate): the same 256x64 UI-DialogBox-Header art, text = MAINMENU_BUTTON, swapped to GameFontNormalMed1 (13px) by MainMenuFrameMixin:OnLoad. Both header idioms (1.12's TOP+12 texture / era's 39-tall frame with the art centered) seat the art within half a unit of each other; the 1.12 transcription is kept, the font and string are era's. - - **AddOns / Edit Mode / Support / Macros ship DISABLED** — nothing behind them yet + - **Edit Mode / Support ship DISABLED** — nothing behind them yet (`GameMenuButton_Pending`): the template's own `-Disabled` art + grey label, exactly how the era menu greys a dead entry (SetEnabled(false) → DisabledFont). Each goes live by deleting one `GameMenuButton_Pending` call and writing its OnClick. @@ -96,7 +101,6 @@ -- The reference GlobalStrings (patch-2.MPQ Interface\FrameXML\GlobalStrings.lua), quoted -- verbatim. `X = X or "..."` so a later real strings table wins, the MicroMenu.xml idiom. MAIN_MENU = MAIN_MENU or "Main Menu" - ADDONS = ADDONS or "AddOns" HUD_EDIT_MODE_MENU = HUD_EDIT_MODE_MENU or "Edit Mode" GAMEMENU_SUPPORT = GAMEMENU_SUPPORT or "Support" MACROS = MACROS or "Macros" @@ -108,9 +112,9 @@ QUIT_NOW = QUIT_NOW or "Exit now" CANCEL = CANCEL or "Cancel" - -- One label setter for a button with nothing behind it yet (only Macros, since 0950 made - -- Options live): set the ref's text, then disable (grey label + the template's -Disabled - -- art). Making it live is deleting the call and writing its ref OnClick. + -- One label setter for a button with nothing behind it yet (Edit Mode and Support): set + -- the ref's text, then disable (grey label + the template's -Disabled art). Making it + -- live is deleting the call and writing its ref OnClick. function GameMenuButton_Pending(button, text) button:SetText(text) button:Disable() @@ -205,9 +209,8 @@ - + 28-bottom padding around our seven 21-unit rungs with three 20-unit section gaps. --> + @@ -231,11 +234,12 @@ - + - diff --git a/crates/benilla-app/assets/ui/KeyBindingsPage.xml b/crates/benilla-app/assets/ui/KeyBindingsPage.xml index 531891a7..438f1c11 100644 --- a/crates/benilla-app/assets/ui/KeyBindingsPage.xml +++ b/crates/benilla-app/assets/ui/KeyBindingsPage.xml @@ -30,9 +30,10 @@ mouse-3/4/5 press binds IMMEDIATELY (the host capture seam hands the canonical ALT-CTRL-SHIFT chord to KeyBindings_OnHostKey); a stolen key names its victim in red (KEY_UNBOUND_ERROR) only when the victim goes bare; left/right clicks select/deselect, - never bind; lone modifiers are ignored; the wheel binds click-commands but refuses - press+release ones (SetBinding returns nil → KEYBINDINGFRAME_MOUSEWHEEL_ERROR, old key - restored); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the + never bind; lone modifiers are ignored; the wheel binds like any other key, to ANY + command (decision 1295 — SetBinding refuses nothing but a key string that is not a key, + and the KEYBINDINGFRAME_MOUSEWHEEL_ERROR arm below is that refusal's message, not a + wheel rule); ESC while armed BINDS Escape (1.12's OnKeyDown has no ESCAPE filter — the era's ESC-cancels law is the one era behavior NOT taken, recorded in 1008; the Unbind Key button is the clearing affordance). ESC with nothing armed never reaches any of this: the ladder is the TOGGLEGAMEMENU binding, and the armed seam swallows the press. @@ -444,8 +445,11 @@ KeyBindings_RepaintVisible() end - -- 1.12 KeyBindingFrame_SetBinding: a refused bind (the wheel on a press+release - -- command) restores the slot's old key and says why. + -- 1.12 KeyBindingFrame_SetBinding, kept verbatim: a refused bind restores the slot's old + -- key and says why. The reference wrote this arm with the wheel in mind and named its + -- string for it, but the engine refuses only a key string that is not a key + -- (decision 1295), so this fires for a chord the capture seam could not name — never for + -- a wheel notch. The message stays 1.12's; ours is not the place to correct Blizzard. function KeyBindings_SetOrRestore(key, command, oldKey) if not SetBinding(key, command) then if oldKey then SetBinding(oldKey, command) end diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index cec25b22..41a94719 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -2486,7 +2486,7 @@ on uncheck only), then the section list — 19 dual-mode rows under the shared faux-scroll kit, sections collapsed by default — and the 1.12 window's message line (KEY_BOUND / the red steal / the - wheel refusal) at the foot. --> + refused key string) at the foot. --> diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index f5f86902..1748c712 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -604,8 +604,8 @@ -- The colour picker next (ColorPickerFrame.xml). The reference hooks ESC on the frame -- itself (`` l.177-184, whose ESCAPE arm is `HideUIPanel(this)` + -- `cancelFunc(previousValues)`); this engine gives plain frames no raw key events, so - -- it routes through this chain exactly like the AddOns list on the next rung. - -- **Cancel-CLICK, not hide** — the ref's own arm restores the previous colour, and + -- it routes through this shared chain like the stack-split spinner and the world map + -- do. **Cancel-CLICK, not hide** — the ref's own arm restores the previous colour, and -- clicking the real button is how the options rung below already spells this (the ref's -- `OptionsFrameCancel:Click()`), so there is one cancel path, not two. local picker = getglobal("ColorPickerFrame") @@ -613,17 +613,6 @@ ColorPickerCancelButton:Click() return end - -- The AddOns list next (decision 1197). The reference's glue twin hooks ESC directly - -- (`AddonList_OnKeyDown`, whose ESCAPE arm is `AddonList_OnCancel`); this engine gives - -- plain frames no raw key events, so it routes through this shared chain like the - -- stack-split spinner and the world map do. **Cancel, not hide** — ESC on that window - -- means "discard what I just ticked", which is the reference's own arm and the reason - -- the panel stages its edits at all. - local addons = getglobal("AddonListFrame") - if addons and addons:IsVisible() then - AddonList_OnCancel() - return - end -- The options window next (l.1483-1484, live since decision 0950 — this rung sat -- unfilled while GameMenuFrame.xml's disabled buttons were the gap). The ref -- cancel-clicks (`OptionsFrameCancel:Click()`); ours hides: benilla applies changes diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 0c27a362..303ddedb 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -362,10 +362,6 @@ KeyBindingsPage.xml # (all far above); sits right before GameMenuFrame so the menu — which reaches it by # name at click time — stays the last, outermost layer. OptionsFrame.xml -# The in-game AddOns list (decision 1197) — the ESC menu's AddOns entry. Before GameMenuFrame, -# which reaches AddonList_Toggle by name at click time; it depends only on the AddOn API verbs -# (1191) and defines its own templates, so it can sit anywhere after the fonts. -AddonList.xml # The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their # event driver. LAST on purpose — it is the outermost layer of the shell, it depends on # everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index 0333ffaf..3cb68872 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -192,7 +192,7 @@ fn surface_report(deep: bool, dump_path: Option) { } println!( " ^ read these: an unprefixed verb 1.12 lacks is what `if SomeName then` finds.\n \ - Most are our own UI's helpers (AddonList_*, KeyBindings_*, Options*), which are only\n \ + Most are our own UI's helpers (KeyBindings_*, Options*), which are only\n \ a naming question — but a POST-1.12 API name here is 1189's hazard exactly, because an\n \ addon that feature-tests it takes a branch written for a client we are not." ); diff --git a/crates/benilla-app/src/bindings/chord.rs b/crates/benilla-app/src/bindings/chord.rs index 40f64ef2..792e0f4a 100644 --- a/crates/benilla-app/src/bindings/chord.rs +++ b/crates/benilla-app/src/bindings/chord.rs @@ -205,8 +205,10 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> { NumpadDecimal => "NUMPADDECIMAL", NumLock => "NUMLOCK", PrintScreen => "PRINTSCREEN", - ScrollLock => "SCROLLLOCK", - Pause => "PAUSE", + // ScrollLock and Pause are deliberately absent: the reference's namer calls their key + // codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there (wow-re + // `keybinding-dispatch-law.md` §2.3), and `IsValidBindingKeyString` would refuse the + // names anyway — arm 4's 26-name table holds neither. CapsLock => "CAPSLOCK", Minus => "-", Equal => "=", @@ -346,8 +348,6 @@ fn token_key(t: &str) -> Option { "NUMPADDECIMAL" => NumpadDecimal, "NUMLOCK" => NumLock, "PRINTSCREEN" => PrintScreen, - "SCROLLLOCK" => ScrollLock, - "PAUSE" => Pause, "CAPSLOCK" => CapsLock, "-" => Minus, "=" => Equal, @@ -369,6 +369,87 @@ fn token_key(t: &str) -> Option { mod tests { use super::*; + /// **Every token this codec names is one `SetBinding` will actually take** (decision 1295). + /// The namer here and `IsValidBindingKeyString` in the engine table are two transcriptions of + /// the same reference, and nothing but this ties them together — a token we name but the + /// validator refuses is a key the capture seam happily offers and `SetBinding` then drops on + /// the floor, which is exactly what `SCROLLLOCK` and `PAUSE` were until 1295. + /// + /// The registry's default chords are covered exhaustively; `KeyCode` cannot be enumerated, so + /// the namer is covered by one key of every SHAPE it produces (letter, digit, F-key, numpad + /// digit, numpad name, arrow, the named editing/lock keys, punctuation). + #[test] + fn every_token_the_codec_names_is_one_setbinding_accepts() { + use benilla_ui::script::keybind::normalize_binding_key; + for spec in super::super::commands::SPECS { + for default in [spec.d1, spec.d2].into_iter().flatten() { + assert_eq!( + normalize_binding_key(default).as_deref(), + Some(default), + "the default chord '{default}' ({}) is not a bindable key string", + spec.name + ); + } + } + for k in [ + KeyCode::KeyW, + KeyCode::Digit0, + KeyCode::F1, + KeyCode::F12, + KeyCode::Numpad7, + KeyCode::NumpadAdd, + KeyCode::NumpadDecimal, + KeyCode::ArrowUp, + KeyCode::Space, + KeyCode::Enter, + KeyCode::Escape, + KeyCode::Backspace, + KeyCode::Tab, + KeyCode::Insert, + KeyCode::Delete, + KeyCode::Home, + KeyCode::End, + KeyCode::PageUp, + KeyCode::PageDown, + KeyCode::NumLock, + KeyCode::CapsLock, + KeyCode::PrintScreen, + KeyCode::Minus, + KeyCode::Equal, + KeyCode::BracketLeft, + KeyCode::Backslash, + KeyCode::Semicolon, + KeyCode::Quote, + KeyCode::Comma, + KeyCode::Period, + KeyCode::Slash, + KeyCode::Backquote, + ] { + let token = key_token(k).expect("named"); + assert!( + normalize_binding_key(token).is_some(), + "{k:?} names '{token}', which SetBinding refuses" + ); + } + for b in [ + MouseButton::Left, + MouseButton::Right, + MouseButton::Middle, + MouseButton::Forward, + MouseButton::Back, + ] { + let token = mouse_token(b).expect("named"); + assert!(normalize_binding_key(token).is_some(), "{b:?} → '{token}'"); + } + for token in ["MOUSEWHEELUP", "MOUSEWHEELDOWN"] { + assert!(normalize_binding_key(token).is_some()); + } + // The other half of the same law: the reference's namer calls these two `UNKNOWN` + // (`0x210`/`0x211`), so we must not name them either — nothing downstream could bind them. + assert_eq!(key_token(KeyCode::ScrollLock), None); + assert_eq!(key_token(KeyCode::Pause), None); + } + #[test] fn the_codec_round_trips_every_keyboard_token() { // Every key with a token parses back to itself (through the alias fold). diff --git a/crates/benilla-app/src/bindings/commands.rs b/crates/benilla-app/src/bindings/commands.rs index a238dd29..7cd516ac 100644 --- a/crates/benilla-app/src/bindings/commands.rs +++ b/crates/benilla-app/src/bindings/commands.rs @@ -49,7 +49,9 @@ pub(crate) struct Spec { } impl Spec { - /// Press+release semantics — the mousewheel-refusal law rides this at `SetBinding` time. + /// Press+release semantics — the reference's ``, whose ONE reader is + /// `RunCommand 0x4b7bf1`: it gates the release half and nothing else. (It used to gate a + /// mousewheel refusal at `SetBinding` time here; that refusal is not in the client — 1295.) pub(crate) fn run_on_up(&self) -> bool { matches!(self.kind, Kind::Held | Kind::EdgeUpDown(..)) } @@ -1127,9 +1129,9 @@ mod tests { /// **The registry against the real client's own two files.** Two columns of this table are /// pure transcription from the 1.12 install, and both are load-bearing in a way nothing else - /// here can see: `runOnUp` — the mousewheel-refusal law rides it, and B265 turned entirely on - /// whether ours matched — and every command's default chords. A hand-typed table drifts; this - /// reads the source of truth instead of trusting the typing. + /// here can see: `runOnUp` — which decides whether a wheel notch delivers a release half at + /// all (1295), and which B265 turned entirely on — and every command's default chords. A + /// hand-typed table drifts; this reads the source of truth instead of trusting the typing. /// /// `Interface\FrameXML\Bindings.xml` carries the flag, `WTF\DefaultBindings.wtf` the chords. /// Both are install content, read at runtime, never committed; the test skips (passes) @@ -1183,7 +1185,7 @@ mod tests { s.run_on_up(), reference_run_on_up, "{}: runOnUp disagrees with the install's Bindings.xml — that flag decides \ - whether the mousewheel may be bound to this command (B265)", + whether a press of this command delivers a release half (1295)", s.name ); let ours: Vec<&str> = [s.d1, s.d2].into_iter().flatten().collect(); diff --git a/crates/benilla-app/src/capture/fixtures.rs b/crates/benilla-app/src/capture/fixtures.rs index 9c96f4fc..83c5bc88 100644 --- a/crates/benilla-app/src/capture/fixtures.rs +++ b/crates/benilla-app/src/capture/fixtures.rs @@ -1118,6 +1118,16 @@ pub(super) fn seed_ui_fixture( warn!("capture: ui-macro seed failed: {e}"); } } + UiFixture::Social => { + // Nothing to seed: the stray capsule (B264) rode the pane's *declaration*, not its + // contents — an empty friends list opens the same frames a full one does. + let Some(script) = script.as_mut() else { + return; + }; + if let Err(e) = script.run("ToggleFriendsFrame(1)") { + warn!("capture: ui-social seed failed to open the pane: {e}"); + } + } UiFixture::ChatEdit => { let Some(script) = script.as_mut() else { return; diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 69496807..ef177afd 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -99,6 +99,12 @@ pub(super) enum UiFixture { /// capture checks the header (left-flush, Say-white), the typed text past the live insets, /// and the three-piece input border. ChatEdit, + /// The social pane (`FriendsFrame`), opened through the live toggle — the instrument for B264 + /// (decision 1298). Its `BenillaFriendsDropDown` host is declared with no anchors, exactly as + /// the reference's own `FriendsDropDown` is; while an unpositioned owner stood in a zero rect + /// for its regions, the dropdown template's texture chain drew a stray capsule at the screen + /// origin every time the pane opened. Needs no server state to show it. + Social, /// The era-styled Options window (decisions 0950/0951), opened through the live panel path — /// the look-pass instrument for the whole options arc: chrome nine-slice seams, tab plates, /// search-box seat, category list art, the window scale. Static (no server state touched), @@ -926,6 +932,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 720, ui: Some(UiFixture::ChatEdit), }, + // The social pane, for the stray-dropdown regression (B264, decision 1298). Run with + // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-social`. + Scenario { + name: "ui-social", + map: MAP_AZEROTH, + eye: GROUND_EYE, + look: GROUND_LOOK, + minute: 720, + ui: Some(UiFixture::Social), + }, // The era Options window over the ground scene. Run with // `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options`. Scenario { diff --git a/crates/benilla-app/src/char_select/addons.rs b/crates/benilla-app/src/char_select/addons.rs index 9612901a..62d3f80d 100644 --- a/crates/benilla-app/src/char_select/addons.rs +++ b/crates/benilla-app/src/char_select/addons.rs @@ -1,100 +1,253 @@ -//! The **character-select AddOns screen** (decision 1197) — the glue half of the director's ask, -//! and the button 1191 §5 recorded as *"not hidden; never built"*. +//! The **character-select AddOns screen** (decisions 1197, 1293) — the one addon surface (the +//! in-game panel was retired at the director's call, 2026-08-14; `ReloadUI` itself stays). //! -//! The reference's is `Interface\GlueXML\AddonList.xml/.lua`, opened by -//! `CharacterSelectAddonsButton`. We have that Lua (read off the patch chain) and it is the -//! authority for **behaviour**; we do not have its XML, so the geometry here is ours, derived from -//! the two constants the Lua does carry: `ADDON_BUTTON_HEIGHT = 16` and -//! `MAX_ADDONS_DISPLAYED = 19` — a 19-row list at 16 px pitch, 304 px of list. Everything else is -//! sized around that and stated as ours rather than passed off as transcribed. +//! The reference is `Interface\GlueXML\AddonList.xml/.lua`, read off the player's own patch chain +//! (`patch.MPQ` — 1293 located it after 1197 believed it missing), and **this screen now draws the +//! authored layout**: the six `HelpFrame-*` plate pieces that are the whole framed panel, the +//! `UI-DialogBox-Header` title plate, the `GlueCloseButton`, the `GlueDropDownMenuTemplate` +//! control, the 19×(16+4) entry column, the `GlueScrollFrameTemplate` bar with the +//! `UI-Character-ScrollBar` track, and the four-button bottom row in the reference's own order +//! (Disable All · Enable All ··· Okay · Cancel). Geometry constants below are the authored +//! numbers, resolved top-down into the 640×512 plate. Behaviour is **re-expressed natively** — +//! transcribing GlueXML is forbidden, not discouraged (the contract's FrameXML rule; 1234, 1260). //! //! ## What the reference does, and what we do //! -//! `AddonList_Update` per row: a tri-state checkbox, the title (`## Title` else the folder name) -//! coloured **gold when loadable, red when enabled-but-broken, grey when disabled**, the status -//! token (`getglobal("ADDON_"..reason)`), a URL/update button, and a security icon. Hovering a row -//! raises `AddonTooltip` with title + notes + `ADDON_DEPENDENCIES`. +//! `AddonList_Update` per row: a **tri-state** checkbox (`GetAddOnEnableState` → 0/1/2, the grey +//! check drawn with `UI-CheckBox-Check-Disabled`), the title (`## Title` else the folder name) +//! coloured **gold when loadable, red when enabled-and-broken — except `DEP_DISABLED`, which reads +//! grey** (AddonList.lua's own exception), and the status token (`getglobal("ADDON_"..reason)`) in +//! `GlueFontNormalSmall` 30 units right of the title box. Titles render their `|cAARRGGBB` markup +//! as colour, exactly as every FontString does ([`title_spans`]). Hovering a row raises +//! `AddonTooltip` (title + notes + `ADDON_DEPENDENCIES`) anchored to the row's left; hovering a +//! MIXED checkbox raises `ENABLED_FOR_SOME`. **A row has no hover highlight and no row-click** — +//! only the checkbox toggles (the entry template carries no `HighlightTexture` and no `OnClick`); +//! reproducing that is also what killed the hover-respawn flashing the director reported: the +//! spawned tree no longer changes under the cursor at all. //! -//! Carried: the list, the checkbox, the three-colour title rule, the status column, the -//! hover tooltip with notes and dependencies, scrolling, Enable All / Disable All, and -//! Okay/Cancel with Cancel meaning *discard* (the reference's `ResetAddOns`). -//! -//! **Not carried, each for a stated reason:** -//! - the **security icon** — `## Secure: 1` is Blizzard's own signature marker and nothing a -//! player installs carries it honestly (1191 §6 already refused to treat it as granting -//! anything). An icon that is always "insecure" tells nobody anything. -//! - the **URL / update buttons** — they launch a browser. That is an outward-facing action from a -//! game client, and it is the director's call to make, not this screen's. `## URL` is shown in -//! the tooltip instead, where it is information rather than a trigger. -//! - the **character dropdown** (`AddonCharacterDropDown`, the "All" vs per-character switch). -//! Ours edits the **selected** character's enable file, because that is the file the world-entry -//! walk will read (1191 §7). "All characters" needs a glue dropdown widget we do not have and a -//! fan-out write across every roster file; it is queued, not forgotten. -//! - `SetScriptMemory` / `GetScriptMemory` — a 1.12 Lua-heap dial with no equivalent here. +//! Carried from 1197/1293: the character dropdown ("All" + the realm roster, staging +//! per-character), the `checkAddonVersion`-inverted force-load checkbox, Okay = `SaveAddOns` +//! (write the per-character enable files; the next login IS the apply point), Cancel/ESC/the X = +//! `ResetAddOns` (discard). **Not built**, per 1197's standing rejections (Script Memory +//! reaffirmed by the director, 2026-08-14): the Script Memory dial, the URL/update launch buttons +//! (`## URL` shows in the tooltip instead — information, not a trigger), the security icon. //! //! ## Why it reads the folder rather than the VM //! //! At character select no addon has been *loaded* — `load_third_party` is a world-entry step //! (1191 §2) — so `GetNumAddOns()` would answer 0. The screen asks //! [`crate::ui_script::addons::installed`] instead: the same discovery, the same manifest parser, -//! the same enable file. Two views of one folder, never two folders. +//! the same enable files. Two views of one folder, never two folders. With the dropdown that read +//! happens **once per roster character at open** ([`AddonsPanel::open_for`]). use bevy::prelude::*; +use bevy::ui_render::ui_material::MaterialNode; -use crate::glue::art::{GlueArt, DIM, GOLD}; +use benilla_ui::markup::{tokens, TokenKind}; +use benilla_ui::script::addon_gate::{can_load, GateRow, Verdict}; +use benilla_ui::script::UiScript; + +use crate::glue::art::{tc_rect, GlueArt, DIM, GOLD}; use crate::glue::backdrop::{backdrop_border, tiled_bg_node}; -use crate::glue::widgets::{glue_button, outlined_text, overlay, GlueBtnKind, GlueText, Hilight}; +use crate::glue::widgets::{ + glue_button, outlined_spans, outlined_text, overlay, ArtSwap, GlueBtnKind, GlueText, Hilight, +}; use crate::glue_strings::GlueStrings; +use crate::sound::GlueSound; use crate::ui_script::addons::{self, InstalledAddOn}; use super::wow_font; -/// `MAX_ADDONS_DISPLAYED` (AddonList.lua l.2) — the reference's own row count. +// ── The authored geometry (AddonList.xml, patch chain) — y resolved top-down in the plate ──────── + +/// `AddonListBackground` — the 640×512 `HelpFrame-*` plate, anchored CENTER +(24, 0). +const BG_W: f32 = 640.0; +const BG_H: f32 = 512.0; +const BG_CENTER_OFF_X: f32 = 24.0; +/// `MAX_ADDONS_DISPLAYED` (AddonList.lua l.2). const MAX_ROWS: usize = 19; -/// `ADDON_BUTTON_HEIGHT` (AddonList.lua l.1). +/// `ADDON_BUTTON_HEIGHT` (l.1); each entry anchors TOP to the previous BOTTOM at (0, −4). const ROW_H: f32 = 16.0; -/// Ours: the list is 19×16 = 304 tall, and the panel is that plus a title and a button row. -const PANEL_W: f32 = 560.0; -const PANEL_H: f32 = 420.0; -const LIST_TOP: f32 = 56.0; -const LIST_LEFT: f32 = 24.0; -const CHECK: f32 = 14.0; +const ROW_PITCH: f32 = ROW_H + 4.0; +/// Entry 1 sits at TOPLEFT (37, −80); entries are 520 wide. +const ROW_LEFT: f32 = 37.0; +const ROW_TOP: f32 = 80.0; +const ROW_W: f32 = 520.0; +/// The entry's title FontString: LEFT (42, 0), 220 wide; the status hangs 30 right of its box. +const TITLE_LEFT: f32 = 42.0; +const TITLE_W: f32 = 220.0; +const STATUS_LEFT: f32 = TITLE_LEFT + TITLE_W + 30.0; +/// `AddonCharacterDropDown` at TOPLEFT (0, −38); its `CharacterCreate-LabelFrame` art is three +/// 64-tall slices (25 | 115 | 25) whose top rides 17 ABOVE the 32-tall frame. +const DROP_TOP: f32 = 38.0; +const DROP_ART_TOP: f32 = DROP_TOP - 17.0; +const DROP_ART_H: f32 = 64.0; +const DROP_SLICE_W: [f32; 3] = [25.0, 115.0, 25.0]; +const DROP_W: f32 = DROP_SLICE_W[0] + DROP_SLICE_W[1] + DROP_SLICE_W[2]; +/// The slices' texcoords in the 128-wide `CharacterCreate-LabelFrame`. +const DROP_TC: [[f32; 4]; 3] = [ + [0.0, 0.1953125, 0.0, 1.0], + [0.1953125, 0.8046875, 0.0, 1.0], + [0.8046875, 1.0, 0.0, 1.0], +]; +/// The open list: TOPLEFT to the dropdown frame's BOTTOMLEFT +(8, 22 up) → (8, 48) in the plate. +const DROP_LIST_LEFT: f32 = 8.0; +const DROP_LIST_TOP: f32 = DROP_TOP + 32.0 - 22.0; +/// `AddonListForceLoad`: a 32² checkbox whose TOP-center sits at (+50, −42); label at LEFT +36. +const FORCE_LEFT: f32 = BG_W / 2.0 + 50.0 - 16.0; +const FORCE_TOP: f32 = 42.0; +/// `AddonListScrollFrame`: TOPLEFT (49, −73), 510×390; its slider column and track hang right. +const SCROLL_TOP: f32 = 73.0; +const SCROLL_H: f32 = 390.0; +const SCROLL_RIGHT: f32 = 49.0 + 510.0; +/// The bottom row: 35 tall, 13 off the plate bottom. +const BTN_TOP: f32 = BG_H - 13.0 - 35.0; /// A red that reads as "this is enabled and will not load" — `SetTextColor(1.0, 0.1, 0.1)`, /// AddonList.lua's own value. const BROKEN: Color = Color::srgb(1.0, 0.1, 0.1); +/// The `AddonTooltip` backdrop tint — its `OnLoad`'s `SetBackdropColor(0.09, 0.09, 0.19)`. +const TIP_FILL: Color = Color::srgb(0.09, 0.09, 0.19); +/// The tooltip's authored width. +const TIP_W: f32 = 220.0; + +/// One checkbox's face — the reference's `GetAddOnEnableState` values by their meaning +/// (0 / 2 / 1 = [`Self::Off`] / [`Self::On`] / [`Self::Mixed`]). `Mixed` exists only in the +/// All view: enabled for SOME of the characters, not all of them. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum BoxState { + Off, + On, + Mixed, +} + +/// What the cursor is over, for the two tooltips: the row strip raises the `AddonTooltip` body, +/// the checkbox raises `ENABLED_FOR_SOME` (only when its state is MIXED) — the reference splits +/// them the same way (the entry's `OnEnter` vs the CheckButton's). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Hover { + Row(usize), + Check(usize), +} /// The screen's state. Opened by the select screen's AddOns button, driven by /// [`drive_addons_panel`], cleared on leaving character select. /// -/// `staged` is the whole point of Okay/Cancel: edits go here, and only Okay writes the file. The -/// reference does the same with `SaveAddOns`/`ResetAddOns` over the client's in-memory records. -#[derive(Resource, Default)] +/// `staged` is the whole point of Okay/Cancel: edits go here — one column per roster character — +/// and only Okay writes the files. The reference does the same with `SaveAddOns`/`ResetAddOns` +/// over the client's in-memory per-character records. +#[derive(Resource)] pub(super) struct AddonsPanel { pub(super) open: bool, /// The installed list as read at open — never re-read while up, so a row's index is stable. + /// Shared **metadata**: its own `enabled` bit is the first column's read and nothing repaints + /// from it; the live enable state is `staged`'s. list: Vec, - /// The edited enable state, parallel to `list`. - staged: Vec, + /// The realm the roster belongs to — half of every enable file's key (0997's `(realm, name)`). + realm: String, + /// The roster's character names at open, the dropdown's entries after "All". Empty on a fresh + /// account; `staged` then holds one anonymous column so the checkboxes still work (below). + chars: Vec, + /// The edited enable state: `staged[c][i]` is character `c`'s bit for `list[i]`. When `chars` + /// is empty this is ONE anonymous column (the folder with nobody's file — everything enabled); + /// it has no file behind it, so Okay writes nothing, exactly what the pre-1293 single-identity + /// screen did with no character. + staged: Vec>, + /// What the files said at open, column for column — Okay writes only the columns that moved + /// off this ([`Self::save_staged`]). + baseline: Vec>, + /// The dropdown's selection: `None` = "All", `Some(c)` = `chars[c]`. Which character's enable + /// state the list edits — and whose staged bits feed the gate. + view: Option, + /// The dropdown's option list is up (drawn over the list area). + dropdown_open: bool, + /// The live `checkAddonVersion` mirror ("1"/absent = check ON = the force-load box UNTICKED), + /// refreshed from the boot VM every frame by [`drive_addons_panel`] — the same live-per-query + /// read the gate's verbs make (1292 §2.2), which is why toggling the box repaints the statuses + /// with nothing rescanned. + version_check: bool, /// First visible row (`AddonList.offset`). offset: usize, - /// The row the cursor is over, for the tooltip. - hovered: Option, + /// The spawned tooltip, keyed by what it describes — **the only thing hover drives, and + /// never a repaint trigger**. The old model set `dirty` on every hover change and respawned + /// the whole tree, which reset the fresh entities' `Interaction` to `None`, which flipped + /// the hover back, which respawned again — the "border flashing" the director reported. + tip: Option<(Hover, Entity)>, root: Option, spawned_s: f32, - /// Set while the spawned tree no longer matches `staged`/`offset`/`hovered` — one flag rather - /// than a diff, because a 19-row list is cheaper to respawn than to reconcile. + /// Set while the spawned tree no longer matches the state — one flag rather than a diff, + /// because a 19-row list is cheaper to respawn than to reconcile. Hover is deliberately NOT + /// part of this (above). dirty: bool, } +impl Default for AddonsPanel { + fn default() -> Self { + Self { + open: false, + list: Vec::new(), + realm: String::new(), + chars: Vec::new(), + staged: Vec::new(), + baseline: Vec::new(), + view: None, + dropdown_open: false, + // The registrar default: "1" = check ON (1292, byte-verified). Hand-written Default + // for this one field — a derived `false` would paint every out-of-date row loadable + // for the frame before the drive system's first CVar read corrects it. + version_check: true, + offset: 0, + tip: None, + root: None, + spawned_s: 0.0, + dirty: false, + } + } +} + impl AddonsPanel { - /// Open for `identity`, reading the folder fresh. The reference's `AddonList_OnShow` also - /// re-reads (`AddonList_Update`), so a folder that changed under us is picked up. - pub(super) fn open_for(&mut self, identity: Option<&(String, String)>) { - self.list = addons::installed(identity); - self.staged = self.list.iter().map(|a| a.enabled).collect(); + /// Open for this realm's roster, reading the folder fresh — once per character, so each + /// staged column is that character's own enable file applied to one shared list. The + /// reference's `AddonList_OnShow` also re-reads (`AddonList_Update`), so a folder that + /// changed under us is picked up. + pub(super) fn open_for(&mut self, realm: String, chars: Vec) { + self.list.clear(); + self.staged.clear(); + if chars.is_empty() { + // A fresh account: no character means no enable file to key. `None` is the "nobody's + // file" read (everything enabled), staged into one anonymous column so the boxes + // still toggle; Okay has no file to write ([`Self::save_staged`] walks `chars`). + self.list = addons::installed(None); + self.staged + .push(self.list.iter().map(|a| a.enabled).collect()); + } else { + for (c, name) in chars.iter().enumerate() { + let id = (realm.clone(), name.clone()); + let rows = addons::installed(Some(&id)); + if c == 0 { + self.staged.push(rows.iter().map(|a| a.enabled).collect()); + self.list = rows; + } else { + // Same folder, same deterministic (alphabetical) discovery, read + // back-to-back: every call answers the same rows in the same order, only the + // `enabled` bits differ. That is what lets ONE metadata list carry N columns. + debug_assert_eq!( + rows.len(), + self.list.len(), + "installed() must be deterministic across per-character reads" + ); + self.staged.push(rows.iter().map(|a| a.enabled).collect()); + } + } + } + self.baseline = self.staged.clone(); + self.realm = realm; + self.chars = chars; + // "All" is the reference's own default selection (AddonList.lua's dropdown initializer). + self.view = None; + self.dropdown_open = false; self.offset = 0; - self.hovered = None; + self.tip = None; self.open = true; self.dirty = true; } @@ -102,11 +255,35 @@ impl AddonsPanel { pub(super) fn close(&mut self) { self.open = false; self.list.clear(); + self.chars.clear(); self.staged.clear(); - self.hovered = None; + self.baseline.clear(); + self.view = None; + self.dropdown_open = false; + self.tip = None; self.dirty = true; } + /// Okay — the reference's `SaveAddOns`: write the per-character enable files, and only for + /// the characters whose staged column actually moved off what their file said at open (the + /// write is [`addons::write_enable_state`]'s merge, so an untouched character's file is not + /// even opened). The anonymous no-roster column has no file and writes nothing. + fn save_staged(&self) { + for (c, name) in self.chars.iter().enumerate() { + if self.staged.get(c) == self.baseline.get(c) { + continue; + } + let id = (self.realm.clone(), name.clone()); + let states: Vec<(String, bool)> = self + .list + .iter() + .zip(self.staged[c].iter()) + .map(|(a, &on)| (a.name.clone(), on)) + .collect(); + addons::write_enable_state(Some(&id), &states); + } + } + /// Are there any installed addons at all? The reference hides its button when not /// (`UpdateAddonButton`), and so do we — a button that opens an empty list is worse than none. /// @@ -122,14 +299,14 @@ impl AddonsPanel { *ANY.get_or_init(|| !addons::installed(None).is_empty()) } - /// The rows currently visible, as `(index, addon, enabled)`. - fn visible(&self) -> impl Iterator { + /// The rows currently visible, as `(index, addon)` — enable state is the view's to answer + /// ([`Self::box_state`]), not the metadata's. + fn visible(&self) -> impl Iterator { self.list .iter() .enumerate() .skip(self.offset) .take(MAX_ROWS) - .map(|(i, a)| (i, a, self.staged.get(i).copied().unwrap_or(a.enabled))) } /// The highest first-row index that still fills the list. @@ -137,65 +314,254 @@ impl AddonsPanel { self.list.len().saturating_sub(MAX_ROWS) } - /// **Why a row will not load** — the string the status column shows and the colour rule keys on. - /// - /// These are the reference's status LABELS, not its tokens, because the glue side has no VM to - /// splice `getglobal("ADDON_"..reason)` against (1197 §3). Each is the exact value its token - /// carries in the shipped `GlobalStrings.lua`, and - /// `the_status_labels_are_the_reference_globalstrings_values` pins all four so they cannot - /// drift from the ESC-menu twin, which DOES go through the globals: - /// - /// | here | token | `GlobalStrings.lua` | - /// |---|---|---| - /// | `"Disabled"` | `ADDON_DISABLED` | `"Disabled"` | - /// | `"Dependency missing"` | `ADDON_DEP_MISSING` | `"Dependency missing"` | - /// | `"Dependency disabled"` | `ADDON_DEP_DISABLED` | `"Dependency disabled"` | - /// | `"Out of date"` | `ADDON_INTERFACE_VERSION` | `"Out of date"` | - /// - /// The last row is why this comment was rewritten: it used to name the token `INCOMPATIBLE`, - /// **which does not exist** — the reference has no `ADDON_INCOMPATIBLE`, and a reader who - /// trusted the name would have spliced a nil global. Out-of-date is reported and never enforced - /// (1191 §6), which is why such a row still shows gold-if-enabled: the status column says so and - /// the client still loads it, and those two facts must not contradict each other. - fn reason(&self, i: usize) -> Option<&'static str> { - let addon = self.list.get(i)?; - if !self.staged.get(i).copied().unwrap_or(addon.enabled) { - return Some("Disabled"); - } - for dep in &addon.dependencies { - match self - .list - .iter() - .position(|a| a.name.eq_ignore_ascii_case(dep)) - { - None => return Some("Dependency missing"), - Some(j) if !self.staged.get(j).copied().unwrap_or(true) => { - return Some("Dependency disabled") + /// One row's checkbox state for the current view. A single character's view is plain + /// two-state over their column; the All view is the reference's tri-state — its + /// `GetAddOnEnableState(nil, i)`: enabled for all / for some (the greyed check) / for none. + fn box_state(&self, i: usize) -> BoxState { + match self.view { + Some(c) => { + if self + .staged + .get(c) + .is_some_and(|col| col.get(i) == Some(&true)) + { + BoxState::On + } else { + BoxState::Off + } + } + None => { + let on = self + .staged + .iter() + .filter(|col| col.get(i) == Some(&true)) + .count(); + if on == 0 { + BoxState::Off + } else if on == self.staged.len() { + BoxState::On + } else { + BoxState::Mixed } - Some(_) => {} } } - addon.out_of_date().then_some("Out of date") } + + /// Is this addon enabled *for the current view* — the bit the gate is fed? A single character + /// reads their own column. The All view answers **"any staged character has it on"** — OURS + /// (1293): the reference feeds `AddOn_CanLoad` a NULL character there and that read's exact + /// semantics are un-carved, so we state the natural reading rather than guess at bytes nobody + /// has verified. + fn effective_enabled(&self, i: usize) -> bool { + self.box_state(i) != BoxState::Off + } + + /// The current view lowered into the gate's adapter rows (1292): the folder's one registry + /// with THIS view's staged enable bits. `loaded` is `false` for every row — nothing has + /// loaded at the glue (`load_third_party` is a world-entry step, 1191 §2), so the loaded + /// short-circuit can never fire here. + fn gate_rows(&self) -> Vec> { + self.list + .iter() + .enumerate() + .map(|(i, a)| GateRow { + name: &a.name, + enabled: self.effective_enabled(i), + interface: a.interface, + load_on_demand: a.load_on_demand, + loaded: false, + dependencies: a.dependencies.iter().map(String::as_str).collect(), + }) + .collect() + } + + /// **Why a row will not load** — the one arbiter's answer (1292), which the status column + /// splices and the title colour keys on. `demand_only=false`: this is the glue surface + /// (`dl=0`) — `NOT_DEMAND_LOADED` is unreachable here. `version_check` is the live CVar + /// mirror, re-read per query like the reference (1292 §2.2). + fn verdict(&self, i: usize) -> Verdict { + can_load(&self.gate_rows(), i, false, self.version_check) + } + + /// AddonList.lua's title colour, verbatim in structure: gold when loadable; red when the + /// player has it ON and it still will not load — **unless the reason is `DEP_DISABLED`**, + /// which reads grey like a disabled row (the Lua's own `reason ~= "DEP_DISABLED"` guard: the + /// player already made that choice one row up, and red would blame the dependent); grey + /// otherwise. Any `|c` markup in the title overrides this base per-span, exactly as the + /// reference FontString renders it ([`title_spans`]). + fn title_colour(&self, i: usize) -> Color { + let verdict = self.verdict(i); + if verdict.loadable() { + GOLD + } else if self.effective_enabled(i) && verdict.token().as_deref() != Some("DEP_DISABLED") { + BROKEN + } else { + DIM + } + } + + /// A checkbox click for the current view. In a single character's view it is a plain toggle + /// of their bit. In the All view it is the reference's CheckButton over the tri-state: a + /// grey (mixed) box **counts as checked**, so clicking On *or* Mixed unchecks — disable for + /// every character — and only a fully-unchecked box enables for all. + fn click_row(&mut self, i: usize) { + match self.view { + Some(c) => { + if let Some(slot) = self.staged.get_mut(c).and_then(|col| col.get_mut(i)) { + *slot = !*slot; + self.dirty = true; + } + } + None => { + let target = self.box_state(i) == BoxState::Off; + for col in &mut self.staged { + if let Some(slot) = col.get_mut(i) { + *slot = target; + } + } + self.dirty = true; + } + } + } + + /// Enable All / Disable All sweep the CURRENT VIEW: the view the player is editing is the + /// view the sweep edits — "All" sweeps every column, a single character's view only theirs. + fn set_all(&mut self, on: bool) { + match self.view { + Some(c) => { + if let Some(col) = self.staged.get_mut(c) { + col.iter_mut().for_each(|s| *s = on); + } + } + None => { + for col in &mut self.staged { + col.iter_mut().for_each(|s| *s = on); + } + } + } + self.dirty = true; + } + + /// The dropdown's current value — "All" or the viewed character's name. + fn view_name<'a>(&'a self, strings: &'a GlueStrings) -> &'a str { + match self.view { + None => strings.text("ALL", "All"), + Some(c) => self.chars.get(c).map(String::as_str).unwrap_or("?"), + } + } +} + +/// The status column's text: the reference's `getglobal("ADDON_"..reason)` splice, run against +/// the parsed GlueStrings table — the glue side loads no VM to splice in (1197 §3), but the +/// string TABLE is loaded, so the lookup is the same one. The fallbacks are the shipped values +/// of the same tokens, pinned by `the_status_labels_are_the_reference_globalstrings_values` +/// (verified against the extracted 1.12 chain, GlueStrings.lua l.44-56). +fn status_label<'a>(strings: &'a GlueStrings, token: &'a str) -> &'a str { + let fallback = match token { + "DISABLED" => "Disabled", + "INTERFACE_VERSION" => "Out of date", + "DEP_MISSING" => "Dependency missing", + "DEP_DISABLED" => "Dependency disabled", + "DEP_INTERFACE_VERSION" => "Dependency out of date", + // `BANNED`/`CORRUPT`/`INSECURE` gate on server signature state we do not model, and + // `NOT_DEMAND_LOADED` is the in-game surface's (`demand_only=false` here) — none is + // producible at the glue (addon_gate's module doc); the raw token is the honest fallback. + other => other, + }; + strings.get(&format!("ADDON_{token}")).unwrap_or(fallback) +} + +/// Split a title into its coloured spans — WoW's `|cAARRGGBB…|r` inline markup, decoded by the +/// byte-verified grammar (`benilla_ui::markup`, RF-0087) instead of drawn literally. `base` is +/// the row's own colour ([`AddonsPanel::title_colour`]); an escape overrides it until `|r`, and +/// the escape's alpha byte is discarded exactly as the client discards it. Link escapes hide +/// their payload and keep their visible text; `||` draws one `|`; a line break becomes a space +/// (a title is one line). +fn title_spans(text: &str, base: Color) -> Vec<(String, Color)> { + let mut spans: Vec<(String, Color)> = Vec::new(); + let mut cur = String::new(); + let mut colour = base; + for (_, tok) in tokens(text) { + let switch = match tok.kind { + TokenKind::Color(rgba) => { + let [r, g, b, _] = rgba.to_f32_at(1.0); + Some(Color::srgb(r, g, b)) + } + TokenKind::ColorReset => Some(base), + TokenKind::EscapedPipe => { + cur.push('|'); + None + } + TokenKind::LineBreak => { + cur.push(' '); + None + } + TokenKind::LinkOpen { .. } | TokenKind::LinkClose => None, + TokenKind::Char(c) => { + cur.push(c); + None + } + }; + if let Some(next) = switch { + if !cur.is_empty() { + spans.push((std::mem::take(&mut cur), colour)); + } + colour = next; + } + } + if !cur.is_empty() { + spans.push((cur, colour)); + } + if spans.is_empty() { + spans.push((String::new(), base)); + } + spans +} + +/// Flip the *Load out of date AddOns* box: the `checkAddonVersion` CVar **inverted** (1292 §2, +/// byte-verified — ticked = `"0"`). Written through [`UiScript::set_cvar_engine`] so it rides the +/// change queue like a Lua `SetCVar` and the host's sync persists it (the minimap-zoom pattern) — +/// nothing else to do: the statuses repaint from [`drive_addons_panel`]'s per-frame mirror, no +/// rescan, because the gate re-reads the flag per query (1292 §2.2). +fn toggle_force_load(script: &mut UiScript) { + let checking = script.cvar("checkAddonVersion").is_none_or(|v| v != "0"); + script.set_cvar_engine("checkAddonVersion", if checking { "0" } else { "1" }); } /// The panel's clickable parts. #[derive(Component, Clone, Copy, PartialEq, Eq)] pub(super) enum AddonsAction { - /// A list row — toggles its checkbox (`AddonList_Enable`). + /// A row's CHECKBOX — the only thing on a row that clicks, as in the reference (the entry + /// template has no `OnClick`; `AddonList_Enable` hangs off the CheckButton alone). Row(usize), + /// The row strip itself: hover raises the `AddonTooltip`; a press does nothing. + RowHover(usize), EnableAll, DisableAll, Okay, + /// Cancel, the ESC key, and the `GlueCloseButton` X — all `AddonList_OnCancel`. Cancel, ScrollUp, ScrollDown, + /// The dropdown control — opens/closes the option list. Also carried by the open list's + /// full-screen backdrop, so a click that misses every option closes it (click-away). + DropdownToggle, + /// One dropdown option: `None` = "All", `Some(c)` = that roster character. + DropdownPick(Option), + /// The *Load out of date AddOns* checkbox ([`toggle_force_load`]). + ForceLoad, } /// The panel root (despawned whole on close). #[derive(Component)] struct AddonsUi; +/// A panel button whose child [`Hilight`] lights on hover (the checkboxes' `UI-CheckBox-Highlight`, +/// the close X, the dropdown arrow, the open list's `UI-QuestTitleHighlight` rows, the scroll +/// arrows) — driven per frame by [`drive_addons_panel`] with **no respawn**, which is the point. +#[derive(Component)] +pub(super) struct HoverLit; + /// Spawn/despawn the panel, run its flows, and repaint when anything it shows has changed. /// /// Ordered before the select screen's own click handling so a click that lands on the panel is @@ -207,18 +573,21 @@ pub(super) fn drive_addons_panel( art: Res, assets: Res, strings: Option>, - identity: Res, - roster: Res, + mut script: Option>, keys: Res>, mut wheel: MessageReader, + mut sounds: MessageWriter, presses: Query<(&AddonsAction, &Interaction), Changed>, - hovers: Query<(&AddonsAction, &Interaction)>, + hovers: Query<(Entity, &AddonsAction, &Interaction)>, + lit: Query<(&Interaction, &Children), With>, + mut hilights: Query<&mut Visibility, With>, window: Query<&Window, With>, ) { if !panel.open { if let Some(root) = panel.root.take() { commands.entity(root).despawn(); } + panel.tip = None; wheel.clear(); return; } @@ -231,24 +600,34 @@ pub(super) fn drive_addons_panel( continue; } match *action { - AddonsAction::Row(i) => { - if let Some(slot) = panel.staged.get_mut(i) { - *slot = !*slot; - panel.dirty = true; - } - } - AddonsAction::EnableAll => { - panel.staged.iter_mut().for_each(|s| *s = true); - panel.dirty = true; - } - AddonsAction::DisableAll => { - panel.staged.iter_mut().for_each(|s| *s = false); - panel.dirty = true; - } + AddonsAction::Row(i) => panel.click_row(i), + AddonsAction::RowHover(_) => {} + AddonsAction::EnableAll => panel.set_all(true), + AddonsAction::DisableAll => panel.set_all(false), AddonsAction::Okay => close_and_save = true, AddonsAction::Cancel => close_and_discard = true, AddonsAction::ScrollUp => scroll(&mut panel, -1), AddonsAction::ScrollDown => scroll(&mut panel, 1), + AddonsAction::DropdownToggle => { + // The reference's dropdown button click (`ToggleDropDownMenu` + + // `PlaySound("igMainMenuOptionCheckBoxOn")`). + sounds.write(GlueSound("igMainMenuOptionCheckBoxOn")); + panel.dropdown_open = !panel.dropdown_open; + panel.dirty = true; + } + AddonsAction::DropdownPick(view) => { + sounds.write(GlueSound("igMainMenuOptionCheckBoxOn")); + panel.view = view; + panel.dropdown_open = false; + panel.dirty = true; + } + AddonsAction::ForceLoad => { + // No VM (a bare test world / a capture): nothing to write to and nothing the + // walk would read differently — the box is inert, honestly. + if let Some(script) = script.as_deref_mut() { + toggle_force_load(script); + } + } } } // `AddonList_OnKeyDown`: ESCAPE cancels, ENTER accepts. @@ -264,36 +643,28 @@ pub(super) fn drive_addons_panel( } } - // The hovered row, for the tooltip — read every frame rather than on change, because a row - // that scrolls out from under a stationary cursor changes what is hovered without an event. - let hovered = hovers.iter().find_map(|(a, i)| match (a, i) { - (AddonsAction::Row(r), Interaction::Hovered | Interaction::Pressed) => Some(*r), - _ => None, - }); - if panel.hovered != hovered { - panel.hovered = hovered; + // The live `checkAddonVersion` mirror (1293 §5): read per frame like the gate's own per-query + // read (1292 §2.2), so the ForceLoad click above — or any other writer — repaints the + // statuses this same frame with nothing rescanned. Absent VM or table = the registrar + // default: check ON. + let version_check = script + .as_deref() + .and_then(|s| s.cvar("checkAddonVersion")) + .is_none_or(|v| v != "0"); + if panel.version_check != version_check { + panel.version_check = version_check; panel.dirty = true; } if close_and_save { - let states: Vec<(String, bool)> = panel - .list - .iter() - .zip(panel.staged.iter()) - .map(|(a, &on)| (a.name.clone(), on)) - .collect(); - // The identity the world-entry walk will read. `AddOnIdentity` is only filled once a - // character has entered the world, so at first launch the roster's own selection is the - // one that matters — the same resolution `load_ingame_ui_on_world_entry` performs. - let who = identity - .0 - .clone() - .or_else(|| crate::ui_macro::identity(&roster)); - addons::write_enable_state(who.as_ref(), &states); + // `AddonList_OnOk` / `AddonList_OnCancel` play the realm dialog's pair. + sounds.write(GlueSound("gsLoginChangeRealmOK")); + panel.save_staged(); panel.close(); return; } if close_and_discard { + sounds.write(GlueSound("gsLoginChangeRealmCancel")); panel.close(); return; } @@ -305,6 +676,7 @@ pub(super) fn drive_addons_panel( if let Some(root) = panel.root.take() { commands.entity(root).despawn(); } + panel.tip = None; } if panel.root.is_none() { let empty = GlueStrings::default(); @@ -319,6 +691,67 @@ pub(super) fn drive_addons_panel( )); panel.spawned_s = s; panel.dirty = false; + // Fresh entities — hover, tooltips and highlights reconcile against them next frame. + return; + } + + // ── hover: highlights + the tooltip, with the tree left alone ───────────────────────────── + for (interaction, children) in &lit { + let want = if *interaction == Interaction::None { + Visibility::Hidden + } else { + Visibility::Inherited + }; + for &child in children { + if let Ok(mut vis) = hilights.get_mut(child) { + if *vis != want { + *vis = want; + } + } + } + } + + let hover = hovers.iter().find_map(|(_, a, i)| { + if !matches!(i, Interaction::Hovered | Interaction::Pressed) { + return None; + } + match a { + AddonsAction::RowHover(r) => Some(Hover::Row(*r)), + AddonsAction::Row(r) => Some(Hover::Check(*r)), + _ => None, + } + }); + // What the tooltip should say for this hover — `None` collapses "nothing hovered" and "a + // checkbox that is not mixed" (the reference's CheckButton only carries a tooltip then). + let want = match hover { + Some(h @ Hover::Row(_)) => Some(h), + Some(h @ Hover::Check(i)) if panel.box_state(i) == BoxState::Mixed => Some(h), + _ => None, + }; + if panel.tip.map(|(h, _)| h) != want { + if let Some((_, e)) = panel.tip.take() { + commands.entity(e).despawn(); + } + if let Some(h) = want { + let row = match h { + Hover::Row(i) | Hover::Check(i) => i, + }; + // Anchor to the row strip — the reference's own + // `AddonTooltip:SetPoint("TOPRIGHT", this, "TOPLEFT", -14, 0)`. + let strip = hovers + .iter() + .find_map(|(e, a, _)| (*a == AddonsAction::RowHover(row)).then_some(e)); + if let (Some(strip), Some(addon)) = (strip, panel.list.get(row)) { + let empty = GlueStrings::default(); + let strings = strings.as_deref().unwrap_or(&empty); + let mixed = panel.box_state(row) == BoxState::Mixed; + let mut tip = Entity::PLACEHOLDER; + commands.entity(strip).with_children(|p| { + tip = spawn_tooltip(p, &art, &assets, strings, addon, h, mixed, s); + }); + panel.tip = Some((h, tip)); + } + } } } @@ -332,6 +765,72 @@ fn scroll(panel: &mut AddonsPanel, delta: i32) { } } +/// One checkbox as the reference `CheckButton` draws it: the `UI-CheckBox-Up` box always (the +/// pressed swap is [`ArtSwap`]'s), the check OVERLAID — gold (`-Check`) when on, grey +/// (`-Check-Disabled`) when mixed (`TriStateCheckbox_SetState`'s state-1 texture) — and the ADD +/// highlight lit on hover ([`HoverLit`]). The old face swapped the box art for the check art, +/// which is why the director's screenshot showed floating checks with no boxes. Plain-fill +/// fallback without art. +fn checkbox_button( + parent: &mut ChildSpawnerCommands, + art: &GlueArt, + action: A, + state: BoxState, + node: Node, +) { + let mut b = parent.spawn((action, Button, HoverLit, node)); + match &art.checkbox { + Some(c) => { + b.insert(( + ImageNode::new(c.up.clone()), + ArtSwap { + up: c.up.clone(), + down: c.down.clone(), + }, + )); + b.with_children(|b| { + match state { + BoxState::On => { + b.spawn((ImageNode::new(c.checked.clone()), overlay())); + } + BoxState::Mixed => match &art.check_disabled { + Some(grey) => { + b.spawn((ImageNode::new(grey.clone()), overlay())); + } + None => { + b.spawn(( + ImageNode { + color: DIM, + ..ImageNode::new(c.checked.clone()) + }, + overlay(), + )); + } + }, + BoxState::Off => {} + } + if let Some(hi) = &c.hi { + b.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(hi.clone()), + overlay(), + )); + } + }); + } + None => { + b.insert(BackgroundColor(match state { + BoxState::On => GOLD, + // Ours: halfway between the art-less GOLD and DIM, so a mixed box still reads + // as its own state. + BoxState::Mixed => Color::srgb(0.75, 0.64, 0.25), + BoxState::Off => DIM, + })); + } + } +} + fn spawn_panel( commands: &mut Commands, art: &GlueArt, @@ -342,12 +841,23 @@ fn spawn_panel( ) -> Entity { let px = |v: f32| Val::Px(v * s); let font = wow_font(assets); - let title = strings.text("ADDONS", "AddOns"); + let abs = |left: f32, top: f32, w: f32, h: f32| Node { + position_type: PositionType::Absolute, + left: px(left), + top: px(top), + width: px(w), + height: px(h), + ..default() + }; commands .spawn(( AddonsUi, GlobalZIndex(1200), // over the select screen's 1100, like the delete dialog + // The reference's own full-screen BACKGROUND layer: black at 0.75 over the glue + // scene, which is also what keeps a stray click off the screen behind (picking + // follows z-order). + BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.75)), Node { width: Val::Percent(100.0), height: Val::Percent(100.0), @@ -358,48 +868,80 @@ fn spawn_panel( )) .with_children(|overlay_ui| { let mut boxed = overlay_ui.spawn(Node { - width: px(PANEL_W), - height: px(PANEL_H), + width: px(BG_W), + height: px(BG_H), + // The plate's authored CENTER offset (+24, 0). + left: px(BG_CENTER_OFF_X), ..default() }); boxed.with_children(|b| { - // The dialog backdrop, exactly as the delete dialog builds it. - if let (Some(bg), Some(border)) = (&art.dialog_bg, &art.dialog_border) { - b.spawn(( - tiled_bg_node(bg.clone(), 32.0, s, Color::WHITE), - Node { - position_type: PositionType::Absolute, - left: px(11.0), - right: px(12.0), - top: px(12.0), - bottom: px(11.0), - ..default() - }, - )); - backdrop_border(b, border, 32.0, Color::WHITE); - } else { - b.spawn(( - BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.95)), - overlay(), - )); + // ── the HelpFrame plate: six pieces tiling 640×512, frame + top band + dark + // inset + bottom band all baked into the art ───────────────────────────────── + match &art.help_frame { + Some(hf) => { + for (img, l, t, w, h) in [ + (&hf.tl, 0.0, 0.0, 256.0, 256.0), + (&hf.top, 256.0, 0.0, 256.0, 256.0), + (&hf.tr, 512.0, 0.0, 128.0, 256.0), + (&hf.bl, 0.0, 256.0, 256.0, 256.0), + (&hf.bottom, 256.0, 256.0, 256.0, 256.0), + (&hf.br, 512.0, 256.0, 128.0, 256.0), + ] { + b.spawn((ImageNode::new(img.clone()), abs(l, t, w, h))); + } + // The ARTWORK divider strip under the top band: the same three top + // pieces' authored sub-band (TexCoords y 0.12109375–0.234375), 29 tall + // at y 50. + const BAND_TC: [f32; 4] = [0.0, 1.0, 0.121_093_75, 0.234_375]; + for (i, (img, l, w)) in [ + (&hf.tl, 0.0, 256.0), + (&hf.top, 256.0, 256.0), + (&hf.tr, 512.0, 128.0), + ] + .into_iter() + .enumerate() + { + b.spawn(( + ImageNode { + image: img.clone(), + rect: Some(tc_rect(hf.sizes[i], BAND_TC)), + ..default() + }, + abs(l, 50.0, w, 29.0), + )); + } + } + None => { + b.spawn(( + BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.95)), + overlay(), + )); + } } - // Title. + // ── the header plate + title (`UI-DialogBox-Header` 256×64 at TOP (−12, +12), + // `ADDON_LIST` GlueFontNormalSmall 14 under its top) ───────────────────────── + if let Some((header, _)) = &art.dialog_header { + b.spawn(( + ImageNode::new(header.clone()), + abs((BG_W - 256.0) / 2.0 - 12.0, -12.0, 256.0, 64.0), + )); + } outlined_text( b, Node { position_type: PositionType::Absolute, - left: px(0.0), - right: px(0.0), - top: px(22.0), + left: px((BG_W - 256.0) / 2.0 - 12.0), + width: px(256.0), + top: px(2.0), justify_content: JustifyContent::Center, ..default() }, (), (), GlueText { - text: title, - size: 18.0, + text: strings.text("ADDON_LIST", "AddOn List"), + size: 12.0, // GlueFontNormalSmall color: GOLD, wrap: false, }, @@ -407,144 +949,46 @@ fn spawn_panel( s, ); - // The rows. - for (slot, (index, addon, enabled)) in panel.visible().enumerate() { - let top = LIST_TOP + slot as f32 * ROW_H; - let reason = panel.reason(index); - // AddonList.lua's three-colour rule: gold when it will load, red when the - // player has it ON and it still will not, grey when they turned it off. - let colour = match (enabled, reason) { - (true, None) => GOLD, - (true, Some("Out of date")) => GOLD, - (true, _) => BROKEN, - (false, _) => DIM, - }; - let mut row = b.spawn(( - AddonsAction::Row(index), + // ── the close X (`GlueCloseButton` at TOPRIGHT (−42, −3)) — Cancel ────────── + { + let mut x = b.spawn(( + AddonsAction::Cancel, Button, - Node { - position_type: PositionType::Absolute, - left: px(LIST_LEFT), - top: px(top), - width: px(PANEL_W - LIST_LEFT * 2.0), - height: px(ROW_H), - align_items: AlignItems::Center, - ..default() - }, + HoverLit, + abs(BG_W - 42.0 - 32.0, 3.0, 32.0, 32.0), )); - row.with_children(|r| { - // The checkbox. The glue art's own `UI-CheckBox-*` when we have it, a - // drawn box when we do not — the same fallback every glue control takes. - let check = &art.checkbox; - let face = match (check.as_ref(), enabled) { - (Some(c), true) => Some(c.checked.clone()), - (Some(c), false) => Some(c.up.clone()), - (None, _) => None, - }; - let mut cb = r.spawn(Node { - position_type: PositionType::Absolute, - left: px(0.0), - top: px((ROW_H - CHECK) / 2.0), - width: px(CHECK), - height: px(CHECK), - ..default() - }); - match face { - Some(image) => { - cb.insert(ImageNode::new(image)); - } - None => { - cb.insert(BackgroundColor(if enabled { GOLD } else { DIM })); - } + if let Some(cb) = &art.close_btn { + x.insert(( + ImageNode::new(cb.up.clone()), + ArtSwap { + up: cb.up.clone(), + down: cb.down.clone(), + }, + )); + if let Some(hi) = &cb.hi { + x.with_children(|x| { + x.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(hi.clone()), + overlay(), + )); + }); } - // Title. - outlined_text( - r, - Node { - position_type: PositionType::Absolute, - left: px(CHECK + 8.0), - top: px(0.0), - ..default() - }, - (), - (), - GlueText { - text: addon.display_title(), - size: 12.0, - color: colour, - wrap: false, - }, - &font, - s, - ); - // Status — the reference's `getglobal("ADDON_"..reason)` column. - if let Some(reason) = reason { + } else { + x.with_children(|x| { outlined_text( - r, + x, Node { - position_type: PositionType::Absolute, - right: px(0.0), - top: px(0.0), + left: px(10.0), + top: px(6.0), ..default() }, (), (), GlueText { - text: reason, - size: 11.0, - color: DIM, - wrap: false, - }, - &font, - s, - ); - } - if let Some(hi) = &art.hilight { - r.spawn(( - Hilight, - Visibility::Hidden, - MaterialNode(hi.clone()), - overlay(), - )); - } - }); - } - - // The scroll pair, shown only when the list is longer than the window — the - // reference's `GlueScrollFrame_Update` hides its bar on the same condition. - if panel.max_offset() > 0 { - for (action, caption, top) in [ - (AddonsAction::ScrollUp, "-", LIST_TOP), - ( - AddonsAction::ScrollDown, - "+", - LIST_TOP + (MAX_ROWS as f32 - 1.0) * ROW_H, - ), - ] { - b.spawn(( - action, - Button, - BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.10)), - Node { - position_type: PositionType::Absolute, - right: px(6.0), - top: px(top), - width: px(14.0), - height: px(14.0), - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - ..default() - }, - )) - .with_children(|sb| { - outlined_text( - sb, - Node::default(), - (), - (), - GlueText { - text: caption, - size: 11.0, + text: "X", + size: 14.0, color: GOLD, wrap: false, }, @@ -555,199 +999,920 @@ fn spawn_panel( } } - // The hover tooltip: title, notes, dependencies — `AddonTooltip_Update`'s three - // lines, in its order. - if let Some(i) = panel.hovered.and_then(|i| panel.list.get(i)) { - let deps = if i.dependencies.is_empty() { - String::new() - } else { - format!("Dependencies: {}", i.dependencies.join(", ")) - }; - let body = [ - i.notes.clone().unwrap_or_default(), - deps, - i.url.clone().unwrap_or_default(), - ] - .into_iter() - .filter(|l| !l.is_empty()) - .collect::>() - .join("\n"); - if !body.is_empty() { + // ── "Configure Addons For:" + the dropdown (`AddonCharacterDropDown`, + // `GlueDropDownMenuTemplate`) ──────────────────────────────────────────────── + outlined_text( + b, + Node { + position_type: PositionType::Absolute, + left: px(20.0), + top: px(DROP_TOP - 14.0), + ..default() + }, + (), + (), + GlueText { + text: strings.text("CONFIGURE_MODS_FOR", "Configure Addons For:"), + size: 12.0, // GlueFontNormalSmall + color: GOLD, + wrap: false, + }, + &font, + s, + ); + { + let mut drop = b.spawn(( + AddonsAction::DropdownToggle, + Button, + abs(0.0, DROP_ART_TOP, DROP_W, DROP_ART_H), + )); + drop.with_children(|d| { + // The three `CharacterCreate-LabelFrame` slices (25 | 115 | 25 of the + // 128-wide art) — the same sheet the create screen's dial rows use. + if let Some((sheet, size)) = &art.label_frame { + let mut left = 0.0; + for (w, tc) in DROP_SLICE_W.iter().zip(DROP_TC) { + d.spawn(( + ImageNode { + image: sheet.clone(), + rect: Some(tc_rect(*size, tc)), + ..default() + }, + abs(left, 0.0, *w, DROP_ART_H), + )); + left += w; + } + } else { + d.spawn(( + BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.10)), + abs(10.0, 20.0, DROP_W - 20.0, 24.0), + )); + } + // The selected value (white, `GlueFontHighlightSmall`) right-aligned + // beside the arrow — the template's own text seat. outlined_text( - b, + d, Node { position_type: PositionType::Absolute, - left: px(LIST_LEFT), - right: px(LIST_LEFT), - bottom: px(58.0), + left: px(10.0), + width: px(DROP_W - 43.0 - 10.0), + top: px(0.0), + height: px(DROP_ART_H), + justify_content: JustifyContent::FlexEnd, + align_items: AlignItems::Center, ..default() }, (), (), GlueText { - text: &body, - size: 11.0, + text: panel.view_name(strings), + size: 12.0, color: Color::WHITE, - wrap: true, + wrap: false, }, &font, s, ); + // The 24² arrow (`UI-ChatIcon-ScrollDown-*` + `UI-Common-MouseHilight`). + let mut arrow = d.spawn(( + AddonsAction::DropdownToggle, + Button, + HoverLit, + abs(DROP_W - 16.0 - 24.0, 18.0, 24.0, 24.0), + )); + match (&art.dropdown_arrow_up, &art.dropdown_arrow_down) { + (Some(up), down) => { + arrow.insert(ImageNode::new(up.clone())); + if let Some(down) = down { + arrow.insert(ArtSwap { + up: up.clone(), + down: down.clone(), + }); + } + if let Some(hi) = &art.mouse_hilight { + arrow.with_children(|a| { + a.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(hi.clone()), + overlay(), + )); + }); + } + } + _ => { + arrow.with_children(|a| { + outlined_text( + a, + Node::default(), + (), + (), + GlueText { + text: "v", + size: 12.0, + color: GOLD, + wrap: false, + }, + &font, + s, + ); + }); + } + } + }); + } + + // ── "Load out of date AddOns" (`AddonListForceLoad`: a 32² checkbox at TOP + // (+50, −42), label at LEFT +36) — the `checkAddonVersion` CVar INVERTED + // (ticked = "0"; 1292 §2.1: force-load actively ERASES the refusal, so ticking + // repaints every INTERFACE_VERSION row as plain loadable) ──────────────────── + checkbox_button( + b, + art, + AddonsAction::ForceLoad, + if panel.version_check { + BoxState::Off + } else { + BoxState::On + }, + abs(FORCE_LEFT, FORCE_TOP, 32.0, 32.0), + ); + outlined_text( + b, + Node { + position_type: PositionType::Absolute, + left: px(FORCE_LEFT + 36.0), + top: px(FORCE_TOP), + height: px(32.0), + align_items: AlignItems::Center, + ..default() + }, + (), + (), + GlueText { + text: strings.text("ADDON_FORCE_LOAD", "Load out of date AddOns"), + size: 12.0, + color: GOLD, + wrap: false, + }, + &font, + s, + ); + + // ── the rows ──────────────────────────────────────────────────────────────── + for (slot, (index, addon)) in panel.visible().enumerate() { + let top = ROW_TOP + slot as f32 * ROW_PITCH; + let state = panel.box_state(index); + // The one arbiter (1292): status and colour both come from the gate over + // the CURRENT VIEW's staged states. + let status = panel.verdict(index).token(); + let colour = panel.title_colour(index); + + // The row strip: tooltip hover only — no click, no highlight (the + // reference entry has neither; see the module doc on the flashing). + b.spawn(( + AddonsAction::RowHover(index), + Button, + abs(ROW_LEFT, top, ROW_W, ROW_H), + )) + .with_children(|r| { + // Title — `GlueFontNormal`, 220 wide, colour markup rendered + // ([`title_spans`]); clipped at the authored box so a long title + // cannot run into the status column. + outlined_spans( + r, + Node { + position_type: PositionType::Absolute, + left: px(TITLE_LEFT), + width: px(TITLE_W), + height: px(ROW_H), + align_items: AlignItems::Center, + overflow: Overflow::clip(), + ..default() + }, + (), + (), + &title_spans(addon.display_title(), colour), + 15.0, // GlueFontNormal + false, + &font, + s, + ); + // Status — `GlueFontNormalSmall` (gold), 30 right of the title box: + // the reference's `getglobal("ADDON_"..reason)` splice. + if let Some(token) = status.as_deref() { + outlined_text( + r, + Node { + position_type: PositionType::Absolute, + left: px(STATUS_LEFT), + height: px(ROW_H), + align_items: AlignItems::Center, + ..default() + }, + (), + (), + GlueText { + text: status_label(strings, token), + size: 12.0, + color: GOLD, + wrap: false, + }, + &font, + s, + ); + } + }); + // The checkbox: a 32² CheckButton at the entry's LEFT +5, spawned after + // the strip so it wins the pick where they overlap. + checkbox_button( + b, + art, + AddonsAction::Row(index), + state, + abs(ROW_LEFT + 5.0, top + (ROW_H - 32.0) / 2.0, 32.0, 32.0), + ); + } + + // ── the scrollbar, shown only when the list overflows (the reference's + // `GlueScrollFrame_Update` hides it on the same condition): the + // `UI-Character-ScrollBar` decorative track + the `GlueScrollBarTemplate` + // column (16² arrows, the knob riding the offset fraction) ────────────────── + if panel.max_offset() > 0 { + if let Some((track, size)) = &art.char_scrollbar { + for (l, t, w, h, tc) in [ + // Top 31×256 at scrollframe TOPRIGHT (−2, +5). + ( + SCROLL_RIGHT - 2.0, + SCROLL_TOP - 5.0, + 31.0, + 256.0, + [0.0, 0.484_375, 0.0, 1.0], + ), + // Middle, spanning to the bottom piece (TexCoords y .75–1). + ( + SCROLL_RIGHT - 2.0, + SCROLL_TOP - 5.0 + 256.0, + 31.0, + (SCROLL_TOP + SCROLL_H + 2.0 - 106.0) - (SCROLL_TOP - 5.0 + 256.0), + [0.0, 0.484_375, 0.75, 1.0], + ), + // Bottom 31×106 at scrollframe BOTTOMRIGHT (−2, −2). + ( + SCROLL_RIGHT - 2.0, + SCROLL_TOP + SCROLL_H + 2.0 - 106.0, + 31.0, + 106.0, + [0.515_625, 1.0, 0.0, 0.414_062_5], + ), + ] { + b.spawn(( + ImageNode { + image: track.clone(), + rect: Some(tc_rect(*size, tc)), + ..default() + }, + abs(l, t, w, h), + )); + } + } + // The slider column: TOPLEFT at scrollframe TOPRIGHT +(6, −16). + let bar_left = SCROLL_RIGHT + 6.0; + let bar_top = SCROLL_TOP + 16.0; + let bar_h = SCROLL_H - 32.0; + if let Some(sc) = &art.scroll { + for (action, up, top) in [ + (AddonsAction::ScrollUp, &sc.up_btn, bar_top - 16.0), + (AddonsAction::ScrollDown, &sc.down_btn, bar_top + bar_h), + ] { + b.spawn(( + action, + Button, + HoverLit, + ImageNode { + image: up.up.clone(), + rect: Some(tc_rect(up.size, crate::glue::art::SCROLL_BTN_TC)), + ..default() + }, + abs(bar_left, top, 16.0, 16.0), + )) + .with_children(|btn| { + btn.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(up.hi.clone()), + overlay(), + )); + }); + } + let travel = bar_h - 16.0; + let frac = panel.offset as f32 / panel.max_offset() as f32; + b.spawn(( + ImageNode { + image: sc.knob.0.clone(), + rect: Some(tc_rect(sc.knob.1, crate::glue::art::SCROLL_BTN_TC)), + ..default() + }, + abs(bar_left, bar_top + frac * travel, 16.0, 16.0), + )); + } else { + for (action, caption, top) in [ + (AddonsAction::ScrollUp, "-", bar_top - 16.0), + (AddonsAction::ScrollDown, "+", bar_top + bar_h), + ] { + b.spawn(( + action, + Button, + BackgroundColor(Color::srgba(1.0, 1.0, 1.0, 0.10)), + Node { + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + ..abs(bar_left, top, 16.0, 16.0) + }, + )) + .with_children(|sb| { + outlined_text( + sb, + Node::default(), + (), + (), + GlueText { + text: caption, + size: 11.0, + color: GOLD, + wrap: false, + }, + &font, + s, + ); + }); + } } } - // The button row. - b.spawn(Node { - position_type: PositionType::Absolute, - left: px(24.0), - right: px(24.0), - bottom: px(20.0), - height: px(28.0), - justify_content: JustifyContent::SpaceBetween, - align_items: AlignItems::Center, - ..default() - }) - .with_children(|row| { - for (action, caption) in [ - (AddonsAction::EnableAll, "Enable All"), - (AddonsAction::DisableAll, "Disable All"), - (AddonsAction::Cancel, "Cancel"), - (AddonsAction::Okay, "Okay"), - ] { + // ── the bottom row, the reference's own order and geometry: Disable All · + // Enable All (`AddonListButtonTemplate` 160×35 from BOTTOMLEFT +16) ··· Okay · + // Cancel (`GlueDialogButtonTemplate` 125×35 from BOTTOMRIGHT −46) ─────────── + for (action, token, fallback, left, w, kind) in [ + ( + AddonsAction::DisableAll, + "DISABLE_ALL_ADDONS", + "Disable All", + 16.0, + 160.0, + GlueBtnKind::List, + ), + ( + AddonsAction::EnableAll, + "ENABLE_ALL_ADDONS", + "Enable All", + 176.0, + 160.0, + GlueBtnKind::List, + ), + ( + AddonsAction::Okay, + "OKAY", + "Okay", + BG_W - 46.0 - 125.0 + 8.0 - 125.0, + 125.0, + GlueBtnKind::Dialog, + ), + ( + AddonsAction::Cancel, + "CANCEL", + "Cancel", + BG_W - 46.0 - 125.0, + 125.0, + GlueBtnKind::Dialog, + ), + ] { + b.spawn(abs(left, BTN_TOP, w, 35.0)).with_children(|slot| { glue_button( - row, + slot, art, &font, action, - caption, - 118.0, - 28.0, - GlueBtnKind::Small, + strings.text(token, fallback), + w, + 35.0, + kind, s, ); + }); + } + + // ── the dropdown's open list (`UIDropDownListTemplate`: the DialogBox + // backdrop, 16-tall rows, the gold check on the current selection, the + // `UI-QuestTitleHighlight` hover), plus a full-screen click-away backdrop + // underneath it in z ──────────────────────────────────────────────────────── + if panel.dropdown_open { + b.spawn(( + AddonsAction::DropdownToggle, + Button, + GlobalZIndex(1205), + Node { + position_type: PositionType::Absolute, + left: Val::Vw(-100.0), + top: Val::Vh(-100.0), + width: Val::Vw(300.0), + height: Val::Vh(300.0), + ..default() + }, + )); + let mut list = b.spawn(( + GlobalZIndex(1210), + Node { + position_type: PositionType::Absolute, + left: px(DROP_LIST_LEFT), + top: px(DROP_LIST_TOP), + flex_direction: FlexDirection::Column, + padding: UiRect::new(px(5.0), px(10.0), px(15.0), px(15.0)), + ..default() + }, + )); + let framed = art.dialog_bg.is_some() && art.dialog_border.is_some(); + if !framed { + list.insert(BackgroundColor(Color::srgba(0.05, 0.05, 0.08, 0.97))); } - }); + list.with_children(|list| { + if framed { + list.spawn(( + tiled_bg_node( + art.dialog_bg.clone().unwrap(), + 32.0, + s, + Color::WHITE, + ), + Node { + position_type: PositionType::Absolute, + left: px(11.0), + right: px(12.0), + top: px(12.0), + bottom: px(11.0), + ..default() + }, + )); + backdrop_border( + list, + art.dialog_border.as_ref().unwrap(), + 32.0, + Color::WHITE, + ); + } + // "All" first, then the roster in its own order — the reference's + // `AddonListCharacterDropDown_Initialize` shape. + for view in std::iter::once(None).chain((0..panel.chars.len()).map(Some)) { + let name = match view { + None => strings.text("ALL", "All"), + Some(c) => panel.chars[c].as_str(), + }; + let current = view == panel.view; + let mut row = list.spawn(( + AddonsAction::DropdownPick(view), + Button, + HoverLit, + Node { + height: px(ROW_H), + min_width: px(120.0), + align_items: AlignItems::Center, + ..default() + }, + )); + row.with_children(|o| { + // The current selection carries the template's 24² check. + if current { + if let Some(c) = &art.checkbox { + o.spawn(( + ImageNode::new(c.checked.clone()), + Node { + position_type: PositionType::Absolute, + left: px(0.0), + top: px((ROW_H - 24.0) / 2.0), + width: px(24.0), + height: px(24.0), + ..default() + }, + )); + } + } + outlined_text( + o, + Node { + left: px(27.0), + ..default() + }, + (), + (), + GlueText { + text: name, + size: 12.0, // GlueFontHighlightSmall + color: Color::WHITE, + wrap: false, + }, + &font, + s, + ); + if let Some(hi) = &art.quest_hilight { + o.spawn(( + Hilight, + Visibility::Hidden, + MaterialNode(hi.clone()), + overlay(), + )); + } + }); + } + }); + } }); }) .id() } +/// The hover tooltip — the reference's `AddonTooltip` (a 220-wide `UI-Tooltip-Border` box, fill +/// `(0.09, 0.09, 0.19)`, title + notes + `ADDON_DEPENDENCIES` in that order) anchored TOPRIGHT to +/// the row's TOPLEFT at (−14, 0), spawned as the row strip's child so the anchor is structural. +/// `## URL` rides as an extra line (1197: information, not a launch button). A MIXED checkbox +/// hover shows `ENABLED_FOR_SOME` alone — the reference's GlueTooltip split, on the same box. +#[allow(clippy::too_many_arguments)] +fn spawn_tooltip( + parent: &mut ChildSpawnerCommands, + art: &GlueArt, + assets: &AssetServer, + strings: &GlueStrings, + addon: &InstalledAddOn, + hover: Hover, + mixed: bool, + s: f32, +) -> Entity { + let px = |v: f32| Val::Px(v * s); + let font = wow_font(assets); + // (text, size, colour) per line; empties dropped below. + let mut lines: Vec<(String, f32, Color)> = Vec::new(); + match hover { + Hover::Row(_) => { + lines.push((addon.display_title().to_string(), 15.0, GOLD)); + if let Some(notes) = &addon.notes { + lines.push((notes.clone(), 12.0, Color::WHITE)); + } + if !addon.dependencies.is_empty() { + lines.push(( + format!( + "{}{}", + strings.text("ADDON_DEPENDENCIES", "Dependencies: "), + addon.dependencies.join(", ") + ), + 12.0, + GOLD, + )); + } + if let Some(url) = &addon.url { + lines.push((url.clone(), 12.0, Color::WHITE)); + } + } + Hover::Check(_) => { + if mixed { + lines.push(( + strings + .text( + "ENABLED_FOR_SOME", + "This addon is only enabled for some characters.", + ) + .to_string(), + 12.0, + Color::WHITE, + )); + } + } + } + let mut tip = parent.spawn(( + GlobalZIndex(1220), + Node { + position_type: PositionType::Absolute, + left: px(-(TIP_W + 14.0)), + top: px(0.0), + width: px(TIP_W), + flex_direction: FlexDirection::Column, + row_gap: px(2.0), + padding: UiRect::all(px(10.0)), + ..default() + }, + )); + let framed = art.tooltip_bg.is_some() && art.tooltip_border.is_some(); + if !framed { + tip.insert(BackgroundColor(TIP_FILL.with_alpha(0.95))); + } + tip.with_children(|t| { + if framed { + t.spawn(( + tiled_bg_node(art.tooltip_bg.clone().unwrap(), 16.0, s, TIP_FILL), + Node { + position_type: PositionType::Absolute, + left: px(5.0), + right: px(5.0), + top: px(5.0), + bottom: px(5.0), + ..default() + }, + )); + backdrop_border(t, art.tooltip_border.as_ref().unwrap(), 16.0, Color::WHITE); + } + for (text, size, colour) in &lines { + outlined_text( + t, + Node::default(), + (), + (), + GlueText { + text, + size: *size, + color: *colour, + wrap: true, + }, + &font, + s, + ); + } + }); + tip.id() +} + #[cfg(test)] mod tests { use super::*; - fn addon(name: &str, deps: &[&str], iface: &[u32]) -> InstalledAddOn { + fn addon(name: &str, deps: &[&str], iface: u32) -> InstalledAddOn { InstalledAddOn { name: name.into(), title: None, notes: None, url: None, dependencies: deps.iter().map(|d| (*d).to_string()).collect(), - interface: iface.to_vec(), + interface: iface, load_on_demand: false, enabled: true, } } - fn panel(list: Vec) -> AddonsPanel { - let staged = list.iter().map(|a| a.enabled).collect(); + /// A panel over `chars` staged columns (each starting at the list's own `enabled` bits), + /// viewing All — the open-time default. Tests that want a single character's view set + /// `p.view = Some(c)` themselves. + fn panel_for(chars: usize, list: Vec) -> AddonsPanel { + let staged: Vec> = (0..chars.max(1)) + .map(|_| list.iter().map(|a| a.enabled).collect()) + .collect(); AddonsPanel { open: true, - list, + realm: "TestRealm".into(), + chars: (0..chars).map(|c| format!("Char{c}")).collect(), + baseline: staged.clone(), staged, + list, ..Default::default() } } - /// **Every label this panel shows is the value its reference token carries**, so the glue - /// screen and the ESC-menu screen cannot say different things about the same addon. + /// The single-character convenience: one column, All view — over one column the All view IS + /// that column (any-on and all-on coincide), so `p.staged[0][i]` drives everything. + fn panel(list: Vec) -> AddonsPanel { + panel_for(1, list) + } + + /// **Every label this panel can show is the value its reference token carries**. /// - /// The glue side has no VM to splice `getglobal("ADDON_"..reason)` against (1197 §3), so it - /// hardcodes the English. That is fine and it is also exactly how the two screens drift: the - /// twin resolves through `GlobalStrings.lua` and this one does not, so nothing but a test - /// connects them. The values below are quoted from the shipped 1.12 `GlobalStrings.lua` - /// (l.16-31, the `ADDON_*` block) — which the client itself loads at boot. + /// The splice is the reference's own (`getglobal("ADDON_"..reason)`), run against the parsed + /// GlueStrings table; what this pins is the FALLBACKS — the strings shown with no client + /// data — quoted from the extracted 1.12 chain (GlueStrings.lua l.44-56). An empty table + /// exercises exactly that path. /// - /// This test exists because the comment on [`AddonsPanel::reason`] used to name the token - /// `INCOMPATIBLE`, and **there is no `ADDON_INCOMPATIBLE`**; the real one is - /// `ADDON_INTERFACE_VERSION`. A wrong token name is invisible until somebody splices it. + /// This test exists because a comment here once named a token `INCOMPATIBLE`, **which does + /// not exist** — the real one is `ADDON_INTERFACE_VERSION`. A wrong token name is invisible + /// until somebody splices it. #[test] fn the_status_labels_are_the_reference_globalstrings_values() { - let mut p = panel(vec![ - addon("Off", &[], &[11200]), - addon("Lib", &[], &[11200]), - addon("Needs", &["Lib"], &[11200]), - addon("Orphan", &["Nowhere"], &[11200]), - addon("Old", &[], &[11100]), - ]); - p.staged[0] = false; - p.staged[1] = false; - - // (label, the token it must equal, the value GlobalStrings.lua gives that token) - for (got, token, want) in [ - (p.reason(0), "ADDON_DISABLED", "Disabled"), - (p.reason(2), "ADDON_DEP_DISABLED", "Dependency disabled"), - (p.reason(3), "ADDON_DEP_MISSING", "Dependency missing"), - (p.reason(4), "ADDON_INTERFACE_VERSION", "Out of date"), + let strings = GlueStrings::default(); // no chain in a test — the pinned fallbacks answer + for (token, want) in [ + ("DISABLED", "Disabled"), + ("DEP_DISABLED", "Dependency disabled"), + ("DEP_MISSING", "Dependency missing"), + ("INTERFACE_VERSION", "Out of date"), + ("DEP_INTERFACE_VERSION", "Dependency out of date"), ] { assert_eq!( - got, - Some(want), - "this label must be {token}'s value verbatim, or the ESC-menu twin — which \ - resolves the token through GlobalStrings — will show a different string" + status_label(&strings, token), + want, + "ADDON_{token}'s fallback must be the shipped value verbatim" ); } } - /// The status column's rule, which is also the title-colour rule — the reference's own - /// `reason` token set, in its own precedence. + /// The status column's rule — THE GATE's verdict (1292): `AddOn_CanLoad`'s byte-verified + /// check order, consulted over the current view's staged states. /// - /// The ordering is the part worth pinning: a row the player turned OFF reads `Disabled` and - /// nothing else, even when its dependency is also missing. The reference does the same - /// (`GetAddOnInfo` reports `DISABLED` before the dependency walk runs), and the alternative - /// tells a player to go fix a dependency for an addon they deliberately switched off. + /// The ordering is the part worth pinning: a row the player turned OFF reads `DISABLED` and + /// nothing else, even when its dependency is also missing — the gate's check 3 precedes the + /// dependency loop, and the alternative tells a player to go fix a dependency for an addon + /// they deliberately switched off. #[test] - fn the_status_column_reports_the_references_reason_in_its_own_precedence() { + fn the_status_column_reports_the_gates_reason_in_the_references_precedence() { let mut p = panel(vec![ - addon("Solo", &[], &[11200]), - addon("Lib", &[], &[11200]), - addon("Needs", &["Lib"], &[11200]), - addon("Orphan", &["Nowhere"], &[11200]), - addon("Old", &[], &[11100]), - addon("Silent", &[], &[]), + addon("Solo", &[], 11200), + addon("Lib", &[], 11200), + addon("Needs", &["Lib"], 11200), + addon("Orphan", &["Nowhere"], 11200), + addon("Old", &[], 11100), + addon("Silent", &[], 0), ]); - assert_eq!(p.reason(0), None, "nothing wrong: no status, gold title"); - assert_eq!(p.reason(2), None, "its dependency is installed and enabled"); - assert_eq!(p.reason(3), Some("Dependency missing")); assert_eq!( - p.reason(4), - Some("Out of date"), - "reported — 1191 §6 does not ENFORCE it, and the two must not contradict" - ); - assert_eq!( - p.reason(5), + p.verdict(0).token(), None, - "a manifest with no `## Interface` is silent, not out of date" + "nothing wrong: no status, gold title" ); + assert_eq!( + p.verdict(2).token(), + None, + "its dependency is installed and enabled" + ); + assert_eq!(p.verdict(3).token().as_deref(), Some("DEP_MISSING")); + assert_eq!(p.verdict(4).token().as_deref(), Some("INTERFACE_VERSION")); + assert_eq!( + p.verdict(5).token().as_deref(), + Some("INTERFACE_VERSION"), + "a manifest with NO `## Interface` parses as 0 and IS out of date — the record ctor \ + leaves [rec+0x1c]=0 and the gate compares it like any other value (decision 1292, \ + byte-verified; supersedes 1191 §6's silent-is-current reading)" + ); + + // Force-load ERASES the refusal (1292 §2.1: reason 7 written, then reset to 0) — with + // the box ticked an out-of-date row is byte-indistinguishable from a current one, so the + // screen shows nothing there, faithfully. + p.version_check = false; + assert_eq!(p.verdict(4).token(), None); + assert_eq!(p.verdict(5).token(), None); + p.version_check = true; // Turning the library off makes its dependent unloadable, and the dependent says so. - p.staged[1] = false; - assert_eq!(p.reason(1), Some("Disabled")); - assert_eq!(p.reason(2), Some("Dependency disabled")); + p.staged[0][1] = false; + assert_eq!(p.verdict(1).token().as_deref(), Some("DISABLED")); + assert_eq!(p.verdict(2).token().as_deref(), Some("DEP_DISABLED")); // ...and a row the player turned off reports only that, even with a broken dependency. - p.staged[3] = false; - assert_eq!(p.reason(3), Some("Disabled")); + p.staged[0][3] = false; + assert_eq!(p.verdict(3).token().as_deref(), Some("DISABLED")); } - /// Okay/Cancel is a **staged** edit: nothing the panel does touches the enable file until + /// AddonList.lua's title colour, including its one exception: an enabled row that will not + /// load reads RED — **unless the reason is `DEP_DISABLED`**, where the Lua's own + /// `reason ~= "DEP_DISABLED"` guard sends it to grey (the player already made that choice + /// on the dependency's row; the first build here painted it red and the director's shot + /// showed the mismatch). + #[test] + fn the_title_colour_follows_addonlist_luas_rule_with_the_dep_disabled_exception() { + let mut p = panel(vec![ + addon("Lib", &[], 11200), + addon("Needs", &["Lib"], 11200), + addon("Old", &[], 11100), + ]); + assert_eq!(p.title_colour(0), GOLD, "loadable = gold"); + assert_eq!(p.title_colour(2), BROKEN, "enabled + out of date = red"); + + p.staged[0][0] = false; + assert_eq!(p.title_colour(0), DIM, "player-disabled = grey"); + assert_eq!( + p.title_colour(1), + DIM, + "enabled but DEP_DISABLED = grey, the Lua's own exception — NOT red" + ); + } + + /// `|cAARRGGBB…|r` markup renders as colour, not as text — the MapCoords regression (its + /// `## Title` is `|cff00ff00MapCoords 0.32`, which the screen printed literally, in full). + /// The grammar is the byte-verified `benilla_ui::markup`; the alpha byte is discarded there, + /// so only rgb reaches the span colour. + #[test] + fn colour_escapes_become_spans_not_text() { + let base = GOLD; + let green = Color::srgb(0.0, 1.0, 0.0); + + assert_eq!( + title_spans("|cff00ff00MapCoords 0.32", base), + vec![("MapCoords 0.32".to_string(), green)], + "the whole-title escape colours everything and prints nothing" + ); + assert_eq!( + title_spans("A |cff00ff00B|r C", base), + vec![ + ("A ".to_string(), base), + ("B".to_string(), green), + (" C".to_string(), base) + ], + "|r restores the row's base colour" + ); + assert_eq!( + title_spans("pipe || pipe", base), + vec![("pipe | pipe".to_string(), base)], + "|| draws one literal pipe" + ); + assert_eq!( + title_spans("|xnot an escape", base), + vec![("|xnot an escape".to_string(), base)], + "a | that opens nothing well-formed is an ordinary character (the grammar's \ + fall-through arm)" + ); + assert_eq!( + title_spans("", base), + vec![(String::new(), base)], + "an empty title still yields one span, so the text entity exists" + ); + } + + /// The All view's gate input is OUR carve (1293): "enabled = any staged character has it on". + #[test] + fn the_all_view_feeds_the_gate_any_character_on() { + let mut p = panel_for( + 2, + vec![addon("Lib", &[], 11200), addon("Needs", &["Lib"], 11200)], + ); + p.staged[0][0] = false; // Char0 turned Lib off; Char1 has not + + assert_eq!( + p.verdict(0).token(), + None, + "any-on: the All view still counts Lib enabled" + ); + assert_eq!(p.verdict(1).token(), None); + + p.view = Some(0); + assert_eq!(p.verdict(0).token().as_deref(), Some("DISABLED")); + assert_eq!(p.verdict(1).token().as_deref(), Some("DEP_DISABLED")); + + p.view = Some(1); + assert_eq!(p.verdict(0).token(), None, "Char1's view is untouched"); + } + + /// The All view's box is the reference's tri-state (`GetAddOnEnableState`: 0/1/2), computed + /// across every staged column; a single character's view is plain two-state over theirs. + #[test] + fn the_all_view_box_is_a_tri_state_over_every_character() { + let mut p = panel_for(3, vec![addon("A", &[], 11200)]); + assert_eq!(p.box_state(0), BoxState::On, "enabled for all three"); + + p.staged[1][0] = false; + assert_eq!( + p.box_state(0), + BoxState::Mixed, + "enabled for SOME (state 1)" + ); + + p.staged[0][0] = false; + p.staged[2][0] = false; + assert_eq!(p.box_state(0), BoxState::Off, "disabled for all"); + + // A single character's view never shows Mixed — it is their own bit, both ways. + p.view = Some(1); + assert_eq!(p.box_state(0), BoxState::Off); + p.staged[1][0] = true; + assert_eq!(p.box_state(0), BoxState::On); + } + + /// An All-view click is the reference's CheckButton over the tri-state: a grey (mixed) box + /// COUNTS AS CHECKED, so clicking it — or a fully-checked one — unchecks (disable for every + /// character), and only a fully-unchecked box enables for all. + #[test] + fn an_all_view_click_follows_the_references_checkbutton() { + let mut p = panel_for(3, vec![addon("A", &[], 11200)]); + p.staged[1][0] = false; // mixed + + p.click_row(0); // mixed = checked → click unchecks + assert!( + p.staged.iter().all(|col| !col[0]), + "mixed → disabled for ALL characters" + ); + + p.click_row(0); + assert!( + p.staged.iter().all(|col| col[0]), + "unchecked → enabled for ALL characters" + ); + + p.click_row(0); + assert!(p.staged.iter().all(|col| !col[0]), "checked → back off"); + + // In a single character's view the same click is a plain toggle of THEIR bit. + p.view = Some(2); + p.click_row(0); + assert!(p.staged[2][0] && !p.staged[0][0] && !p.staged[1][0]); + } + + /// Okay/Cancel is a **staged** edit: nothing the panel does touches an enable file until /// Okay. `Cancel` is the reference's `ResetAddOns`, and `close` is what implements it. #[test] fn edits_are_staged_and_cancel_discards_them() { - let mut p = panel(vec![addon("A", &[], &[11200]), addon("B", &[], &[11200])]); - assert_eq!(p.staged, vec![true, true]); - p.staged[0] = false; + let mut p = panel(vec![addon("A", &[], 11200), addon("B", &[], 11200)]); + assert_eq!(p.staged, vec![vec![true, true]]); + p.staged[0][0] = false; assert!( p.list[0].enabled, "the read-in list is never mutated — only `staged` is, which is what makes Cancel free" @@ -760,17 +1925,107 @@ mod tests { ); } + /// **Okay fans out per character** (1293 — the reference's `SaveAddOns` over the dropdown's + /// roster): every character whose staged column moved off its open-time baseline gets their + /// own merge-write, and an untouched character's file is not even created. Hermetic under + /// `BENILLA_HOME` like the walk's own folder tests (`ui_script/addons.rs`). + #[test] + fn okay_writes_every_changed_characters_file_and_only_those() { + let _l = crate::local_state::test_env::ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = + std::env::temp_dir().join(format!("benilla-charsel-fanout-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&tmp); + let home = tmp.join("benilla-config"); + for name in ["Alpha", "Beta"] { + let dir = home.join("AddOns").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(format!("{name}.toc")), "## Interface: 11200\n").unwrap(); + } + let _c = crate::local_state::test_env::EnvGuard::unset("WOW_CAPTURE"); + let _h = + crate::local_state::test_env::EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); + + let mut p = AddonsPanel::default(); + p.open_for( + "TestRealm".into(), + vec!["Alice".into(), "Bob".into(), "Carol".into()], + ); + assert!(p.view.is_none(), "the reference's default selection is All"); + assert_eq!(p.list.len(), 2, "discovery found the two hermetic addons"); + assert_eq!(p.staged.len(), 3, "one staged column per roster character"); + assert_eq!(p.staged, p.baseline); + + // Distinct edits: Alice loses Alpha, Bob loses Beta, Carol touches nothing. + p.staged[0][0] = false; + p.staged[1][1] = false; + p.save_staged(); + + // Read back through the same folder view the world-entry walk uses — the two views of + // one folder that 1197 §3 demands can never disagree. + let alice = ("TestRealm".to_string(), "Alice".to_string()); + let bob = ("TestRealm".to_string(), "Bob".to_string()); + let carol = ("TestRealm".to_string(), "Carol".to_string()); + let read = |id: &(String, String)| -> Vec { + addons::installed(Some(id)) + .iter() + .map(|a| a.enabled) + .collect() + }; + assert_eq!( + read(&alice), + vec![false, true], + "Alice's file carries HER edit" + ); + assert_eq!(read(&bob), vec![true, false], "Bob's carries HIS"); + assert!( + !addons::enable_state_path(Some(&carol)).unwrap().exists(), + "an unchanged column writes no file — only the diffs against the open-time baseline" + ); + let _ = std::fs::remove_dir_all(&tmp); + } + + /// The force-load box IS the `checkAddonVersion` CVar inverted, and a toggle goes through + /// the engine write ([`UiScript::set_cvar_engine`]) so it rides the change queue — that is + /// the whole persistence story (the host's sync drains the queue and dirties the config); + /// the panel itself only repaints from its per-frame mirror, rescanning nothing (1292 §2.2). + #[test] + fn the_force_load_box_flips_the_cvar_through_the_engine_queue() { + let mut script = UiScript::new().unwrap(); + script.register_cvars([("checkAddonVersion", "1")]); + + toggle_force_load(&mut script); + assert_eq!( + script.take_cvar_changes(), + vec![("checkAddonVersion".to_string(), "0".to_string())], + "tick: check ON (\"1\") flips to \"0\" — box ticked = version gate open" + ); + assert_eq!(script.cvar("checkAddonVersion").as_deref(), Some("0")); + + toggle_force_load(&mut script); + assert_eq!( + script.take_cvar_changes(), + vec![("checkAddonVersion".to_string(), "1".to_string())], + "untick: back to the registrar default, and the queue carries it again" + ); + } + /// Scrolling clamps at both ends and never moves a list that fits. #[test] fn the_offset_clamps_to_the_list() { - let mut short = panel((0..5).map(|i| addon(&format!("A{i}"), &[], &[])).collect()); + let mut short = panel( + (0..5) + .map(|i| addon(&format!("A{i}"), &[], 11200)) + .collect(), + ); assert_eq!(short.max_offset(), 0); scroll(&mut short, 1); assert_eq!(short.offset, 0, "a list that fits does not scroll"); let mut long = panel( (0..MAX_ROWS + 4) - .map(|i| addon(&format!("A{i}"), &[], &[])) + .map(|i| addon(&format!("A{i}"), &[], 11200)) .collect(), ); assert_eq!(long.max_offset(), 4); diff --git a/crates/benilla-app/src/char_select/input.rs b/crates/benilla-app/src/char_select/input.rs index 9615cc4c..5c47f6c9 100644 --- a/crates/benilla-app/src/char_select/input.rs +++ b/crates/benilla-app/src/char_select/input.rs @@ -82,7 +82,17 @@ pub(super) fn select_input( // The reference plays `gsCharacterSelectionOpen`-family click here; ours reuses // the create-screen open sound rather than inventing a name the client lacks. sounds.write(GlueSound("gsCharacterSelectionCreateNew")); - panel.open_for(crate::ui_macro::identity(&roster).as_ref()); + // The whole roster rides along so the panel's "Configure Addons For:" dropdown + // can fan out over every character (decision 1293). The realm resolves exactly + // as `ui_macro::identity`'s does — same fallback, so the enable files the panel + // writes stay keyed the way the world-entry walk reads them (0997, 1191 §7). + let realm = roster + .realm + .as_ref() + .map(|r| r.name.clone()) + .unwrap_or_else(|| "Realm".into()); + let chars = roster.chars.iter().map(|c| c.name.clone()).collect(); + panel.open_for(realm, chars); } SelectAction::Back => back_to_login = true, _ => {} diff --git a/crates/benilla-app/src/char_select/mod.rs b/crates/benilla-app/src/char_select/mod.rs index dcea5783..6504810e 100644 --- a/crates/benilla-app/src/char_select/mod.rs +++ b/crates/benilla-app/src/char_select/mod.rs @@ -122,6 +122,7 @@ impl Plugin for CharSelectPlugin { dialog::drive_delete_dialog, // Before the list refresh, and before `select_input` reads a click that // landed on the panel rather than the screen (decision 1196). + debug_select_addons, addons::drive_addons_panel, refresh::refresh_list, refresh::refresh_banner_and_buttons, @@ -586,6 +587,43 @@ fn debug_select_dialog( *done = true; } +/// The shot instrument's AddOns dial (`WOW_CHARSELECT_ADDONS=1`): open the AddOns panel a few +/// seconds after the screen is up, exactly as the screen's own button would — so the reference +/// AddonList layout is exercised and capturable headlessly (pair with +/// `WOW_CHARSELECT_SHOT_OUT`). Inert without the env; fires once. +fn debug_select_addons( + roster: Res, + mut panel: ResMut, + time: Res