- a size-less `<Model>` pane's rect is its file's box in layout units: the reference's geometry overrides (`0x76d080`/`0x76d0d0`) answer the bbox extent whenever no size is authored, `bboxExtent · 768·√(a²+1) · layoutScale` FrameXML units (`1280 · extent` at 4:3), written into the pane's layout input when the file's facts land, re-derived on an aspect change, and ended for good by any authored size; `SetPosition` is the same unit, which is how `0x4a7b20` centres the arrow at `½·GetWidth, ½·GetHeight` through the override — so the map arrow's sprite arm and its hand-sized 33.6 px footprint go (its rect is `0.0262 × 0.0263` layout units, 33.5 × 33.7 FrameXML units at 4:3, 41.0 × 41.2 at 16:9), and the stock `ItemAnim` pane plays the item push unadapted (`ReplaceIconTexture` on the type-14 batch, the 1000 ms clamp, the stock `OnAnimFinished`), `ContainerFrameAdapters.xml` losing 0887's Lua card and its hand-measured 42.41 × 124.72 rect — the implicit one at 16:9 — and keeping the keyring fork and the three bag verbs (2015); and the cooldown is the reference's own model: the texture-transform law `uv' = R_q((uv + t − p) ⊙ s) + p`, `p = (½, ½)`, the raw quaternion component-lerped and never normalised, is one function (`uv_transform`) with the shader's UV fold as its twin — rotation and scale tracks bake per file sequence slot beside the translation, the mat-anim table gains an affine row `[cos − 1, sin, sx − 1, sy − 1]` on `anim_slots.z` whose identity is the zero row so every static batch reads exactly what it did, and a UI tile clones the twin material per animated batch and writes both rows every frame off the pane's play head, never the world clock; the stock `Cooldown.xml` runs off the chain on 2007's two handlers, the file's four quadrant quads turned by their own rotation tracks (`0 → −90°` over each quadrant's 250 ms window, clockwise from 12 o'clock) and its alpha tracks revealing the star for the flash; retired: the native `Cooldown` widget and `SetCooldown` (an Era API 1.12 never had — `CreateFrame("Cooldown")` is the reference's unknown-type refusal now), the loader's `Model → Cooldown` substitution, the app's hand-built pie and star, and our `Cooldown.xml`; `assets/ui` is eight files (2019)
- `UpdateMapHighlight` gets its zone-level exits (B360): at zone zoom ours returned the nil tail for every cursor position, so hovering Deadwind Pass from Duskwood showed no label where 1.12 shows "Deadwind Pass"; the reference's `0x4a7fa0` runs two lookups at zone level and answers a NAME only — first the current zone's exploration-gated overlay list (the builder at `0x4a6ad9` gating on the explored byte), each row's HitRect scaled by 1/1002 and 1/668 against the normalized cursor, edges inclusive, the first hit naming its Area1, then the continent grid re-windowed through the displayed zone's own rect, naming the neighbouring zone or city and nothing for the displayed zone itself — and only the name-only tail is reachable from there, fileName nil and the six coords zero, so the stock frame hides the highlight quad, as the director's own 1.12 shot shows; and `SetVerticalScroll` stores the offset as given (B370, B363): the reference's `0x786db0` compares the new offset against the old and writes it to `[+0x328]` verbatim, `0x787100` re-anchors the scroll child off it, and the range at `[+0x320]` is never read on that path — every clamp the reference exhibits is FrameXML's, through the scroll bar's `[min, max]`; ours clamped into `[0, GetVerticalScrollRange()]`, the child's overflow past the FRAME, which is 40 px short of where `FauxScrollFrame_Update` sends the bar on the 220-tall skills list (three rows lost) and 15 px short on the 287-tall who list (one); the setter fires `OnVerticalScroll` only on a change, as the bytes do, `BenillaScroll_Step` bounds its wheel in Lua where the reference's handler does, and the ScrollFrame kind's prose stops calling its offset unpinned (2017)
- overdraw's cheap half, the sky first: since 0588 every sky element wrote `frag_depth = 0.0`, which costs a pipeline its early-Z, so on an outdoor frame every full-screen dome fragment under the ground and the buildings was shaded in full (the gradient's `asin`, `atan2` and eight table lookups at dawn and dusk) and thrown away; one shared `sky_vertex.wgsl` — bevy's mesh vertex verbatim plus `out.position.z = 0.0` — is now the vertex stage of the gradient dome, the cloud dome, the star patches and the celestial discs and glares, and the model lane's `WOW_SKY_DEPTH` branch moves from its fragment to its vertex, so no sky fragment declares `frag_depth` and the rasterizer rejects the covered screen before the shader runs; every sky `specialize` zeroes the rasterizer bias constant the `sky_order` rung had been leaking into `DepthBiasState::constant`, harmless only while the fragment overwrote its depth, and the prose beside the pin follows it; then the retained `static_gx` pass draws BEFORE bevy's opaque pass (`StartMainPass → static_gx → MainOpaquePass`) — buildings and doodads are the frame's best occluders and a terrain fragment its dearest, six samples a pixel — so walls reject the terrain behind them instead of the reverse, the pass taking the first-use clear with it and the dev depth probe ordered after it; measured on 16 capture scenarios: 14 bit-identical, `northshire-dusk` 1 px and `water-night` 11 px, attributed to exact depth ties on static/terrain silhouettes and last-bit interpolation noise; a depth prepass and near-to-far bins are declined until a journal says where the time goes (2016)
- the grass cut goes per pixel because of a quarter-mip: `0x6813d0` sets `D3DSAMP_MIPMAPLODBIAS = +0.25` on stage 0 (`0x6813f4`) once per frame before the detail-doodad pass — capture-confirmed on all 29 doodad batches, and outside the `[0x6b1000, 0x6b3200)` scan 2004 had called complete; it decides the whole look because the `clutter_cut` probe finds 6 of 31 shipped detail atlases carry a BINARY alpha pyramid (mip 0 has DXT3's 16 levels, every level below exactly two, both starting zones among them), so `a × ramp` degenerates to a global on/off and every texel of a leaf crosses at the same depth — Elwynn's survivor coverage is flat at 47.6 % across the whole 52.5–61.1 yd band, then 0.0 %, while the probe's box-filtered control erodes 47.7 → 20.9 over the same band, so the art has the gradient the shipped pyramid threw away, and `clutter_size` puts the median tuft at 0.92 yd tall, 1.03 yd deep, a full yard for the boundary plane to sweep; the escape is bilinear: full alpha needs a 2×2 all-opaque texel neighbourhood, and those run out between mip 2 and 3 (179/56/10/0/0/0 on the dominant Elwynn cell), so the reference's λ ≈ 2.9–3.3 sits past the cliff and the tuft erodes continuously — 2.2 % of a tuft's pixels leave in the last half-yard with the bias, 11.6 % without, 24.2 % one mip sharper; wgpu has no sampler LOD bias, so the clutter lane re-samples base colour at `view.mip_bias + 0.25`, keeping the sampler every other model batch shares at the reference's 0; ruled out on the way: our BLP decoder (all nine uploaded levels byte-identical against the archive's 52 793 BLP2s), mip regeneration, the `has_mipmaps` byte's value 2 (a plain boolean, an authoring marker), anisotropy and geometry; what it does not fix is resolution-dependent, and so is the reference's own look — λ with the bias is 3.22 at the capture's 648p but 2.48 at 1080p and 1.48 at 2160p, so at a modern resolution the reference itself pops leaf by leaf, and scaling the bias with the viewport is one reversible line left for a look call (2021)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
790 lines
33 KiB
Rust
790 lines
33 KiB
Rust
//! The pet action bar (the reference's `PetActionBarFrame.xml`, 1953) driven end to end through the REAL shipped XML
|
|
//! (decision 0982) — the `multibar_stance_tests` pattern: a self-contained loader, then the
|
|
//! whole chain from a pushed slot list to the quads it actually paints.
|
|
|
|
use benilla_ui::script::{PetActionView, QuadContent, UiScript};
|
|
|
|
use super::test_ui::load_ui as load_xml;
|
|
|
|
/// The pet bar's own load prerequisites, in manifest order: UiPanels (`SetDesaturation`, the
|
|
/// disabled-bar grey), UIParent (the managed bottom stack its OnShow/OnHide re-fires), Cooldown
|
|
/// (`CooldownFrame_SetTimer`), ActionBar (the `MainMenuBar` anchor target) — then the bar.
|
|
/// The reference's pet bar needs what the manifest loads before it: the action-bar chain
|
|
/// (`ActionButton_UpdateState`, `MainMenuBar` as its parent, `CooldownFrame_SetTimer`), the
|
|
/// options window's uvars for `MultiActionBars.lua`, `SetDesaturation` and `GetBindingText` from
|
|
/// our UIParent.lua slices, `TEXT` — and the chat window, whose edit box `ShowPetActionBar`
|
|
/// raises over the sliding bar (PetActionBarFrame.lua l.175). One chain, in the manifest's order,
|
|
/// for every test here (1953).
|
|
pub(super) fn load_pet_bar(s: &UiScript) {
|
|
for file in [
|
|
"Interface\\FrameXML\\Fonts.xml",
|
|
r"Interface\FrameXML\UIParent.xml",
|
|
"Interface\\FrameXML\\Cooldown.xml",
|
|
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
|
"Interface\\FrameXML\\TextStatusBar.lua",
|
|
"Interface\\FrameXML\\TextStatusBar.xml",
|
|
"Interface\\FrameXML\\GlobalStrings.lua",
|
|
"Interface\\FrameXML\\BasicControls.xml",
|
|
"Interface\\FrameXML\\MainMenuBar.xml",
|
|
r"Interface\FrameXML\MoneyFrame.lua",
|
|
r"Interface\FrameXML\MoneyFrame.xml",
|
|
"Interface\\FrameXML\\GameTooltip.xml",
|
|
"Interface\\FrameXML\\ActionBarFrame.xml",
|
|
"Interface\\FrameXML\\BonusActionBarFrame.xml",
|
|
r"Interface\FrameXML\UIPanelTemplates.lua",
|
|
r"Interface\FrameXML\UIPanelTemplates.xml",
|
|
r"Interface\FrameXML\OptionsFrameTemplates.xml",
|
|
r"Interface\FrameXML\ReputationFrame.xml",
|
|
"Interface\\FrameXML\\LocaleProperties.lua",
|
|
"Interface\\FrameXML\\StaticPopup.xml",
|
|
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
|
"ScrollTemplates.xml",
|
|
"KeyBindingsPage.xml",
|
|
"OptionsFrame.xml",
|
|
"Interface\\FrameXML\\MultiActionBars.xml",
|
|
"Interface\\FrameXML\\PetActionBarFrame.xml",
|
|
"Interface\\FrameXML\\UIMenu.xml",
|
|
"Interface\\FrameXML\\ChatFrame.xml",
|
|
"Interface\\FrameXML\\FloatingChatFrame.xml",
|
|
] {
|
|
load_xml(s, file);
|
|
}
|
|
}
|
|
|
|
/// GlobalStrings is loaded from the MPQ at runtime, not by the loader — a token slot's `name` is
|
|
/// a KEY into it, so the tests declare the two keys they read. (That the real keys exist in the
|
|
/// shipped file is a separate fact, asserted in `ui_pet`'s own tests by name.)
|
|
pub(super) fn declare_token_strings(s: &UiScript) {
|
|
s.run(
|
|
"PET_ACTION_ATTACK = 'Attack' \
|
|
PET_MODE_DEFENSIVE = 'Defensive'",
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
/// The words the drag moves — `ACT_COMMAND`/Attack and `ACT_ENABLED`/Claw as the server packs
|
|
/// them. Carried on the views because the drag is word arithmetic (decision 1010).
|
|
const ATTACK_WORD: u32 = 0x0700_0002;
|
|
const CLAW_WORD: u32 = 0xC100_0BC2;
|
|
|
|
/// A hunter's bar as the server actually sends it: Attack (a lit command token), an empty spell
|
|
/// slot, Claw (a spell with autocast running), and Defensive (a lit reaction token).
|
|
pub(super) fn hunter_slots() -> Vec<PetActionView> {
|
|
let mut slots = vec![PetActionView::default(); 10];
|
|
slots[0] = PetActionView {
|
|
name: Some("PET_ACTION_ATTACK".into()),
|
|
texture: Some("PET_ATTACK_TEXTURE".into()),
|
|
is_token: true,
|
|
active: true,
|
|
attack_active: true,
|
|
packed: ATTACK_WORD,
|
|
..Default::default()
|
|
};
|
|
slots[3] = PetActionView {
|
|
name: Some("Claw".into()),
|
|
subtext: Some("Rank 3".into()),
|
|
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
|
|
spell_id: Some(3010),
|
|
autocast_allowed: true,
|
|
autocast_enabled: true,
|
|
packed: CLAW_WORD,
|
|
..Default::default()
|
|
};
|
|
slots[8] = PetActionView {
|
|
name: Some("PET_MODE_DEFENSIVE".into()),
|
|
texture: Some("PET_DEFENSIVE_TEXTURE".into()),
|
|
is_token: true,
|
|
active: true,
|
|
packed: 0x0600_0001,
|
|
..Default::default()
|
|
};
|
|
slots
|
|
}
|
|
|
|
/// Count `path` quads **in the pet bar's own row**. The prerequisite `ActionBar.xml` brings 12
|
|
/// action buttons wearing the same `UI-Quickslot2` ring, so an unscoped count would be measuring
|
|
/// the main bar. Cut by the quad's CENTRE, not an edge: the button rings overhang their buttons by
|
|
/// a dozen pixels each way (54 px of ring on a 30 px button), so the two rows' extents actually
|
|
/// overlap even though their centres — 21 for the main bar, 70..75 for the pet bar — do not.
|
|
fn textures(quads: &[benilla_ui::script::ExtractedQuad], path: &str) -> usize {
|
|
quads
|
|
.iter()
|
|
.filter(|q| q.rect.is_none_or(|r| (r.top + r.bottom) / 2.0 >= 50.0))
|
|
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
|
|
.count()
|
|
}
|
|
|
|
fn texture_rect(
|
|
quads: &[benilla_ui::script::ExtractedQuad],
|
|
path: &str,
|
|
) -> Option<benilla_ui::layout::Rect> {
|
|
quads
|
|
.iter()
|
|
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
|
|
.and_then(|q| q.rect)
|
|
}
|
|
|
|
/// The whole bar, end to end: hidden with no pet, shown with one, the three slot classes each
|
|
/// painting what they should, and hidden again when the pet goes.
|
|
#[test]
|
|
fn the_shipped_pet_bar_drives_end_to_end() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
|
|
// No pet: the bar is hidden, and nothing of it reaches the quad pass.
|
|
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
|
s.resolve();
|
|
assert_eq!(
|
|
textures(&s.extract(), "Interface\\PetActionBar\\UI-PetBar"),
|
|
0,
|
|
"no pet, no shelf"
|
|
);
|
|
|
|
// A pet appears.
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
s.tick(0.05);
|
|
s.resolve();
|
|
let quads = s.extract();
|
|
|
|
// The shelf art: both UI-PetBar strips draw.
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\PetActionBar\\UI-PetBar"),
|
|
2,
|
|
"the two shelf strips"
|
|
);
|
|
|
|
// A TOKEN slot resolved its texture through the global the app named — the `getglobal` fork.
|
|
// Seeing the ICON PATH here (not the literal "PET_ATTACK_TEXTURE") is the whole proof.
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\Icons\\Ability_GhoulFrenzy"),
|
|
1,
|
|
"Attack's icon came from PET_ATTACK_TEXTURE"
|
|
);
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\Icons\\Ability_Defend"),
|
|
1,
|
|
"Defensive's icon came from PET_DEFENSIVE_TEXTURE"
|
|
);
|
|
// A SPELL slot took its path verbatim.
|
|
assert_eq!(textures(&quads, "Interface\\Icons\\Ability_Druid_Rake"), 1);
|
|
|
|
// Occupancy: 3 named slots show their filled ring, the 7 unnamed ones are hidden outright
|
|
// (the reference hides an unnamed button rather than showing an empty well).
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\Buttons\\UI-Quickslot2"),
|
|
3,
|
|
"one filled ring per occupied slot"
|
|
);
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\Buttons\\UI-Quickslot"),
|
|
0,
|
|
"an unnamed pet slot hides; it does not draw the empty ring"
|
|
);
|
|
|
|
// The checked ring is on both lit slots, and nowhere else.
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\Buttons\\CheckButtonHilight"),
|
|
2,
|
|
"Attack + Defensive are lit; Claw is not"
|
|
);
|
|
|
|
// Autocast: the static ring on the one slot that allows it, and the stock shine MODEL on the
|
|
// one slot where it is running — `$parentAutoCast`, `UI-AutoCastButton.mdx` over the whole
|
|
// button at `scale="1.2"`, rendered as a tile (decisions 2013/2014). A hidden pane is not
|
|
// extracted, so one pane is exactly one running shine.
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
|
|
1,
|
|
"only Claw can autocast"
|
|
);
|
|
let shines: Vec<_> = quads
|
|
.iter()
|
|
.filter(|q| q.rect.is_none_or(|r| (r.top + r.bottom) / 2.0 >= 50.0))
|
|
.filter(|q| matches!(&q.content, QuadContent::ModelPane { model: Some(p), model_scale, .. }
|
|
if p == "Interface\\Buttons\\UI-AutoCastButton.mdx" && (*model_scale - 1.2).abs() < 1e-6))
|
|
.collect();
|
|
assert_eq!(
|
|
shines.len(),
|
|
1,
|
|
"the shine pane on Claw alone — enabled, not merely allowed"
|
|
);
|
|
|
|
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
|
|
// and MainMenuBar is the 1024x53 frame at screen BOTTOM ⇒ its BOTTOMLEFT is (0,0), so the
|
|
// frame spans y[54,97]. Button 1 sits at the frame's own BOTTOMLEFT +(36,2) ⇒ x[72,102]
|
|
// y[56,86] — the same 72 the reference lands on (its PETACTIONBAR_XPOS 36 + the button's 36).
|
|
let attack =
|
|
texture_rect(&quads, "Interface\\Icons\\Ability_GhoulFrenzy").expect("Attack icon");
|
|
assert_eq!(
|
|
(attack.left, attack.bottom, attack.right, attack.top),
|
|
(72.0, 56.0, 102.0, 86.0)
|
|
);
|
|
|
|
// The pet goes: the bar slides out over the reference's PETACTIONBAR_SLIDETIME (0.09 s,
|
|
// `PetActionBarFrame_OnUpdate`) and hides at the end of the slide, shine marker and all.
|
|
s.set_pet_actions(false, true, true, Vec::new());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
for _ in 0..3 {
|
|
s.tick(0.05);
|
|
}
|
|
s.resolve();
|
|
let gone = s.extract();
|
|
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
|
|
assert!(
|
|
!gone.iter().any(
|
|
|q| matches!(&q.content, QuadContent::ModelPane { model: Some(p), .. }
|
|
if p == "Interface\\Buttons\\UI-AutoCastButton.mdx")
|
|
),
|
|
"the shine pane hides with its bar"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The click law: left runs the slot, right flips autocast, and a left click on the ATTACK slot
|
|
/// while the pet is already attacking calls it off instead of re-ordering it (the reference's
|
|
/// `IsPetAttackActive` fork — the one branch that makes the Attack button a toggle).
|
|
#[test]
|
|
fn clicks_route_through_the_attack_toggle_fork() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
|
|
// Button 1 (Attack) spans x[72,102] y[56,86] ⇒ centre (87,71). Claw is slot 4: the buttons
|
|
// chain +8 on a 30 px width, so button 4's left = 72 + 3*38 = 186 ⇒ centre (201,71).
|
|
let click = |s: &mut UiScript, x: f32, y: f32, button: &str| {
|
|
s.mouse_button(x, y, button, true);
|
|
s.mouse_button(x, y, button, false);
|
|
};
|
|
|
|
// The pet IS attacking, so a left click on Attack calls it off — no action is queued.
|
|
click(&mut s, 87.0, 71.0, "LeftButton");
|
|
assert_eq!(s.take_pet_stop_attacks(), 1);
|
|
assert!(
|
|
s.take_pet_actions().is_empty(),
|
|
"the call-off replaces the press, it does not accompany it"
|
|
);
|
|
|
|
// Not attacking any more: the same click orders the attack.
|
|
let mut slots = hunter_slots();
|
|
slots[0].active = false;
|
|
slots[0].attack_active = false;
|
|
s.set_pet_actions(true, true, true, slots);
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
click(&mut s, 87.0, 71.0, "LeftButton");
|
|
assert_eq!(s.take_pet_actions(), vec![1]);
|
|
assert_eq!(s.take_pet_stop_attacks(), 0);
|
|
|
|
// Right-clicking the spell slot flips its autocast; right-clicking a command token does not
|
|
// (a token has no spell id for the wire verb to name).
|
|
click(&mut s, 201.0, 71.0, "RightButton");
|
|
assert_eq!(s.take_pet_autocast_toggles(), vec![4]);
|
|
click(&mut s, 87.0, 71.0, "RightButton");
|
|
assert!(
|
|
s.take_pet_autocast_toggles().is_empty(),
|
|
"a command token cannot autocast"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **A click takes the ring off; the repaint puts it back** — the contract decision 1021's signal
|
|
/// count depends on, pinned here so it cannot quietly rot.
|
|
///
|
|
/// `PetActionButton_OnClick`'s first line is `this:SetChecked(0)` and it runs for every click, so
|
|
/// the ring always comes off. What puts it back is `PET_BAR_UPDATE` — and the repaint that answers
|
|
/// it must re-derive `SetChecked` from `isActive` per slot, **not** diff the views, or a press that
|
|
/// changed no state would have no way home.
|
|
///
|
|
/// The reference signals that repaint from the state writes (`0x4bc940`/`0x4bc960`) and **not**
|
|
/// from a `TogglePetAutocast` it refuses (`0x4bcbf7` — a token is not autocastable, i.e. every
|
|
/// right-click on Follow, Stay or a reaction). So on a right-click the ring genuinely stays off
|
|
/// until something else repaints the bar. That is the reference's own quirk, checked there by the
|
|
/// director; decision 1027 diverged from it and 1030 put it back.
|
|
#[test]
|
|
fn a_click_drops_the_ring_and_the_repaint_restores_it() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
|
|
let checked = |s: &UiScript| {
|
|
s.eval::<bool>("return PetActionButton1:GetChecked()")
|
|
.unwrap()
|
|
};
|
|
|
|
// Button 1 (Attack, lit) spans x[72,102] y[56,86] ⇒ centre (87,71). Both mouse buttons take
|
|
// the ring off — the CheckButton toggles itself, then `SetChecked(0)` lands it at 0 either way.
|
|
for button in ["RightButton", "LeftButton"] {
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
assert!(checked(&s), "{button}: the Attack token starts lit");
|
|
|
|
s.mouse_button(87.0, 71.0, button, true);
|
|
s.mouse_button(87.0, 71.0, button, false);
|
|
assert!(
|
|
!checked(&s),
|
|
"{button}: the ref's own SetChecked(0), reproduced"
|
|
);
|
|
|
|
// A repaint carrying the very SAME views restores it — the property the signal count buys.
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
assert!(
|
|
checked(&s),
|
|
"{button}: an unchanged repaint must re-light, not diff to a no-op"
|
|
);
|
|
}
|
|
let _ = s.take_pet_actions();
|
|
let _ = s.take_pet_stop_attacks();
|
|
let _ = s.take_pet_autocast_toggles();
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// A DISABLED bar still draws — every icon desaturated, nothing hidden. That pair
|
|
/// (`PetHasActionBar` true, `GetPetActionsUsable` false) is what a feared or mind-controlled pet
|
|
/// looks like, and collapsing it to "hide the bar" would lose the state entirely.
|
|
#[test]
|
|
fn a_disabled_bar_greys_rather_than_hides() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
|
|
s.set_pet_actions(true, false, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
let quads = s.extract();
|
|
assert_eq!(
|
|
textures(&quads, "Interface\\PetActionBar\\UI-PetBar"),
|
|
2,
|
|
"the bar is still on screen"
|
|
);
|
|
// Since decision 1327 `SetDesaturation` takes its SHADER arm — the icon carries the greyscale
|
|
// flag to the renderer and keeps its own tint, rather than the reference's no-shader 0.5
|
|
// vertex-colour fallback (which is what this asserted while nothing greyed).
|
|
let grey = quads
|
|
.iter()
|
|
.find_map(|q| match &q.content {
|
|
QuadContent::Texture {
|
|
path: Some(p),
|
|
desaturated,
|
|
..
|
|
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*desaturated),
|
|
_ => None,
|
|
})
|
|
.expect("Claw's icon");
|
|
assert!(grey, "a disabled bar greys every icon on it");
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The pet bar with and without the bottom-left multibar RAISED. Since 1500 that bar is a player
|
|
/// option shipping off, so the flag now raises it rather than merely loading the file — both states
|
|
/// are ones a real session lands in. `shelf` counts the two `UI-PetBar` strips; `attack_top` is the
|
|
/// top edge of Attack's icon, the bar's own row in one number.
|
|
fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
if with_multibar {
|
|
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update()")
|
|
.unwrap();
|
|
}
|
|
declare_token_strings(&s);
|
|
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
s.tick(0.05);
|
|
s.resolve();
|
|
let quads = s.extract();
|
|
let shelf = quads
|
|
.iter()
|
|
.filter(|q| {
|
|
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
|
if p == "Interface\\PetActionBar\\UI-PetBar")
|
|
})
|
|
.count();
|
|
let attack_top = texture_rect(&quads, "Interface\\Icons\\Ability_GhoulFrenzy")
|
|
.expect("Attack's icon draws")
|
|
.top;
|
|
(shelf, attack_top)
|
|
}
|
|
|
|
/// **The row the pet bar shares with the bottom-left multibar** (decision 0988, director-caught:
|
|
/// the bar drew straight across a live row of spells, border and all).
|
|
///
|
|
/// Two rules, both the reference's, both applied by `UIParent_ManageFramePositions`: with that bar
|
|
/// up the pet bar rises by the ref's 43 px, and its shelf art — which is a border only while the
|
|
/// bar sits directly on the main bar — is hidden rather than drawn over the buttons below.
|
|
///
|
|
/// The regression this locks is specifically that the manage pass RUNS: the bar's own XML anchor
|
|
/// is the base position, so a pass that never fired would leave it exactly where the bug was.
|
|
#[test]
|
|
fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
|
|
let (low_shelf, low_top) = pet_bar_row(false);
|
|
let (high_shelf, high_top) = pet_bar_row(true);
|
|
|
|
assert_eq!(
|
|
low_shelf, 2,
|
|
"on the main bar, the shelf IS the bar's border"
|
|
);
|
|
assert_eq!(high_shelf, 0, "raised, it would draw across the row below");
|
|
|
|
let risen = low_top - high_top;
|
|
assert!(
|
|
(risen.abs() - 43.0).abs() < 0.5,
|
|
"the bar must rise by the ref's 43 px (moved {risen})"
|
|
);
|
|
}
|
|
|
|
/// **The drag, through the shipped XML** (decision 1010). One verb serves both ends — the button's
|
|
/// `OnDragStart` and its `OnReceiveDrag` both call `PickupPetAction` — so what makes this a move
|
|
/// rather than two pick-ups is the binding's own fork on whether the cursor is already carrying.
|
|
///
|
|
/// Also locks the grid: while a pet action rides the cursor every slot shows, including the empty
|
|
/// ones, so there is somewhere visible to drop it. That is the whole reason `PET_BAR_SHOWGRID`
|
|
/// exists, and the reason an unnamed button's `Hide()` is conditional.
|
|
#[test]
|
|
fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
|
|
// Slot 5 is empty, so its button is hidden — until the grid comes up.
|
|
assert!(!s.eval::<bool>("return PetActionButton5:IsShown()").unwrap());
|
|
|
|
s.run("this = PetActionButton4; PetActionButton_OnDragStart()")
|
|
.unwrap();
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
assert_eq!(
|
|
s.take_pet_set_actions(),
|
|
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]],
|
|
"the pickup blanks the slot it came from and tells the server"
|
|
);
|
|
s.tick(0.01);
|
|
s.resolve();
|
|
assert_eq!(
|
|
s.eval::<i64>("return PetActionBarFrame.showgrid").unwrap(),
|
|
1
|
|
);
|
|
assert!(
|
|
s.eval::<bool>("return PetActionButton5:IsShown()").unwrap(),
|
|
"the grid reveals the empty slots to drop onto"
|
|
);
|
|
|
|
s.run("this = PetActionButton5; PetActionButton_OnReceiveDrag()")
|
|
.unwrap();
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
assert_eq!(
|
|
s.take_pet_set_actions(),
|
|
vec![vec![(4, CLAW_WORD)]],
|
|
"and the drop writes the word verbatim into slot 5 (0-based 4)"
|
|
);
|
|
s.tick(0.01);
|
|
assert_eq!(
|
|
s.eval::<i64>("return PetActionBarFrame.showgrid").unwrap(),
|
|
0,
|
|
"the grid goes down with the payload"
|
|
);
|
|
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
|
|
}
|
|
|
|
/// The pet bar honours **Lock ActionBars** too (decision 1136) — the reference's second consumer of
|
|
/// the same uvar (`PetActionBarFrame.lua:270/278`), reading the global `ActionBar.xml` declares.
|
|
/// Both drag ends refuse while locked; the shift-click pick-up above stays live, exactly as the
|
|
/// reference leaves it (l.253-255).
|
|
#[test]
|
|
fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
|
|
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
|
|
s.run("this = PetActionButton4; PetActionButton_OnDragStart()")
|
|
.unwrap();
|
|
assert!(
|
|
s.take_pet_set_actions().is_empty(),
|
|
"a locked bar sends nothing — the slot was never picked up"
|
|
);
|
|
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
|
|
|
|
// Shift-click is still the way through, and then the drop is refused too while locked.
|
|
s.set_modifiers(true, false, false);
|
|
s.run("this = PetActionButton4; PetActionButton_OnClick(\"LeftButton\")")
|
|
.unwrap();
|
|
s.set_modifiers(false, false, false);
|
|
assert_eq!(
|
|
s.take_pet_set_actions(),
|
|
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]],
|
|
"shift-click picked it up despite the lock"
|
|
);
|
|
s.run("this = PetActionButton5; PetActionButton_OnReceiveDrag()")
|
|
.unwrap();
|
|
assert!(
|
|
s.take_pet_set_actions().is_empty(),
|
|
"…and a locked slot will not take the drop"
|
|
);
|
|
|
|
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
|
|
s.run("this = PetActionButton5; PetActionButton_OnReceiveDrag()")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.take_pet_set_actions(),
|
|
vec![vec![(4, CLAW_WORD)]],
|
|
"unlocked, the same drop lands"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// A shift-click is the same pick-up, whichever mouse button carried it — the reference's own fork
|
|
/// puts shift above the left/right split, so it never toggles autocast by accident.
|
|
#[test]
|
|
fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
|
|
s.run(
|
|
"IsShiftKeyDown = function() return 1 end \
|
|
this = PetActionButton4 \
|
|
PetActionButton_OnClick('RightButton')",
|
|
)
|
|
.unwrap();
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
assert!(
|
|
s.take_pet_actions().is_empty() && s.take_pet_autocast_toggles().is_empty(),
|
|
"shift outranks both click arms — no cast, no autocast flip"
|
|
);
|
|
assert_eq!(
|
|
s.take_pet_set_actions(),
|
|
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]]
|
|
);
|
|
}
|
|
|
|
/// **The keybind pair** (decision 1052) — what a bound `BONUSACTIONBUTTONn` runs. The ref's
|
|
/// binding body calls `BonusActionButtonDown/Up`, one-liners onto `PetActionButtonDown/Up`
|
|
/// (l.218-231), so the whole lane is these two functions: press shows the pushed art, release
|
|
/// fires the slot.
|
|
///
|
|
/// The load-bearing half is what the key path does NOT do: it is a bare `CastPetAction`, with none
|
|
/// of `PetActionButton_OnClick`'s forks. Pinned below on an Attack token fed as already-active — a
|
|
/// left click on that same view calls the attack off (the test above) while the key re-issues it.
|
|
///
|
|
/// **What that fixture is and is not.** `attack_active` is pushed straight onto the view here, and
|
|
/// on a real *pet* bar it can never be true: `ui_pet`'s `possessing` carve (`0x4bd420`) raises the
|
|
/// latch only for a unit you are POSSESSING, so on a hunter's bar the click fork is dead code and
|
|
/// key and click behave identically. The state below is a **possess** bar's — Mind Control, Eye of
|
|
/// Kilrogg — which is the one case where the two paths genuinely diverge, and therefore the only
|
|
/// one worth pinning. Read as "ordinary pets differ from clicks", this test would be lying.
|
|
#[test]
|
|
fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
|
|
let state = |s: &UiScript| {
|
|
s.eval::<String>("return PetActionButton1:GetButtonState()")
|
|
.unwrap()
|
|
};
|
|
|
|
// Down shows the pushed art and fires nothing (the ref's runOnUp shape).
|
|
s.run("PetActionButtonDown(1)").unwrap();
|
|
assert_eq!(state(&s), "PUSHED");
|
|
assert!(
|
|
s.take_pet_actions().is_empty(),
|
|
"the press is visual only — the ref fires on the release"
|
|
);
|
|
|
|
// Up releases the art and casts — slot 1 is Attack and the view says the order is live, so a
|
|
// LEFT CLICK here would call it off instead (a possess bar's case; see the header). The key
|
|
// has no such fork.
|
|
s.run("PetActionButtonUp(1)").unwrap();
|
|
assert_eq!(state(&s), "NORMAL");
|
|
assert_eq!(s.take_pet_actions(), vec![1]);
|
|
assert_eq!(
|
|
s.take_pet_stop_attacks(),
|
|
0,
|
|
"IsPetAttackActive lives in OnClick, which a key press never reaches"
|
|
);
|
|
|
|
// The state guard is the whole re-entrancy story: an up with nothing pushed does nothing
|
|
// (a focus-stolen release, a stuck-latch sweep), and a second down does not re-fire.
|
|
s.run("PetActionButtonUp(1)").unwrap();
|
|
assert!(
|
|
s.take_pet_actions().is_empty(),
|
|
"an unmatched release fires nothing"
|
|
);
|
|
s.run("PetActionButtonDown(1) PetActionButtonDown(1)")
|
|
.unwrap();
|
|
assert_eq!(state(&s), "PUSHED");
|
|
s.run("PetActionButtonUp(1)").unwrap();
|
|
assert_eq!(s.take_pet_actions(), vec![1], "one press, one cast");
|
|
|
|
// An EMPTY slot is inert through the same path (CastPetAction's own guard): slot 2 of the
|
|
// hunter bar carries no name, and its button is hidden.
|
|
s.run("PetActionButtonDown(2) PetActionButtonUp(2)")
|
|
.unwrap();
|
|
assert!(
|
|
s.take_pet_actions().is_empty(),
|
|
"an unnamed slot queues nothing"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
// The autocast trail's MOTION is the model's own since decisions 2013/2014: the stock
|
|
// `$parentAutoCast` Model's four bones and four emitters, rendered as a tile on the pane's clock
|
|
// — the script layer never moves a spark, and no test here needs to.
|
|
|
|
/// A hunter bar hovered through the REAL gesture, with the real binding registry and the real CVar
|
|
/// table behind it — `mouse_move` runs the shipped `<OnEnter>` with `this` bound, which is the only
|
|
/// way this fork gets exercised the way nazriel exercised it.
|
|
///
|
|
/// Buttons are 30 px chained +8 from the bar's own origin: button 1 spans x[72,102] y[56,86] ⇒
|
|
/// centre (87,71), button 4's left = 72 + 3·38 ⇒ centre (201,71) — the geometry
|
|
/// `clicks_route_through_the_attack_toggle_fork` already leans on.
|
|
const ATTACK_BUTTON: (f32, f32) = (87.0, 71.0);
|
|
const CLAW_BUTTON: (f32, f32) = (201.0, 71.0);
|
|
|
|
fn hovered_pet_bar() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
// The real command set and the real registered defaults, the way the app's own seed installs
|
|
// them — so `GetBindingKey("BONUSACTIONBUTTON1")` answers CTRL-1 (its byte-real default) and
|
|
// `GetCVar("UberTooltips")` answers "1" (its byte-read registrar value) rather than nil.
|
|
s.register_bindings(&crate::bindings::registry_commands());
|
|
s.register_cvars(crate::cvars::registered_pairs());
|
|
load_pet_bar(&s);
|
|
declare_token_strings(&s);
|
|
s.set_pet_actions(true, true, true, hunter_slots());
|
|
s.fire_event("PET_BAR_UPDATE", vec![]);
|
|
s.resolve();
|
|
s
|
|
}
|
|
|
|
fn tooltip_line1(s: &UiScript) -> String {
|
|
s.eval::<String>("return GameTooltipTextLeft1:GetText() or \"\"")
|
|
.unwrap()
|
|
}
|
|
|
|
/// **B230 — Attack/Follow/Stay name their keybinding; the pet's own spells do not.**
|
|
///
|
|
/// nazriel: *"On 1.12 when you hover over Attack, Follow, Stay pet action buttons they show the
|
|
/// keybinding - which is not the case with Benilla"*. The reference agrees with him and is
|
|
/// narrower than "the pet bar": `PetActionButton_OnEnter` (PetActionBarFrame.lua l.285-305) forks
|
|
/// on `isToken or UberTooltips == "0"`, and only THAT branch concatenates
|
|
/// `NORMAL_FONT_COLOR_CODE.." ("..GetBindingText(GetBindingKey("BONUSACTIONBUTTON"..id), "KEY_")..")"`.
|
|
/// The other branch is a bare `SetPetAction` and appends nothing — so a fix that suffixed every
|
|
/// slot would be wrong, and the control below is what says ours doesn't.
|
|
///
|
|
/// The exact string is pinned, colour codes and all, because the ref's concatenation puts the
|
|
/// colour BEFORE the space (`Attack|cffffd200 (CTRL-1)|r`, not `Attack |cffffd200(CTRL-1)|r` —
|
|
/// which is what the stock `MicroButtonTooltipText` — the same idea, the other shape — produces).
|
|
#[test]
|
|
fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
|
|
let mut s = hovered_pet_bar();
|
|
|
|
s.mouse_move(ATTACK_BUTTON.0, ATTACK_BUTTON.1);
|
|
s.resolve();
|
|
assert_eq!(
|
|
tooltip_line1(&s),
|
|
"Attack|cffffd200 (CTRL-1)|r",
|
|
"a command token carries BONUSACTIONBUTTON1's key in the normal-font colour"
|
|
);
|
|
// Uber on + a token ⇒ still the default corner; only the CVar-off leg owner-anchors.
|
|
assert!(
|
|
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
|
|
"a token's plate takes the default corner while UberTooltips is on"
|
|
);
|
|
|
|
// The CONTROL: slot 4 is Claw, a real pet spell, and it goes through the engine channel —
|
|
// its name, and nothing in parentheses.
|
|
s.mouse_move(CLAW_BUTTON.0, CLAW_BUTTON.1);
|
|
s.resolve();
|
|
let claw = tooltip_line1(&s);
|
|
assert_eq!(claw, "Claw", "a pet SPELL renders through SetPetAction");
|
|
assert!(
|
|
!claw.contains('('),
|
|
"…and appends no binding: {claw:?} (the ref's other branch)"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The append is UNCONDITIONAL in that branch, so an unbound row really does render empty
|
|
/// parentheses — `GetBindingText` answers `""` for a nil key (UIParent.xml, transcribed from the
|
|
/// ref's own l.1819-1821), and nothing guards the `"("`. That is the reference's behaviour, not a
|
|
/// slip of ours: MicroButtonTooltipText guards its own suffix and this one does not.
|
|
///
|
|
/// Stock it is unreachable — BONUSACTIONBUTTON1-10 default to CTRL-1..CTRL-0 — so it takes a
|
|
/// deliberate unbind to see, which is exactly what this does.
|
|
#[test]
|
|
fn an_unbound_token_row_renders_the_references_empty_parentheses() {
|
|
let mut s = hovered_pet_bar();
|
|
s.run(r#"SetBinding("CTRL-1")"#).unwrap();
|
|
assert!(
|
|
s.eval::<Option<String>>(r#"return GetBindingKey("BONUSACTIONBUTTON1")"#)
|
|
.unwrap()
|
|
.is_none(),
|
|
"the row really is unbound now"
|
|
);
|
|
|
|
s.mouse_move(ATTACK_BUTTON.0, ATTACK_BUTTON.1);
|
|
s.resolve();
|
|
assert_eq!(
|
|
tooltip_line1(&s),
|
|
"Attack|cffffd200 ()|r",
|
|
"unbound: the parentheses stay, empty — the ref's own unguarded concatenation"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The CVar is the fork's OTHER half, and it is not decoration: with `UberTooltips` off, a real pet
|
|
/// SPELL stops going through the engine channel and takes the token branch too — same built text,
|
|
/// same binding suffix — and the plate moves from the screen corner to beside the button
|
|
/// (`SetOwner(this, "ANCHOR_RIGHT")`). Both are the reference's, in the same five lines.
|
|
///
|
|
/// This is also the row's reason to exist in `cvars::REGISTERED`: benilla registers `UberTooltips`
|
|
/// at all because these Lua sites read it (the honest-tree rule's live-consumer half).
|
|
#[test]
|
|
fn ubertooltips_off_takes_the_spells_through_the_token_branch_and_moves_the_plate() {
|
|
let mut s = hovered_pet_bar();
|
|
s.set_cvar_engine("UberTooltips", "0");
|
|
|
|
s.mouse_move(CLAW_BUTTON.0, CLAW_BUTTON.1);
|
|
s.resolve();
|
|
assert_eq!(
|
|
tooltip_line1(&s),
|
|
"Claw|cffffd200 (CTRL-4)|r",
|
|
"with the CVar off a pet spell is built here too, binding and all"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>("return GameTooltip:IsOwned(PetActionButton4)")
|
|
.unwrap(),
|
|
"…and the plate sits beside the button, not at the screen corner"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|