pets get their action bar, macros arrive, and hunters draw the bow
- a hunter's or warlock's pet has its action bar now: attack, follow, stay, the pet's own spells with cooldowns and autocast sparkles - the macro system lands: the macro window, the icon chooser, /cast and /macro, and macros dragged onto the action bar - hunter shots look right at last: the bow draws and releases on the shot, and the arrow lays a spark trail its whole flight - the options window re-dresses in 1.12's own art with the classic dialog look, and gets a working search box and clickable steppers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Generated
-1
@@ -595,7 +595,6 @@ dependencies = [
|
||||
"rand 0.8.6",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"smithay-clipboard",
|
||||
"toml",
|
||||
]
|
||||
|
||||
@@ -63,6 +63,44 @@ pub(crate) fn str_at(rs: &RecordSet, r: &Record, i: usize) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The **macro icon chooser** list — every `SpellIcon.dbc` texture path whose basename begins
|
||||
/// `Spell_` or `Ability_`, in DBC row order, deduplicated (decision 0983).
|
||||
///
|
||||
/// Those two prefixes are the client's own filter, byte-read from its string table: the literals
|
||||
/// `"Spell_"` (`0x44cb30`) and `"Ability_"` (`0x44cb38`) sit inside the `UIMacros.cpp` translation
|
||||
/// unit, beside `Interface\Icons\` (`0x44ca64`), the save format `MACRO %d "%s" %s` (`0x44cb60`)
|
||||
/// and the `GetNumMacroIcons`/`GetMacroIconInfo` binding names — and that TU owns nothing else
|
||||
/// that would want an icon-name prefix. It is also why the vanilla macro picker famously shows
|
||||
/// *spell and ability art only*, never the `INV_*` item icons: on build 5875 the filter keeps
|
||||
/// **519 of the 1018** distinct paths.
|
||||
///
|
||||
/// Row order is kept rather than sorted: the chooser is a scrolled grid, and the DBC's own order
|
||||
/// is the only order the reference could be reading.
|
||||
pub fn load_macro_icons(chain: &mut Chain) -> Result<Vec<String>> {
|
||||
let bytes = chain
|
||||
.read_file("DBFilesClient\\SpellIcon.dbc")
|
||||
.context("reading SpellIcon.dbc")?;
|
||||
let mut schema = Schema::new("SpellIcon");
|
||||
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
schema.add_field(SchemaField::new("TextureFilename", FieldType::String));
|
||||
let set = parse(&bytes, schema, "SpellIcon.dbc")?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut icons = Vec::new();
|
||||
for r in set.records() {
|
||||
let Some(path) = str_at(&set, r, 1) else {
|
||||
continue;
|
||||
};
|
||||
let base = path.rsplit('\\').next().unwrap_or(&path);
|
||||
let keeps = ["Spell_", "Ability_"].iter().any(|p| {
|
||||
base.len() >= p.len() && base.as_bytes()[..p.len()].eq_ignore_ascii_case(p.as_bytes())
|
||||
});
|
||||
if keeps && seen.insert(path.to_ascii_lowercase()) {
|
||||
icons.push(path);
|
||||
}
|
||||
}
|
||||
Ok(icons)
|
||||
}
|
||||
|
||||
/// `SpellIcon.dbc`: id → texture path (`Interface\Icons\…`, extensionless). 1033 records × 2
|
||||
/// fields (`ID(0)`, `TextureFilename(1, str)`) — verified against build 5875 (`spells.rs`'s own
|
||||
/// module doc). Shared by [`crate::spells`] (the action-bar catalog) and [`crate::skill_lines`]
|
||||
|
||||
@@ -24,6 +24,7 @@ mod creatures;
|
||||
mod dbc;
|
||||
mod unit_blood;
|
||||
pub use creatures::{load_creature_catalog, CreatureCatalog, CreatureModel, NpcAppearance};
|
||||
pub use dbc::load_macro_icons;
|
||||
pub use unit_blood::{load_blood_catalog, BloodCatalog};
|
||||
mod itemsets;
|
||||
pub use itemsets::{load_item_sets, ItemSetCatalog, ItemSetInfo};
|
||||
|
||||
@@ -136,7 +136,26 @@ fn weather_slot(ghost: bool, stormy: bool, underwater: bool) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// **The zero-match fallback record**, addressed by its `Light.dbc` **ID column** (not its row
|
||||
/// index) — wow-re `system/lighting/scratch/no-light-row-fallback.md`.
|
||||
///
|
||||
/// The client builds a per-map light array in `dn_light_array_build 0x6d6170` (`0x6d61a9 cmp
|
||||
/// [row+4], mapId`), and when **no row matches the map at all** its tail (`0x6d62b2`–`0x6d62c9`)
|
||||
/// writes `idMap[1]` — this record — into slot 0, unchecked. `dn_light_select 0x6d2d00` then
|
||||
/// no-ops (count ≤ 1 ⇒ empty blend heap) and the colour-table build commits that one record whole.
|
||||
/// The count is *seeded* at 1 (`0x6d6188 mov edi,1`), so the no-data white table (fog end 1e10) is
|
||||
/// the before-first-load state and is unreachable in world.
|
||||
///
|
||||
/// Shipped row 1 is the Azeroth global: map 0, `(0,0,0)`, falloff 0/0, params
|
||||
/// `(12, 13, 10, 11, 4)`. So a map with no `Light.dbc` row of its own — **Deeprun Tram (369) is the
|
||||
/// one that matters, and it is the only shipped map reachable in play with none** — renders under
|
||||
/// LightParams 12's ordinary six-key day curve, not under any invented constant.
|
||||
const FALLBACK_LIGHT_ID: u32 = 1;
|
||||
|
||||
struct Light {
|
||||
/// The row's own `Light.dbc` **ID column** — not its row index. Carried because the client's
|
||||
/// zero-match fallback addresses one specific record *by id* ([`FALLBACK_LIGHT_ID`]).
|
||||
id: u32,
|
||||
map: u32,
|
||||
/// WoW net-protocol **world** coords (yards), converted from the DBC's mirrored/axis-swapped
|
||||
/// inch frame via [`dbc_to_world`]. Meaningless when `global` (the sentinel was `(0,0,0)`).
|
||||
@@ -243,7 +262,8 @@ impl LightCatalog {
|
||||
let rs = parse(&bytes, light_schema(), "Light")?;
|
||||
let mut v = Vec::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
if let (Some(map), Some(x), Some(y), Some(z), Some(start), Some(end)) = (
|
||||
if let (Some(id), Some(map), Some(x), Some(y), Some(z), Some(start), Some(end)) = (
|
||||
u32_at(r, 0),
|
||||
u32_at(r, 1),
|
||||
f32_at(r, 2),
|
||||
f32_at(r, 3),
|
||||
@@ -260,6 +280,7 @@ impl LightCatalog {
|
||||
// values (it does NOT survive the world transform).
|
||||
let global = x == 0.0 && y == 0.0 && z == 0.0;
|
||||
v.push(Light {
|
||||
id,
|
||||
map,
|
||||
pos: if global {
|
||||
[0.0; 3]
|
||||
@@ -446,11 +467,27 @@ impl LightCatalog {
|
||||
(param >= 1).then(|| self.sample_param(param, time))
|
||||
};
|
||||
|
||||
// Base = the continent global light (lowest priority, covers everywhere).
|
||||
// Base = the continent global light (lowest priority, covers everywhere), else — when the
|
||||
// map has **no `Light.dbc` row at all** — the client's zero-match tail fallback, record
|
||||
// [`FALLBACK_LIGHT_ID`] (`0x6d62b2`; see its doc). Deeprun Tram (369) is the shipped map
|
||||
// this is for: with the old invented default it rendered as a bright noon scene (fog
|
||||
// [140,183,234] @ 1000 yd) instead of LightParams 12's own curve (fog [77,120,143] @ 500).
|
||||
//
|
||||
// Scoped to zero-match ON PURPOSE. Seven shipped maps (33/37/129/169/209/489/531) carry
|
||||
// positioned rows but no `(0,0,0)` global; there the reference seeds count = 1 and fills
|
||||
// slots 1.. with the positioned rows, leaving slot 0 unwritten — what it then blends
|
||||
// against is NOT settled by the finding this rests on, so that case keeps the behaviour it
|
||||
// has rather than inheriting a guess.
|
||||
let map_has_no_light = !self.lights.iter().any(|l| l.map == map);
|
||||
let mut acc = self
|
||||
.lights
|
||||
.iter()
|
||||
.find(|l| l.map == map && l.global)
|
||||
.or_else(|| {
|
||||
map_has_no_light
|
||||
.then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID))
|
||||
.flatten()
|
||||
})
|
||||
.and_then(atmo_of)
|
||||
.unwrap_or(Atmosphere::DEFAULT);
|
||||
|
||||
@@ -634,7 +671,9 @@ impl LightCatalog {
|
||||
fn pick_light(&self, map: u32, pos: [f32; 3]) -> Option<&Light> {
|
||||
let mut local: Option<&Light> = None;
|
||||
let mut global: Option<&Light> = None;
|
||||
let mut any = false;
|
||||
for l in self.lights.iter().filter(|l| l.map == map) {
|
||||
any = true;
|
||||
if l.global {
|
||||
global = Some(l);
|
||||
continue;
|
||||
@@ -646,7 +685,16 @@ impl LightCatalog {
|
||||
local = Some(l);
|
||||
}
|
||||
}
|
||||
local.or(global)
|
||||
// Same zero-match tail as `sample_blended` (see [`FALLBACK_LIGHT_ID`]): a map with no row
|
||||
// of its own resolves record 1. Kept in step deliberately — this is the single-sphere
|
||||
// approximation the water tint reads, and a rowless map that answered one thing to the
|
||||
// area blend and another here would tint its water off a different atmosphere than it fogs
|
||||
// with (decision 0706's split, in miniature).
|
||||
local.or(global).or_else(|| {
|
||||
(!any)
|
||||
.then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID))
|
||||
.flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `LightParams` `p` actually has band rows in this chain — the client's `maxId` guard on
|
||||
|
||||
@@ -284,6 +284,56 @@ pub struct VisualStages {
|
||||
pub area_kit: u32,
|
||||
}
|
||||
|
||||
impl VisualStages {
|
||||
/// The client's **ranged weapon-visual merge** — a per-FIELD zero-fill, not a per-row
|
||||
/// fallback (`0x60d450`, byte-read end to end for decision 0986; this SUPERSEDES 0370's
|
||||
/// row-level reading, whose disassembly stopped at the null-own-visual arm).
|
||||
///
|
||||
/// The resolver reaches the fill from **both** arms, and they converge on one block:
|
||||
///
|
||||
/// ```text
|
||||
/// 60d4b4 test esi,esi ; jne 0x60d555 ; esi = the spell's OWN SpellVisual row
|
||||
/// 60d4bc xor eax,eax ; -- null-own arm: zero outKit ...
|
||||
/// 60d4c6 rep stosd ; ... then fall through into the fill
|
||||
/// 60d555 rep movsd ; -- own arm: copy the OWN row into outKit ...
|
||||
/// 60d561 jmp 0x60d4d2 ; ... and jump INTO the same fill
|
||||
/// 60d4d2 test ebx,ebx ; je 0x60d54c ; ebx = the WEAPON's SpellVisual row (0 if none)
|
||||
/// 60d4d6..60d54c ; per field: `if out.F == 0 { out.F = weapon.F }`
|
||||
/// ```
|
||||
///
|
||||
/// So a RANGED-attribute spell (`Attributes & 0x2`) that **has** its own visual still borrows
|
||||
/// the equipped ranged weapon's row for every slot its own row leaves at zero. That is how a
|
||||
/// hunter shot animates at all: Serpent Sting (visual 3179), Multi-Shot (567), Arcane Shot
|
||||
/// (3299), Aimed/Concussive (3180), Viper/Scorpid Sting (3181/3219) and Volley (3300) every
|
||||
/// one carry `precast = cast = 0` beside a populated impact + missile block, and take
|
||||
/// **LoadBow (kit 7) → AttackBow (kit 164)** off the bow's `ItemDisplayInfo` col-10 visual 5
|
||||
/// (thrown → 98, gun → 224). Read as a row-level fallback they got no caster fire clip at all.
|
||||
///
|
||||
/// The merged set is exactly the client's eight sites. The two it pointedly does NOT touch are
|
||||
/// [`Self::state`] and [`Self::channel`] (`+0x10`/`+0x14`), nor the dest-anchored
|
||||
/// `+0x2c/+0x30/+0x34` block. Field 8 (`missilePathType`, `+0x20`) is merged there too; it is
|
||||
/// dead-by-absence, so this struct does not carry it.
|
||||
///
|
||||
/// The missile pair is the one non-uniform site (`60d4fd`–`60d517`): the gate is written as a
|
||||
/// **literal 1**, not the weapon's own value, and the model only comes across with it.
|
||||
#[must_use]
|
||||
pub fn merged_over_weapon(&self, weapon: &VisualStages) -> VisualStages {
|
||||
let fill = |own: u32, w: u32| if own == 0 { w } else { own };
|
||||
let mut out = *self;
|
||||
out.precast = fill(self.precast, weapon.precast); // 60d4d6
|
||||
out.cast = fill(self.cast, weapon.cast); // 60d4e3
|
||||
out.impact = fill(self.impact, weapon.impact); // 60d4f0
|
||||
if self.missile_gate == 0 && weapon.missile_gate != 0 {
|
||||
out.missile_gate = 1; // 60d50b — the literal, not the weapon's value
|
||||
out.missile_model = weapon.missile_model; // 60d512
|
||||
}
|
||||
out.missile_attach = fill(self.missile_attach, weapon.missile_attach); // 60d525
|
||||
out.missile_sound = self.missile_sound.or(weapon.missile_sound); // 60d532
|
||||
out.strike_sound = self.strike_sound.or(weapon.strike_sound); // 60d53f
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// One `SpellVisualKit.dbc` row's body-animation + sound + the nine attach-point emitter slots
|
||||
/// (the columns consumed through decision 0099 phase 3; see the module doc for what remains).
|
||||
// No `Eq`: the `CharProc` params are floats (the client's own `fld` columns).
|
||||
|
||||
@@ -701,3 +701,124 @@ fn real_zero_param_chain_slots_are_padding() {
|
||||
);
|
||||
assert!(kit.chain_proc().is_none(), "…but none of them is a beam");
|
||||
}
|
||||
|
||||
/// [`VisualStages::merged_over_weapon`] — the `60d4d2`–`60d54c` fill, field by field: a populated
|
||||
/// slot survives, an empty one takes the weapon's, and the two the client pointedly skips
|
||||
/// (`state`/`channel`) stay empty however loud the weapon's row is.
|
||||
#[test]
|
||||
fn the_weapon_merge_fills_only_the_empty_slots() {
|
||||
let weapon = VisualStages {
|
||||
precast: 7,
|
||||
cast: 164,
|
||||
impact: 1947,
|
||||
state: 999, // +0x10 — never merged
|
||||
channel: 998, // +0x14 — never merged
|
||||
missile_gate: 1,
|
||||
missile_model: 42,
|
||||
missile_attach: 3,
|
||||
missile_sound: Some(4222),
|
||||
strike_sound: Some(1143),
|
||||
area_gate: 1,
|
||||
area_effect: 7,
|
||||
area_kit: 9,
|
||||
};
|
||||
// Multi-Shot's real shape: its own impact + missile, both body kits empty.
|
||||
let own = VisualStages {
|
||||
impact: 658,
|
||||
missile_gate: 1,
|
||||
missile_model: 528,
|
||||
missile_attach: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let merged = own.merged_over_weapon(&weapon);
|
||||
assert_eq!((merged.precast, merged.cast), (7, 164), "empty slots fill");
|
||||
assert_eq!(merged.impact, 658, "a populated slot is kept");
|
||||
assert_eq!(
|
||||
(merged.missile_model, merged.missile_attach),
|
||||
(528, 1),
|
||||
"its own missile block survives — the gate was already nonzero"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.missile_sound,
|
||||
Some(4222),
|
||||
"the flight loop fills from the bow (Serpent Sting / Multi-Shot carry none)"
|
||||
);
|
||||
assert_eq!(
|
||||
(merged.state, merged.channel),
|
||||
(0, 0),
|
||||
"state/channel are NOT in the client's fill list"
|
||||
);
|
||||
assert_eq!(
|
||||
(merged.area_gate, merged.area_effect, merged.area_kit),
|
||||
(0, 0, 0),
|
||||
"neither is the dest-anchored block"
|
||||
);
|
||||
|
||||
// The missile pair's own arm: an own row with NO missile takes the weapon's — and the gate is
|
||||
// written as the literal 1 (`60d50b`), not the weapon's value.
|
||||
let no_missile = VisualStages::default().merged_over_weapon(&VisualStages {
|
||||
missile_gate: 5,
|
||||
missile_model: 42,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!((no_missile.missile_gate, no_missile.missile_model), (1, 42));
|
||||
// …and a weapon with no missile never plants one.
|
||||
let neither = VisualStages::default().merged_over_weapon(&VisualStages::default());
|
||||
assert_eq!((neither.missile_gate, neither.missile_model), (0, 0));
|
||||
}
|
||||
|
||||
/// The real 5875 tables, end to end: every live hunter shot leaves both body-kit slots empty, so
|
||||
/// the bow's own visual 5 is the only source of a draw/release clip — and the merge lands
|
||||
/// **LoadBow (105) → AttackBow (46)** on each while its impact + missile survive untouched. The
|
||||
/// pin behind bug B153; a data change that filled these rows would make the merge a no-op and
|
||||
/// this test would say so.
|
||||
#[test]
|
||||
fn real_hunter_shots_take_the_bows_load_and_release_clips() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = crate::open_chain(&data).expect("open chain");
|
||||
let cat = load_spell_visual_catalog(&mut chain).expect("load spell visuals");
|
||||
// `ItemDisplayInfo` col 10 for every bow (wow-re `throw-ranged-attack-anim.md`'s table).
|
||||
let bow = *cat.stages(5).expect("the bow's substitute visual 5");
|
||||
assert_eq!(
|
||||
(bow.precast, bow.cast),
|
||||
(7, 164),
|
||||
"visual 5 = LoadBow kit 7 → AttackBow kit 164"
|
||||
);
|
||||
for (spell_visual, own_impact) in [
|
||||
(3179, 276), // Serpent Sting
|
||||
(567, 658), // Multi-Shot
|
||||
(3299, 419), // Arcane Shot
|
||||
(3180, 419), // Aimed / Concussive Shot
|
||||
(3181, 2850), // Viper Sting
|
||||
(3219, 2893), // Scorpid Sting
|
||||
(3300, 419), // Volley
|
||||
] {
|
||||
let own = *cat.stages(spell_visual).expect("a live hunter-shot row");
|
||||
assert_eq!(
|
||||
(own.precast, own.cast),
|
||||
(0, 0),
|
||||
"visual {spell_visual} authors no body kit of its own"
|
||||
);
|
||||
let merged = own.merged_over_weapon(&bow);
|
||||
assert_eq!(
|
||||
(merged.precast, merged.cast),
|
||||
(7, 164),
|
||||
"visual {spell_visual} takes the bow's Load/Attack pair"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.impact, own_impact,
|
||||
"visual {spell_visual} keeps its own impact kit"
|
||||
);
|
||||
assert_eq!(
|
||||
merged.missile_model, own.missile_model,
|
||||
"visual {spell_visual} keeps its own projectile"
|
||||
);
|
||||
}
|
||||
// The kit ids resolve to the AnimationData rows the caster actually plays.
|
||||
assert_eq!(cat.kit(7).and_then(|k| k.anim_id), Some(105), "LoadBow");
|
||||
assert_eq!(cat.kit(164).and_then(|k| k.anim_id), Some(46), "AttackBow");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! **A map with no `Light.dbc` row of its own falls back to record ID 1, not to an invention.**
|
||||
//!
|
||||
//! `Light.dbc` carries rows for 26 map ids; **Deeprun Tram (369) has none** — not even the
|
||||
//! falloff-0 `(0,0,0)` global that maps 0/1 carry. The client handles that in
|
||||
//! `dn_light_array_build 0x6d6170`: the per-map filter (`0x6d61a9 cmp [row+4], mapId`) matches
|
||||
//! nothing, and the tail (`0x6d62b2`–`0x6d62c9`) writes `idMap[1]` — the record whose **ID column**
|
||||
//! is 1, not row-index 1 — into slot 0. `dn_light_select 0x6d2d00` then no-ops (count ≤ 1 ⇒ empty
|
||||
//! blend heap) and the colour table commits that record whole. (wow-re
|
||||
//! `system/lighting/scratch/no-light-row-fallback.md`.)
|
||||
//!
|
||||
//! Row 1 is the Azeroth global → LightParams **12**, whose bands are an ordinary six-key day curve.
|
||||
//! Before this was wired, benilla substituted a hardcoded bright noon (fog `[140,183,234]` at
|
||||
//! 1000 yd) and the Tram's undersea stretch rendered as a daylight scene against the reference's
|
||||
//! deep blue. The numbers below are read straight off the shipped DBC — LightParams 12 → int bands
|
||||
//! 199..216 (sub-0 diffuse, sub-1 ambient, sub-7 fog) and float bands 67..72 (sub-0 fog end in
|
||||
//! inches, sub-1 start fraction) — so they pin the *identity of the record*, which is the part that
|
||||
//! was wrong.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use benilla_formats::{Chain, LightCatalog, Submersion};
|
||||
|
||||
fn vanilla_data_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
|
||||
}
|
||||
|
||||
/// `Map.dbc` id of Deeprun Tram — the WMO-only map with no `Light.dbc` row.
|
||||
const MAP_DEEPRUN_TRAM: u32 = 369;
|
||||
/// Half-minutes; 1440 = noon.
|
||||
const NOON: u32 = 1440;
|
||||
|
||||
#[test]
|
||||
fn deeprun_tram_takes_light_record_1_not_an_invented_default() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = Chain::open(&data).expect("open vanilla patch chain");
|
||||
let cat = LightCatalog::load(&mut chain).expect("load Light/LightParams/*Band");
|
||||
|
||||
// The Tram's own position is irrelevant — nothing covers it, which is the whole point.
|
||||
let tram = cat.sample_blended(
|
||||
MAP_DEEPRUN_TRAM,
|
||||
[25.0, -1256.0, -117.0],
|
||||
NOON,
|
||||
false,
|
||||
Submersion::Dry,
|
||||
false,
|
||||
);
|
||||
|
||||
// LightParams 12 at noon, off the shipped bands.
|
||||
let rgb = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32);
|
||||
assert_eq!(
|
||||
rgb(tram.fog_color),
|
||||
[77, 120, 143],
|
||||
"fog must be LightParams 12's own band, not the old [140,183,234] invention"
|
||||
);
|
||||
assert_eq!(rgb(tram.ambient), [104, 130, 154], "ambient = band sub-1");
|
||||
assert_eq!(rgb(tram.sun_diffuse), [255, 136, 0], "diffuse = band sub-0");
|
||||
assert!(
|
||||
(tram.fog_end - 500.0).abs() < 1.0,
|
||||
"fog end {} should be 18000 inches / 36 = 500 yd (was 1000)",
|
||||
tram.fog_end
|
||||
);
|
||||
assert!(
|
||||
(tram.fog_start_frac - 0.25).abs() < 1e-3,
|
||||
"start fraction {} should be 0.25 (was 0.40)",
|
||||
tram.fog_start_frac
|
||||
);
|
||||
|
||||
// The fallback is a RECORD LOOKUP, not a per-map constant: every map with no row of its own
|
||||
// lands on the same record, regardless of id or sample position. (Deliberately not compared
|
||||
// against map 0 sampled at its origin — local Azeroth spheres cover that point and blend over
|
||||
// the global, so it resolves [62,96,102], not the bare record.)
|
||||
let other_rowless = cat.sample_blended(
|
||||
4242,
|
||||
[9000.0, -1000.0, 60.0],
|
||||
NOON,
|
||||
false,
|
||||
Submersion::Dry,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
rgb(other_rowless.fog_color),
|
||||
rgb(tram.fog_color),
|
||||
"any map with no Light.dbc row takes the same record 1"
|
||||
);
|
||||
|
||||
// And it is a real day curve, not a frozen constant: midnight differs from noon.
|
||||
let midnight = cat.sample_blended(
|
||||
MAP_DEEPRUN_TRAM,
|
||||
[25.0, -1256.0, -117.0],
|
||||
0,
|
||||
false,
|
||||
Submersion::Dry,
|
||||
false,
|
||||
);
|
||||
assert_ne!(
|
||||
rgb(midnight.fog_color),
|
||||
rgb(tram.fog_color),
|
||||
"the fallback runs the ordinary day/night pipeline, so the hours must differ"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scoping guard for the fix: the seven shipped maps that carry positioned rows but **no**
|
||||
/// `(0,0,0)` global are a *different* case, and one the byte finding does not settle (the reference
|
||||
/// seeds the array count at 1 and fills slots 1.. with the positioned rows, leaving slot 0
|
||||
/// unwritten). They must not be swept into the record-1 fallback on the strength of a guess.
|
||||
#[test]
|
||||
fn maps_with_positioned_rows_but_no_global_are_left_alone() {
|
||||
let data = vanilla_data_dir();
|
||||
if !data.is_dir() {
|
||||
eprintln!("skipping: vanilla client not present at {}", data.display());
|
||||
return;
|
||||
}
|
||||
let mut chain = Chain::open(&data).expect("open vanilla patch chain");
|
||||
let cat = LightCatalog::load(&mut chain).expect("load Light/LightParams/*Band");
|
||||
|
||||
// Map 169 (Emerald Dream) carries 4 rows, none of them a global. Sampled far from all of them,
|
||||
// it must NOT come back as record 1's atmosphere.
|
||||
let far = cat.sample_blended(169, [0.0, 0.0, 0.0], NOON, false, Submersion::Dry, false);
|
||||
let tram = cat.sample_blended(
|
||||
MAP_DEEPRUN_TRAM,
|
||||
[25.0, -1256.0, -117.0],
|
||||
NOON,
|
||||
false,
|
||||
Submersion::Dry,
|
||||
false,
|
||||
);
|
||||
let rgb = |c: [f32; 3]| c.map(|v| (v * 255.0).round() as i32);
|
||||
assert_ne!(
|
||||
rgb(far.fog_color),
|
||||
rgb(tram.fog_color),
|
||||
"a map WITH rows must not inherit the zero-match fallback"
|
||||
);
|
||||
}
|
||||
@@ -14,10 +14,10 @@ use crate::messages::{
|
||||
EnvironmentalDamageLog, ExplorationXp, FriendEntry, FriendStatusUpdate, GossipOption,
|
||||
GroupLootInfo, GroupMemberEntry, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo,
|
||||
LootAllPassed, LootItem, LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart,
|
||||
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete,
|
||||
QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems, QuestTemplate,
|
||||
SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask, TradeStatus,
|
||||
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells,
|
||||
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestRequestItems,
|
||||
QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss, TaxiMask,
|
||||
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
};
|
||||
|
||||
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
|
||||
@@ -418,6 +418,18 @@ pub enum SessionEvent {
|
||||
success: bool,
|
||||
reason: Option<u8>,
|
||||
},
|
||||
/// The pet action bar's whole state (`SMSG_PET_SPELLS`, decision 0982) — or its **teardown**,
|
||||
/// which is the same event carrying a zero `pet_guid`. Server-authoritative: this is not a
|
||||
/// delta, it is the bar, and every visible pet-bar change arrives through it.
|
||||
PetSpells(Box<PetSpells>),
|
||||
/// The pet's react/command state alone (`SMSG_PET_MODE`) — a state change with no bar edit
|
||||
/// behind it (the reaction buttons' usual wire).
|
||||
PetMode(PetMode),
|
||||
/// A refused pet order (`SMSG_PET_ACTION_FEEDBACK`): one reason code for the red error line.
|
||||
PetActionFeedback { reason: u8 },
|
||||
/// The pet's cast refusal (`SMSG_PET_CAST_FAILED`) — [`Self::CastResult`]'s vocabulary, but
|
||||
/// the caster is the pet, so it never touches OUR cast state.
|
||||
PetCastFailed { spell_id: u32, reason: Option<u8> },
|
||||
/// An item template's display head (`SMSG_ITEM_QUERY_SINGLE_RESPONSE`, answering our
|
||||
/// `CMSG_ITEM_QUERY_SINGLE`). Keyed by template entry; `None` = the server doesn't know it
|
||||
/// (undiscovered) — cached negative, like an unknown creature entry. Boxed for the same reason
|
||||
|
||||
@@ -75,6 +75,18 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
CastOutcome::Failed { reason } => Some(reason),
|
||||
},
|
||||
}],
|
||||
ServerPacket::PetSpells(spells) => vec![SessionEvent::PetSpells(Box::new(spells))],
|
||||
ServerPacket::PetMode(mode) => vec![SessionEvent::PetMode(mode)],
|
||||
ServerPacket::PetActionFeedback { reason } => {
|
||||
vec![SessionEvent::PetActionFeedback { reason }]
|
||||
}
|
||||
ServerPacket::PetCastFailed { spell_id, outcome } => vec![SessionEvent::PetCastFailed {
|
||||
spell_id,
|
||||
reason: match outcome {
|
||||
CastOutcome::Ok => None,
|
||||
CastOutcome::Failed { reason } => Some(reason),
|
||||
},
|
||||
}],
|
||||
ServerPacket::ItemQueryResponse { entry, info } => {
|
||||
vec![SessionEvent::ItemTemplate { entry, info }]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod opcode;
|
||||
mod opcode_names;
|
||||
mod packet;
|
||||
mod parse;
|
||||
mod pet;
|
||||
mod pose;
|
||||
mod progression;
|
||||
mod quest;
|
||||
@@ -128,6 +129,14 @@ pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, TransportPose};
|
||||
pub use opcode_names::opcode_name;
|
||||
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
|
||||
pub use parse::parse_server;
|
||||
pub use pet::{
|
||||
pet_action, pet_set_action, pet_stop_attack, PetActionEntry, PetMode, PetSpellCooldown,
|
||||
PetSpells, PET_ACTION_SLOTS, PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED,
|
||||
PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON, PET_COMMAND_ATTACK,
|
||||
PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY, PET_COOLDOWN_PERMANENT,
|
||||
PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE, PET_STATE_BAR_DISABLED,
|
||||
PET_TYPE_SPELL_FIRST, PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
|
||||
};
|
||||
pub use pose::{set_sheathed, stand_state_change};
|
||||
pub use progression::{learn_talent, ExplorationXp, LevelUpInfo, XpGain};
|
||||
pub use quest::{
|
||||
|
||||
@@ -124,6 +124,20 @@ pub const SMSG_LEARNED_SPELL: u16 = 0x012B; // 299
|
||||
pub const SMSG_SUPERCEDED_SPELL: u16 = 0x012C; // 300
|
||||
pub const SMSG_CAST_RESULT: u16 = 0x0130; // 304
|
||||
|
||||
// The pet action bar's inbound wire (decision 0982; VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
// 377/378/710/312). Bodies in [`super::pet`].
|
||||
/// The whole pet bar in one body — the ten slots, the react/command state, the pet's spell list
|
||||
/// and its cooldowns. Its **8-byte guid-only form is the teardown** (`Player::RemovePetActionBar`),
|
||||
/// and the only signal that the bar has gone away.
|
||||
pub const SMSG_PET_SPELLS: u16 = 0x0179; // 377
|
||||
/// The state-only refresh: the same four state bytes, with no bar behind them.
|
||||
pub const SMSG_PET_MODE: u16 = 0x017A; // 378
|
||||
/// One reason byte for a refused pet order (the red error line).
|
||||
pub const SMSG_PET_ACTION_FEEDBACK: u16 = 0x02C6; // 710
|
||||
/// The pet's twin of [`SMSG_CAST_RESULT`] — the same `SpellCastResult` vocabulary, its own opcode
|
||||
/// because the caster that failed is the pet, not us.
|
||||
pub const SMSG_PET_CAST_FAILED: u16 = 0x0138; // 312
|
||||
|
||||
/// The client's ack that a server-authored spline (`SMSG_MONSTER_MOVE` to our own guid — Charge,
|
||||
/// knockback, taxi) finished. Body: a `MovementInfo` at the endpoint, the `splineId` being acked,
|
||||
/// and a trailing float the server `read_skip`s (VERIFIED vmangos `Opcodes_1_12_1.h`: 713;
|
||||
@@ -224,6 +238,20 @@ pub const CMSG_CREATURE_QUERY: u16 = 0x0060; // 96
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h:85`: 82. Body in [`super::client::pet_name_query`]. Answered
|
||||
/// by [`SMSG_PET_NAME_QUERY_RESPONSE`].
|
||||
pub const CMSG_PET_NAME_QUERY: u16 = 0x0052; // 82
|
||||
|
||||
// The pet action bar's outbound verbs (decision 0982; VERIFIED vmangos `Opcodes_1_12_1.h`:
|
||||
// 373/372/755/746). Bodies in [`super::pet`].
|
||||
/// One pet bar press: the slot's own packed word, echoed back with a target guid. The server
|
||||
/// re-splits it and dispatches on the type byte — command, reaction, or cast.
|
||||
pub const CMSG_PET_ACTION: u16 = 0x0175; // 373
|
||||
/// Move/swap a pet bar slot (the drag). One or two `(position, packed)` pairs; the server tells
|
||||
/// the two forms apart **by body size alone**.
|
||||
pub const CMSG_PET_SET_ACTION: u16 = 0x0174; // 372
|
||||
/// Ask for a pet spell's autocast bit to be set to a given value (the right-click).
|
||||
pub const CMSG_PET_SPELL_AUTOCAST: u16 = 0x02F3; // 755
|
||||
/// Call the pet off — the Attack button's second press.
|
||||
pub const CMSG_PET_STOP_ATTACK: u16 = 0x02EA; // 746
|
||||
|
||||
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 94 (decision 0236). Body in
|
||||
/// [`super::gameobject::gameobject_query`] — the ask-once GO template lookup, identical shape to
|
||||
/// `CMSG_CREATURE_QUERY`.
|
||||
|
||||
@@ -10,11 +10,11 @@ use super::{
|
||||
FriendStatusUpdate, GameObjectQueryInfo, GossipOption, GroupLootInfo, GroupMemberEntry,
|
||||
InitWorldStates, ItemInfo, ItemPushResult, JumpInfo, LevelUpInfo, LootAllPassed, LootItem,
|
||||
LootRoll, LootRollWon, LootStartRoll, MailListEntry, MirrorTimerStart, MoveMode, Object,
|
||||
PartyMemberStatsInfo, PeriodicAuraLog, QuestComplete, QuestDetails, QuestGiverList,
|
||||
QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate, ResurrectRequestBody,
|
||||
SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog, SpellEnergizeLog, SpellGo,
|
||||
SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus, TradeStatusExtended,
|
||||
TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, QuestComplete, QuestDetails,
|
||||
QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems, QuestTemplate,
|
||||
ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown, SpellDamageLog,
|
||||
SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask, TradeStatus,
|
||||
TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
};
|
||||
|
||||
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
|
||||
@@ -387,6 +387,21 @@ pub enum ServerPacket {
|
||||
spell_id: u32,
|
||||
outcome: CastOutcome,
|
||||
},
|
||||
/// `SMSG_PET_SPELLS` — the pet action bar's whole state, or (with a zero `pet_guid`) its
|
||||
/// teardown (layout in [`super::pet::read_pet_spells`], decision 0982).
|
||||
PetSpells(PetSpells),
|
||||
/// `SMSG_PET_MODE` — the pet's react/command state alone, no bar behind it
|
||||
/// (layout in [`super::pet::read_pet_mode`]).
|
||||
PetMode(PetMode),
|
||||
/// `SMSG_PET_ACTION_FEEDBACK` — one reason byte for a refused pet order.
|
||||
PetActionFeedback {
|
||||
reason: u8,
|
||||
},
|
||||
/// `SMSG_PET_CAST_FAILED` — the pet's cast refusal, in `SMSG_CAST_RESULT`'s vocabulary.
|
||||
PetCastFailed {
|
||||
spell_id: u32,
|
||||
outcome: CastOutcome,
|
||||
},
|
||||
/// `SMSG_ATTACKSTART` — a unit began melee auto-attack (including our own echo).
|
||||
AttackStart {
|
||||
attacker: u64,
|
||||
@@ -429,8 +444,10 @@ pub enum ServerPacket {
|
||||
},
|
||||
/// `SMSG_CANCEL_AUTO_REPEAT` — stop our own ranged auto-repeat visual; self-only, empty body
|
||||
/// (vmangos `WorldPackets::Misc::CancelAutoRepeat`). Consumed by the local cancel funnel
|
||||
/// (`net/apply/spells.rs::cancel_auto_repeat`, decision 0406) — dormant against vmangos,
|
||||
/// which never sends it (zero send sites; the cast-result fail is the live stop path).
|
||||
/// (`net/apply/spells.rs::cancel_auto_repeat`, decision 0406). vmangos DOES send it —
|
||||
/// `SpellCaster::InterruptSpell` → `Player::SendAutoRepeatCancel`, on every player autorepeat
|
||||
/// interrupt, target death included (corrected 2026-08-05; the earlier "zero send sites" note
|
||||
/// here was wrong).
|
||||
CancelAutoRepeat,
|
||||
/// `SMSG_SPELL_COOLDOWN` — server-pushed cooldowns for the player or pet, by caster guid
|
||||
/// (layout + the `cooldown_ms == 0` "use Spell.dbc" fork in [`super::spellbook::read_spell_cooldown`];
|
||||
@@ -1094,6 +1111,10 @@ impl ServerPacket {
|
||||
ServerPacket::LearnedSpell { .. } => "SMSG_LEARNED_SPELL".into(),
|
||||
ServerPacket::SupercededSpell { .. } => "SMSG_SUPERCEDED_SPELL".into(),
|
||||
ServerPacket::CastResult { .. } => "SMSG_CAST_RESULT".into(),
|
||||
ServerPacket::PetSpells(_) => "SMSG_PET_SPELLS".into(),
|
||||
ServerPacket::PetMode(_) => "SMSG_PET_MODE".into(),
|
||||
ServerPacket::PetActionFeedback { .. } => "SMSG_PET_ACTION_FEEDBACK".into(),
|
||||
ServerPacket::PetCastFailed { .. } => "SMSG_PET_CAST_FAILED".into(),
|
||||
ServerPacket::AttackStart { .. } => "SMSG_ATTACKSTART".into(),
|
||||
ServerPacket::AttackStop { .. } => "SMSG_ATTACKSTOP".into(),
|
||||
ServerPacket::AttackerState(_) => "SMSG_ATTACKERSTATEUPDATE".into(),
|
||||
|
||||
@@ -12,9 +12,9 @@ use crate::wire::{
|
||||
|
||||
use super::{
|
||||
action_bar, area_trigger, attack, bank, channel, chat, combat_log, death, duel, gameobject,
|
||||
gossip, group, items, loot, mail, mirror_timer, monster_move, movement, opcode, progression,
|
||||
quest, social, spellbook, spells, taxi, trade, trainer, update_object, vendor, world_state,
|
||||
Character, CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
|
||||
gossip, group, items, loot, mail, mirror_timer, monster_move, movement, opcode, pet,
|
||||
progression, quest, social, spellbook, spells, taxi, trade, trainer, update_object, vendor,
|
||||
world_state, Character, CreatureQueryInfo, MoveMode, ServerPacket, SpeedKind,
|
||||
};
|
||||
|
||||
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
|
||||
@@ -393,6 +393,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
let (spell_id, outcome) = spells::read_cast_result(&mut r)?;
|
||||
ServerPacket::CastResult { spell_id, outcome }
|
||||
}
|
||||
opcode::SMSG_PET_SPELLS => ServerPacket::PetSpells(pet::read_pet_spells(&mut r)?),
|
||||
opcode::SMSG_PET_MODE => ServerPacket::PetMode(pet::read_pet_mode(&mut r)?),
|
||||
opcode::SMSG_PET_ACTION_FEEDBACK => ServerPacket::PetActionFeedback {
|
||||
reason: pet::read_pet_action_feedback(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_PET_CAST_FAILED => {
|
||||
let (spell_id, outcome) = pet::read_pet_cast_failed(&mut r)?;
|
||||
ServerPacket::PetCastFailed { spell_id, outcome }
|
||||
}
|
||||
opcode::SMSG_ATTACKSTART => {
|
||||
let (attacker, victim) = attack::read_attack_start(&mut r)?;
|
||||
ServerPacket::AttackStart { attacker, victim }
|
||||
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Pet messages — the pet action bar's state packet and the client verbs that drive it
|
||||
//! (decisions 0982, 0988). Mirrored on the outbound side by `world::writer::pet`.
|
||||
//!
|
||||
//! The **shape of the system** is worth stating once, because it is neither the action bar's nor
|
||||
//! the one it looks like. The bar's **contents** are the server's: `SMSG_PET_SPELLS` carries all
|
||||
//! ten slots and nothing else does. The bar's **state** — the lit command, the lit reaction, the
|
||||
//! autocast bit, "the pet is attacking" — is the **client's**, applied on the press and never
|
||||
//! confirmed. Not as an optimisation: the server does not answer these packets at all, and the
|
||||
//! real client writes its own state and repaints before the send (wow-re §10.1/§10.2). A client
|
||||
//! that waited would show a bar that never lights.
|
||||
//!
|
||||
//! Compare the player's 120-slot bar, which is client-authoritative and merely *echoed* to the
|
||||
//! server ([`super::action_bar`], decisions 0216 §7/0218 §4): different split, same lesson — know
|
||||
//! which half of a surface each end owns before writing either.
|
||||
//!
|
||||
//! `SMSG_PET_SPELLS` carries the whole bar in one body, and the same body serves a hunter/warlock
|
||||
//! pet, a possessed minion and a charmed creature (vmangos `Player::PetSpellInitialize` /
|
||||
//! `PossessSpellInitialize` / `CharmSpellInitialize`, `Objects/Player.cpp:17519-17672` — three
|
||||
//! builders, one byte layout: the possess builder's `int32 duration` + `uint32 0` occupies exactly
|
||||
//! the pet builder's `uint32 0` + four state bytes). Its **guid-only** form (an 8-byte body
|
||||
//! carrying a zero guid — `Player::RemovePetActionBar`) is the teardown: the bar goes away.
|
||||
|
||||
use std::io::{self, Read};
|
||||
|
||||
use crate::wire::{read_u16_le, read_u32_le, read_u64_le, read_u8};
|
||||
|
||||
/// Pet action-bar slots (vmangos `MAX_UNIT_ACTION_BAR_INDEX` = `ACTION_BAR_INDEX_END(10) -
|
||||
/// ACTION_BAR_INDEX_START(0)`, `Objects/UnitDefines.h:781-787`); the client's own
|
||||
/// `NUM_PET_ACTION_SLOTS` is the same 10 (`PetActionBarFrame.lua:4`).
|
||||
pub const PET_ACTION_SLOTS: usize = 10;
|
||||
|
||||
/// `ActiveStates` — the value the **server** puts in a packed word's top byte (vmangos
|
||||
/// `Objects/UnitDefines.h:724-732`). These are what you BUILD a word from; they are **not** what
|
||||
/// you decode one into (see [`PetActionEntry::kind`], and the note below).
|
||||
///
|
||||
/// A passive spell: shown, never clickable, no autocast.
|
||||
pub const PET_ACT_PASSIVE: u8 = 0x01;
|
||||
/// A castable spell with autocast OFF — `0x01 | ` [`PET_AUTOCAST_ALLOWED`]`>>24`.
|
||||
pub const PET_ACT_DISABLED: u8 = 0x81;
|
||||
/// A castable spell with autocast ON — `0x81 | ` [`PET_AUTOCAST_ON`]`>>24`.
|
||||
pub const PET_ACT_ENABLED: u8 = 0xC1;
|
||||
/// A command token — the action is a [`PET_COMMAND_STAY`]-family value, not a spell id.
|
||||
pub const PET_ACT_COMMAND: u8 = 0x07;
|
||||
/// A reaction token — the action is a [`PET_REACT_PASSIVE`]-family value, not a spell id.
|
||||
pub const PET_ACT_REACTION: u8 = 0x06;
|
||||
|
||||
/// The **client's** slot type, after its own `& 0x3F` mask (wow-re
|
||||
/// `system/ui/scratch/pet-action-bar-api.md` §1, `0x4bdccd`). This is the vocabulary a decoded
|
||||
/// word speaks, and it is deliberately SHORTER than `ActiveStates`': the mask drops bits 30/31,
|
||||
/// which is precisely what collapses [`PET_ACT_PASSIVE`], [`PET_ACT_DISABLED`] and
|
||||
/// [`PET_ACT_ENABLED`] — three server states — onto **one** client type, with the autocast pair
|
||||
/// carried separately as [`PET_AUTOCAST_ALLOWED`]/[`PET_AUTOCAST_ON`].
|
||||
///
|
||||
/// `GetPetActionInfo`'s jump table sends types 1–5 down the spell branch, 6 to reaction, 7 to
|
||||
/// command (§2). The two token constants are numerically the `ACT_` ones, so a `kind()` compare
|
||||
/// against `PET_ACT_COMMAND`/`PET_ACT_REACTION` still reads correctly; the spell family does not,
|
||||
/// which is why [`PetActionEntry::is_spell`] is a range and not a set of equalities.
|
||||
pub const PET_TYPE_SPELL_FIRST: u8 = 1;
|
||||
/// The last type the client's jump table routes to the spell branch.
|
||||
pub const PET_TYPE_SPELL_LAST: u8 = 5;
|
||||
|
||||
/// Bit 31 of a packed word: this slot **may** autocast (wow-re §2.1, `0x4bdd65`). The client
|
||||
/// additionally requires the spell to exist in `Spell.dbc` before reporting it.
|
||||
pub const PET_AUTOCAST_ALLOWED: u32 = 0x8000_0000;
|
||||
/// Bit 30: autocast is currently **running** (wow-re §2.1, `0x4bdda4`). This is the bit
|
||||
/// `TogglePetAutocast` flips in place before it sends (§10.2, `0x4bcbff`).
|
||||
pub const PET_AUTOCAST_ON: u32 = 0x4000_0000;
|
||||
|
||||
/// `CommandStates` (vmangos `UnitDefines.h:755-761`) — the action value carried by a
|
||||
/// [`PET_ACT_COMMAND`] slot, and what `CharmInfo::GetCommandState` reports.
|
||||
pub const PET_COMMAND_STAY: u32 = 0;
|
||||
pub const PET_COMMAND_FOLLOW: u32 = 1;
|
||||
pub const PET_COMMAND_ATTACK: u32 = 2;
|
||||
pub const PET_COMMAND_DISMISS: u32 = 3;
|
||||
|
||||
/// `ReactStates` (vmangos `UnitDefines.h:734-739`) — the action value carried by a
|
||||
/// [`PET_ACT_REACTION`] slot, and what `CharmInfo::GetReactState` reports.
|
||||
pub const PET_REACT_PASSIVE: u32 = 0;
|
||||
pub const PET_REACT_DEFENSIVE: u32 = 1;
|
||||
pub const PET_REACT_AGGRESSIVE: u32 = 2;
|
||||
|
||||
/// The bit that marks the pet's bar **disabled**, in [`PetSpells::state`]'s own dword.
|
||||
///
|
||||
/// Two independent readings meet on this one bit, which is why it is worth stating: vmangos writes
|
||||
/// `pet->IsEnabled() ? 0x0 : 0x8` as the state field's **fourth byte** (`Player.cpp:17536`), and
|
||||
/// the client reads **bit 27** of the whole dword (wow-re §1/§4, `0x4bd08d`). `0x8 << 24` is bit
|
||||
/// 27 — the same bit, reached from both ends.
|
||||
///
|
||||
/// It does two things, both verified in the client: it forces the reaction compare's left side to
|
||||
/// Passive (§2.2) and it fails the usability predicate (§4). A disabled bar still draws.
|
||||
pub const PET_STATE_BAR_DISABLED: u32 = 0x0800_0000;
|
||||
|
||||
/// The pet's `UNIT_FIELD_FLAGS` bits that make its bar unusable — vmangos
|
||||
/// `UNIT_FLAG_STUNNED 0x00040000` / `UNIT_FLAG_CONFUSED 0x00400000` / `UNIT_FLAG_FLEEING
|
||||
/// 0x00800000` (`UnitDefines.h:509-514`), which are exactly bits 18/22/23, the three the client's
|
||||
/// usability predicate tests (wow-re §4 step 6, `0x4bd075`-`0x4bd08b`).
|
||||
///
|
||||
/// i.e. **a stunned, confused or feared pet cannot be ordered** — and note `UNIT_FLAG_POSSESSED`
|
||||
/// (bit 24) is deliberately NOT among them: a possessed unit is exactly the case where the bar
|
||||
/// must still work.
|
||||
pub const PET_UNUSABLE_UNIT_FLAGS: u32 = 0x0004_0000 | 0x0040_0000 | 0x0080_0000;
|
||||
|
||||
/// The permanent-cooldown marker OR-ed into a pet cooldown's **category** duration (vmangos
|
||||
/// `Unit::WritePetSpellsCooldown`, `Objects/Unit.cpp:11270-11271`) — the twin of the player
|
||||
/// initial-spell list's top-bit convention. Strip it before using the number as a duration.
|
||||
pub const PET_COOLDOWN_PERMANENT: u32 = 0x0800_0000;
|
||||
|
||||
/// One packed pet action word, decoded **the way the client decodes it** (wow-re §1, verified at
|
||||
/// `0x4bdcc7`/`0x4bdccd`/`0x4bdce3`):
|
||||
///
|
||||
/// ```text
|
||||
/// kind = (packed >> 24) & 0x3F the 0x3F mask is load-bearing — see below
|
||||
/// action = packed & 0xFFFF SIXTEEN bits, not 24
|
||||
/// bit 31 = autocast allowed bits 16..23 are never read by anything
|
||||
/// bit 30 = autocast on
|
||||
/// ```
|
||||
///
|
||||
/// The mask is the whole reason the server's five `ActiveStates` become the client's three
|
||||
/// branches: `0x01`, `0x81` and `0xC1` all mask to type **1**, and what separated them —
|
||||
/// castability and autocast — is read from bits 31/30 instead. Decoding `>> 24` unmasked (which
|
||||
/// benilla did until this was pinned) happens to work for the exact bytes vmangos sends and
|
||||
/// silently mis-sorts anything else.
|
||||
///
|
||||
/// What `action` *means* is `kind`'s to say: a spell id on the spell branch, a `CommandStates`
|
||||
/// for [`PET_ACT_COMMAND`], a `ReactStates` for [`PET_ACT_REACTION`]. The raw word is kept
|
||||
/// because the client echoes it **verbatim** in a [`pet_action`] body — re-packing from the split
|
||||
/// pair would be a second chance to get it wrong.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PetActionEntry {
|
||||
/// The whole word, exactly as it arrived (what [`pet_action`] echoes).
|
||||
pub packed: u32,
|
||||
}
|
||||
|
||||
impl PetActionEntry {
|
||||
/// Bits 0–15: the spell id, command state or react state.
|
||||
pub fn action(self) -> u32 {
|
||||
self.packed & 0xFFFF
|
||||
}
|
||||
/// The client's slot type — bits 24–29 (see the type doc for why the mask matters).
|
||||
pub fn kind(self) -> u8 {
|
||||
((self.packed >> 24) & 0x3F) as u8
|
||||
}
|
||||
/// Does this slot take the **spell** branch (client types 1–5), rather than the reaction or
|
||||
/// command branch? A type outside 1–7 is neither: it falls into the client's own default arm,
|
||||
/// which we treat as an empty slot rather than reproducing (wow-re §2.5 quirk 1).
|
||||
pub fn is_spell(self) -> bool {
|
||||
(PET_TYPE_SPELL_FIRST..=PET_TYPE_SPELL_LAST).contains(&self.kind())
|
||||
}
|
||||
/// Is the slot EMPTY? **The whole word is zero** — the client tests the dword, not a field
|
||||
/// (`0x4bdcbd test ecx,ecx`).
|
||||
///
|
||||
/// vmangos's own unused middle slots are `(0, ACT_DISABLED)` = `0x8100_0000`, which is *not*
|
||||
/// zero and so takes the spell branch — where spell id 0 has no `Spell.dbc` record, so every
|
||||
/// return including the autocast pair comes back nil anyway (§2.1's no-record sub-path). Two
|
||||
/// routes, one empty-looking button; this is the client's route.
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.packed == 0
|
||||
}
|
||||
/// Bit 31 — this slot may autocast. (The client also requires the spell to resolve in
|
||||
/// `Spell.dbc`; that half belongs to whoever holds the catalog.)
|
||||
pub fn autocast_allowed(self) -> bool {
|
||||
self.packed & PET_AUTOCAST_ALLOWED != 0
|
||||
}
|
||||
/// Bit 30 — autocast is currently running.
|
||||
pub fn autocast_on(self) -> bool {
|
||||
self.packed & PET_AUTOCAST_ON != 0
|
||||
}
|
||||
/// The word with bit 30 set or cleared — exactly `TogglePetAutocast`'s own in-place flip
|
||||
/// (wow-re §10.2, `0x4bcbff`: `if (bit30 clear) v |= 0x40000000 else v &= 0xBFFFFFFF`), which
|
||||
/// the client writes back to the slot **before** sending and which is then what goes on the
|
||||
/// wire.
|
||||
pub fn with_autocast(self, on: bool) -> Self {
|
||||
Self {
|
||||
packed: if on {
|
||||
self.packed | PET_AUTOCAST_ON
|
||||
} else {
|
||||
self.packed & !PET_AUTOCAST_ON
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u32> for PetActionEntry {
|
||||
fn from(packed: u32) -> Self {
|
||||
Self { packed }
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of `SMSG_PET_SPELLS`' trailing cooldown block, decoded. The wire widths are
|
||||
/// [`read_cooldown_block`]'s problem — the two senders disagree about them — so this is the
|
||||
/// decoded shape only.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PetSpellCooldown {
|
||||
pub spell_id: u32,
|
||||
pub category: u16,
|
||||
pub spell_cd_ms: u32,
|
||||
/// The category remainder, possibly carrying [`PET_COOLDOWN_PERMANENT`] in its top bits.
|
||||
pub category_cd_ms: u32,
|
||||
}
|
||||
|
||||
/// `SMSG_PET_SPELLS` in full — the pet action bar's entire state, in one body.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PetSpells {
|
||||
/// The controlled unit (pet, possessed minion or charmed creature). **Zero means the bar is
|
||||
/// gone**: `Player::RemovePetActionBar` sends an 8-byte body holding a zero guid and nothing
|
||||
/// else, and [`read_pet_spells`] answers that with an otherwise-default value.
|
||||
pub pet_guid: u64,
|
||||
/// The charm's remaining duration in ms (`0` for a real pet — only the possess/charm builders
|
||||
/// fill it, from the `SPELL_AURA_MOD_POSSESS`/`MOD_CHARM` aura).
|
||||
pub duration_ms: u32,
|
||||
/// The pet's mode word, **stored verbatim** — react state in byte 0, command state in byte 1,
|
||||
/// [`PET_STATE_BAR_DISABLED`] at bit 27.
|
||||
///
|
||||
/// One dword rather than four bytes because that is what the client keeps (`[0xb71468]`, wow-re
|
||||
/// §1) and because two of its readings depend on the *whole* word rather than a field: the
|
||||
/// reaction compare zeroes itself on bit 27, and the command compare is an **unmasked**
|
||||
/// `state >> 8`, so a set bit 27 puts it out of range of any action and no command can light
|
||||
/// (§2.3). Splitting it into bytes loses both behaviours silently.
|
||||
pub state: u32,
|
||||
/// The ten bar slots, in bar order.
|
||||
pub bar: [PetActionEntry; PET_ACTION_SLOTS],
|
||||
/// The pet's whole known-spell list — the pet spellbook, packed the same way (spell id +
|
||||
/// its current autocast type). Empty for a temporary pet or a charm; only a permanent pet
|
||||
/// gets one (`Pet::IsPermanentPetFor` gates the loop, `Player.cpp:17549`).
|
||||
pub spells: Vec<PetActionEntry>,
|
||||
/// The pet's running cooldowns.
|
||||
pub cooldowns: Vec<PetSpellCooldown>,
|
||||
}
|
||||
|
||||
impl PetSpells {
|
||||
/// Byte 0 of [`Self::state`] — a [`PET_REACT_PASSIVE`]-family value.
|
||||
pub fn react_state(&self) -> u32 {
|
||||
self.state & 0xFF
|
||||
}
|
||||
/// `state >> 8` — a [`PET_COMMAND_STAY`]-family value, **unmasked on purpose**.
|
||||
///
|
||||
/// The client's own command compare is `(state >> 8) == action` with no `& 0xFF`
|
||||
/// (`0x4bdf0f`), so when bit 27 is set this is a number no command state can equal and every
|
||||
/// command button goes dark. Masking here would be "tidier" and would quietly delete that.
|
||||
pub fn command_state(&self) -> u32 {
|
||||
self.state >> 8
|
||||
}
|
||||
/// Bit 27 — the server says the bar is disabled.
|
||||
pub fn bar_disabled(&self) -> bool {
|
||||
self.state & PET_STATE_BAR_DISABLED != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `SMSG_PET_SPELLS` (VERIFIED vmangos `Player::PetSpellInitialize`,
|
||||
/// `Objects/Player.cpp:17519-17561`, with `CharmInfo::BuildActionBar` `Unit.cpp:8782-8786` and
|
||||
/// `Unit::WritePetSpellsCooldown` `Unit.cpp:11245-11280`):
|
||||
///
|
||||
/// ```text
|
||||
/// u64 petGuid
|
||||
/// u32 duration (0 for a pet; the charm/possess remainder otherwise)
|
||||
/// u32 state react | command<<8 | 0<<16 | enabledFlag<<24 — kept VERBATIM
|
||||
/// u32 × 10 the bar, packed
|
||||
/// u8 spellCount then that many u32, packed the same way
|
||||
/// <the cooldown block> see read_cooldown_block — the one field the client and vmangos disagree on
|
||||
/// ```
|
||||
///
|
||||
/// vmangos writes the state as four separate bytes and the client reads one dword
|
||||
/// (`0x4bd9d3 GetInt32` → `[0xb71468]`); those are the same four bytes, and holding the dword is
|
||||
/// what lets bit 27 and the unmasked `>> 8` behave (see [`PetSpells::state`]).
|
||||
///
|
||||
/// The **8-byte teardown** form (`Player::RemovePetActionBar`, `Player.cpp:17672-17677`) is a lone
|
||||
/// zero guid: this returns [`PetSpells::default`] for it, so a caller's only test is
|
||||
/// `pet_guid == 0`. Anything short of a full body after a non-zero guid is a real parse error and
|
||||
/// surfaces as one — a truncated bar is not a bar.
|
||||
pub(super) fn read_pet_spells(r: &mut &[u8]) -> io::Result<PetSpells> {
|
||||
let pet_guid = read_u64_le(r)?;
|
||||
if r.is_empty() {
|
||||
// The teardown. vmangos always writes a zero guid here; carrying whatever arrived keeps
|
||||
// the reader honest, and the `pet_guid == 0` test is the caller's contract either way.
|
||||
return Ok(PetSpells {
|
||||
pet_guid,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let duration_ms = read_u32_le(r)?;
|
||||
let state = read_u32_le(r)?;
|
||||
|
||||
let mut bar = [PetActionEntry::default(); PET_ACTION_SLOTS];
|
||||
for slot in &mut bar {
|
||||
*slot = read_u32_le(r)?.into();
|
||||
}
|
||||
|
||||
let spell_count = read_u8(r)?;
|
||||
let mut spells = Vec::with_capacity(spell_count as usize);
|
||||
for _ in 0..spell_count {
|
||||
spells.push(read_u32_le(r)?.into());
|
||||
}
|
||||
|
||||
let cooldowns = read_cooldown_block(r)?;
|
||||
|
||||
Ok(PetSpells {
|
||||
pet_guid,
|
||||
duration_ms,
|
||||
state,
|
||||
bar,
|
||||
spells,
|
||||
cooldowns,
|
||||
})
|
||||
}
|
||||
|
||||
/// The trailing cooldown block — **the one place the real client and vmangos disagree about this
|
||||
/// packet**, so it is read by measuring rather than by choosing a side.
|
||||
///
|
||||
/// - The **client** reads `u8 count` then 12-byte entries `{u16 spellId, u16 category, u32, u32}`
|
||||
/// (wow-re §8, getter widths verified at `0x4bda58`/`0x4bda6c`/`0x4bda82`; the arbitration is
|
||||
/// §12.6).
|
||||
/// - **vmangos** writes `u16 count` then 14-byte entries `{u32 spellId, u16 category, u32, u32}`
|
||||
/// (`Unit::WritePetSpellsCooldown`, `Objects/Unit.cpp:11225-11258`) — the later-expansion
|
||||
/// layout. A real 1.12 client talking to vmangos mis-parses this block; we are not going to
|
||||
/// reproduce that bug just to match it.
|
||||
///
|
||||
/// The block is the packet's **tail**, so the remaining length settles it outright: `n` client
|
||||
/// entries leave `12n` bytes, `n` vmangos entries leave `1 + 14n` (the `+1` being the u16 count's
|
||||
/// high byte, which the `u8` read did not consume). `12n == 1 + 14m` has no solution in
|
||||
/// non-negative integers with `n == m`, and for `count == 0` both leave nothing to read. So the
|
||||
/// discriminator is exact, not a heuristic — and the day vmangos is fixed, this keeps working.
|
||||
fn read_cooldown_block(r: &mut &[u8]) -> io::Result<Vec<PetSpellCooldown>> {
|
||||
let count = usize::from(read_u8(r)?);
|
||||
if count == 0 {
|
||||
// vmangos leaves its count's high byte behind; the client's form leaves nothing. Either
|
||||
// way there is nothing to read, and trailing slack is not an error.
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let vmangos = r.len() == 1 + 14 * count;
|
||||
if vmangos {
|
||||
let _high_byte = read_u8(r)?;
|
||||
}
|
||||
let mut cooldowns = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let spell_id = if vmangos {
|
||||
read_u32_le(r)?
|
||||
} else {
|
||||
u32::from(read_u16_le(r)?)
|
||||
};
|
||||
cooldowns.push(PetSpellCooldown {
|
||||
spell_id,
|
||||
category: read_u16_le(r)?,
|
||||
spell_cd_ms: read_u32_le(r)?,
|
||||
category_cd_ms: read_u32_le(r)?,
|
||||
});
|
||||
}
|
||||
Ok(cooldowns)
|
||||
}
|
||||
|
||||
/// `SMSG_PET_MODE` — the state-only refresh: the same four bytes `SMSG_PET_SPELLS` carries, with
|
||||
/// no bar behind them. The server sends it when a command or reaction changes without the bar's
|
||||
/// contents moving.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PetMode {
|
||||
pub pet_guid: u64,
|
||||
/// The same verbatim dword [`PetSpells::state`] holds — the client stores it through the very
|
||||
/// same writer (`0x4bc930`) from both packets.
|
||||
pub state: u32,
|
||||
}
|
||||
|
||||
/// Read `SMSG_PET_MODE` — `u64 petGuid, u32 state` (client handler `0x4bdb10`, wow-re §8;
|
||||
/// vmangos's four state bytes at `WorldPackets::Pet::PetMode::AppendBodyTo`,
|
||||
/// `Server/Packets/Pet.cpp:101-108`, are that dword).
|
||||
pub(super) fn read_pet_mode(r: &mut impl Read) -> io::Result<PetMode> {
|
||||
Ok(PetMode {
|
||||
pet_guid: read_u64_le(r)?,
|
||||
state: read_u32_le(r)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `SMSG_PET_ACTION_FEEDBACK` (VERIFIED vmangos `PetActionFeedback::AppendBodyTo`,
|
||||
/// `Server/Packets/Pet.cpp:87-90`): one reason byte. The client turns it into the red error line
|
||||
/// ("Your pet has no path to that location", …).
|
||||
pub(super) fn read_pet_action_feedback(r: &mut impl Read) -> io::Result<u8> {
|
||||
read_u8(r)
|
||||
}
|
||||
|
||||
/// Read `SMSG_PET_CAST_FAILED` (VERIFIED vmangos `PetCastFailed::AppendBodyTo`,
|
||||
/// `Server/Packets/Pet.cpp:127-132`): `u32 spellId, u8 status, u8 reason`. The pet's twin of
|
||||
/// `SMSG_CAST_RESULT` — the same `SpellCastResult` vocabulary, decoded the same way (status `2` =
|
||||
/// fail) so the one message table renders both. vmangos only ever sends the fail status here, but
|
||||
/// reading it the shared way costs nothing and keeps the two decodes from drifting.
|
||||
pub(super) fn read_pet_cast_failed(r: &mut impl Read) -> io::Result<(u32, super::CastOutcome)> {
|
||||
let spell_id = read_u32_le(r)?;
|
||||
let status = read_u8(r)?;
|
||||
let outcome = if status == 2 {
|
||||
super::CastOutcome::Failed {
|
||||
reason: read_u8(r)?,
|
||||
}
|
||||
} else {
|
||||
super::CastOutcome::Ok
|
||||
};
|
||||
Ok((spell_id, outcome))
|
||||
}
|
||||
|
||||
/// Body of `CMSG_PET_ACTION` (VERIFIED vmangos `WorldPackets::Pet::PetAction::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Pet.cpp:9-14`; opcode 373 `Opcodes_1_12_1.h:374`): `u64 petGuid, u32 packedData,
|
||||
/// u64 targetGuid`.
|
||||
///
|
||||
/// **`packed` is the slot's own word, echoed** — the server re-splits it exactly as the client
|
||||
/// packed it (`UNIT_ACTION_BUTTON_ACTION/TYPE` at `PetHandler.cpp:37-38`) and dispatches on the
|
||||
/// type: `ACT_COMMAND` runs `HandlePetCommand`, `ACT_REACTION` sets the react state, and the three
|
||||
/// spell types cast. `target` is `0` when the action needs none.
|
||||
pub fn pet_action(pet_guid: u64, packed: u32, target_guid: u64) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(20);
|
||||
body.extend_from_slice(&pet_guid.to_le_bytes());
|
||||
body.extend_from_slice(&packed.to_le_bytes());
|
||||
body.extend_from_slice(&target_guid.to_le_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_PET_STOP_ATTACK` (VERIFIED vmangos `PetStopAttack::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Pet.cpp:33-36`; opcode 746 `Opcodes_1_12_1.h:747`): the lone `u64` pet guid.
|
||||
/// The Attack button's *second* press — `PetActionBarFrame.lua:258-262` calls `PetStopAttack()`
|
||||
/// instead of `CastPetAction` when the attack is already running.
|
||||
pub fn pet_stop_attack(pet_guid: u64) -> Vec<u8> {
|
||||
pet_guid.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_PET_SET_ACTION` (opcode 372): `u64 petGuid` then one or two
|
||||
/// `(u32 position, u32 packedData)` pairs. The server distinguishes the forms **by body size
|
||||
/// alone** — 24 bytes ⇒ two entries, anything else ⇒ one (`PetSetAction::ReadFromWorldPacket`,
|
||||
/// `Server/Packets/Pet.cpp:52-62`) — so a one-entry body must not be padded.
|
||||
///
|
||||
/// This one opcode carries **both** of the bar's write verbs, which is the thing to know about it:
|
||||
///
|
||||
/// - **the autocast toggle** — one pair, the pressed slot and its word with bit 30 flipped. That
|
||||
/// is `TogglePetAutocast`'s whole send (wow-re §10.2, `0x4bcc1e push 0x174`), and the server
|
||||
/// reads the flip straight out of the type byte: `ACT_ENABLED` ⇒ `ToggleAutocast(true)`,
|
||||
/// `ACT_DISABLED` ⇒ `false` (`HandlePetSetAction`, `PetHandler.cpp`). `CMSG_PET_SPELL_AUTOCAST`
|
||||
/// (0x2F3) is **not** this verb — it belongs to `ToggleSpellAutocast`, the pet *spellbook*
|
||||
/// frame's binding, and indexes the spellbook rather than the bar (wow-re §10.2/§11).
|
||||
/// - **the drag** — one or two pairs, from `0x4bc9a0` (wow-re §10.4): the optional first pair
|
||||
/// relocates whatever occupied the target slot, the mandatory second writes the drop. vmangos
|
||||
/// requires the two-pair form whenever a command/reaction token moves.
|
||||
pub fn pet_set_action(pet_guid: u64, entries: &[(u32, u32)]) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(8 + 8 * entries.len());
|
||||
body.extend_from_slice(&pet_guid.to_le_bytes());
|
||||
for &(position, packed) in entries {
|
||||
body.extend_from_slice(&position.to_le_bytes());
|
||||
body.extend_from_slice(&packed.to_le_bytes());
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A hunter pet's `SMSG_PET_SPELLS`, byte-for-byte as vmangos builds it: the default
|
||||
/// 3 commands / 4 spell slots / 3 reactions bar, one known spell, one running cooldown.
|
||||
fn pet_spells_body() -> Vec<u8> {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&0xF140_0000_0000_002Au64.to_le_bytes()); // pet guid
|
||||
b.extend_from_slice(&0u32.to_le_bytes()); // duration (0 = a real pet)
|
||||
b.push(PET_REACT_DEFENSIVE as u8);
|
||||
b.push(PET_COMMAND_FOLLOW as u8);
|
||||
b.push(0);
|
||||
b.push(0); // enabled
|
||||
// The bar, exactly CharmInfo::InitPetActionBar's default: attack/follow/stay,
|
||||
// four spell slots (one filled with Claw 3010, autocast on), aggressive/def/passive.
|
||||
for packed in [
|
||||
PET_COMMAND_ATTACK | (u32::from(PET_ACT_COMMAND) << 24),
|
||||
PET_COMMAND_FOLLOW | (u32::from(PET_ACT_COMMAND) << 24),
|
||||
PET_COMMAND_STAY | (u32::from(PET_ACT_COMMAND) << 24),
|
||||
3010 | (u32::from(PET_ACT_ENABLED) << 24),
|
||||
u32::from(PET_ACT_DISABLED) << 24, // empty
|
||||
u32::from(PET_ACT_DISABLED) << 24, // empty
|
||||
u32::from(PET_ACT_DISABLED) << 24, // empty
|
||||
PET_REACT_AGGRESSIVE | (u32::from(PET_ACT_REACTION) << 24),
|
||||
PET_REACT_DEFENSIVE | (u32::from(PET_ACT_REACTION) << 24),
|
||||
PET_REACT_PASSIVE | (u32::from(PET_ACT_REACTION) << 24),
|
||||
] {
|
||||
b.extend_from_slice(&packed.to_le_bytes());
|
||||
}
|
||||
b.push(1); // spell count
|
||||
b.extend_from_slice(&(3010u32 | (u32::from(PET_ACT_ENABLED) << 24)).to_le_bytes());
|
||||
// The cooldown block in VMANGOS's layout: u16 count, u32 spell id (14-byte entries).
|
||||
b.extend_from_slice(&1u16.to_le_bytes());
|
||||
b.extend_from_slice(&3010u32.to_le_bytes());
|
||||
b.extend_from_slice(&0u16.to_le_bytes()); // category
|
||||
b.extend_from_slice(&4_500u32.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// The same packet with the cooldown block in the **client's** layout: u8 count, u16 spell id
|
||||
/// (12-byte entries). Both must decode identically — see [`read_cooldown_block`].
|
||||
fn pet_spells_body_client_cooldowns() -> Vec<u8> {
|
||||
let mut b = pet_spells_body();
|
||||
b.truncate(b.len() - 16); // drop vmangos's u16 count + its one 14-byte entry
|
||||
b.push(1); // u8 count
|
||||
b.extend_from_slice(&3010u16.to_le_bytes());
|
||||
b.extend_from_slice(&0u16.to_le_bytes()); // category
|
||||
b.extend_from_slice(&4_500u32.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pet_spells_golden() {
|
||||
let body = pet_spells_body();
|
||||
let mut r = &body[..];
|
||||
let p = read_pet_spells(&mut r).unwrap();
|
||||
assert!(r.is_empty(), "the body is fully consumed");
|
||||
|
||||
assert_eq!(p.pet_guid, 0xF140_0000_0000_002A);
|
||||
assert_eq!(p.duration_ms, 0);
|
||||
assert_eq!(p.react_state(), PET_REACT_DEFENSIVE);
|
||||
assert_eq!(p.command_state(), PET_COMMAND_FOLLOW);
|
||||
assert!(!p.bar_disabled());
|
||||
|
||||
// The three branches, read off the same word shape.
|
||||
assert_eq!(p.bar[0].kind(), PET_ACT_COMMAND);
|
||||
assert_eq!(p.bar[0].action(), PET_COMMAND_ATTACK);
|
||||
assert!(!p.bar[0].is_spell());
|
||||
assert_eq!(p.bar[9].kind(), PET_ACT_REACTION);
|
||||
assert_eq!(p.bar[9].action(), PET_REACT_PASSIVE);
|
||||
|
||||
// The mask at work: the server sent 0xC1, and the client's type is 1 — the autocast pair
|
||||
// having moved out of the type byte into bits 31/30.
|
||||
assert_eq!(p.bar[3].kind(), PET_TYPE_SPELL_FIRST);
|
||||
assert_eq!(p.bar[3].action(), 3010);
|
||||
assert!(p.bar[3].is_spell() && !p.bar[3].is_empty());
|
||||
assert!(p.bar[3].autocast_allowed() && p.bar[3].autocast_on());
|
||||
|
||||
// vmangos's unused middle slot (0, ACT_DISABLED) is NOT the client's empty word — it takes
|
||||
// the spell branch, where spell id 0 resolves to nothing. Same button, different route.
|
||||
assert!(!p.bar[4].is_empty());
|
||||
assert!(p.bar[4].is_spell() && p.bar[4].action() == 0);
|
||||
assert!(
|
||||
PetActionEntry::default().is_empty(),
|
||||
"zero IS the empty word"
|
||||
);
|
||||
|
||||
assert_eq!(p.spells.len(), 1);
|
||||
assert_eq!(p.spells[0].action(), 3010);
|
||||
assert_eq!(
|
||||
p.cooldowns,
|
||||
vec![PetSpellCooldown {
|
||||
spell_id: 3010,
|
||||
category: 0,
|
||||
spell_cd_ms: 4_500,
|
||||
category_cd_ms: 0,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
/// The cooldown block's two layouts decode to the same thing — the client's 12-byte form and
|
||||
/// vmangos's 14-byte one, told apart by the tail length alone.
|
||||
#[test]
|
||||
fn both_cooldown_layouts_decode_identically() {
|
||||
let from_vmangos = read_pet_spells(&mut &pet_spells_body()[..]).unwrap();
|
||||
let body = pet_spells_body_client_cooldowns();
|
||||
let mut r = &body[..];
|
||||
let from_client = read_pet_spells(&mut r).unwrap();
|
||||
assert!(r.is_empty(), "the client-layout body is fully consumed");
|
||||
assert_eq!(from_client.cooldowns, from_vmangos.cooldowns);
|
||||
assert_eq!(from_client, from_vmangos);
|
||||
}
|
||||
|
||||
/// A pet with no cooldowns at all: the client's form ends the packet, vmangos's leaves its
|
||||
/// count's high byte behind. Neither is an error and neither invents an entry.
|
||||
#[test]
|
||||
fn an_empty_cooldown_block_reads_either_way() {
|
||||
assert!(read_cooldown_block(&mut &[0u8][..]).unwrap().is_empty());
|
||||
assert!(read_cooldown_block(&mut &[0u8, 0][..]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// The teardown: an 8-byte body of zero guid ⇒ a default value whose `pet_guid` is 0. This is
|
||||
/// the ONLY signal that the bar has gone away, so it must not read as a parse error.
|
||||
#[test]
|
||||
fn the_guid_only_body_is_the_teardown() {
|
||||
let body = 0u64.to_le_bytes();
|
||||
let mut r = &body[..];
|
||||
let p = read_pet_spells(&mut r).unwrap();
|
||||
assert_eq!(p, PetSpells::default());
|
||||
assert_eq!(p.pet_guid, 0);
|
||||
}
|
||||
|
||||
/// A truncated bar is a parse error, not a half-read bar — the failure mode that would
|
||||
/// otherwise paint 4 real slots and 6 zeroes.
|
||||
#[test]
|
||||
fn a_truncated_body_errors() {
|
||||
let body = pet_spells_body();
|
||||
let mut r = &body[..20];
|
||||
assert!(read_pet_spells(&mut r).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pet_mode_and_the_client_verbs_golden() {
|
||||
// SMSG_PET_MODE: guid + the same state dword, no bar behind it. vmangos's fourth byte 0x8
|
||||
// IS the client's bit 27 — the two ends of the same bit.
|
||||
let mut body = 0x2Au64.to_le_bytes().to_vec();
|
||||
body.extend_from_slice(&[2, 0, 0, 0x8]);
|
||||
let mut r = &body[..];
|
||||
let mode = read_pet_mode(&mut r).unwrap();
|
||||
assert_eq!(mode.pet_guid, 0x2A);
|
||||
assert_eq!(mode.state, 0x0800_0002);
|
||||
assert_eq!(mode.state & PET_STATE_BAR_DISABLED, PET_STATE_BAR_DISABLED);
|
||||
|
||||
// CMSG_PET_ACTION: guid, the slot word VERBATIM, target guid.
|
||||
let packed = 3010 | (u32::from(PET_ACT_ENABLED) << 24);
|
||||
assert_eq!(
|
||||
pet_action(0x2A, packed, 0x99),
|
||||
[
|
||||
0x2Au64.to_le_bytes().to_vec(),
|
||||
packed.to_le_bytes().to_vec(),
|
||||
0x99u64.to_le_bytes().to_vec(),
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
|
||||
// CMSG_PET_STOP_ATTACK: the lone guid.
|
||||
assert_eq!(pet_stop_attack(0x2A), 0x2Au64.to_le_bytes());
|
||||
|
||||
// CMSG_PET_SET_ACTION: the server tells one entry from two BY BODY SIZE, so the one-entry
|
||||
// form is 16 bytes and the two-entry form is exactly 24.
|
||||
assert_eq!(pet_set_action(0x2A, &[(3, packed)]).len(), 16);
|
||||
assert_eq!(pet_set_action(0x2A, &[(3, packed), (4, 0)]).len(), 24);
|
||||
}
|
||||
|
||||
/// The autocast flip touches **bit 30 and nothing else** — the client's own in-place edit, and
|
||||
/// the word it produces is exactly what `CMSG_PET_SET_ACTION` then carries.
|
||||
#[test]
|
||||
fn the_autocast_flip_moves_only_bit_30() {
|
||||
let on = PetActionEntry::from(3010 | (u32::from(PET_ACT_ENABLED) << 24));
|
||||
assert!(on.autocast_allowed() && on.autocast_on());
|
||||
|
||||
let off = on.with_autocast(false);
|
||||
assert_eq!(off.packed, 3010 | (u32::from(PET_ACT_DISABLED) << 24));
|
||||
assert_eq!(off.action(), 3010);
|
||||
assert_eq!(off.kind(), PET_TYPE_SPELL_FIRST, "the TYPE never moves");
|
||||
assert!(off.autocast_allowed() && !off.autocast_on());
|
||||
assert_eq!(off.with_autocast(true), on);
|
||||
|
||||
// A passive spell can be asked to autocast and the word still says it may not — bit 31 is
|
||||
// the server's to grant, and flipping 30 does not forge it.
|
||||
let passive = PetActionEntry::from(3010 | (u32::from(PET_ACT_PASSIVE) << 24));
|
||||
assert!(!passive.with_autocast(true).autocast_allowed());
|
||||
}
|
||||
|
||||
/// The `& 0x3F` mask, stated on its own: the server's five `ActiveStates` land on three client
|
||||
/// branches, and the two dropped bits reappear as the autocast pair.
|
||||
#[test]
|
||||
fn the_type_mask_collapses_the_spell_states() {
|
||||
for act in [PET_ACT_PASSIVE, PET_ACT_DISABLED, PET_ACT_ENABLED] {
|
||||
let e = PetActionEntry::from(3010 | (u32::from(act) << 24));
|
||||
assert_eq!(e.kind(), PET_TYPE_SPELL_FIRST, "{act:#04x} masks to 1");
|
||||
assert!(e.is_spell());
|
||||
}
|
||||
assert_eq!(
|
||||
PetActionEntry::from(u32::from(PET_ACT_COMMAND) << 24).kind(),
|
||||
PET_ACT_COMMAND,
|
||||
"the token types are below the mask, so they survive it unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
PetActionEntry::from(u32::from(PET_ACT_REACTION) << 24).kind(),
|
||||
PET_ACT_REACTION
|
||||
);
|
||||
|
||||
// A type outside 1-7 is the client's default arm; we treat it as nothing at all rather
|
||||
// than reproduce its under-push (wow-re §2.5 quirk 1).
|
||||
let odd = PetActionEntry::from(3010 | (0x33u32 << 24));
|
||||
assert!(!odd.is_spell());
|
||||
assert!(odd.kind() != PET_ACT_COMMAND && odd.kind() != PET_ACT_REACTION);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ const FIELD_GAMEOBJECT_LEVEL: u16 = 22;
|
||||
/// real client turns an idle unit's body to *face* this target (there is no wire facing for a
|
||||
/// stationary re-face — vmangos `SetInFront` is server-only); benilla mirrors that (`face_target`).
|
||||
const FIELD_UNIT_TARGET: u16 = 16;
|
||||
/// `UNIT_FIELD_SUMMON` (idx 8, `OBJECT_END(6) + 0x2`, a 2-field guid, PUBLIC — VERIFIED vmangos
|
||||
/// `UpdateFields_1_12_1.h:42`) — the **inverse** of [`FIELD_UNIT_SUMMONEDBY`] below: the unit this
|
||||
/// one has summoned. On our own descriptor it is the pet, and so the anchor of the `"pet"` unit
|
||||
/// token; the pet action bar reads its own guid off `SMSG_PET_SPELLS` instead, which is why the
|
||||
/// two can legitimately disagree for a beat around a summon (decision 0982).
|
||||
const FIELD_UNIT_SUMMON: u16 = 8;
|
||||
/// `UNIT_FIELD_SUMMONEDBY` (idx 12, `OBJECT_END(6) + 0x6`, a 2-field guid — vmangos
|
||||
/// `UpdateFields_1_12_1.h`) — the summoner of a pet/guardian/totem. With CREATEDBY below, the
|
||||
/// combat-text source-ownership classifier's "owned by me" test (wow-re `0x5efea0`).
|
||||
|
||||
@@ -32,6 +32,12 @@ impl ObjectFields {
|
||||
pub fn unit_channel_spell(&self) -> u32 {
|
||||
self.get_u32(FIELD_UNIT_CHANNEL_SPELL).unwrap_or(0)
|
||||
}
|
||||
/// `UNIT_FIELD_SUMMON` — the guid of the unit this one has summoned, or `None`. On our own
|
||||
/// descriptor that is **our pet**, and so the anchor of the `"pet"` unit token (decision 0982).
|
||||
/// The inverse of [`Self::unit_summoned_by`].
|
||||
pub fn unit_summon(&self) -> Option<u64> {
|
||||
self.get_guid(FIELD_UNIT_SUMMON).filter(|&g| g != 0)
|
||||
}
|
||||
/// `UNIT_FIELD_SUMMONEDBY` — the summoner's guid (pet/guardian/totem), or `None` (absent or
|
||||
/// explicit 0 — an unowned unit).
|
||||
pub fn unit_summoned_by(&self) -> Option<u64> {
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
//! | [`spells`] | casting, the four cancels, and the aura cancel |
|
||||
//! | [`attack`] | the auto-attack toggle: melee start/stop, ranged auto-repeat stop |
|
||||
//! | [`action_bar`] | the one client-authoritative slot write |
|
||||
//! | [`pet`] | the pet bar's four intents: press, autocast, call off, drag |
|
||||
//! | [`progression`] | the talent spend |
|
||||
//! | [`skills`] | the skills pane's abandon |
|
||||
//! | [`pose`] | the client-volunteered body state: sheath, stand, the mounted flourish |
|
||||
@@ -65,6 +66,7 @@ mod lifecycle;
|
||||
mod loot;
|
||||
mod mail;
|
||||
mod names;
|
||||
mod pet;
|
||||
mod pose;
|
||||
mod progression;
|
||||
mod pvp;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! The pet bar's `WorldWriter` sends — mirroring [`crate::messages::pet`] (decisions 0982, 0988).
|
||||
//!
|
||||
//! Three verbs, and the thing to know about them is that **the server answers none of them**.
|
||||
//! Every one is an intent the client has already applied to its own state before sending
|
||||
//! (wow-re §10.1/§10.2: the reaction, command and autocast paths all write `[0xb71468]` or the
|
||||
//! slot word and fire `PET_BAR_UPDATE` *before* the packet leaves). The bar's contents change only
|
||||
//! when a fresh `SMSG_PET_SPELLS` arrives — a summon, a swap, a learned spell — never as a reply
|
||||
//! to one of these.
|
||||
//!
|
||||
//! Every one carries the pet's guid: the server re-checks ownership on each
|
||||
//! (`PetHandler.cpp`'s `GetCharmerOrOwnerGuid` gate) and silently drops anything naming a unit we
|
||||
//! don't control.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::messages::{self, opcode};
|
||||
|
||||
use super::WorldWriter;
|
||||
|
||||
impl WorldWriter {
|
||||
/// Press one pet bar slot (`CMSG_PET_ACTION`, layout in [`messages::pet_action`]) — the one
|
||||
/// verb behind all three slot classes, because the server dispatches on the **type byte inside
|
||||
/// the word we echo**: a command token runs `HandlePetCommand`, a reaction token sets the react
|
||||
/// state, a spell casts. `target` is the unit the action should aim at, or `0`.
|
||||
pub fn pet_action(&mut self, pet_guid: u64, packed: u32, target_guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_PET_ACTION,
|
||||
&messages::pet_action(pet_guid, packed, target_guid),
|
||||
)
|
||||
}
|
||||
|
||||
/// Call the pet off its target (`CMSG_PET_STOP_ATTACK`, layout in
|
||||
/// [`messages::pet_stop_attack`]) — the Attack button's second press.
|
||||
pub fn pet_stop_attack(&mut self, pet_guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_PET_STOP_ATTACK,
|
||||
&messages::pet_stop_attack(pet_guid),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write one or two pet bar slots (`CMSG_PET_SET_ACTION`, layout — and the reason this one
|
||||
/// opcode carries two different verbs — in [`messages::pet_set_action`]).
|
||||
///
|
||||
/// **The autocast toggle is this send**, with one entry: the pressed slot and its word with
|
||||
/// bit 30 flipped. The drag is the same send with one or two. Unlike the player bar's
|
||||
/// [`WorldWriter::set_action_button`], a swap here IS atomic — the server tells the forms
|
||||
/// apart by body size and applies both entries together.
|
||||
pub fn pet_set_action(&mut self, pet_guid: u64, entries: &[(u32, u32)]) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_PET_SET_ACTION,
|
||||
&messages::pet_set_action(pet_guid, entries),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -198,25 +198,7 @@ impl Loader<'_> {
|
||||
) {
|
||||
let color = children_named(region, "Color").next().map(color_of);
|
||||
if is_texture {
|
||||
if let Some(atlas) = region.attr("atlas") {
|
||||
// `atlas="member"` (decision 0950): the Era dialect's texture reference — one
|
||||
// SetAtlas resolves sheet + UV sub-rect (+ nominal size with `useAtlasSize`)
|
||||
// through the app-pushed manifest table; `file=` remains the 1.12 MPQ path.
|
||||
self.call_region(
|
||||
region_wrapper,
|
||||
"SetAtlas",
|
||||
(atlas.to_string(), region.attr_bool("useAtlasSize")),
|
||||
dbg,
|
||||
);
|
||||
if let Some(c) = color {
|
||||
self.call_region(
|
||||
region_wrapper,
|
||||
"SetVertexColor",
|
||||
(c[0], c[1], c[2], c[3]),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
} else if let Some(file) = region.attr("file") {
|
||||
if let Some(file) = region.attr("file") {
|
||||
self.call_region(region_wrapper, "SetTexture", file.to_string(), dbg);
|
||||
if let Some(c) = color {
|
||||
// A texture with both a file and a <Color> is a tint (vertex color).
|
||||
|
||||
@@ -1000,85 +1000,4 @@ mod region_template_tests {
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return Label:GetText()").unwrap(), "hello");
|
||||
}
|
||||
|
||||
/// `atlas=` on a Button/CheckButton state texture and on a Slider `<ThumbTexture>` — the Era
|
||||
/// dialect (0950) extended to the widget texture paths (0957): the slot is created empty and
|
||||
/// resolved through SetAtlas, so the region answers GetAtlas and (with `useAtlasSize`) wears
|
||||
/// the member's nominal size. The byte-verified 1.12 loader tables know only file=/<Color>.
|
||||
#[test]
|
||||
fn state_textures_and_the_thumb_take_the_atlas_attr() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.set_era_atlases([
|
||||
(
|
||||
"checkbox-minimal".to_string(),
|
||||
crate::script::EraAtlasEntry {
|
||||
file: "era:textures/4614134.blp".to_string(),
|
||||
uv: [1.0 / 64.0, 31.0 / 64.0, 1.0 / 64.0, 30.0 / 64.0],
|
||||
size: [30.0, 29.0],
|
||||
},
|
||||
),
|
||||
(
|
||||
"checkmark-minimal".to_string(),
|
||||
crate::script::EraAtlasEntry {
|
||||
file: "era:textures/4614134.blp".to_string(),
|
||||
uv: [1.0 / 64.0, 31.0 / 64.0, 32.0 / 64.0, 61.0 / 64.0],
|
||||
size: [30.0, 29.0],
|
||||
},
|
||||
),
|
||||
(
|
||||
"minimal_sliderbar_button".to_string(),
|
||||
crate::script::EraAtlasEntry {
|
||||
file: "era:textures/4567914.blp".to_string(),
|
||||
uv: [1.0 / 128.0, 21.0 / 128.0, 20.0 / 128.0, 39.0 / 128.0],
|
||||
size: [20.0, 19.0],
|
||||
},
|
||||
),
|
||||
]);
|
||||
let doc = parse(
|
||||
r#"<Ui>
|
||||
<CheckButton name="Box">
|
||||
<Size><AbsDimension x="30" y="29"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
<NormalTexture atlas="checkbox-minimal"/>
|
||||
<CheckedTexture atlas="checkmark-minimal"/>
|
||||
</CheckButton>
|
||||
<Slider name="Bar" orientation="HORIZONTAL" minValue="0" maxValue="1">
|
||||
<Size><AbsDimension x="200" y="19"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||||
<ThumbTexture atlas="minimal_sliderbar_button" useAtlasSize="true"/>
|
||||
</Slider>
|
||||
</Ui>"#,
|
||||
)
|
||||
.expect("valid FrameXML");
|
||||
let report = load(&s, &doc, &no_files);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Box:GetNormalTexture():GetAtlas()")
|
||||
.unwrap(),
|
||||
"checkbox-minimal"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Box:GetCheckedTexture():GetAtlas()")
|
||||
.unwrap(),
|
||||
"checkmark-minimal"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Bar:GetThumbTexture():GetAtlas()")
|
||||
.unwrap(),
|
||||
"minimal_sliderbar_button"
|
||||
);
|
||||
// useAtlasSize rode along — the thumb wears the member's nominal 20×19 (what the
|
||||
// engine's thumb-travel math reads).
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Bar:GetThumbTexture():GetWidth()")
|
||||
.unwrap(),
|
||||
20.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Bar:GetThumbTexture():GetHeight()")
|
||||
.unwrap(),
|
||||
19.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,24 +85,20 @@ impl Loader<'_> {
|
||||
this.call(wrapper, method, file.to_string(), dbg);
|
||||
} else if let Some(c) = children_named(t, "Color").next().map(color_of) {
|
||||
this.call(wrapper, method, (c[0], c[1], c[2], c[3]), dbg);
|
||||
} else if t.attr("atlas").is_some() {
|
||||
// `atlas=` (decision 0950, the Era dialect — same seam as a `<Layers>`
|
||||
// texture's): the byte-verified 1.12 table knows only file=/<Color>, so the
|
||||
// setter first creates the slot empty and SetAtlas below resolves it.
|
||||
} else if t.name().is_some() {
|
||||
// A NAMED state texture with no art of its own — `<NormalTexture
|
||||
// name="$parentIcon">` on the ref's own MacroFrameButtonTemplate, whose art
|
||||
// arrives later through `SetTexture`. The setter is what MATERIALIZES the
|
||||
// slot's region, so without this call the getter below finds nothing and the
|
||||
// name never publishes: `getglobal("MacroButton1Icon")` would be nil and the
|
||||
// window's whole Update would die on it. `""` is the live API's own blank form
|
||||
// (`set_slot_texture`'s empty-string arm), so this creates without painting.
|
||||
this.call(wrapper, method, String::new(), dbg);
|
||||
}
|
||||
// alphaMode / <Size> / <Anchors> / <TexCoords> apply to the region the setter just
|
||||
// created — fetch it back through the matching getter and use the region methods.
|
||||
let getter = method.replacen("Set", "Get", 1);
|
||||
if let Ok(region) = wrapper.call_method::<Table>(getter.as_str(), ()) {
|
||||
if let Some(atlas) = t.attr("atlas") {
|
||||
this.call_region(
|
||||
®ion,
|
||||
"SetAtlas",
|
||||
(atlas.to_string(), t.attr_bool("useAtlasSize")),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
if let Some(mode) = t.attr("alphaMode") {
|
||||
this.call_region(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
@@ -338,28 +334,11 @@ impl Loader<'_> {
|
||||
);
|
||||
} else if let Some(c) = children_named(tt, "Color").next().map(color_of) {
|
||||
self.call(wrapper, "SetThumbTexture", (c[0], c[1], c[2], c[3]), dbg);
|
||||
} else if tt.attr("atlas").is_some() {
|
||||
// `atlas=` — the Era dialect (0950), same posture as the Button state textures
|
||||
// above: create the slot empty, resolve via SetAtlas below.
|
||||
self.call(
|
||||
wrapper,
|
||||
"SetThumbTexture",
|
||||
(String::new(), layer.clone()),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
// alphaMode / <Size> / <Anchors> / <TexCoords> apply to the thumb region the setter just
|
||||
// created — fetch it back through the getter and use the region methods (same shape as a
|
||||
// Button's state textures).
|
||||
if let Ok(region) = wrapper.call_method::<Table>("GetThumbTexture", ()) {
|
||||
if let Some(atlas) = tt.attr("atlas") {
|
||||
self.call_region(
|
||||
®ion,
|
||||
"SetAtlas",
|
||||
(atlas.to_string(), tt.attr_bool("useAtlasSize")),
|
||||
dbg,
|
||||
);
|
||||
}
|
||||
if let Some(mode) = tt.attr("alphaMode") {
|
||||
self.call_region(®ion, "SetBlendMode", mode.to_string(), dbg);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use super::Model;
|
||||
/// pack/unpack included) — this crate is deliberately engine-free (no protocol dependency, see
|
||||
/// the crate doc), so it can't just import `benilla_protocol::messages::ACTION_KIND_*`.
|
||||
pub(crate) const ACTION_KIND_SPELL: u8 = 0x00;
|
||||
pub(crate) const ACTION_KIND_MACRO: u8 = 0x40;
|
||||
pub(crate) const ACTION_KIND_ITEM: u8 = 0x80;
|
||||
|
||||
/// What one action slot displays. The app resolves icons (Spell.dbc × SpellIcon.dbc for a spell,
|
||||
@@ -163,7 +164,7 @@ impl super::UiScript {
|
||||
/// reads truthy. Deliberately NOT Lua's plain truthiness (where `0` is truthy) — the reference
|
||||
/// passes literal `0` from a keybind meaning "never place", which only a numeric-zero check
|
||||
/// reproduces.
|
||||
fn truthy_nonzero(v: &Value) -> bool {
|
||||
pub(super) fn truthy_nonzero(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Nil => false,
|
||||
Value::Boolean(b) => *b,
|
||||
|
||||
@@ -289,7 +289,9 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(other @ (CursorPayload::Spell(_) | CursorPayload::Action(_))) => {
|
||||
Some(
|
||||
other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
|
||||
) => {
|
||||
model.cursor = Some(other);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ pub const EQUIPMENT_BAG: i64 = -100;
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// What the cursor carries — the client's payload-mode global [0xb4d900] as a typed enum
|
||||
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell; our Action arm is the
|
||||
/// client's bar-slot pickup; the money/macro/preview arms stay unbuilt). One transition seam for
|
||||
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell, **8 = macro**; our Action arm
|
||||
/// is the client's bar-slot pickup; the money/preview arms stay unbuilt). One transition seam for
|
||||
/// every surface, so sounds, CURSOR_UPDATE, and lock display can't drift apart per window
|
||||
/// (decision 0216).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -49,6 +49,7 @@ pub enum CursorPayload {
|
||||
Item(CursorItem),
|
||||
Spell(CursorSpell),
|
||||
Action(CursorAction),
|
||||
Macro(CursorMacro),
|
||||
}
|
||||
|
||||
/// The item currently held on the cursor (`PickupContainerItem`/`SplitContainerItem`/
|
||||
@@ -110,6 +111,18 @@ pub struct CursorAction {
|
||||
pub texture: Option<String>,
|
||||
}
|
||||
|
||||
/// A macro payload ([`super::macros`]'s `PickupMacro` produces it) — the client's cursor mode
|
||||
/// **8**, whose payload global is the bare macro id (`[0xb4e2fc]`, wow-re `action-item-slot.md`'s
|
||||
/// payload table, where mode 8 is the ONE non-item/non-spell payload `PlaceAction` accepts).
|
||||
/// Unlike every other arm this one carries no source slot to swap back to: a macro lives in the
|
||||
/// macro table, not in the surface it was dragged from, so a refused place just leaves it held.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CursorMacro {
|
||||
/// The 1-based macro index (1..=18 account, 19..=36 character — [`super::macros`]'s space).
|
||||
pub index: u32,
|
||||
pub texture: Option<String>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// The one transition seam: CURSOR_UPDATE / ITEM_LOCK_CHANGED / DELETE_ITEM_CONFIRM
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -181,7 +194,7 @@ pub(crate) fn clear_cursor(model: &mut Model) {
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, item.bag, item.slot);
|
||||
}
|
||||
Some(CursorPayload::Spell(_) | CursorPayload::Action(_)) => {
|
||||
Some(CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)) => {
|
||||
queue_cursor_update(model);
|
||||
}
|
||||
None => {}
|
||||
@@ -237,7 +250,7 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
|
||||
true
|
||||
}
|
||||
(
|
||||
Some(CursorPayload::Spell(_) | CursorPayload::Action(_)),
|
||||
Some(CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
|
||||
WorldPick::Terrain | WorldPick::Nothing,
|
||||
) => {
|
||||
clear_cursor(model);
|
||||
@@ -443,6 +456,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
)),
|
||||
// The Era `GetCursorInfo` shape for a macro: the kind word + the macro index.
|
||||
Some(CursorPayload::Macro(m)) => Ok((
|
||||
Value::String(lua.create_string("macro")?),
|
||||
Value::Integer(i64::from(m.index)),
|
||||
Value::Nil,
|
||||
Value::Nil,
|
||||
)),
|
||||
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
|
||||
}
|
||||
})?,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
use mlua::Lua;
|
||||
|
||||
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
|
||||
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
|
||||
use crate::script::{ActionSlot, Model};
|
||||
|
||||
use super::{queue_cursor_update, CursorAction, CursorPayload};
|
||||
@@ -114,6 +114,11 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
|
||||
CursorPayload::Action(a) => (a.kind, a.action, a.texture.clone()),
|
||||
CursorPayload::Item(i) => (ACTION_KIND_ITEM, i.item_id, i.texture.clone()),
|
||||
CursorPayload::Spell(s) => (ACTION_KIND_SPELL, s.spell_id, s.texture.clone()),
|
||||
// Mode 8 — the one non-item/non-spell payload the reference's `PlaceAction` accepts
|
||||
// (`action-item-slot.md` §5's payload table: pet actions and class abilities are refused,
|
||||
// macros are not). It packs the bare macro id under the MACRO tag, exactly as the SPELL
|
||||
// and ITEM arms pack theirs.
|
||||
CursorPayload::Macro(m) => (ACTION_KIND_MACRO, m.index, m.texture.clone()),
|
||||
};
|
||||
let displaced = model.actions.insert(
|
||||
id,
|
||||
|
||||
@@ -101,7 +101,9 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
true
|
||||
}
|
||||
Some(other @ (CursorPayload::Spell(_) | CursorPayload::Action(_))) => {
|
||||
Some(
|
||||
other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_)),
|
||||
) => {
|
||||
model.cursor = Some(other);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//! The Era atlas seam (decision 0950): `atlas=` in XML, `SetAtlas`/`GetAtlas` at runtime, and the
|
||||
//! warn-once miss drain — against a hand-pushed table. Disk and manifest.json belong to the app;
|
||||
//! these tests prove the script/loader side alone.
|
||||
|
||||
use crate::framexml;
|
||||
use crate::loader;
|
||||
use crate::script::{EraAtlasEntry, UiScript};
|
||||
|
||||
/// The real `options_innerframe` numbers (fdid 1318750, rect [1,150,887,768] in 1024×1024) so a
|
||||
/// regression here reads like the manifest it mirrors.
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.set_era_atlases([
|
||||
(
|
||||
"options_innerframe".to_string(),
|
||||
EraAtlasEntry {
|
||||
file: "era:textures/1318750.blp".to_string(),
|
||||
uv: [1.0 / 1024.0, 887.0 / 1024.0, 150.0 / 1024.0, 768.0 / 1024.0],
|
||||
size: [886.0, 618.0],
|
||||
},
|
||||
),
|
||||
(
|
||||
"checkbox-minimal".to_string(),
|
||||
EraAtlasEntry {
|
||||
file: "era:textures/4614134.blp".to_string(),
|
||||
uv: [1.0 / 64.0, 31.0 / 64.0, 1.0 / 64.0, 30.0 / 64.0],
|
||||
size: [30.0, 29.0],
|
||||
},
|
||||
),
|
||||
]);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_atlas_attribute_resolves_texture_uv_and_size() {
|
||||
let mut s = harness();
|
||||
let doc = framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="T">
|
||||
<Size><AbsDimension x="920" y="724"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers><Layer level="OVERLAY">
|
||||
<Texture name="$parentInner" atlas="Options_InnerFrame" useAtlasSize="true"/>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let report = loader::load(&s, &doc, &|_| None);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// Case-insensitive resolve; the canonical (lowercase) name is what GetAtlas reports.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TInner:GetAtlas()").unwrap(),
|
||||
"options_innerframe"
|
||||
);
|
||||
let (l, r, t, b): (f32, f32, f32, f32) = s.eval("return TInner:GetTexCoord()").unwrap();
|
||||
assert!((l - 1.0 / 1024.0).abs() < 1e-6 && (r - 887.0 / 1024.0).abs() < 1e-6);
|
||||
assert!((t - 150.0 / 1024.0).abs() < 1e-6 && (b - 768.0 / 1024.0).abs() < 1e-6);
|
||||
// useAtlasSize applied the member's nominal size.
|
||||
assert_eq!(s.eval::<f32>("return TInner:GetWidth()").unwrap(), 886.0);
|
||||
assert_eq!(s.eval::<f32>("return TInner:GetHeight()").unwrap(), 618.0);
|
||||
// Nothing missed.
|
||||
assert!(s.take_era_atlas_misses().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_setatlas_swaps_and_settexture_clears() {
|
||||
let s = harness();
|
||||
let doc = framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="T">
|
||||
<Size><AbsDimension x="100" y="100"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers><Layer>
|
||||
<Texture name="$parentTex" atlas="options_innerframe"/>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let report = loader::load(&s, &doc, &|_| None);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
|
||||
// The category-row idiom: swap atlas at runtime (hover/selected), size riding along.
|
||||
s.run("TTex:SetAtlas(\"checkbox-minimal\", true)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return TTex:GetAtlas()").unwrap(),
|
||||
"checkbox-minimal"
|
||||
);
|
||||
assert_eq!(s.eval::<f32>("return TTex:GetWidth()").unwrap(), 30.0);
|
||||
|
||||
// A plain SetTexture makes the region ordinary again — GetAtlas goes nil.
|
||||
s.run("TTex:SetTexture(\"Interface\\\\Buttons\\\\UI-Panel-Button-Up\")")
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return TTex:GetAtlas() == nil").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_atlas_is_a_warn_once_miss_not_an_error() {
|
||||
let mut s = harness();
|
||||
let doc = framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="T">
|
||||
<Size><AbsDimension x="100" y="100"/></Size>
|
||||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||||
<Layers><Layer>
|
||||
<Texture name="$parentTex" atlas="not-extracted-yet"/>
|
||||
</Layer></Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.unwrap();
|
||||
// The load survives (a stale extraction must not kill the whole XML pass) …
|
||||
let report = loader::load(&s, &doc, &|_| None);
|
||||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||||
// … the region draws nothing …
|
||||
assert!(s.eval::<bool>("return TTex:GetAtlas() == nil").unwrap());
|
||||
// … and the miss names itself exactly once, then the drain clears.
|
||||
s.run("TTex:SetAtlas(\"not-extracted-yet\")").unwrap();
|
||||
assert_eq!(
|
||||
s.take_era_atlas_misses(),
|
||||
vec!["not-extracted-yet".to_string()]
|
||||
);
|
||||
assert!(s.take_era_atlas_misses().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
//! Player **macros** (decision 0983) — the one seam in this crate that owns its game state
|
||||
//! outright, because the 1.12 macro system has no server side at all: there is no macro opcode on
|
||||
//! the wire (vmangos has none, and the client's own `UIMacros.cpp` persists to
|
||||
//! `WTF/…/macros-cache.txt`), so the macro table *is* client state. Contrast [`super::action`],
|
||||
//! whose 120-slot table the app owns because the server hands it back at login.
|
||||
//!
|
||||
//! That ownership is forced by the reference's own API shape, not chosen for convenience:
|
||||
//! `MacroPopupOkayButton_OnClick` does `index = CreateMacro(…)` and immediately selects `index`,
|
||||
//! so the mutation must be synchronous and must return the new slot — a queue-an-intent-and-wait
|
||||
//! seam cannot answer it. The app therefore **seeds** the table once ([`UiScript::set_macros`],
|
||||
//! from `benilla/macros/…`), **reads** it back to persist ([`UiScript::macros`]) whenever
|
||||
//! [`UiScript::take_macros_dirty`] says something moved, and pushes the icon-chooser list
|
||||
//! ([`UiScript::set_macro_icons`]) it builds off `SpellIcon.dbc`.
|
||||
//!
|
||||
//! ## The index space (VERIFIED, the shipped `Blizzard_MacroUI`)
|
||||
//!
|
||||
//! `MAX_MACROS = 18` per tab; `MacroFrame.macroBase` is `0` for the account tab and `18` for the
|
||||
//! character tab, and every binding takes `macroBase + i`. So **1..=18 are the account macros and
|
||||
//! 19..=36 the character macros**, each list dense from its base — `MacroFrame_Update` draws
|
||||
//! `i <= numMacros` and disables the rest, so a gap is not representable. [`MacroIndex`] is that
|
||||
//! split, made once at the boundary.
|
||||
//!
|
||||
//! ## What the engine does NOT know
|
||||
//!
|
||||
//! A macro's **bound spell** — the spell whose cooldown/usability/range a macro action-bar slot
|
||||
//! reports (byte-verified: `0x4e5a50`'s macro arm returns `[rec+0x564]`, wow-re
|
||||
//! `action-spell-icon-apis.md` §2) — is the app's derivation, because resolving a name to a spell
|
||||
//! id needs the catalog and the player's book. The engine stores the body; `benilla::ui_macro`
|
||||
//! parses it. Same split as everywhere else in this crate: data and layout here, game knowledge
|
||||
//! there.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::cursor::{queue_cursor_update, CursorMacro, CursorPayload};
|
||||
use super::Model;
|
||||
|
||||
/// Macros per tab — the shipped `Blizzard_MacroUI.lua`'s own `MAX_MACROS = 18`, and the account
|
||||
/// tab's size *and* the character tab's base (`MacroFrame_SetCharacterMacros` sets
|
||||
/// `macroBase = MAX_MACROS`).
|
||||
pub const MAX_MACROS: usize = 18;
|
||||
|
||||
/// The macro **name** length cap — `MacroPopupEditBox`'s `letters="16"` and its label
|
||||
/// `MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"`. Enforced here as well as in the
|
||||
/// box, because `CreateMacro`/`EditMacro` are callable from any script.
|
||||
pub const MAX_MACRO_NAME: usize = 16;
|
||||
|
||||
/// The macro **body** length cap — `MacroFrameText`'s `letters="255"` and its counter
|
||||
/// `MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"`.
|
||||
pub const MAX_MACRO_BODY: usize = 255;
|
||||
|
||||
/// One macro, as `GetMacroInfo` reports it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MacroView {
|
||||
/// The player's name for it (≤ [`MAX_MACRO_NAME`] characters).
|
||||
pub name: String,
|
||||
/// The chosen icon's **texture path** (`Interface\Icons\Ability_Ambush`). Stored resolved
|
||||
/// rather than as an index into the chooser list: the reference's own save format writes the
|
||||
/// icon by NAME (`MACRO %d "%s" %s`, byte-verified at `0x44cb60`), so a name is what survives
|
||||
/// a restart — an index would silently re-point if the list ever changed.
|
||||
pub texture: Option<String>,
|
||||
/// The macro body: the lines run when the macro is used, `\n`-separated, ≤ [`MAX_MACRO_BODY`].
|
||||
pub body: String,
|
||||
/// `GetMacroInfo`'s fourth return (`isLocal`) and `CreateMacro`/`EditMacro`'s `local`
|
||||
/// argument. The 1.12 client keeps a second file for these (`macros-local.txt` beside
|
||||
/// `macros-cache.txt`, both byte-verified strings at `0x45de74`/`0x45de88`); the shipped macro
|
||||
/// UI never passes the flag, so it is carried faithfully and is otherwise inert.
|
||||
pub local_only: bool,
|
||||
}
|
||||
|
||||
/// The whole macro table: the two dense lists behind the frame's two tabs (module docs).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MacroState {
|
||||
/// Indices 1..=18.
|
||||
pub account: Vec<MacroView>,
|
||||
/// Indices 19..=36.
|
||||
pub character: Vec<MacroView>,
|
||||
}
|
||||
|
||||
/// A 1-based Lua macro index resolved into `(which list, position in it)` — the one place the
|
||||
/// 1..36 space is split, so no binding re-derives the base.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MacroIndex {
|
||||
/// True for 19..=36 (the character tab).
|
||||
per_character: bool,
|
||||
/// 0-based position within that tab's list.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl MacroIndex {
|
||||
/// Split a 1-based Lua index; `None` outside 1..=36. Deliberately NOT range-checked against
|
||||
/// the live lists — callers that need an occupied slot go through [`MacroState::get`].
|
||||
fn split(index: usize) -> Option<Self> {
|
||||
let zero = index.checked_sub(1)?;
|
||||
match zero {
|
||||
0..MAX_MACROS => Some(Self {
|
||||
per_character: false,
|
||||
pos: zero,
|
||||
}),
|
||||
_ if zero < MAX_MACROS * 2 => Some(Self {
|
||||
per_character: true,
|
||||
pos: zero - MAX_MACROS,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based Lua index this position occupies.
|
||||
fn lua_index(self) -> usize {
|
||||
self.pos + 1 + if self.per_character { MAX_MACROS } else { 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl MacroState {
|
||||
/// The macro at a 1-based Lua index, or `None` for an empty/out-of-range slot.
|
||||
pub fn get(&self, index: usize) -> Option<&MacroView> {
|
||||
let at = MacroIndex::split(index)?;
|
||||
self.list(at.per_character).get(at.pos)
|
||||
}
|
||||
|
||||
fn list(&self, per_character: bool) -> &Vec<MacroView> {
|
||||
if per_character {
|
||||
&self.character
|
||||
} else {
|
||||
&self.account
|
||||
}
|
||||
}
|
||||
|
||||
fn list_mut(&mut self, per_character: bool) -> &mut Vec<MacroView> {
|
||||
if per_character {
|
||||
&mut self.character
|
||||
} else {
|
||||
&mut self.account
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetMacroIndexByName`'s search — case-insensitive, account list first (the reference walks
|
||||
/// the one flat 1..36 space, and the account half is its low end). `0` when nothing matches,
|
||||
/// which is the reference's own miss value (a number, never nil — the shipped
|
||||
/// `Usage: GetMacroIndexByName(name)` binding pushes a number).
|
||||
pub fn index_by_name(&self, name: &str) -> usize {
|
||||
for per_character in [false, true] {
|
||||
for (i, m) in self.list(per_character).iter().enumerate() {
|
||||
if m.name.eq_ignore_ascii_case(name) {
|
||||
return MacroIndex {
|
||||
per_character,
|
||||
pos: i,
|
||||
}
|
||||
.lua_index();
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp a player-supplied string to a character (not byte) cap — the EditBox's own
|
||||
/// `max_letters` rule, applied again at the API so a script cannot store what the box refuses.
|
||||
fn clamp_chars(s: &str, max: usize) -> String {
|
||||
s.chars().take(max).collect()
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Seed the whole macro table, replacing whatever was there — the app's load path
|
||||
/// (`benilla/macros/…`). Does **not** mark the table dirty: the app already has what it just
|
||||
/// handed over, and a save triggered by its own load would be a write-back loop.
|
||||
pub fn set_macros(&mut self, state: MacroState) {
|
||||
let mut model = self.model_mut();
|
||||
model.macros = state;
|
||||
model.macros_dirty = false;
|
||||
model.macros_generation += 1;
|
||||
}
|
||||
|
||||
/// Read the live macro table — the app's save path, taken when [`Self::take_macros_dirty`]
|
||||
/// reports a change.
|
||||
pub fn macros(&self) -> MacroState {
|
||||
self.model_mut().macros.clone()
|
||||
}
|
||||
|
||||
/// Did a script mutate the table since the last call (`CreateMacro`/`EditMacro`/`DeleteMacro`)?
|
||||
/// The app persists on a `true` and fires `UPDATE_MACROS` — the reference's own event for
|
||||
/// exactly this transition (byte-verified string at `0x452460`).
|
||||
pub fn take_macros_dirty(&mut self) -> bool {
|
||||
std::mem::take(&mut self.model_mut().macros_dirty)
|
||||
}
|
||||
|
||||
/// Push the icon-chooser list — the full texture paths `GetMacroIconInfo` serves, in the order
|
||||
/// the popup's grid shows them. Built by the app off `SpellIcon.dbc` (`benilla::ui_macro`).
|
||||
pub fn set_macro_icons(&mut self, icons: Vec<String>) {
|
||||
self.model_mut().macro_icons = icons;
|
||||
}
|
||||
|
||||
/// The macro table's **generation** — bumped by every seed and every mutation, so a per-frame
|
||||
/// consumer can gate an expensive re-resolve on a `u64` compare instead of cloning the table
|
||||
/// to diff it. The action bar's identity feed reads it as a third input beside its `dirty`
|
||||
/// flag and the item-template epoch (the same shape decision 0660 gave that one): a macro's
|
||||
/// icon changes when the macro is edited, which touches neither of the other two.
|
||||
///
|
||||
/// Distinct from [`Self::take_macros_dirty`] on purpose — that one is a *drained* edge with
|
||||
/// exactly one owner (the save), and a second consumer draining it would silently eat the
|
||||
/// other's save.
|
||||
pub fn macros_generation(&self) -> u64 {
|
||||
self.model_mut().macros_generation
|
||||
}
|
||||
}
|
||||
|
||||
/// `CreateMacro(name, iconIndex, body, local, perCharacter)` — the shipped binding's own argument
|
||||
/// list (byte-verified usage string at `0x44cb74`). Returns the new macro's 1-based index, or
|
||||
/// `None` on the two failures the client names in its own log lines (`0x44cbb4`/`0x44cbdc`):
|
||||
/// an empty name, and a full tab.
|
||||
fn create_macro(
|
||||
model: &mut Model,
|
||||
name: &str,
|
||||
texture: Option<String>,
|
||||
body: &str,
|
||||
local_only: bool,
|
||||
per_character: bool,
|
||||
) -> Option<usize> {
|
||||
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
|
||||
if name.is_empty() {
|
||||
model
|
||||
.warnings
|
||||
.push("CreateMacro() failed, no name specified".into());
|
||||
return None;
|
||||
}
|
||||
let list = model.macros.list_mut(per_character);
|
||||
if list.len() >= MAX_MACROS {
|
||||
model.warnings.push(format!(
|
||||
"CreateMacro() failed, already have {MAX_MACROS} macros"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
list.push(MacroView {
|
||||
name,
|
||||
texture,
|
||||
body: clamp_chars(body, MAX_MACRO_BODY),
|
||||
local_only,
|
||||
});
|
||||
let pos = list.len() - 1;
|
||||
model.macros_dirty = true;
|
||||
model.macros_generation += 1;
|
||||
Some(MacroIndex { per_character, pos }.lua_index())
|
||||
}
|
||||
|
||||
/// `EditMacro(index, name, icon, body, local)` — every argument is optional past the index, and an
|
||||
/// omitted one leaves that field alone. That is load-bearing, not defensive: the shipped UI calls
|
||||
/// it **twice with disjoint halves** — `EditMacro(sel, name, icon)` from the rename popup and
|
||||
/// `EditMacro(sel, nil, nil, text)` from `MacroFrame_SaveMacro` — so a nil that overwrote would
|
||||
/// blank the body every time the name changed.
|
||||
fn edit_macro(
|
||||
model: &mut Model,
|
||||
index: usize,
|
||||
name: Option<String>,
|
||||
texture: Option<Option<String>>,
|
||||
body: Option<String>,
|
||||
local_only: Option<bool>,
|
||||
) -> Option<usize> {
|
||||
let at = MacroIndex::split(index)?;
|
||||
let entry = model.macros.list_mut(at.per_character).get_mut(at.pos)?;
|
||||
if let Some(name) = name {
|
||||
let name = clamp_chars(name.trim(), MAX_MACRO_NAME);
|
||||
// An explicit blank is refused rather than stored: `MacroPopupOkayButton_Update` already
|
||||
// keeps OKAY disabled on an empty box, so reaching here means a script did it.
|
||||
if !name.is_empty() {
|
||||
entry.name = name;
|
||||
}
|
||||
}
|
||||
if let Some(texture) = texture {
|
||||
entry.texture = texture;
|
||||
}
|
||||
if let Some(body) = body {
|
||||
entry.body = clamp_chars(&body, MAX_MACRO_BODY);
|
||||
}
|
||||
if let Some(local_only) = local_only {
|
||||
entry.local_only = local_only;
|
||||
}
|
||||
model.macros_dirty = true;
|
||||
model.macros_generation += 1;
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// `DeleteMacro(index)` — removes the slot and **closes the gap**, which is why every action-bar
|
||||
/// slot holding a higher macro index would now point at the wrong macro. The reference has the
|
||||
/// same property (its own list is dense, and `MacroFrame_Update` draws it densely); the app's
|
||||
/// action feed re-resolves against the shifted table, so a bar button follows the slot, not the
|
||||
/// macro. Named in decision 0983 as faithful-and-surprising rather than fixed.
|
||||
fn delete_macro(model: &mut Model, index: usize) -> bool {
|
||||
let Some(at) = MacroIndex::split(index) else {
|
||||
return false;
|
||||
};
|
||||
let list = model.macros.list_mut(at.per_character);
|
||||
if at.pos >= list.len() {
|
||||
return false;
|
||||
}
|
||||
list.remove(at.pos);
|
||||
model.macros_dirty = true;
|
||||
model.macros_generation += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// `PickupMacro(index)` — the macro button's `OnDragStart` and the selected-macro button's
|
||||
/// `OnClick` (both in the shipped `Blizzard_MacroUI.xml`). Loads the cursor with the macro
|
||||
/// payload (the client's mode **8**, `[0xb4e2fc]` — wow-re `cursor-dragdrop-payload.md` §1), which
|
||||
/// `PlaceAction` then packs as `macroId | 0x40000000` (`action-item-slot.md`'s payload table).
|
||||
///
|
||||
/// Refuses while the cursor already holds something, matching `PickupSpell`'s precedent: a macro
|
||||
/// button is a SOURCE, never a fit-checked drop target, so silently discarding the held payload
|
||||
/// would be worse than doing nothing.
|
||||
fn pickup_macro(model: &mut Model, index: usize) -> bool {
|
||||
if model.cursor.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(entry) = model.macros.get(index) else {
|
||||
return false;
|
||||
};
|
||||
let payload = CursorMacro {
|
||||
index: index as u32,
|
||||
texture: entry.texture.clone(),
|
||||
};
|
||||
model.cursor = Some(CursorPayload::Macro(payload));
|
||||
queue_cursor_update(model);
|
||||
true
|
||||
}
|
||||
|
||||
/// Coerce a Lua argument that the shipped UI passes as either a real value or `nil`/`false`.
|
||||
fn opt_string(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::String(s)) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference's own truthiness for the `local`/`perCharacter` flags: `MacroPopupOkayButton_OnClick`
|
||||
/// passes the Lua boolean `(MacroFrame.macroBase > 0)`, and the shipped `local` argument is always
|
||||
/// `nil`. `0` reads falsy here (the numeric convention `UseAction`'s `checkCursor` established —
|
||||
/// `super::action::truthy_nonzero`'s law, shared so the two cannot drift).
|
||||
fn flag(v: Option<&Value>) -> bool {
|
||||
v.is_some_and(super::action::truthy_nonzero)
|
||||
}
|
||||
|
||||
/// Register the macro globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetNumMacros() -> numAccountMacros, numCharacterMacros.
|
||||
g.set(
|
||||
"GetNumMacros",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok((
|
||||
model.macros.account.len() as i64,
|
||||
model.macros.character.len() as i64,
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMacroInfo(index) -> name, texture, body, isLocal. An empty/out-of-range slot answers a
|
||||
// single nil (the out-of-range shape every list binding in this crate uses).
|
||||
g.set(
|
||||
"GetMacroInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(m) = model.macros.get(index) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
};
|
||||
let texture = match &m.texture {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
Value::String(lua.create_string(&m.name)?),
|
||||
texture,
|
||||
Value::String(lua.create_string(&m.body)?),
|
||||
if m.local_only {
|
||||
Value::Integer(1)
|
||||
} else {
|
||||
Value::Nil
|
||||
},
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMacroIndexByName(name) -> index (0 = no match).
|
||||
g.set(
|
||||
"GetMacroIndexByName",
|
||||
lua.create_function(|lua, name: String| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.macros.index_by_name(&name) as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetNumMacroIcons() -> the chooser list's length.
|
||||
g.set(
|
||||
"GetNumMacroIcons",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(model.macro_icons.len() as i64)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMacroIconInfo(index) -> texture path; 1-based, out of range -> nil.
|
||||
g.set(
|
||||
"GetMacroIconInfo",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
let path = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
index
|
||||
.checked_sub(1)
|
||||
.and_then(|i| model.macro_icons.get(i))
|
||||
.cloned()
|
||||
};
|
||||
match path {
|
||||
Some(p) => Ok(Value::String(lua.create_string(&p)?)),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CreateMacro(name, iconIndex, body, local, perCharacter) -> index | nil.
|
||||
// `iconIndex` is an index into the chooser list (the shipped popup passes
|
||||
// `MacroPopupFrame.selectedIcon`), resolved to a path HERE so the stored macro keeps a name
|
||||
// that survives a restart.
|
||||
g.set(
|
||||
"CreateMacro",
|
||||
lua.create_function(|lua, args: MultiValue| {
|
||||
let a: Vec<Value> = args.into_iter().collect();
|
||||
let name = opt_string(a.first()).unwrap_or_default();
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let texture = icon_path(&model, a.get(1));
|
||||
let body = opt_string(a.get(2)).unwrap_or_default();
|
||||
let (local_only, per_character) = (flag(a.get(3)), flag(a.get(4)));
|
||||
Ok(
|
||||
match create_macro(&mut model, &name, texture, &body, local_only, per_character) {
|
||||
Some(i) => Value::Integer(i as i64),
|
||||
None => Value::Nil,
|
||||
},
|
||||
)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// EditMacro(index, name, icon, body, local) -> index | nil. Absent arguments leave their
|
||||
// field alone (see `edit_macro` — the shipped UI relies on it).
|
||||
g.set(
|
||||
"EditMacro",
|
||||
lua.create_function(|lua, args: MultiValue| {
|
||||
let a: Vec<Value> = args.into_iter().collect();
|
||||
let Some(index) = a.first().and_then(|v| v.as_integer()).map(|i| i as usize) else {
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// An icon argument that is present-and-resolvable replaces; an absent/nil one leaves
|
||||
// the icon alone. (`MacroFrame_SaveMacro` passes nil here on every body-only save.)
|
||||
let texture = match a.get(2) {
|
||||
None | Some(Value::Nil) => None,
|
||||
other => Some(icon_path(&model, other)),
|
||||
};
|
||||
let name = opt_string(a.get(1));
|
||||
let body = opt_string(a.get(3));
|
||||
let local_only = match a.get(4) {
|
||||
None | Some(Value::Nil) => None,
|
||||
other => Some(flag(other)),
|
||||
};
|
||||
Ok(
|
||||
match edit_macro(&mut model, index, name, texture, body, local_only) {
|
||||
Some(i) => Value::Integer(i as i64),
|
||||
None => Value::Nil,
|
||||
},
|
||||
)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"DeleteMacro",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
Ok(delete_macro(&mut model, index))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"PickupMacro",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
Ok(pickup_macro(&mut model, index))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a chooser-list **index** argument to a texture path. A string is taken as a path
|
||||
/// already (nothing shipped does that, but a script may, and the store holds paths anyway).
|
||||
fn icon_path(model: &Model, v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::String(s)) => Some(s.to_string_lossy()),
|
||||
Some(v) => {
|
||||
let i = v.as_integer()?;
|
||||
let i = usize::try_from(i).ok()?.checked_sub(1)?;
|
||||
model.macro_icons.get(i).cloned()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn seeded() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_macro_icons(vec![
|
||||
"Interface\\Icons\\Ability_Ambush".into(),
|
||||
"Interface\\Icons\\Ability_BackStab".into(),
|
||||
"Interface\\Icons\\Spell_Fire_FlameBolt".into(),
|
||||
]);
|
||||
s
|
||||
}
|
||||
|
||||
/// The whole create → read → edit → delete round trip through the reference's own signatures,
|
||||
/// including the index space's account/character split.
|
||||
#[test]
|
||||
fn the_macro_api_round_trip_over_the_two_tabs() {
|
||||
let s = seeded();
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(0, 0)
|
||||
);
|
||||
|
||||
// Account tab: perCharacter falsy -> index 1.
|
||||
let i = s
|
||||
.eval::<i64>(r#"return CreateMacro("Ambush", 1, "/cast Ambush", nil, nil)"#)
|
||||
.unwrap();
|
||||
assert_eq!(i, 1);
|
||||
// Character tab: perCharacter truthy -> the 19 base.
|
||||
let j = s
|
||||
.eval::<i64>(r#"return CreateMacro("Bolt", 3, "/cast Fireball", nil, true)"#)
|
||||
.unwrap();
|
||||
assert_eq!(j, 19, "the character tab starts at MAX_MACROS + 1");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(1, 1)
|
||||
);
|
||||
|
||||
// GetMacroInfo's four returns, with the icon index resolved to a path at create time.
|
||||
let (name, tex, body, is_local) = s
|
||||
.eval::<(String, String, String, Value)>(
|
||||
"local n, t, b, l = GetMacroInfo(1) return n, t, b, l",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(name.as_str(), tex.as_str(), body.as_str()),
|
||||
("Ambush", "Interface\\Icons\\Ability_Ambush", "/cast Ambush")
|
||||
);
|
||||
assert_eq!(is_local, Value::Nil);
|
||||
|
||||
// By-name is case-insensitive and searches the account list first; a miss is 0.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetMacroIndexByName("ambush")"#)
|
||||
.unwrap(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetMacroIndexByName("bolt")"#)
|
||||
.unwrap(),
|
||||
19
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return GetMacroIndexByName("nope")"#)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
// Delete closes the gap: the character macro keeps its own base.
|
||||
assert!(s.eval::<bool>("return DeleteMacro(1)").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(0, 1)
|
||||
);
|
||||
assert!(s.eval::<bool>("return GetMacroInfo(1) == nil").unwrap());
|
||||
assert_eq!(s.eval::<String>("return GetMacroInfo(19)").unwrap(), "Bolt");
|
||||
}
|
||||
|
||||
/// `EditMacro`'s partial-update law — the shipped UI's two disjoint calls. A rename must not
|
||||
/// blank the body, and a body save must not blank the name or icon.
|
||||
#[test]
|
||||
fn edit_macro_leaves_omitted_fields_alone() {
|
||||
let s = seeded();
|
||||
s.run(r#"CreateMacro("Old", 1, "/cast Ambush")"#).unwrap();
|
||||
|
||||
// MacroPopupOkayButton_OnClick's edit form: name + icon, no body.
|
||||
s.run(r#"EditMacro(1, "New", 2)"#).unwrap();
|
||||
let (name, tex, body) = s
|
||||
.eval::<(String, String, String)>("local n, t, b = GetMacroInfo(1) return n, t, b")
|
||||
.unwrap();
|
||||
assert_eq!(name, "New");
|
||||
assert_eq!(tex, "Interface\\Icons\\Ability_BackStab");
|
||||
assert_eq!(body, "/cast Ambush", "the body survives a rename");
|
||||
|
||||
// MacroFrame_SaveMacro's form: body only, name and icon nil.
|
||||
s.run(r#"EditMacro(1, nil, nil, "/cast Backstab")"#)
|
||||
.unwrap();
|
||||
let (name, tex, body) = s
|
||||
.eval::<(String, String, String)>("local n, t, b = GetMacroInfo(1) return n, t, b")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(name.as_str(), tex.as_str(), body.as_str()),
|
||||
(
|
||||
"New",
|
||||
"Interface\\Icons\\Ability_BackStab",
|
||||
"/cast Backstab"
|
||||
),
|
||||
"a body save touches neither the name nor the icon"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two refusals the client names in its own log lines, and the caps the edit boxes carry.
|
||||
#[test]
|
||||
fn create_refuses_a_blank_name_and_a_full_tab_and_clamps_the_caps() {
|
||||
let s = seeded();
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return CreateMacro("", 1, "x") == nil"#)
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return CreateMacro(" ", 1, "x") == nil"#)
|
||||
.unwrap());
|
||||
|
||||
for i in 1..=MAX_MACROS {
|
||||
let made = s
|
||||
.eval::<i64>(&format!(r#"return CreateMacro("m{i}", 1, "")"#))
|
||||
.unwrap();
|
||||
assert_eq!(made as usize, i);
|
||||
}
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return CreateMacro("one too many", 1, "") == nil"#)
|
||||
.unwrap(),
|
||||
"the 19th account macro is refused"
|
||||
);
|
||||
// …and the character tab is a separate 18.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(r#"return CreateMacro("c", 1, "", nil, 1)"#)
|
||||
.unwrap(),
|
||||
19
|
||||
);
|
||||
|
||||
// The caps are the shipped boxes' own letters= values, enforced at the API too.
|
||||
let s = seeded();
|
||||
s.run(&format!(
|
||||
r#"CreateMacro("{}", 1, "{}")"#,
|
||||
"N".repeat(40),
|
||||
"b".repeat(400)
|
||||
))
|
||||
.unwrap();
|
||||
let (name, body) = s
|
||||
.eval::<(String, String)>("local n, _, b = GetMacroInfo(1) return n, b")
|
||||
.unwrap();
|
||||
assert_eq!(name.chars().count(), MAX_MACRO_NAME);
|
||||
assert_eq!(body.chars().count(), MAX_MACRO_BODY);
|
||||
}
|
||||
|
||||
/// The dirty flag is the app's save trigger: every mutation raises it, a seed never does, and
|
||||
/// the drain clears it.
|
||||
#[test]
|
||||
fn mutations_raise_the_dirty_flag_and_a_seed_does_not() {
|
||||
let mut s = seeded();
|
||||
assert!(!s.take_macros_dirty());
|
||||
|
||||
s.run(r#"CreateMacro("a", 1, "")"#).unwrap();
|
||||
assert!(s.take_macros_dirty());
|
||||
assert!(!s.take_macros_dirty(), "the drain clears it");
|
||||
|
||||
s.run(r#"EditMacro(1, nil, nil, "/say hi")"#).unwrap();
|
||||
assert!(s.take_macros_dirty());
|
||||
|
||||
s.run("DeleteMacro(1)").unwrap();
|
||||
assert!(s.take_macros_dirty());
|
||||
|
||||
// A no-op delete changes nothing and raises nothing.
|
||||
s.run("DeleteMacro(7)").unwrap();
|
||||
assert!(!s.take_macros_dirty());
|
||||
|
||||
// The app's own seed must not trigger a save (a write-back loop).
|
||||
s.set_macros(MacroState {
|
||||
account: vec![MacroView {
|
||||
name: "loaded".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
character: Vec::new(),
|
||||
});
|
||||
assert!(!s.take_macros_dirty());
|
||||
assert_eq!(s.macros().account.len(), 1);
|
||||
}
|
||||
|
||||
/// `PickupMacro` loads the cursor with the macro payload, and refuses while holding.
|
||||
#[test]
|
||||
fn pickup_macro_loads_the_cursor_and_refuses_while_holding() {
|
||||
use crate::script::cursor::CursorPayload;
|
||||
|
||||
let s = seeded();
|
||||
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
!s.eval::<bool>("return PickupMacro(5)").unwrap(),
|
||||
"empty slot"
|
||||
);
|
||||
assert!(s.eval::<bool>("return PickupMacro(1)").unwrap());
|
||||
assert_eq!(
|
||||
s.cursor_payload(),
|
||||
Some(CursorPayload::Macro(CursorMacro {
|
||||
index: 1,
|
||||
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
|
||||
}))
|
||||
);
|
||||
// GetCursorInfo's Era shape for a macro: the kind word + the index.
|
||||
assert_eq!(
|
||||
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
|
||||
.unwrap(),
|
||||
("macro".to_string(), 1)
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return PickupMacro(1)").unwrap(),
|
||||
"a macro button is a source, never a drop target"
|
||||
);
|
||||
}
|
||||
|
||||
/// The chooser list is the app's, served 1-based, and an out-of-range index is nil rather
|
||||
/// than an error (`MacroPopupFrame_Update` indexes past the end on the last row every time).
|
||||
#[test]
|
||||
fn the_icon_chooser_list_is_one_based_with_a_nil_tail() {
|
||||
let s = seeded();
|
||||
assert_eq!(s.eval::<i64>("return GetNumMacroIcons()").unwrap(), 3);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMacroIconInfo(2)").unwrap(),
|
||||
"Interface\\Icons\\Ability_BackStab"
|
||||
);
|
||||
assert!(s.eval::<bool>("return GetMacroIconInfo(4) == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return GetMacroIconInfo(0) == nil").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -52,8 +52,6 @@ mod editbox;
|
||||
pub(crate) use editbox::adopt_text_region;
|
||||
mod cvars;
|
||||
mod duel;
|
||||
#[cfg(test)]
|
||||
mod era_atlas_tests;
|
||||
mod event;
|
||||
mod extract;
|
||||
mod follow;
|
||||
@@ -64,6 +62,7 @@ mod item_text;
|
||||
mod layout;
|
||||
mod loot;
|
||||
mod loot_roll;
|
||||
mod macros;
|
||||
mod mail;
|
||||
mod merchant;
|
||||
mod messageframe;
|
||||
@@ -72,6 +71,7 @@ mod model;
|
||||
mod net_stats;
|
||||
mod object;
|
||||
mod party;
|
||||
mod pet;
|
||||
mod pointer;
|
||||
mod pvp;
|
||||
mod quest;
|
||||
@@ -113,7 +113,8 @@ pub use char_stats::{
|
||||
pub use container::{ContainerMove, ContainerSlot, ContainerState, EnchantView, UiCursorMode};
|
||||
pub use craft::{CraftReagent, CraftRecipe, CraftState};
|
||||
pub use cursor::{
|
||||
CursorAction, CursorItem, CursorPayload, CursorSpell, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
|
||||
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorSpell, EnchantConfirm, WorldPick,
|
||||
EQUIPMENT_BAG,
|
||||
};
|
||||
pub use death::{DeathAction, DeathUiState};
|
||||
pub use duel::DuelRequest;
|
||||
@@ -124,10 +125,12 @@ pub use item_stats::{item_usable, ItemSetView, ItemTemplateView, PlayerReqState}
|
||||
pub use item_text::ItemTextState;
|
||||
pub use loot::{LootRow, LootState};
|
||||
pub use loot_roll::{LootRollEntry, LootRollsState};
|
||||
pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NAME};
|
||||
pub use mail::{MailInboxRow, MailSendRequest, MailState};
|
||||
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
|
||||
pub(crate) use model::Model;
|
||||
pub use party::{PartyMemberInfo, PartyRequest, PartyState};
|
||||
pub use pet::PetActionView;
|
||||
pub use quest::{QuestAction, QuestItemView, QuestPanel, QuestSelect, QuestState};
|
||||
pub use quest_log::{QuestLogDetail, QuestLogEntryView, QuestLogObjectiveView, QuestLogState};
|
||||
pub use session::SessionRequest;
|
||||
@@ -135,7 +138,7 @@ pub use shapeshift::ShapeshiftFormView;
|
||||
pub use skills::{SkillEntry, SkillsState};
|
||||
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
|
||||
pub use sound::SoundRequest;
|
||||
pub use spellbook::{SpellBookState, SpellSlotView, SpellTabView};
|
||||
pub use spellbook::{resolve_spell_by_name, SpellBookState, SpellSlotView, SpellTabView};
|
||||
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
|
||||
pub use taxi::{TaxiNodeType, TaxiUiNode, TaxiUiState};
|
||||
pub use tooltip_spell::SpellTooltipView;
|
||||
@@ -146,9 +149,9 @@ pub use trainer::{
|
||||
TrainerState,
|
||||
};
|
||||
pub use types::{
|
||||
EditAction, EditBoxTextUi, EditOutcome, EditUnit, EraAtlasEntry, ExtractedQuad, FontObject,
|
||||
FontShadow, JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent,
|
||||
ScriptValue, TexCoords,
|
||||
EditAction, EditBoxTextUi, EditOutcome, EditUnit, ExtractedQuad, FontObject, FontShadow,
|
||||
JustifyH, JustifyV, LineMeasureRequest, MeasureRequest, Outline, QuadContent, ScriptValue,
|
||||
TexCoords,
|
||||
};
|
||||
pub(crate) use types::{MeasuredText, RegionData};
|
||||
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
|
||||
@@ -276,8 +279,10 @@ impl UiScript {
|
||||
container::install(&lua)?;
|
||||
cursor::install(&lua)?;
|
||||
spellbook::install(&lua)?;
|
||||
macros::install(&lua)?;
|
||||
talent::install(&lua)?;
|
||||
shapeshift::install(&lua)?;
|
||||
pet::install(&lua)?;
|
||||
gossip::install(&lua)?;
|
||||
merchant::install(&lua)?;
|
||||
bank::install(&lua)?;
|
||||
@@ -333,26 +338,6 @@ impl UiScript {
|
||||
}
|
||||
|
||||
/// Replace the Era atlas table (decision 0950) — pushed once at boot, before the XML loads.
|
||||
/// Keys are stored lowercased (the DB2 dump's own convention); `SetAtlas`/`atlas=` match
|
||||
/// case-insensitively. The app builds the entries from `WoW-era/_extracted_ui/manifest.json`;
|
||||
/// the script side only stores resolved paint, never touching disk.
|
||||
pub fn set_era_atlases(&mut self, entries: impl IntoIterator<Item = (String, EraAtlasEntry)>) {
|
||||
let mut model = self.model_mut();
|
||||
model.era_atlas = entries
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_ascii_lowercase(), v))
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Drain the atlas names `SetAtlas` failed to resolve (each reported once, sorted) — the app
|
||||
/// logs them as WARNs pointing at `scripts/era-extract.py`: a stale extraction must name
|
||||
/// itself, never silently draw blank.
|
||||
pub fn take_era_atlas_misses(&mut self) -> Vec<String> {
|
||||
let mut misses: Vec<String> = self.model_mut().era_atlas_missing.drain().collect();
|
||||
misses.sort();
|
||||
misses
|
||||
}
|
||||
|
||||
/// Push the modifier-key state (shift, ctrl, alt) behind `IsShiftKeyDown`/`IsControlKeyDown`/
|
||||
/// `IsAltKeyDown`. The app's input pass calls this BEFORE feeding the frame's mouse events, so
|
||||
/// a click handler's modifier fork (the reference's shift-split / ctrl-dressup /
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
|
||||
|
||||
use super::{
|
||||
backdrop, bank, char_stats, container, craft, cursor, death, duel, follow, gossip, inspect,
|
||||
item_text, loot, loot_roll, mail, merchant, party, quest, quest_log, session, skills, slider,
|
||||
social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, EraAtlasEntry,
|
||||
FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
item_text, loot, loot_roll, macros, mail, merchant, party, quest, quest_log, session, skills,
|
||||
slider, social, spellbook, taxi, trade, tradeskill, trainer, ActionSlot, AuraState, FontObject,
|
||||
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -71,13 +71,6 @@ pub(crate) struct Model {
|
||||
pub(crate) chat_tab: bool,
|
||||
/// Region visuals (texture path/color/text) + region layout (anchors/size/justify).
|
||||
pub(crate) region_data: HashMap<RegionHandle, RegionData>,
|
||||
/// The Era atlas table (decision 0950): lowercased member name → resolved paint. Pushed once
|
||||
/// by [`super::UiScript::set_era_atlases`] before the XML loads; read by `SetAtlas` and the
|
||||
/// loader's `atlas=` attribute.
|
||||
pub(crate) era_atlas: HashMap<String, EraAtlasEntry>,
|
||||
/// Atlas names `SetAtlas` could not resolve — warn-once, drained by
|
||||
/// [`super::UiScript::take_era_atlas_misses`] for app-side WARNs.
|
||||
pub(crate) era_atlas_missing: HashSet<String>,
|
||||
/// Per-frame installed [`Backdrop`] (the tooltip/dialog/panel plate — `<Backdrop>` or
|
||||
/// `SetBackdrop`). Absent ⇒ the frame draws no plate. Stored beside the arena like `region_data`
|
||||
/// (the arena models structure, not paint). The client's `frame+0x1ac` pointer.
|
||||
@@ -242,6 +235,20 @@ pub(crate) struct Model {
|
||||
/// `GetSpellTabInfo`/`GetSpellName`/… bindings read ([`spellbook`]). Durable player state like
|
||||
/// `actions` above, never `Option` — "no known spells yet" is simply empty vectors.
|
||||
pub(crate) spellbook: spellbook::SpellBookState,
|
||||
/// The player's **macros** (decision 0983) — the one game-state table this crate owns
|
||||
/// outright, because 1.12 macros have no server side at all ([`macros`]'s module docs). The
|
||||
/// app seeds it from `benilla/macros/…` and reads it back to persist.
|
||||
pub(crate) macros: macros::MacroState,
|
||||
/// A script mutated [`Self::macros`] since the app's last
|
||||
/// [`super::UiScript::take_macros_dirty`] drain — the save + `UPDATE_MACROS` trigger.
|
||||
pub(crate) macros_dirty: bool,
|
||||
/// Bumped by every seed and every mutation — the *undrained* change signal per-frame
|
||||
/// consumers gate on ([`super::UiScript::macros_generation`]), so the drained
|
||||
/// [`Self::macros_dirty`] edge keeps its single owner.
|
||||
pub(crate) macros_generation: u64,
|
||||
/// The macro-icon chooser list (full texture paths, app-built off `SpellIcon.dbc`) —
|
||||
/// `GetNumMacroIcons`/`GetMacroIconInfo`.
|
||||
pub(crate) macro_icons: Vec<String>,
|
||||
/// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`]
|
||||
/// drain.
|
||||
pub(crate) spell_casts: Vec<u32>,
|
||||
@@ -281,6 +288,19 @@ pub(crate) struct Model {
|
||||
/// [`super::UiScript::take_shapeshift_casts`] drain.
|
||||
pub(crate) shapeshift_casts: Vec<u32>,
|
||||
|
||||
/// The pet action bar the app pushes — the ten slots plus the two bar-wide bits the
|
||||
/// `PetHasActionBar`/`GetPetActionsUsable`/`GetPetActionInfo` family reads ([`super::pet`],
|
||||
/// decision 0982). Unlike the stance bar's, this state is **server-authoritative**: it is
|
||||
/// replaced wholesale on every `SMSG_PET_SPELLS`, and is empty whenever there is no pet.
|
||||
pub(crate) pet_bar: super::pet::PetBarState,
|
||||
/// 1-based slot indices `CastPetAction` queued since the app's last
|
||||
/// [`super::UiScript::take_pet_actions`] drain.
|
||||
pub(crate) pet_actions_pressed: Vec<u32>,
|
||||
/// 1-based slot indices `TogglePetAutocast` queued.
|
||||
pub(crate) pet_autocast_toggles: Vec<u32>,
|
||||
/// `PetStopAttack()` calls queued — a count, since the verb takes no argument.
|
||||
pub(crate) pet_stop_attacks: u32,
|
||||
|
||||
/// The per-bag container snapshot (keyed by live-API bag id, 0 = backpack) the app pushes,
|
||||
/// and the `UseContainerItem` intents it drains — the container seam ([`container`]).
|
||||
pub(crate) containers: HashMap<i64, container::ContainerState>,
|
||||
@@ -702,8 +722,6 @@ impl Model {
|
||||
link_spans: HashMap::new(),
|
||||
chat_tab: false,
|
||||
region_data: HashMap::new(),
|
||||
era_atlas: HashMap::new(),
|
||||
era_atlas_missing: HashSet::new(),
|
||||
backdrops: HashMap::new(),
|
||||
font_objects: HashMap::new(),
|
||||
region_resolved: HashMap::new(),
|
||||
@@ -750,6 +768,10 @@ impl Model {
|
||||
action_sets: Vec::new(),
|
||||
ui_errors: Vec::new(),
|
||||
spellbook: spellbook::SpellBookState::default(),
|
||||
macros: macros::MacroState::default(),
|
||||
macros_dirty: false,
|
||||
macros_generation: 0,
|
||||
macro_icons: Vec::new(),
|
||||
spell_casts: Vec::new(),
|
||||
casting: false,
|
||||
spell_stop: false,
|
||||
@@ -759,6 +781,10 @@ impl Model {
|
||||
talent_learns: Vec::new(),
|
||||
shapeshift_forms: Vec::new(),
|
||||
shapeshift_casts: Vec::new(),
|
||||
pet_bar: super::pet::PetBarState::default(),
|
||||
pet_actions_pressed: Vec::new(),
|
||||
pet_autocast_toggles: Vec::new(),
|
||||
pet_stop_attacks: 0,
|
||||
containers: HashMap::new(),
|
||||
container_uses: Vec::new(),
|
||||
container_cooldowns: HashMap::new(),
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! The pet action bar seam (decision 0982) — the eight bindings `PetActionBarFrame.lua` consumes
|
||||
//! (`GetPetActionInfo`/`GetPetActionsUsable`/`GetPetActionCooldown`/`PetHasActionBar`/
|
||||
//! `CastPetAction`/`TogglePetAutocast`/`IsPetAttackActive`/`PetStopAttack`) over an app-pushed
|
||||
//! slot snapshot, in [`super::shapeshift`]'s two-way shape: the app resolves everything (which
|
||||
//! slot is a command, a reaction or a spell; its icon, name, checked and autocast bits; its
|
||||
//! cooldown) and pushes it ([`super::UiScript::set_pet_actions`]); the engine drains the click
|
||||
//! intents ([`super::UiScript::take_pet_actions`] and kin) back out.
|
||||
//!
|
||||
//! **The engine holds no pet knowledge**: a slot here is "a name, a subtext, a texture, four bits
|
||||
//! and a cooldown triple". In particular it does not know that a token slot's `name`/`texture` are
|
||||
//! *the names of globals* rather than values — that convention belongs to the reference's own
|
||||
//! `GetPetActionInfo` and is reproduced faithfully by the app, which sets `is_token` and lets the
|
||||
//! Lua do the `getglobal` (the shipped `PetActionBarFrame.lua:98-104` fork).
|
||||
//!
|
||||
//! Return conventions are the 1.12 API's own, matching [`super::action`]: 1/nil booleans, and the
|
||||
//! cooldown as `(start_s on the GetTime clock, duration_s, enable)` with the same
|
||||
//! elapsed-goes-cold rule `GetActionCooldown` uses.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// One pet bar slot, fully resolved by the app before pushing.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct PetActionView {
|
||||
/// `GetPetActionInfo`'s first return, and the slot's OCCUPANCY test — `None` hides the button
|
||||
/// (`PetActionBarFrame.lua:122-128`). For a spell slot this is the spell's name; for a token
|
||||
/// slot it is **the name of a global** (`"PET_ACTION_ATTACK"`), which the Lua resolves.
|
||||
pub name: Option<String>,
|
||||
/// The second return — the spell's rank line, or `None` for a token.
|
||||
pub subtext: Option<String>,
|
||||
/// The third return: an icon path for a spell, **the name of a global**
|
||||
/// (`"PET_ATTACK_TEXTURE"`) for a token. `None` leaves the button art empty and swaps its
|
||||
/// NormalTexture to the unfilled `UI-Quickslot`.
|
||||
pub texture: Option<String>,
|
||||
/// Is this a command/reaction token (so `name`/`texture` are global names)?
|
||||
pub is_token: bool,
|
||||
/// The slot's spell, when it has one — what `GameTooltip:SetPetAction` renders. `None` for a
|
||||
/// token and for an empty slot. Not a `GetPetActionInfo` return: the reference's tooltip
|
||||
/// channel reaches the pet spellbook itself, and this is that reach.
|
||||
pub spell_id: Option<u32>,
|
||||
/// The checked ring.
|
||||
pub active: bool,
|
||||
/// This slot CAN autocast — the static `UI-AutoCastableOverlay` ring.
|
||||
pub autocast_allowed: bool,
|
||||
/// …and it currently does — the sparkle trail.
|
||||
pub autocast_enabled: bool,
|
||||
/// Whether a left click on this slot means "call the pet off" rather than "do this"
|
||||
/// (`IsPetAttackActive`, the Attack button's second press).
|
||||
pub attack_active: bool,
|
||||
/// `(start_ms on the GetTime clock, duration_ms, enabled)` — [`super::action::ActionState`]'s
|
||||
/// exact shape; `None` = no cooldown.
|
||||
pub cooldown: Option<(i64, u32, bool)>,
|
||||
}
|
||||
|
||||
/// [`PetActionView`] as stored: the cooldown converted to the `GetTime` clock at push time (the
|
||||
/// [`super::shapeshift::StoredShapeshiftForm`] pattern).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct StoredPetAction {
|
||||
pub(crate) view: PetActionView,
|
||||
/// `(start_s, duration_s, enabled)` in `GetTime` seconds; `None` = no cooldown.
|
||||
pub(crate) cooldown: Option<(f64, f64, bool)>,
|
||||
}
|
||||
|
||||
/// The pet bar's pushed state: the slots plus the two bar-wide bits the reference exposes
|
||||
/// separately from them.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct PetBarState {
|
||||
/// `PetHasActionBar()` — is there a bar at all. Distinct from "the slot list is empty": a
|
||||
/// possessed minion has a bar of pure commands, and a bar of ten empty slots is still a bar.
|
||||
pub(crate) has_bar: bool,
|
||||
/// `GetPetActionsUsable()` — false desaturates every icon on the bar at once.
|
||||
pub(crate) actions_usable: bool,
|
||||
pub(crate) slots: Vec<StoredPetAction>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push the whole pet bar, replacing whatever was there. A bare setter — firing
|
||||
/// `PET_BAR_UPDATE` is the app's diff-and-fire job, mirroring `set_shapeshift_forms`.
|
||||
pub fn set_pet_actions(
|
||||
&mut self,
|
||||
has_bar: bool,
|
||||
actions_usable: bool,
|
||||
slots: Vec<PetActionView>,
|
||||
) {
|
||||
let mut model = self.model_mut();
|
||||
model.pet_bar = PetBarState {
|
||||
has_bar,
|
||||
actions_usable,
|
||||
slots: slots
|
||||
.into_iter()
|
||||
.map(|view| {
|
||||
// The cooldown arrives with its absolute start already on the `GetTime` clock
|
||||
// (ms) — storing is a pure unit conversion, `set_shapeshift_forms`' seam.
|
||||
let cooldown = view.cooldown.map(|(start_ms, duration_ms, enabled)| {
|
||||
(
|
||||
start_ms as f64 / 1000.0,
|
||||
f64::from(duration_ms) / 1000.0,
|
||||
enabled,
|
||||
)
|
||||
});
|
||||
StoredPetAction { view, cooldown }
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Drain the 1-based slot indices `CastPetAction` queued since the last call. What each index
|
||||
/// *means* on the wire (a command, a reaction, a cast) is the app's to decide at drain time
|
||||
/// from the slot it still owns.
|
||||
pub fn take_pet_actions(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().pet_actions_pressed)
|
||||
}
|
||||
|
||||
/// Drain the 1-based slot indices `TogglePetAutocast` queued.
|
||||
pub fn take_pet_autocast_toggles(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().pet_autocast_toggles)
|
||||
}
|
||||
|
||||
/// Drain the `PetStopAttack()` calls queued (a count — the verb carries no argument).
|
||||
pub fn take_pet_stop_attacks(&mut self) -> u32 {
|
||||
std::mem::replace(&mut self.model_mut().pet_stop_attacks, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based button index → stored slot, the reference's own indexing.
|
||||
fn slot_at(model: &Model, i: u32) -> Option<&StoredPetAction> {
|
||||
usize::try_from(i.checked_sub(1)?)
|
||||
.ok()
|
||||
.and_then(|n| model.pet_bar.slots.get(n))
|
||||
}
|
||||
|
||||
/// Register the pet-bar globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
let flag = |b: bool| if b { Value::Integer(1) } else { Value::Nil };
|
||||
|
||||
// PetHasActionBar() → 1/nil. The bar frame's whole show/hide gate.
|
||||
g.set(
|
||||
"PetHasActionBar",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(flag(model.pet_bar.has_bar))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPetActionsUsable() → 1/nil — one answer for the whole bar (the SetDesaturation sweep).
|
||||
g.set(
|
||||
"GetPetActionsUsable",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(flag(model.pet_bar.actions_usable))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPetActionInfo(i) → name, subtext, texture, isToken, isActive, autoCastAllowed,
|
||||
// autoCastEnabled. An out-of-range index answers a single nil, which the Lua's `if (name)`
|
||||
// occupancy test reads exactly as an empty slot (the spellbook bindings' shape).
|
||||
g.set(
|
||||
"GetPetActionInfo",
|
||||
lua.create_function(move |lua, i: u32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let Some(slot) = slot_at(&model, i) else {
|
||||
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
||||
};
|
||||
let v = &slot.view;
|
||||
let text = |s: &Option<String>| match s {
|
||||
Some(s) => Ok(Value::String(lua.create_string(s)?)),
|
||||
None => Ok::<_, mlua::Error>(Value::Nil),
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
text(&v.name)?,
|
||||
text(&v.subtext)?,
|
||||
text(&v.texture)?,
|
||||
flag(v.is_token),
|
||||
flag(v.active),
|
||||
flag(v.autocast_allowed),
|
||||
flag(v.autocast_enabled),
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPetActionCooldown(i) → start, duration, enable — GetActionCooldown's triple and its
|
||||
// elapsed-goes-cold rule (an elapsed/absent cooldown answers (0, 0, 1) so a re-feed never
|
||||
// replays the sweep).
|
||||
g.set(
|
||||
"GetPetActionCooldown",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(match slot_at(&model, i).and_then(|s| s.cooldown) {
|
||||
Some((start, duration, enabled)) if start + duration > now || !enabled => {
|
||||
(start, duration, i32::from(enabled))
|
||||
}
|
||||
_ => (0.0, 0.0, 1),
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// IsPetAttackActive(i) → a BOOLEAN — the left-click fork: true means the press should call
|
||||
// the pet OFF (`PetStopAttack`) instead of running the slot.
|
||||
//
|
||||
// The odd one out of this file's returns, and deliberately so: it is the single pet binding
|
||||
// that pushes a real Lua boolean (`0x6f39f0`) rather than the 1/nil the rest use, so it
|
||||
// answers `false`, never nil, even out of range. Consumers only ever test it for truth, so
|
||||
// the difference is invisible in use — but a seam that quietly upgraded it to the house
|
||||
// convention would be lying about the API.
|
||||
g.set(
|
||||
"IsPetAttackActive",
|
||||
lua.create_function(move |lua, i: u32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(slot_at(&model, i).is_some_and(|s| s.view.attack_active))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CastPetAction(i) — queue the press. An EMPTY slot queues nothing: the reference's bar hides
|
||||
// an unnamed button, so a press on one can only come from the show-grid state, where it must
|
||||
// be inert.
|
||||
g.set(
|
||||
"CastPetAction",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if slot_at(&model, i).is_some_and(|s| s.view.name.is_some()) {
|
||||
model.pet_actions_pressed.push(i);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// TogglePetAutocast(i) — queue the right-click. Only a slot that CAN autocast queues: the
|
||||
// wire verb names a spell id, and a command token has none.
|
||||
g.set(
|
||||
"TogglePetAutocast",
|
||||
lua.create_function(|lua, i: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if slot_at(&model, i).is_some_and(|s| s.view.autocast_allowed) {
|
||||
model.pet_autocast_toggles.push(i);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// PetStopAttack() — queue the call-off. No argument: the wire carries only the pet's guid.
|
||||
g.set(
|
||||
"PetStopAttack",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.pet_stop_attacks += 1;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PetActionView;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A hunter's bar, cut down to the three slot classes that matter: the Attack command (a
|
||||
/// token, currently attacking), Claw (a spell with autocast ON and a running cooldown), and an
|
||||
/// empty middle slot.
|
||||
fn slots() -> Vec<PetActionView> {
|
||||
vec![
|
||||
PetActionView {
|
||||
name: Some("PET_ACTION_ATTACK".into()),
|
||||
texture: Some("PET_ATTACK_TEXTURE".into()),
|
||||
is_token: true,
|
||||
active: true,
|
||||
attack_active: true,
|
||||
..Default::default()
|
||||
},
|
||||
PetActionView {
|
||||
name: Some("Claw".into()),
|
||||
subtext: Some("Rank 3".into()),
|
||||
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
|
||||
autocast_allowed: true,
|
||||
autocast_enabled: true,
|
||||
cooldown: Some((9400, 1500, true)),
|
||||
..Default::default()
|
||||
},
|
||||
PetActionView::default(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_info_reads_and_out_of_range_is_one_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert!(s.eval::<bool>("return PetHasActionBar() == nil").unwrap());
|
||||
assert!(s.eval::<bool>("return GetPetActionInfo(1) == nil").unwrap());
|
||||
|
||||
s.set_pet_actions(true, true, slots());
|
||||
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
|
||||
assert_eq!(s.eval::<i64>("return GetPetActionsUsable()").unwrap(), 1);
|
||||
|
||||
// The token slot returns GLOBAL NAMES, and says so with isToken.
|
||||
let (name, subtext, texture, is_token, active, allowed, enabled) = s
|
||||
.eval::<(
|
||||
String,
|
||||
Option<String>,
|
||||
String,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
Option<i64>,
|
||||
)>("return GetPetActionInfo(1)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
name.as_str(),
|
||||
subtext,
|
||||
texture.as_str(),
|
||||
is_token,
|
||||
active,
|
||||
allowed,
|
||||
enabled
|
||||
),
|
||||
(
|
||||
"PET_ACTION_ATTACK",
|
||||
None,
|
||||
"PET_ATTACK_TEXTURE",
|
||||
Some(1),
|
||||
Some(1),
|
||||
None,
|
||||
None
|
||||
)
|
||||
);
|
||||
|
||||
// The spell slot returns a real name, a rank line and an icon PATH, and is not a token.
|
||||
assert!(s
|
||||
.eval::<bool>(
|
||||
"local n, sub, tex, tok, act, allow, on = GetPetActionInfo(2) \
|
||||
return n == 'Claw' and sub == 'Rank 3' and tok == nil and act == nil \
|
||||
and allow == 1 and on == 1 and string.find(tex, 'Icons') ~= nil"
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
// The empty slot exists (so it is not the out-of-range single nil) but has no name — the
|
||||
// reference's own "hide this button" test.
|
||||
assert!(s
|
||||
.eval::<bool>("local n, _, tex = GetPetActionInfo(3) return n == nil and tex == nil")
|
||||
.unwrap());
|
||||
assert!(s.eval::<bool>("return GetPetActionInfo(4) == nil").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooldown_triple_stamps_to_the_vm_clock_and_goes_cold() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.tick(10.0); // GetTime == 10
|
||||
s.set_pet_actions(true, true, slots());
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(1)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1),
|
||||
"no cooldown reads cold"
|
||||
);
|
||||
let (start, duration, enable) = s
|
||||
.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
||||
.unwrap();
|
||||
assert!((start - 9.4).abs() < 1e-9, "start {start}");
|
||||
assert!((duration - 1.5).abs() < 1e-9);
|
||||
assert_eq!(enable, 1);
|
||||
|
||||
s.tick(2.0); // now == 12 > 9.4 + 1.5
|
||||
assert_eq!(
|
||||
s.eval::<(f64, f64, i32)>("return GetPetActionCooldown(2)")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 1)
|
||||
);
|
||||
}
|
||||
|
||||
/// The three intent queues, and the two gates that keep a meaningless intent off the wire: an
|
||||
/// empty slot cannot be pressed, and a slot with no autocast cannot be toggled (its wire verb
|
||||
/// names a spell id, which a command token has not got).
|
||||
#[test]
|
||||
fn intents_queue_and_the_meaningless_ones_are_dropped() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_pet_actions(true, true, slots());
|
||||
|
||||
s.run("CastPetAction(1) CastPetAction(3) CastPetAction(9)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_pet_actions(),
|
||||
vec![1],
|
||||
"empty + out-of-range dropped"
|
||||
);
|
||||
assert!(s.take_pet_actions().is_empty(), "drain empties");
|
||||
|
||||
s.run("TogglePetAutocast(1) TogglePetAutocast(2)").unwrap();
|
||||
assert_eq!(
|
||||
s.take_pet_autocast_toggles(),
|
||||
vec![2],
|
||||
"only the autocastable slot"
|
||||
);
|
||||
|
||||
assert_eq!(s.take_pet_stop_attacks(), 0);
|
||||
s.run("PetStopAttack() PetStopAttack()").unwrap();
|
||||
assert_eq!(s.take_pet_stop_attacks(), 2);
|
||||
assert_eq!(s.take_pet_stop_attacks(), 0, "drain empties");
|
||||
}
|
||||
|
||||
/// `IsPetAttackActive` is per-slot, and it is what turns the Attack button's second press into
|
||||
/// a call-off — the reference's `PetActionButton_OnClick` fork.
|
||||
///
|
||||
/// It answers a **boolean** on every path, including out of range — the one binding here that
|
||||
/// does not use the 1/nil convention.
|
||||
#[test]
|
||||
fn attack_active_is_a_per_slot_boolean() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_pet_actions(true, true, slots());
|
||||
assert!(s.eval::<bool>("return IsPetAttackActive(1)").unwrap());
|
||||
assert!(!s.eval::<bool>("return IsPetAttackActive(2)").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return IsPetAttackActive(9) == false")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// A disabled bar still EXISTS — `PetHasActionBar` stays true while `GetPetActionsUsable`
|
||||
/// goes false. The pair is what greys every icon without taking the bar off screen.
|
||||
#[test]
|
||||
fn a_disabled_bar_is_still_a_bar() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_pet_actions(true, false, slots());
|
||||
assert_eq!(s.eval::<i64>("return PetHasActionBar()").unwrap(), 1);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetPetActionsUsable() == nil")
|
||||
.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -236,10 +236,12 @@ impl UiScript {
|
||||
// drop — all under one short borrow. A press fires OnClick when the hit frame registered
|
||||
// "<Button>ButtonDown"; a release fires it when press+release landed on the same frame AND
|
||||
// it registered "<Button>ButtonUp" — UNLESS a started drag is being resolved instead.
|
||||
let (click_id, drag_release, world_dropped): (
|
||||
#[allow(clippy::type_complexity)]
|
||||
let (click_id, drag_release, world_dropped, slider_jump): (
|
||||
Option<u32>,
|
||||
Option<cursor::DragRelease>,
|
||||
bool,
|
||||
Option<(u32, f32)>,
|
||||
) = {
|
||||
let mut model = self.model_mut();
|
||||
let hit_handle = hit_id.and_then(|id| model.id_to_frame.get(&id).copied());
|
||||
@@ -253,16 +255,20 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
cursor::arm_drag(&mut model, hit_handle, button, (x, y));
|
||||
// A left press on a Slider's thumb begins the engine thumb drag (decision 0250 §5),
|
||||
// independent of the click/drag-trio path above (a Slider isn't drag_registered).
|
||||
if button == "LeftButton" {
|
||||
super::slider::begin_drag(&mut model, hit_handle, x, y);
|
||||
}
|
||||
// A left press on a Slider begins the engine drag (0250 §5): a thumb press grabs
|
||||
// it in place; a track press seats the thumb under the cursor first (the returned
|
||||
// value jump — 0989), independent of the click/drag-trio path above (a Slider
|
||||
// isn't drag_registered).
|
||||
let jump = if button == "LeftButton" {
|
||||
super::slider::begin_drag(&mut model, hit_handle, x, y)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let wants = format!("{button}Down");
|
||||
let click = hit_handle
|
||||
.filter(|&h| button::wants_click(&model, h, &wants))
|
||||
.and(hit_id);
|
||||
(click, None, false)
|
||||
(click, None, false, jump)
|
||||
} else {
|
||||
let pressed = model.mouse_down_on.remove(button);
|
||||
// A left release ends any in-flight thumb drag (decision 0250 §5).
|
||||
@@ -294,9 +300,21 @@ impl UiScript {
|
||||
.filter(|&h| same_frame && button::wants_click(&model, h, &wants))
|
||||
.and(hit_id)
|
||||
};
|
||||
(click, release, dropped)
|
||||
(click, release, dropped, None)
|
||||
}
|
||||
};
|
||||
// A track press's value jump fires OnValueChanged first (outside the borrow), the same
|
||||
// seam as `mouse_move`'s in-drag changes — the press IS the first move of the gesture.
|
||||
if let Some((id, value)) = slider_jump {
|
||||
if let Err(e) = event::fire_widget_handler(
|
||||
&self.lua,
|
||||
id,
|
||||
"OnValueChanged",
|
||||
vec![Value::Number(f64::from(value))],
|
||||
) {
|
||||
self.push_error(e);
|
||||
}
|
||||
}
|
||||
let script = if down { "OnMouseDown" } else { "OnMouseUp" };
|
||||
if let Some(id) = hit_id {
|
||||
if let Err(e) = event::fire_widget_handler(&self.lua, id, script, vec![btn.clone()]) {
|
||||
|
||||
@@ -231,11 +231,9 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
Value::String(s) if s.to_str()?.is_empty() => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.atlas = None;
|
||||
}
|
||||
Value::String(s) => {
|
||||
data.texture = Some(s.to_str()?.to_string());
|
||||
data.atlas = None;
|
||||
}
|
||||
Value::Number(_) | Value::Integer(_) => {
|
||||
data.color = Some([
|
||||
@@ -250,7 +248,6 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
Value::Nil => {
|
||||
data.texture = None;
|
||||
data.color = None;
|
||||
data.atlas = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -274,7 +271,6 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
data.texture = Some(path);
|
||||
data.circular = true;
|
||||
data.portrait_unit = None;
|
||||
data.atlas = None;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -640,54 +636,6 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok((l, r, t, b))
|
||||
})?,
|
||||
)?;
|
||||
// SetAtlas(name[, useAtlasSize]) — the Era atlas resolve (decision 0950): one call sets the
|
||||
// sheet texture + UV sub-rect from the app-pushed table ([`super::UiScript::set_era_atlases`],
|
||||
// baked offline from the UiTextureAtlas DB2s by scripts/era-extract.py), and with
|
||||
// useAtlasSize the member's nominal size too. The reference's SetAtlas hard-errors on an
|
||||
// unknown name; ours blanks the region and records a warn-once miss instead
|
||||
// ([`super::UiScript::take_era_atlas_misses`]) — a stale extraction must not kill the whole
|
||||
// XML load, but it must name itself.
|
||||
m.set(
|
||||
"SetAtlas",
|
||||
lua.create_function(
|
||||
|lua, (this, name, use_size): (Table, String, Option<bool>)| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
let key = name.to_ascii_lowercase();
|
||||
let Some(entry) = model.era_atlas.get(&key).cloned() else {
|
||||
model.era_atlas_missing.insert(key);
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.texture = None;
|
||||
d.atlas = None;
|
||||
return Ok(());
|
||||
};
|
||||
let d = model.region_data.entry(rh).or_default();
|
||||
d.circular = false;
|
||||
d.portrait_unit = None;
|
||||
d.texture = Some(entry.file);
|
||||
d.tex_coords = Some(TexCoords::Rect(entry.uv));
|
||||
d.atlas = Some(key);
|
||||
if use_size.unwrap_or(false) {
|
||||
let new = Some((entry.size[0], entry.size[1]));
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
// GetAtlas() → the member name the last SetAtlas applied (nil for an ordinary texture).
|
||||
m.set(
|
||||
"GetAtlas",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
Ok(model.region_data.get(&rh).and_then(|d| d.atlas.clone()))
|
||||
})?,
|
||||
)?;
|
||||
// SetFontObject("GameFontNormal") — re-point this FontString at a named virtual Font object: its
|
||||
// resolved paint (face/height/color/outline) is copied into the region as the new default. An
|
||||
// explicit SetFont/SetTextColor afterward overrides. An unknown name errors (never silently no-op).
|
||||
|
||||
@@ -310,39 +310,59 @@ pub(crate) struct SliderDrag {
|
||||
}
|
||||
|
||||
/// On a LeftButton press at `(x, y)` whose hit frame is `hit`: if that frame is an **enabled**
|
||||
/// Slider with a resolved rect and the cursor lies on its thumb, begin a drag capture (records
|
||||
/// [`Model::slider_drag`]). Returns whether a capture began — the caller needs nothing from it (the
|
||||
/// press still fires OnMouseDown normally). A press off the thumb (on the track) captures nothing:
|
||||
/// only the thumb drags, matching the vanilla scrollbar where the arrows do the stepping.
|
||||
pub(super) fn begin_drag(model: &mut Model, hit: Option<FrameHandle>, x: f32, y: f32) -> bool {
|
||||
let Some(h) = hit else { return false };
|
||||
let Some(r) = model.resolved.get(&h).copied() else {
|
||||
return false;
|
||||
};
|
||||
/// Slider with a resolved rect, begin a drag capture (records [`Model::slider_drag`]). A press ON
|
||||
/// the thumb grabs it **offset-preserving** (the grabbed point stays under the cursor — 0250 §5);
|
||||
/// a press on the TRACK seats the thumb's **center** under the cursor — the value jumps there and
|
||||
/// the same capture begins, one continuous press-drag gesture (decision 0989: "the only way to
|
||||
/// set the bar was the arrows"). Returns `Some((frame id, new value))` when the press itself
|
||||
/// changed the value (the track jump) — the caller fires `OnValueChanged` outside the model
|
||||
/// borrow, exactly like [`drag_move`]'s changes — else `None`.
|
||||
pub(super) fn begin_drag(
|
||||
model: &mut Model,
|
||||
hit: Option<FrameHandle>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
) -> Option<(u32, f32)> {
|
||||
let h = hit?;
|
||||
let r = model.resolved.get(&h).copied()?;
|
||||
let (enabled, vertical, fraction, thumb) = match model.arena.frame(h).map(|f| &f.kind_state) {
|
||||
Some(KindState::Slider(s)) => (s.enabled, s.vertical, s.fraction(), s.thumb),
|
||||
_ => return false,
|
||||
_ => return None,
|
||||
};
|
||||
if !enabled {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
let size = thumb
|
||||
.and_then(|rh| model.region_data.get(&rh))
|
||||
.and_then(|d| d.size);
|
||||
let trect = thumb_rect(r, size, vertical, fraction);
|
||||
if !point_in_rect(trect, x, y) {
|
||||
return false;
|
||||
}
|
||||
let grab_offset = if vertical {
|
||||
y - trect.top
|
||||
let on_thumb = point_in_rect(trect, x, y);
|
||||
let grab_offset = if on_thumb {
|
||||
// The grabbed thumb point rides under the cursor for the whole drag.
|
||||
if vertical {
|
||||
y - trect.top
|
||||
} else {
|
||||
x - trect.left
|
||||
}
|
||||
} else {
|
||||
x - trect.left
|
||||
// Track press: grab the thumb by its center, so the seat-under-cursor jump below and
|
||||
// every subsequent move share one formula ([`drag_move`]). The offset is
|
||||
// cursor − leading edge at grab: +tw/2 horizontal; −th/2 vertical (top is the leading
|
||||
// edge and sits ABOVE the center in y-up, hence bottom − top, which is −th).
|
||||
if vertical {
|
||||
(trect.bottom - trect.top) * 0.5
|
||||
} else {
|
||||
(trect.right - trect.left) * 0.5
|
||||
}
|
||||
};
|
||||
model.slider_drag = Some(SliderDrag {
|
||||
slider: h,
|
||||
grab_offset,
|
||||
});
|
||||
true
|
||||
if on_thumb {
|
||||
return None; // no value change from the grab itself
|
||||
}
|
||||
drag_move(model, x, y)
|
||||
}
|
||||
|
||||
/// On a pointer move at `(x, y)` while a thumb is captured: recompute the value from the cursor
|
||||
|
||||
@@ -106,6 +106,15 @@ impl super::UiScript {
|
||||
self.model_mut().spellbook = state;
|
||||
}
|
||||
|
||||
/// Read the book back — for the ONE app-side consumer that must resolve a spell name by the
|
||||
/// same law `CastSpellByName` does: a macro's bound spell (`benilla::ui_macro`, decision
|
||||
/// 0983). Going through the pushed book rather than re-deriving from the catalog is what
|
||||
/// stops the bar's cooldown swirl and the macro's own cast disagreeing about which rank a
|
||||
/// bare `/cast Fireball` means.
|
||||
pub fn spellbook(&self) -> SpellBookState {
|
||||
self.model_mut().spellbook.clone()
|
||||
}
|
||||
|
||||
/// Drain the spell ids `CastSpell` queued since the last call.
|
||||
pub fn take_spell_casts(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().spell_casts)
|
||||
@@ -151,6 +160,81 @@ fn slot_index(id: u32, book_type: &str) -> Option<usize> {
|
||||
usize::try_from(id.checked_sub(1)?).ok()
|
||||
}
|
||||
|
||||
/// Resolve a spell **by name** against the player's book — the law behind `CastSpellByName` and,
|
||||
/// through it, `/cast` and every macro's `/cast` line (decision 0983).
|
||||
///
|
||||
/// The grammar is the one the client documents in its own help text:
|
||||
/// `MACRO_HELP_TEXT_LINE4 = "- To cast a spell from a macro use the following syntax:
|
||||
/// /cast <name> (<subtext>)"`. So:
|
||||
///
|
||||
/// - **`Fireball`** — the highest-ranked *known* Fireball. Rank order is the book's own
|
||||
/// `NameSubtext` number (`"Rank 8"` → 8) through the reference's own leading-number parse
|
||||
/// ([`super::super::…`]'s twin lives in `benilla::ui_spellbook`); an unranked subtext sorts as 0,
|
||||
/// and ties fall to the later book slot, which is the order the book itself lists ranks in.
|
||||
/// - **`Fireball(Rank 1)`** / **`Fireball (Rank 1)`** — that exact subtext, case-insensitively.
|
||||
/// Both spacings, because vanilla macros in the wild are written both ways and the reference's
|
||||
/// own help text prints the spaced form while its FrameXML never re-spaces the argument.
|
||||
///
|
||||
/// Name matching is case-insensitive and whole-name — a *prefix* rule would silently cast
|
||||
/// "Frostbolt" for "Frost" and there is nothing in the reference suggesting one. A spell the
|
||||
/// player does not know resolves to `None` and the cast simply does not happen: the reference has
|
||||
/// no error line for it either (`SlashCmdList["CAST"]` discards the binding's result).
|
||||
///
|
||||
/// Passives are skipped, matching [`pickup_spell`]'s sibling rule in `CastSpell`: a passive is
|
||||
/// permanent player state, never something a macro casts.
|
||||
pub fn resolve_spell_by_name<'a>(
|
||||
book: &'a SpellBookState,
|
||||
query: &str,
|
||||
) -> Option<&'a SpellSlotView> {
|
||||
let (name, subtext) = split_subtext(query);
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
book.slots
|
||||
.iter()
|
||||
.filter(|s| !s.passive && s.name.eq_ignore_ascii_case(name))
|
||||
.filter(|s| match subtext {
|
||||
Some(want) => s
|
||||
.rank
|
||||
.as_deref()
|
||||
.is_some_and(|r| r.eq_ignore_ascii_case(want)),
|
||||
None => true,
|
||||
})
|
||||
// Highest rank wins when no subtext pinned one; the book's own later slot breaks a tie.
|
||||
.enumerate()
|
||||
.max_by_key(|(i, s)| (rank_number(s.rank.as_deref()), *i))
|
||||
.map(|(_, s)| s)
|
||||
}
|
||||
|
||||
/// `Name(Subtext)` / `Name (Subtext)` → `("Name", Some("Subtext"))`; a bare name → `(name, None)`.
|
||||
/// An unclosed parenthesis is not a subtext — the whole string stays the name, so a spell whose
|
||||
/// own name holds a `(` cannot be silently truncated.
|
||||
fn split_subtext(query: &str) -> (&str, Option<&str>) {
|
||||
let q = query.trim();
|
||||
let Some(open) = q.find('(') else {
|
||||
return (q, None);
|
||||
};
|
||||
let Some(close) = q.rfind(')') else {
|
||||
return (q, None);
|
||||
};
|
||||
if close < open {
|
||||
return (q, None);
|
||||
}
|
||||
(q[..open].trim(), Some(q[open + 1..close].trim()))
|
||||
}
|
||||
|
||||
/// The rank number inside a `NameSubtext` (`"Rank 8"` → 8), by the reference's own leading-number
|
||||
/// parse: skip to the first digit, fold the digit run. A subtext with no digits (`"Racial"`,
|
||||
/// `"Passive"`) and an absent one both read 0.
|
||||
fn rank_number(subtext: Option<&str>) -> u32 {
|
||||
subtext
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.skip_while(|c| !c.is_ascii_digit())
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.fold(0u32, |acc, c| acc * 10 + c.to_digit(10).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// `PickupSpell(id, bookType)` — the drag/shift-click entry point (ref `SpellButton_OnClick`'s
|
||||
/// other two forks, `SpellBookFrame.lua:263-290`). The book is a SOURCE, never a placement
|
||||
/// target — the ref's plain click always casts unconditionally, never checking `GetCursorInfo`
|
||||
@@ -325,6 +409,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// CastSpellByName(name [, onSelf]) — the reference binding `0x4b4ab0`, whose only two callers
|
||||
// share the `0x4b3300` dispatcher with `CastSpell` above (wow-re ledger), so this queues onto
|
||||
// the same `spell_casts` list and the app's one cast tail handles both. `SlashCmdList["CAST"]`
|
||||
// is literally `CastSpellByName(msg)`, which is why `/cast` needs nothing else, and it is the
|
||||
// command the whole macro system is built to run.
|
||||
//
|
||||
// `onSelf` is accepted and carried no further (benilla has no self-cast modifier yet — the
|
||||
// same named gap `UseAction`'s third argument already has).
|
||||
g.set(
|
||||
"CastSpellByName",
|
||||
lua.create_function(|lua, (name, _on_self): (String, MultiValue)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if let Some(slot) = resolve_spell_by_name(&model.spellbook, &name) {
|
||||
let spell_id = slot.spell_id;
|
||||
model.spell_casts.push(spell_id);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
g.set(
|
||||
"PickupSpell",
|
||||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||||
|
||||
@@ -104,37 +104,56 @@ fn slider_thumb_drag_maps_cursor_to_value() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slider_track_click_and_disabled_thumb_do_not_drag() {
|
||||
fn slider_track_press_seats_the_thumb_and_a_disabled_slider_ignores_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.run(
|
||||
r#"
|
||||
bar = CreateFrame("Slider", "SlNoDrag")
|
||||
bar = CreateFrame("Slider", "SlTrack")
|
||||
bar:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
|
||||
bar:SetSize(16, 100)
|
||||
bar:SetThumbTexture("Interface\\Buttons\\UI-ScrollBar-Knob")
|
||||
bar:GetThumbTexture():SetSize(16, 16)
|
||||
bar:SetMinMaxValues(0, 100)
|
||||
bar:SetValue(0)
|
||||
fired = {}
|
||||
bar:SetScript("OnValueChanged", function() table.insert(fired, arg1) end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// A press on the TRACK below the thumb (108, 120) — inside the slider but not on the thumb
|
||||
// [184,200] — captures nothing: only the thumb drags (the arrows step, decision 0250).
|
||||
// A press on the TRACK below the thumb (108, 120) — inside the slider, not on the thumb
|
||||
// [184,200] — SEATS the thumb's center under the cursor and fires OnValueChanged from the
|
||||
// press itself (0989's track-press law; 0250 §5 covered only the thumb grab). Track
|
||||
// y in [100, 200], travel 84: cursor 120 → thumb top 128 → fraction (200−128)/84 = 72/84.
|
||||
s.mouse_button(108.0, 120.0, "LeftButton", true);
|
||||
s.mouse_move(108.0, 150.0);
|
||||
let v: f32 = s.eval("return SlNoDrag:GetValue()").unwrap();
|
||||
assert_eq!(v, 0.0, "track click does not drag");
|
||||
s.mouse_button(108.0, 150.0, "LeftButton", false);
|
||||
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
|
||||
assert!(
|
||||
(v - 100.0 * (72.0 / 84.0)).abs() < 1e-3,
|
||||
"the press seats the thumb center at the cursor (got {v})"
|
||||
);
|
||||
let n: usize = s.eval("return table.getn(fired)").unwrap();
|
||||
assert_eq!(n, 1, "the jump fired OnValueChanged once, from the press");
|
||||
|
||||
// A disabled slider ignores a press even on the thumb.
|
||||
s.run(r#"SlNoDrag:Disable()"#).unwrap();
|
||||
s.mouse_button(108.0, 192.0, "LeftButton", true);
|
||||
s.mouse_move(108.0, 150.0);
|
||||
let v: f32 = s.eval("return SlNoDrag:GetValue()").unwrap();
|
||||
assert_eq!(v, 0.0, "disabled slider does not drag");
|
||||
// The SAME press keeps dragging — the capture began at the track press.
|
||||
s.mouse_move(108.0, 108.0);
|
||||
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
|
||||
assert_eq!(v, 100.0, "the gesture drags on without re-grabbing");
|
||||
s.mouse_button(108.0, 108.0, "LeftButton", false);
|
||||
|
||||
// A disabled slider ignores a press anywhere — thumb and track alike.
|
||||
s.run(r#"SlTrack:SetValue(0); SlTrack:Disable(); fired = {}"#)
|
||||
.unwrap();
|
||||
for y in [192.0, 150.0] {
|
||||
s.mouse_button(108.0, y, "LeftButton", true);
|
||||
s.mouse_move(108.0, 130.0);
|
||||
let v: f32 = s.eval("return SlTrack:GetValue()").unwrap();
|
||||
assert_eq!(v, 0.0, "disabled slider does not move (press at y={y})");
|
||||
s.mouse_button(108.0, 130.0, "LeftButton", false);
|
||||
}
|
||||
let n: usize = s.eval("return table.getn(fired)").unwrap();
|
||||
assert_eq!(n, 0, "disabled: no OnValueChanged at all");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -336,6 +336,32 @@ pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetPetAction(index) — the pet-bar hover (decision 0982). Only ever reached for a
|
||||
// SPELL slot: the reference's `PetActionButton_OnEnter` builds a token slot's tooltip inline
|
||||
// from `tooltipName`/`tooltipSubtext` and never calls this. A slot with no spell (a token, an
|
||||
// empty slot, an out-of-range index) is a no-op, leaving whatever was shown — the same shape
|
||||
// as SetSpell's out-of-range.
|
||||
m.set(
|
||||
"SetPetAction",
|
||||
lua.create_function(|lua, (this, index): (Table, usize)| {
|
||||
let (spell_id, name) = {
|
||||
let model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
match model
|
||||
.pet_bar
|
||||
.slots
|
||||
.get(index.saturating_sub(1))
|
||||
.filter(|s| !s.view.is_token)
|
||||
{
|
||||
Some(s) => match s.view.spell_id {
|
||||
Some(id) => (id, s.view.name.clone()),
|
||||
None => return Ok(()),
|
||||
},
|
||||
None => return Ok(()),
|
||||
}
|
||||
};
|
||||
set_spell_by_id(lua, &this, spell_id, name, false, false, None)
|
||||
})?,
|
||||
)?;
|
||||
// GameTooltip:SetPlayerBuff(index [, filter]) — the buff-bar hover: the aura variant (white
|
||||
// AuraDescription) + the duration-remaining line only this entry point appends
|
||||
// (byte-verified; remaining computed live off the aura's GetTime expiry — the TEXT shape is
|
||||
|
||||
@@ -296,18 +296,6 @@ pub struct ExtractedQuad {
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
/// One Era atlas member (decision 0950): the resolved paint `SetAtlas`/`atlas=` apply. The app
|
||||
/// builds these from `WoW-era/_extracted_ui/manifest.json` — `file` is an app-resolvable texture
|
||||
/// path (the `era:` scheme), `uv` the normalized `[left, right, top, bottom]` sub-rect
|
||||
/// ([`TexCoords::Rect`] order), `size` the member's nominal draw size in UI units (what
|
||||
/// `useAtlasSize` applies).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct EraAtlasEntry {
|
||||
pub file: String,
|
||||
pub uv: [f32; 4],
|
||||
pub size: [f32; 2],
|
||||
}
|
||||
|
||||
/// The visual state of a region leaf (`Texture`/`FontString`), stored beside the arena because
|
||||
/// [`crate::widget::Region`] models only structure (kind/owner/layer), not paint. See [`object`]'s
|
||||
/// `SetTexture`/`SetVertexColor`/`SetText`.
|
||||
@@ -334,10 +322,6 @@ pub(crate) struct RegionData {
|
||||
pub(crate) alpha: Option<f32>,
|
||||
/// A texture path (`SetTexture("Interface\\...")`).
|
||||
pub(crate) texture: Option<String>,
|
||||
/// The Era atlas member this texture was last set from (`SetAtlas`, decision 0950) — what
|
||||
/// `GetAtlas` reports. Bookkeeping only: `texture`/`tex_coords`/`size` carry the resolved
|
||||
/// paint; any later `SetTexture` clears it (an ordinary region again).
|
||||
pub(crate) atlas: Option<String>,
|
||||
/// A color: a solid fill (`SetTexture(r,g,b,a)`) or a vertex tint (`SetVertexColor`).
|
||||
pub(crate) color: Option<[f32; 4]>,
|
||||
/// This region is a **portrait**: draw its texture masked to the inscribed circle (set by
|
||||
|
||||
@@ -50,11 +50,9 @@ crossbeam-channel = "0.5"
|
||||
benilla-assets = { path = "../benilla-assets" }
|
||||
benilla-formats = { path = "../benilla-formats" }
|
||||
benilla-protocol = { path = "../benilla-protocol" }
|
||||
# The Era atlas manifest (decision 0950): `WoW-era/_extracted_ui/manifest.json`, written offline by
|
||||
# scripts/era-extract.py and read once at boot. Both already in the tree (benilla-assets carries
|
||||
# serde; serde_json rides Bevy transitively) — declared so the call site is an honest dependency.
|
||||
# The CVar config diff (`benilla/config.toml`, decision 0954): serde derive on the persisted
|
||||
# shape, toml as its format. (serde_json left with the era manifest — 0978.)
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
# ECS-native physics: collider storage + broadphase BVH + shape-casts, with a thin custom kinematic
|
||||
# character controller on top (decision 0009). Pinned 0.6: it targets Bevy 0.18 (verified single,
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
outermost layer: until now ESC's chain ran out of things to close and did nothing, and the
|
||||
Main Menu micro button was drawn but dead (MicroMenu.xml's SCOPE note).
|
||||
|
||||
PROVENANCE (0985 — the three-source split, stated the same way in every era-shaped
|
||||
window; OptionsFrame.xml carries the sibling block):
|
||||
· STRUCTURE & BEHAVIOR — the 1.15 era client (wow-ui-source-era @ 1.15.9.68940): the
|
||||
ladder shape, its groups and gaps, which entries exist (the SCOPE section below).
|
||||
If 1.15 has the mechanism, it STAYS.
|
||||
· CHROME MECHANISM — this window predates 1.13: the 1.12 client's OWN GameMenuFrame
|
||||
is the chrome ground truth (the dialog backdrop, header plate, red buttons — the
|
||||
same dialog family the 1.13/1.14 options windows kept), quoted below.
|
||||
· ART BYTES — the 1.12 MPQ chain only; nothing later-client at runtime (0978's law).
|
||||
|
||||
Ground truth, all quoted rather than re-derived:
|
||||
· **The frame** — ref `GameMenuFrame.xml` (patch.MPQ, extracted): 195x246, `frameStrata`
|
||||
DIALOG, the UI-DialogBox backdrop with the 11/12/12/11 insets and 32px tile/edge, the
|
||||
@@ -263,7 +273,16 @@
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="GameMenuButtonSupport" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnLoad>GameMenuButton_Pending(self, MACROS)</OnLoad></Scripts>
|
||||
<!-- LIVE since decision 0983 (MacroFrame.xml). The ref's own body
|
||||
(GameMenuFrame.xml l.118-122): hide the menu, then ShowMacroFrame(). -->
|
||||
<Scripts>
|
||||
<OnLoad>self:SetText(MACROS)</OnLoad>
|
||||
<OnClick>
|
||||
PlaySound("igMainMenuOption")
|
||||
HideUIPanel(getglobal("GameMenuFrame"))
|
||||
ShowMacroFrame()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The three that are live. The ref's OnShow on both Logout and Quit disables the
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
<Ui>
|
||||
<!--
|
||||
benilla's MACRO window (decision 0983) — the 384x512 macro editor + its name/icon popup,
|
||||
transcribed from the real 1.12 `Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml`
|
||||
and `.lua` (extracted from the MPQ chain to session scratch; director-local reference
|
||||
artifacts, never committed — the 0068/0138 pattern. Line cites below are into that
|
||||
extraction). All the art it names is shipped: `Interface\MacroFrame\MacroFrame-Icon`,
|
||||
`-BotLeft`, `-BotRight`, `MacroPopup-{Top,Bot}{Left,Right}` (verified present in
|
||||
interface.MPQ), plus the PaperDollInfoFrame top halves and the ClassTrainerFrame bar the
|
||||
ref itself borrows.
|
||||
|
||||
Docks the left UIPanel slot with the ref's own registration row
|
||||
(`UIPanelWindows["MacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }`,
|
||||
Blizzard_MacroUI.lua:7 — mirrored in UiPanels.xml). `whileDead` is the reference's, and it
|
||||
matters: writing a macro while you run back as a ghost is a real thing people do.
|
||||
|
||||
The usual house deviation, applied consistently: every frame here is Benilla-prefixed. The
|
||||
ref's global entry point `ShowMacroFrame()` keeps its BARE name (UIParent.lua:198 — the
|
||||
game menu's Macros button and `SlashCmdList["MACRO"]` both call it), matching this
|
||||
codebase's own `ToggleCharacter`/`ToggleQuestLog`/`ToggleSpellBook` precedent.
|
||||
|
||||
THE MODEL IS THE ENGINE'S. Unlike every other window in this folder, the macro table is not
|
||||
an app feed: 1.12 macros have no server side at all, so `benilla_ui::script::macros` owns
|
||||
the table and this file drives it through the reference's own API —
|
||||
`GetNumMacros`/`GetMacroInfo`/`GetMacroIndexByName`/`CreateMacro`/`EditMacro`/
|
||||
`DeleteMacro`/`PickupMacro`/`GetNumMacroIcons`/`GetMacroIconInfo`. `UPDATE_MACROS` (the
|
||||
ref's own event) fires on every change, including the load at world entry, and is what
|
||||
redraws this window.
|
||||
|
||||
DELIBERATE DEVIATIONS, each re-cited at its site:
|
||||
- THE BODY BOX SIZES ITS OWN SCROLL CHILD. The ref's `MacroFrameScrollFrame` inherits
|
||||
`UIPanelScrollFrameTemplate`, whose C-side `UpdateScrollChildRect` auto-grows the child
|
||||
to the text — the engine gap TalentFrame.xml and QuestLogFrame.xml both document. Here
|
||||
the child is an EditBox whose height is recomputed from the text on every change
|
||||
(`BenillaMacroFrameText_Resize`), over-estimating wrapped lines rather than clipping.
|
||||
- `MacroFrameTextButton` (the ref's invisible click-to-focus plate over the body) is kept
|
||||
— this engine's EditBox does not self-focus on a click through a ScrollFrame.
|
||||
- The ref's `PanelTemplates_TabResize(-15, ...)` width tuning and its manual
|
||||
`HighlightTexture:SetWidth(GetTextWidth() + 31)` are dropped: `TabButtonTemplate`
|
||||
(UiPanels.xml) already does its own text-fit on load, and re-doing it here would fight
|
||||
it (the same call the trainer/talent tabs already omit).
|
||||
- `PlaySound("igCharacterInfoOpen"/"Close"/"gsTitleOptionOK")` are kept — this engine has
|
||||
PlaySound and those kit names resolve.
|
||||
-->
|
||||
|
||||
<Script><![CDATA[
|
||||
-- ref Blizzard_MacroUI.lua:1-5, verbatim.
|
||||
MAX_MACROS = 18
|
||||
NUM_MACRO_ICONS_SHOWN = 20
|
||||
NUM_ICONS_PER_ROW = 5
|
||||
NUM_ICON_ROWS = 4
|
||||
MACRO_ICON_ROW_HEIGHT = 36
|
||||
|
||||
-- The body box's own scroll sizing (this file's header comment). `letters="255"` caps the
|
||||
-- body, and the box is ~286 wide at GameFontHighlightSmall, so ~48 characters fit a line.
|
||||
-- The child height is `(explicit lines + wrap allowance) * line height`, which can only
|
||||
-- OVER-estimate — an over-estimate costs a little empty scroll room, an under-estimate
|
||||
-- clips text the player typed.
|
||||
BENILLA_MACRO_LINE_H = 14
|
||||
BENILLA_MACRO_CHARS_PER_LINE = 48
|
||||
BENILLA_MACRO_BODY_H = 85 -- the ref's own MacroFrameScrollFrame height (ref l.396)
|
||||
|
||||
function BenillaMacroFrameText_Resize()
|
||||
local box = getglobal("BenillaMacroFrameText")
|
||||
local text = box:GetText() or ""
|
||||
local lines, pos = 1, 1
|
||||
while true do
|
||||
local at = string.find(text, "\n", pos, true)
|
||||
if not at then break end
|
||||
lines, pos = lines + 1, at + 1
|
||||
end
|
||||
local wrap = math.ceil(string.len(text) / BENILLA_MACRO_CHARS_PER_LINE)
|
||||
local h = (lines + wrap) * BENILLA_MACRO_LINE_H
|
||||
if h < BENILLA_MACRO_BODY_H then h = BENILLA_MACRO_BODY_H end
|
||||
box:SetHeight(h)
|
||||
local scroll = getglobal("BenillaMacroFrameScrollFrame")
|
||||
scroll:UpdateScrollChildRect()
|
||||
BenillaScroll_SyncBar(scroll)
|
||||
end
|
||||
|
||||
function BenillaMacroFrameScrollFrame_OnLoad(scrollFrame)
|
||||
scrollFrame:SetScrollChild(getglobal("BenillaMacroFrameText"))
|
||||
BenillaScroll_SyncBar(scrollFrame)
|
||||
end
|
||||
|
||||
-- Tab 2's label is the ref's own `format(CHARACTER_SPECIFIC_MACROS, UnitName("player"))`
|
||||
-- (ref MacroFrame.xml l.509), re-derived on every show because the player name is not known
|
||||
-- at load time here. Guarded on the global string itself: a VM without GlobalStrings (the
|
||||
-- shipped-XML load test) must not die in an OnLoad.
|
||||
function BenillaMacroFrame_TabTwoLabel()
|
||||
if not CHARACTER_SPECIFIC_MACROS then return "Character" end
|
||||
return format(CHARACTER_SPECIFIC_MACROS, UnitName("player") or "")
|
||||
end
|
||||
|
||||
-- ref UIParent.lua:198-203. No LoadAddOn here: benilla has no load-on-demand addon layer,
|
||||
-- so the window is always loaded and this is a plain Show.
|
||||
function ShowMacroFrame()
|
||||
ShowUIPanel(getglobal("BenillaMacroFrame"))
|
||||
end
|
||||
|
||||
-- ref Blizzard_MacroUI.lua:13-17.
|
||||
function BenillaMacroFrame_OnLoad(frame)
|
||||
BenillaMacroFrame_SetAccountMacros()
|
||||
PanelTemplates_SetNumTabs(frame, 2)
|
||||
PanelTemplates_SetTab(frame, 1)
|
||||
end
|
||||
|
||||
-- ref l.19-29. The OnHide save is the reference's own "closing the window commits the
|
||||
-- body you were typing" — the single most load-bearing line in this file.
|
||||
function BenillaMacroFrame_OnShow()
|
||||
BenillaMacroFrame_Update()
|
||||
PlaySound("igCharacterInfoOpen")
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_OnHide()
|
||||
getglobal("BenillaMacroPopupFrame"):Hide()
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
PlaySound("igCharacterInfoClose")
|
||||
end
|
||||
|
||||
-- ref l.31-49.
|
||||
function BenillaMacroFrame_SetAccountMacros()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
frame.macroBase = 0
|
||||
local numAccountMacros = GetNumMacros()
|
||||
if numAccountMacros > 0 then
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + 1)
|
||||
else
|
||||
BenillaMacroFrame_SelectMacro(nil)
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_SetCharacterMacros()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
frame.macroBase = MAX_MACROS
|
||||
local numAccountMacros, numCharacterMacros = GetNumMacros()
|
||||
if numCharacterMacros > 0 then
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + 1)
|
||||
else
|
||||
BenillaMacroFrame_SelectMacro(nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- DEVIATION (this window is always loaded; the ref's is LoadOnDemand). The ref's
|
||||
-- `MacroFrame_OnLoad` — which picks the first macro — runs the instant the addon loads,
|
||||
-- i.e. immediately before the first open, so it always sees the real macro table. Ours
|
||||
-- runs at boot, before the app has loaded a single macro from disk, so the selection has
|
||||
-- to be re-established whenever the table can have moved. One place: the top of _Update,
|
||||
-- which every show, every UPDATE_MACROS, every tab switch and every delete already runs
|
||||
-- through. A still-valid selection is left alone (reopening the window keeps your place,
|
||||
-- exactly as the ref's does).
|
||||
function BenillaMacroFrame_EnsureSelection()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
local numAccount, numChar = GetNumMacros()
|
||||
local numMacros = numAccount
|
||||
if frame.macroBase ~= 0 then numMacros = numChar end
|
||||
local sel = frame.selectedMacro
|
||||
if sel and sel > frame.macroBase and sel <= (frame.macroBase + numMacros) then
|
||||
return
|
||||
end
|
||||
if numMacros > 0 then
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + 1)
|
||||
else
|
||||
BenillaMacroFrame_SelectMacro(nil)
|
||||
end
|
||||
end
|
||||
|
||||
-- ref l.51-120, transcribed.
|
||||
function BenillaMacroFrame_Update()
|
||||
BenillaMacroFrame_EnsureSelection()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
local numAccountMacros, numCharacterMacros = GetNumMacros()
|
||||
local numMacros
|
||||
if frame.macroBase == 0 then
|
||||
numMacros = numAccountMacros
|
||||
else
|
||||
numMacros = numCharacterMacros
|
||||
end
|
||||
|
||||
for i = 1, MAX_MACROS do
|
||||
local macroButton = getglobal("BenillaMacroButton" .. i)
|
||||
local macroIcon = getglobal("BenillaMacroButton" .. i .. "Icon")
|
||||
local macroName = getglobal("BenillaMacroButton" .. i .. "Name")
|
||||
if i <= numMacros then
|
||||
local name, texture, body = GetMacroInfo(frame.macroBase + i)
|
||||
macroIcon:SetTexture(texture)
|
||||
macroName:SetText(name)
|
||||
macroButton:Enable()
|
||||
if frame.selectedMacro and (i == (frame.selectedMacro - frame.macroBase)) then
|
||||
macroButton:SetChecked(1)
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):SetText(name)
|
||||
getglobal("BenillaMacroFrameText"):SetText(body)
|
||||
getglobal("BenillaMacroFrameSelectedMacroButton"):SetID(i)
|
||||
getglobal("BenillaMacroFrameSelectedMacroButtonIcon"):SetTexture(texture)
|
||||
else
|
||||
macroButton:SetChecked(0)
|
||||
end
|
||||
else
|
||||
macroButton:SetChecked(0)
|
||||
macroIcon:SetTexture("")
|
||||
macroName:SetText("")
|
||||
macroButton:Disable()
|
||||
end
|
||||
end
|
||||
|
||||
if frame.selectedMacro ~= nil then
|
||||
BenillaMacroFrame_ShowDetails()
|
||||
else
|
||||
BenillaMacroFrame_HideDetails()
|
||||
end
|
||||
|
||||
if numMacros == MAX_MACROS then
|
||||
getglobal("BenillaMacroNewButton"):Disable()
|
||||
else
|
||||
getglobal("BenillaMacroNewButton"):Enable()
|
||||
end
|
||||
|
||||
if getglobal("BenillaMacroPopupFrame"):IsVisible() then
|
||||
getglobal("BenillaMacroEditButton"):Disable()
|
||||
getglobal("BenillaMacroDeleteButton"):Disable()
|
||||
else
|
||||
getglobal("BenillaMacroEditButton"):Enable()
|
||||
getglobal("BenillaMacroDeleteButton"):Enable()
|
||||
end
|
||||
|
||||
if not frame.selectedMacro then
|
||||
getglobal("BenillaMacroDeleteButton"):Disable()
|
||||
end
|
||||
-- The body's scroll range follows whatever SetText just put in the box.
|
||||
BenillaMacroFrameText_Resize()
|
||||
end
|
||||
|
||||
-- ref l.122-126. The engine calls nothing by this name; the spellbook's shift-click does
|
||||
-- (SpellBookFrame.xml, ref SpellBookFrame.lua:272-280) — the "shift click a spell in your
|
||||
-- spellbook with the macro UI open" line MACRO_HELP_TEXT_LINE5 advertises.
|
||||
function BenillaMacroFrame_AddMacroLine(line)
|
||||
local box = getglobal("BenillaMacroFrameText")
|
||||
if box:IsVisible() then
|
||||
box:SetText((box:GetText() or "") .. line)
|
||||
getglobal("BenillaMacroFrame").textChanged = 1
|
||||
BenillaMacroFrameText_Resize()
|
||||
end
|
||||
end
|
||||
|
||||
-- ref l.128-138.
|
||||
function BenillaMacroButton_OnClick(button)
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
BenillaMacroFrame_SelectMacro(frame.macroBase + button:GetID())
|
||||
BenillaMacroFrame_Update()
|
||||
getglobal("BenillaMacroPopupFrame"):Hide()
|
||||
getglobal("BenillaMacroFrameText"):ClearFocus()
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_SelectMacro(id)
|
||||
getglobal("BenillaMacroFrame").selectedMacro = id
|
||||
end
|
||||
|
||||
-- ref l.140-150.
|
||||
function BenillaMacroNewButton_OnClick()
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
popup.mode = "new"
|
||||
popup:Show()
|
||||
end
|
||||
|
||||
function BenillaMacroEditButton_OnClick()
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
popup.mode = "edit"
|
||||
popup:Show()
|
||||
end
|
||||
|
||||
-- ref l.152-169.
|
||||
function BenillaMacroFrame_HideDetails()
|
||||
getglobal("BenillaMacroEditButton"):Hide()
|
||||
getglobal("BenillaMacroFrameCharLimitText"):Hide()
|
||||
getglobal("BenillaMacroFrameEnterMacroText"):Hide()
|
||||
getglobal("BenillaMacroFrameText"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroBackground"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroButton"):Hide()
|
||||
end
|
||||
|
||||
function BenillaMacroFrame_ShowDetails()
|
||||
getglobal("BenillaMacroEditButton"):Show()
|
||||
getglobal("BenillaMacroFrameCharLimitText"):Show()
|
||||
getglobal("BenillaMacroFrameEnterMacroText"):Show()
|
||||
getglobal("BenillaMacroFrameText"):Show()
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):Show()
|
||||
getglobal("BenillaMacroFrameSelectedMacroBackground"):Show()
|
||||
getglobal("BenillaMacroFrameSelectedMacroButton"):Show()
|
||||
end
|
||||
|
||||
-- ref l.171-213.
|
||||
function BenillaMacroPopupFrame_OnShow(popup)
|
||||
if popup.mode == "new" then
|
||||
getglobal("BenillaMacroFrameText"):Hide()
|
||||
getglobal("BenillaMacroFrameSelectedMacroButtonIcon"):SetTexture("")
|
||||
popup.selectedIcon = nil
|
||||
end
|
||||
getglobal("BenillaMacroFrameText"):ClearFocus()
|
||||
getglobal("BenillaMacroPopupEditBox"):SetFocus()
|
||||
|
||||
PlaySound("igCharacterInfoOpen")
|
||||
BenillaMacroPopupFrame_Update(true) -- the ONE caller that resets the name box
|
||||
BenillaMacroPopupOkayButton_Update()
|
||||
|
||||
getglobal("BenillaMacroEditButton"):Disable()
|
||||
getglobal("BenillaMacroDeleteButton"):Disable()
|
||||
getglobal("BenillaMacroNewButton"):Disable()
|
||||
getglobal("BenillaMacroFrameTab1"):Disable()
|
||||
getglobal("BenillaMacroFrameTab2"):Disable()
|
||||
end
|
||||
|
||||
function BenillaMacroPopupFrame_OnHide(popup)
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
if popup.mode == "new" then
|
||||
getglobal("BenillaMacroFrameText"):Show()
|
||||
getglobal("BenillaMacroFrameText"):SetFocus()
|
||||
end
|
||||
|
||||
getglobal("BenillaMacroEditButton"):Enable()
|
||||
getglobal("BenillaMacroDeleteButton"):Enable()
|
||||
local numAccountMacros, numCharacterMacros = GetNumMacros()
|
||||
local numMacros
|
||||
if frame.macroBase == 0 then
|
||||
numMacros = numAccountMacros
|
||||
else
|
||||
numMacros = numCharacterMacros
|
||||
end
|
||||
if numMacros < MAX_MACROS then
|
||||
getglobal("BenillaMacroNewButton"):Enable()
|
||||
end
|
||||
PanelTemplates_UpdateTabs(frame)
|
||||
end
|
||||
|
||||
-- ref l.215-250, with ONE faithfulness note that is easy to get wrong. The ref's body
|
||||
-- opens `if ( this.mode == "new" ) then MacroPopupEditBox:SetText("")` — and `this` there
|
||||
-- is the FrameScript caller, not the popup: from `MacroPopupFrame_OnShow` it IS the popup
|
||||
-- (so a fresh "new" clears the name box), but from `MacroPopupButton_OnClick` it is the
|
||||
-- clicked BUTTON, whose `.mode` is nil, so neither branch runs and the name you already
|
||||
-- typed survives the icon click. Substituting the popup for `this` would blank the name on
|
||||
-- every icon click — the reference does not do that. Modelled explicitly instead: only the
|
||||
-- OnShow path asks for the name reset.
|
||||
function BenillaMacroPopupFrame_Update(resetName)
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local scrollFrame = getglobal("BenillaMacroPopupScrollFrame")
|
||||
local numMacroIcons = GetNumMacroIcons()
|
||||
local macroPopupOffset = BenillaFauxScrollFrame_GetOffset(scrollFrame)
|
||||
|
||||
if resetName then
|
||||
if popup.mode == "new" then
|
||||
getglobal("BenillaMacroPopupEditBox"):SetText("")
|
||||
elseif popup.mode == "edit" then
|
||||
local name = GetMacroInfo(getglobal("BenillaMacroFrame").selectedMacro)
|
||||
getglobal("BenillaMacroPopupEditBox"):SetText(name or "")
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, NUM_MACRO_ICONS_SHOWN do
|
||||
local icon = getglobal("BenillaMacroPopupButton" .. i .. "Icon")
|
||||
local button = getglobal("BenillaMacroPopupButton" .. i)
|
||||
local index = (macroPopupOffset * NUM_ICONS_PER_ROW) + i
|
||||
if index <= numMacroIcons then
|
||||
icon:SetTexture(GetMacroIconInfo(index))
|
||||
button:Show()
|
||||
else
|
||||
icon:SetTexture("")
|
||||
button:Hide()
|
||||
end
|
||||
if index == popup.selectedIcon then
|
||||
button:SetChecked(1)
|
||||
else
|
||||
button:SetChecked(nil)
|
||||
end
|
||||
end
|
||||
|
||||
BenillaFauxScrollFrame_Update(scrollFrame,
|
||||
math.ceil(numMacroIcons / NUM_ICONS_PER_ROW), NUM_ICON_ROWS, MACRO_ICON_ROW_HEIGHT)
|
||||
end
|
||||
|
||||
-- ref l.252-261. The "edit" arm's second Enable is the ref's own: renaming an existing
|
||||
-- macro does not require re-picking an icon.
|
||||
function BenillaMacroPopupOkayButton_Update()
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local okay = getglobal("BenillaMacroPopupOkayButton")
|
||||
local text = getglobal("BenillaMacroPopupEditBox"):GetText() or ""
|
||||
if string.len(text) > 0 and popup.selectedIcon then
|
||||
okay:Enable()
|
||||
else
|
||||
okay:Disable()
|
||||
end
|
||||
if popup.mode == "edit" and string.len(text) > 0 then
|
||||
okay:Enable()
|
||||
end
|
||||
end
|
||||
|
||||
-- ref l.263-268.
|
||||
function BenillaMacroPopupButton_OnClick(button)
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local scrollFrame = getglobal("BenillaMacroPopupScrollFrame")
|
||||
popup.selectedIcon = button:GetID()
|
||||
+ (BenillaFauxScrollFrame_GetOffset(scrollFrame) * NUM_ICONS_PER_ROW)
|
||||
getglobal("BenillaMacroFrameSelectedMacroButtonIcon")
|
||||
:SetTexture(GetMacroIconInfo(popup.selectedIcon))
|
||||
BenillaMacroPopupOkayButton_Update()
|
||||
BenillaMacroPopupFrame_Update()
|
||||
end
|
||||
|
||||
-- ref l.270-280. `perCharacter` is the ref's own `(MacroFrame.macroBase > 0)`.
|
||||
function BenillaMacroPopupOkayButton_OnClick()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
local name = getglobal("BenillaMacroPopupEditBox"):GetText()
|
||||
local index = 1
|
||||
if popup.mode == "new" then
|
||||
index = CreateMacro(name, popup.selectedIcon, nil, nil, (frame.macroBase > 0))
|
||||
elseif popup.mode == "edit" then
|
||||
index = EditMacro(frame.selectedMacro, name, popup.selectedIcon)
|
||||
end
|
||||
popup:Hide()
|
||||
BenillaMacroFrame_SelectMacro(index)
|
||||
BenillaMacroFrame_Update()
|
||||
end
|
||||
|
||||
-- ref l.282-287.
|
||||
function BenillaMacroFrame_SaveMacro()
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
if frame.textChanged and frame.selectedMacro then
|
||||
EditMacro(frame.selectedMacro, nil, nil, getglobal("BenillaMacroFrameText"):GetText())
|
||||
frame.textChanged = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- The body box's OnTextChanged (ref MacroFrame.xml l.411-427): mark dirty, close a "new"
|
||||
-- popup, refresh the character counter, and keep the view pinned to the bottom while
|
||||
-- typing (the ref's own scrollBar:SetValue(max) tail).
|
||||
function BenillaMacroFrameText_OnTextChanged(box)
|
||||
local frame = getglobal("BenillaMacroFrame")
|
||||
frame.textChanged = 1
|
||||
local popup = getglobal("BenillaMacroPopupFrame")
|
||||
if popup.mode == "new" then
|
||||
popup:Hide()
|
||||
end
|
||||
getglobal("BenillaMacroFrameCharLimitText")
|
||||
:SetText(format(MACROFRAME_CHAR_LIMIT, box:GetNumLetters()))
|
||||
BenillaMacroFrameText_Resize()
|
||||
local bar = getglobal("BenillaMacroFrameScrollFrameBar")
|
||||
local _, maxValue = bar:GetMinMaxValues()
|
||||
if maxValue > 0 and box.maxScroll ~= maxValue then
|
||||
box.maxScroll = maxValue
|
||||
bar:SetValue(maxValue)
|
||||
end
|
||||
end
|
||||
|
||||
-- The window's own event driver: UPDATE_MACROS is the ref's event for "the macro table
|
||||
-- changed" and covers BOTH an edit made here and the app's load at world entry, so a
|
||||
-- freshly-entered world shows the saved macros without this file knowing about files.
|
||||
function BenillaMacroFrame_OnEvent(frame, event)
|
||||
if event == "UPDATE_MACROS" then
|
||||
BenillaMacroFrame_EnsureSelection()
|
||||
if frame:IsVisible() then
|
||||
BenillaMacroFrame_Update()
|
||||
end
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
ref MacroFrameButtonTemplate (l.4-52): a 36x36 CheckButton whose NormalTexture IS the icon
|
||||
($parentIcon), over the empty-slot plate, with the name outlined along the bottom.
|
||||
-->
|
||||
<CheckButton name="BenillaMacroFrameButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="36" y="36"/></Size>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<FontString name="$parentName" inherits="GameFontHighlightSmallOutline">
|
||||
<Size><AbsDimension x="36" y="10"/></Size>
|
||||
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="2"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture file="Interface\Buttons\UI-EmptySlot-Disabled">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<NormalTexture name="$parentIcon">
|
||||
<Size><AbsDimension x="36" y="36"/></Size>
|
||||
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
|
||||
</NormalTexture>
|
||||
<HighlightTexture alphaMode="ADD" file="Interface\Buttons\ButtonHilight-Square"/>
|
||||
<CheckedTexture alphaMode="ADD" file="Interface\Buttons\CheckButtonHilight"/>
|
||||
</CheckButton>
|
||||
|
||||
<!-- ref MacroButtonTemplate (l.53-65): the list button — click selects, drag picks up. -->
|
||||
<CheckButton name="BenillaMacroButtonTemplate" inherits="BenillaMacroFrameButtonTemplate" virtual="true">
|
||||
<Scripts>
|
||||
<OnLoad>self:RegisterForDrag("LeftButton")</OnLoad>
|
||||
<OnClick>BenillaMacroButton_OnClick(self)</OnClick>
|
||||
<OnDragStart>PickupMacro(getglobal("BenillaMacroFrame").macroBase + self:GetID())</OnDragStart>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<!-- ref MacroPopupButtonTemplate (l.66-72). -->
|
||||
<CheckButton name="BenillaMacroPopupButtonTemplate" inherits="BenillaMacroFrameButtonTemplate" virtual="true">
|
||||
<Scripts>
|
||||
<OnClick>BenillaMacroPopupButton_OnClick(self)</OnClick>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<!-- The panel button face (the TrainerFrame precedent — this engine ships no
|
||||
UIPanelButtonTemplate/UIPanelButtonGrayTemplate). -->
|
||||
<Button name="BenillaMacroPanelButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="80" y="22"/></Size>
|
||||
<NormalFont inherits="GameFontNormal"/>
|
||||
<HighlightFont inherits="GameFontHighlight"/>
|
||||
<DisabledFont inherits="GameFontDisable"/>
|
||||
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></NormalTexture>
|
||||
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></PushedTexture>
|
||||
<DisabledTexture file="Interface\Buttons\UI-Panel-Button-Disabled"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></DisabledTexture>
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD"><TexCoords left="0" right="0.625" top="0" bottom="0.6875"/></HighlightTexture>
|
||||
</Button>
|
||||
|
||||
<!-- ref MacroFrame (l.73-618). No <Anchors>: ShowUIPanel SetPoints this into the left slot. -->
|
||||
<Frame name="BenillaMacroFrame" hidden="true">
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BenillaMacroFramePortrait" file="Interface\MacroFrame\MacroFrame-Icon">
|
||||
<Size><AbsDimension x="60" y="60"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<!-- The four quadrant slabs: the ref borrows PaperDollInfoFrame's two top halves and
|
||||
uses MacroFrame's own two bottoms (ref l.103-134). -->
|
||||
<Layer level="BORDER">
|
||||
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\PaperDollInfoFrame\UI-Character-General-TopRight">
|
||||
<Size><AbsDimension x="128" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroFrame-BotLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroFrame-BotRight">
|
||||
<Size><AbsDimension x="128" y="256"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<FontString inherits="GameFontNormal" text="CREATE_MACROS">
|
||||
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-17"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
<Layer level="ARTWORK">
|
||||
<!-- The divider between the 18-button grid and the detail pane (ref l.146-171). -->
|
||||
<Texture name="BenillaMacroHorizontalBarLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
|
||||
<Size><AbsDimension x="256" y="16"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="15" y="-220"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0" bottom="0.25"/>
|
||||
</Texture>
|
||||
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-HorizontalBar">
|
||||
<Size><AbsDimension x="75" y="16"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroHorizontalBarLeft" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0" right="0.29296875" top="0.25" bottom="0.5"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMacroFrameSelectedMacroBackground" file="Interface\Buttons\UI-EmptySlot">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="16" y="-228"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<FontString name="BenillaMacroFrameSelectedMacroName" inherits="GameFontNormalLarge" justifyH="LEFT">
|
||||
<Size><AbsDimension x="256" y="16"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground" relativePoint="TOPRIGHT">
|
||||
<Offset><AbsDimension x="-4" y="-15"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<FontString name="BenillaMacroFrameEnterMacroText" inherits="GameFontHighlightSmall" text="ENTER_MACRO_LABEL">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="8" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<FontString name="BenillaMacroFrameCharLimitText" inherits="GameFontHighlightSmall">
|
||||
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="-15" y="105"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The detail pane's own big button: a drag source for the selected macro (ref
|
||||
l.217-231 — its OnClick unchecks itself and picks up, never re-selects). -->
|
||||
<CheckButton name="BenillaMacroFrameSelectedMacroButton" inherits="BenillaMacroButtonTemplate" id="0">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground">
|
||||
<Offset><AbsDimension x="14" y="-14"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
self:SetChecked(nil)
|
||||
PickupMacro(getglobal("BenillaMacroFrame").selectedMacro)
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<!-- The 18-button grid: 3 rows of 6 (ref l.232-393; the ref's own 13px column gap and
|
||||
10px row gap). -->
|
||||
<CheckButton name="BenillaMacroButton1" inherits="BenillaMacroButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="42" y="-83"/></Offset></Anchor></Anchors>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaMacroButton2" inherits="BenillaMacroButtonTemplate" id="2"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton1" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton3" inherits="BenillaMacroButtonTemplate" id="3"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton2" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton4" inherits="BenillaMacroButtonTemplate" id="4"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton3" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton5" inherits="BenillaMacroButtonTemplate" id="5"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton4" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton6" inherits="BenillaMacroButtonTemplate" id="6"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton5" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton7" inherits="BenillaMacroButtonTemplate" id="7"><Anchors><Anchor point="TOP" relativeTo="BenillaMacroButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton8" inherits="BenillaMacroButtonTemplate" id="8"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton7" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton9" inherits="BenillaMacroButtonTemplate" id="9"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton8" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton10" inherits="BenillaMacroButtonTemplate" id="10"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton9" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton11" inherits="BenillaMacroButtonTemplate" id="11"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton10" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton12" inherits="BenillaMacroButtonTemplate" id="12"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton11" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton13" inherits="BenillaMacroButtonTemplate" id="13"><Anchors><Anchor point="TOP" relativeTo="BenillaMacroButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton14" inherits="BenillaMacroButtonTemplate" id="14"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton13" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton15" inherits="BenillaMacroButtonTemplate" id="15"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton14" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton16" inherits="BenillaMacroButtonTemplate" id="16"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton15" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton17" inherits="BenillaMacroButtonTemplate" id="17"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton16" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroButton18" inherits="BenillaMacroButtonTemplate" id="18"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroButton17" relativePoint="RIGHT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
|
||||
<!-- The body's tooltip-backdrop plate (ref MacroFrameTextBackground l.449-477), drawn
|
||||
before the scroll frame so the text sits on it. -->
|
||||
<Frame name="BenillaMacroFrameTextBackground">
|
||||
<Size><AbsDimension x="322" y="95"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="18" y="-305"/></Offset></Anchor></Anchors>
|
||||
<Backdrop bgFile="Interface\Tooltips\UI-Tooltip-Background" edgeFile="Interface\Tooltips\UI-Tooltip-Border" tile="true">
|
||||
<EdgeSize><AbsValue val="16"/></EdgeSize>
|
||||
<TileSize><AbsValue val="16"/></TileSize>
|
||||
<BackgroundInsets><AbsInset left="5" right="5" top="5" bottom="5"/></BackgroundInsets>
|
||||
</Backdrop>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
self:SetBackdropBorderColor(TOOLTIP_DEFAULT_COLOR.r, TOOLTIP_DEFAULT_COLOR.g, TOOLTIP_DEFAULT_COLOR.b)
|
||||
self:SetBackdropColor(TOOLTIP_DEFAULT_BACKGROUND_COLOR.r, TOOLTIP_DEFAULT_BACKGROUND_COLOR.g, TOOLTIP_DEFAULT_BACKGROUND_COLOR.b)
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The body editor (ref MacroFrameScrollFrame l.394-435). A real ScrollFrame with a
|
||||
self-sizing EditBox child + a real draggable Slider — this file's header comment. -->
|
||||
<ScrollFrame name="BenillaMacroFrameScrollFrame">
|
||||
<Size><AbsDimension x="286" y="85"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="11" y="-18"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Frames>
|
||||
<!-- `$parentBar` is the real-scroll kit's own naming contract
|
||||
(ScrollTemplates.xml: `BenillaScroll_SyncBar` derives the bar from the
|
||||
frame's name). Its OnValueChanged REPLACES the faux handler the shared
|
||||
Slider template carries — the TalentFrame precedent. -->
|
||||
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
|
||||
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged>
|
||||
</Scripts>
|
||||
</Slider>
|
||||
<EditBox name="BenillaMacroFrameText" multiLine="true" letters="255" autoFocus="false">
|
||||
<Size><AbsDimension x="286" y="85"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<FontString inherits="GameFontHighlightSmall"/>
|
||||
<Scripts>
|
||||
<OnTextChanged>BenillaMacroFrameText_OnTextChanged(self)</OnTextChanged>
|
||||
<OnEscapePressed>self:ClearFocus()</OnEscapePressed>
|
||||
</Scripts>
|
||||
</EditBox>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaMacroFrameScrollFrame_OnLoad(self)</OnLoad>
|
||||
</Scripts>
|
||||
</ScrollFrame>
|
||||
|
||||
<!-- ref MacroFrameTextButton (l.436-448): the click-to-focus plate over the body. -->
|
||||
<Button name="BenillaMacroFrameTextButton">
|
||||
<Size><AbsDimension x="286" y="85"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameScrollFrame" relativePoint="TOPLEFT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>getglobal("BenillaMacroFrameText"):SetFocus()</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<!-- The two tabs (ref l.478-520). Tab 2's label is the ref's own
|
||||
`format(CHARACTER_SPECIFIC_MACROS, UnitName("player"))`, re-set on every show
|
||||
because the player name is not known at load time in this client. -->
|
||||
<Button name="BenillaMacroFrameTab1" inherits="TabButtonTemplate" text="GENERAL_MACROS" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="65" y="-39"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
PanelTemplates_SetTab(getglobal("BenillaMacroFrame"), self:GetID())
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
BenillaMacroFrame_SetAccountMacros()
|
||||
BenillaMacroFrame_Update()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroFrameTab2" inherits="TabButtonTemplate" id="2">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroFrameTab1" relativePoint="RIGHT"/></Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>self:SetText(BenillaMacroFrame_TabTwoLabel())</OnLoad>
|
||||
<OnClick>
|
||||
PanelTemplates_SetTab(getglobal("BenillaMacroFrame"), self:GetID())
|
||||
BenillaMacroFrame_SaveMacro()
|
||||
BenillaMacroFrame_SetCharacterMacros()
|
||||
BenillaMacroFrame_Update()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
|
||||
<Button name="BenillaMacroEditButton" inherits="BenillaMacroPanelButtonTemplate" text="CHANGE_MACRO_NAME_ICON">
|
||||
<Size><AbsDimension x="170" y="22"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrameSelectedMacroBackground">
|
||||
<Offset><AbsDimension x="51" y="-30"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts><OnClick>BenillaMacroEditButton_OnClick()</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroDeleteButton" inherits="BenillaMacroPanelButtonTemplate" text="DELETE">
|
||||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="17" y="79"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
-- The ref relies on the button being Disabled (MacroFrame_Update) to keep
|
||||
-- `selectedMacro` non-nil here; guard anyway — a script Click() bypasses
|
||||
-- the disabled state and DeleteMacro(nil) is a hard error, not a no-op.
|
||||
if not getglobal("BenillaMacroFrame").selectedMacro then return end
|
||||
DeleteMacro(getglobal("BenillaMacroFrame").selectedMacro)
|
||||
BenillaMacroFrame_SetAccountMacros()
|
||||
if getglobal("BenillaMacroFrame").macroBase ~= 0 then
|
||||
BenillaMacroFrame_SetCharacterMacros()
|
||||
end
|
||||
BenillaMacroFrame_Update()
|
||||
getglobal("BenillaMacroFrameText"):ClearFocus()
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroNewButton" inherits="BenillaMacroPanelButtonTemplate" text="NEW">
|
||||
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="222" y="-422"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>BenillaMacroNewButton_OnClick()</OnClick></Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroExitButton" inherits="BenillaMacroPanelButtonTemplate" text="EXIT">
|
||||
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="303" y="-422"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnClick>HideUIPanel(getglobal("BenillaMacroFrame"))</OnClick></Scripts>
|
||||
</Button>
|
||||
<!-- The top-right X (ref a UIPanelCloseButton at TOPRIGHT (-29,-8); the minimize-button
|
||||
art stands in — this engine ships no UIPanelCloseButton, TrainerFrame's precedent). -->
|
||||
<Button name="BenillaMacroFrameCloseButton">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor></Anchors>
|
||||
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
|
||||
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
|
||||
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
|
||||
<Scripts><OnClick>HideUIPanel(getglobal("BenillaMacroFrame"))</OnClick></Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>
|
||||
BenillaMacroFrame_OnLoad(self)
|
||||
self:RegisterEvent("UPDATE_MACROS")
|
||||
</OnLoad>
|
||||
<OnEvent>BenillaMacroFrame_OnEvent(self, event)</OnEvent>
|
||||
<OnShow>
|
||||
getglobal("BenillaMacroFrameTab2"):SetText(BenillaMacroFrame_TabTwoLabel())
|
||||
BenillaMacroFrame_OnShow()
|
||||
UpdateMicroButtons()
|
||||
</OnShow>
|
||||
<OnHide>
|
||||
BenillaMacroFrame_OnHide()
|
||||
UpdateMicroButtons()
|
||||
</OnHide>
|
||||
<OnMouseWheel>BenillaScroll_OnMouseWheel(getglobal("BenillaMacroFrameScrollFrame"), arg1)</OnMouseWheel>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- ref MacroPopupFrame (l.619-1005): the name + icon chooser, hung off the window's right edge. -->
|
||||
<Frame name="BenillaMacroPopupFrame" toplevel="true" hidden="true">
|
||||
<Size><AbsDimension x="297" y="298"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaMacroFrame" relativePoint="TOPRIGHT">
|
||||
<Offset><AbsDimension x="-40" y="-40"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-TopLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-TopRight">
|
||||
<Size><AbsDimension x="64" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="0"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-BotLeft">
|
||||
<Size><AbsDimension x="256" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-256"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\MacroFrame\MacroPopup-BotRight">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="256" y="-256"/></Offset></Anchor></Anchors>
|
||||
</Texture>
|
||||
<FontString inherits="GameFontHighlightSmall" text="MACRO_POPUP_TEXT">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-21"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
<FontString inherits="GameFontHighlightSmall" text="MACRO_POPUP_CHOOSE_ICON">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-69"/></Offset></Anchor></Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<EditBox name="BenillaMacroPopupEditBox" letters="16" historyLines="0">
|
||||
<Size><AbsDimension x="200" y="20"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="29" y="-35"/></Offset></Anchor></Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="BenillaMacroPopupNameLeft" file="Interface\ClassTrainerFrame\UI-ClassTrainer-FilterBorder">
|
||||
<Size><AbsDimension x="12" y="29"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="-11" y="0"/></Offset></Anchor></Anchors>
|
||||
<TexCoords left="0" right="0.09375" top="0" bottom="1.0"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMacroPopupNameMiddle" file="Interface\ClassTrainerFrame\UI-ClassTrainer-FilterBorder">
|
||||
<Size><AbsDimension x="175" y="29"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupNameLeft" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0.09375" right="0.90625" top="0" bottom="1.0"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaMacroPopupNameRight" file="Interface\ClassTrainerFrame\UI-ClassTrainer-FilterBorder">
|
||||
<Size><AbsDimension x="12" y="29"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupNameMiddle" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0.90625" right="1.0" top="0" bottom="1.0"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<FontString inherits="ChatFontNormal"/>
|
||||
<Scripts>
|
||||
<OnTextChanged>
|
||||
BenillaMacroPopupOkayButton_Update()
|
||||
getglobal("BenillaMacroFrameSelectedMacroName"):SetText(self:GetText())
|
||||
</OnTextChanged>
|
||||
<OnEscapePressed>getglobal("BenillaMacroPopupFrame"):Hide()</OnEscapePressed>
|
||||
<OnEnterPressed>
|
||||
-- The ref writes `IsEnabled() ~= 0`; this engine's IsEnabled returns
|
||||
-- a BOOLEAN (button.rs), for which `~= 0` is always true — plain
|
||||
-- truthiness is the correct read here.
|
||||
if getglobal("BenillaMacroPopupOkayButton"):IsEnabled() then
|
||||
BenillaMacroPopupOkayButton_OnClick()
|
||||
end
|
||||
</OnEnterPressed>
|
||||
</Scripts>
|
||||
</EditBox>
|
||||
|
||||
<!-- The icon grid's faux scroll (ref a ClassTrainerListScrollFrameTemplate). -->
|
||||
<Frame name="BenillaMacroPopupScrollFrame" inherits="BenillaFauxScrollFrameTemplate">
|
||||
<Size><AbsDimension x="252" y="152"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-85"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<!-- The faux kit's contract (ScrollTemplates.xml): the owner sets updateFunc,
|
||||
the shared Slider handler calls it whenever the row offset moves. -->
|
||||
<OnLoad>
|
||||
BenillaFauxScrollFrame_OnLoad(self)
|
||||
self.updateFunc = BenillaMacroPopupFrame_Update
|
||||
</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<CheckButton name="BenillaMacroPopupButton1" inherits="BenillaMacroPopupButtonTemplate" id="1">
|
||||
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="24" y="-85"/></Offset></Anchor></Anchors>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton2" inherits="BenillaMacroPopupButtonTemplate" id="2"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton1" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton3" inherits="BenillaMacroPopupButtonTemplate" id="3"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton2" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton4" inherits="BenillaMacroPopupButtonTemplate" id="4"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton3" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton5" inherits="BenillaMacroPopupButtonTemplate" id="5"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton4" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton6" inherits="BenillaMacroPopupButtonTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroPopupButton1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton7" inherits="BenillaMacroPopupButtonTemplate" id="7"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton6" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton8" inherits="BenillaMacroPopupButtonTemplate" id="8"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton7" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton9" inherits="BenillaMacroPopupButtonTemplate" id="9"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton8" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton10" inherits="BenillaMacroPopupButtonTemplate" id="10"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton9" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton11" inherits="BenillaMacroPopupButtonTemplate" id="11"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroPopupButton6" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton12" inherits="BenillaMacroPopupButtonTemplate" id="12"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton11" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton13" inherits="BenillaMacroPopupButtonTemplate" id="13"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton12" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton14" inherits="BenillaMacroPopupButtonTemplate" id="14"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton13" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton15" inherits="BenillaMacroPopupButtonTemplate" id="15"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton14" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton16" inherits="BenillaMacroPopupButtonTemplate" id="16"><Anchors><Anchor point="TOPLEFT" relativeTo="BenillaMacroPopupButton11" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton17" inherits="BenillaMacroPopupButtonTemplate" id="17"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton16" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton18" inherits="BenillaMacroPopupButtonTemplate" id="18"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton17" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton19" inherits="BenillaMacroPopupButtonTemplate" id="19"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton18" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
<CheckButton name="BenillaMacroPopupButton20" inherits="BenillaMacroPopupButtonTemplate" id="20"><Anchors><Anchor point="LEFT" relativeTo="BenillaMacroPopupButton19" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor></Anchors></CheckButton>
|
||||
|
||||
<Button name="BenillaMacroPopupCancelButton" inherits="BenillaMacroPanelButtonTemplate" text="CANCEL">
|
||||
<Size><AbsDimension x="78" y="22"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-11" y="13"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
getglobal("BenillaMacroPopupFrame"):Hide()
|
||||
BenillaMacroFrame_Update()
|
||||
PlaySound("gsTitleOptionOK")
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="BenillaMacroPopupOkayButton" inherits="BenillaMacroPanelButtonTemplate" text="OKAY">
|
||||
<Size><AbsDimension x="78" y="22"/></Size>
|
||||
<Anchors><Anchor point="RIGHT" relativeTo="BenillaMacroPopupCancelButton" relativePoint="LEFT"><Offset><AbsDimension x="-2" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts>
|
||||
<OnClick>
|
||||
BenillaMacroPopupOkayButton_OnClick()
|
||||
PlaySound("gsTitleOptionOK")
|
||||
</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnShow>BenillaMacroPopupFrame_OnShow(self)</OnShow>
|
||||
<OnHide>BenillaMacroPopupFrame_OnHide(self)</OnHide>
|
||||
<OnMouseWheel>BenillaFauxScrollFrame_OnMouseWheel(getglobal("BenillaMacroPopupScrollFrame"), arg1)</OnMouseWheel>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
||||
<Ui>
|
||||
<!--
|
||||
The pet action bar (ref PetActionBarFrame.xml/.lua, extracted from patch.MPQ; decision
|
||||
0982). Hunter, warlock, and every possessed/charmed unit: ten buttons over the ten packed
|
||||
words the server sends in SMSG_PET_SPELLS, driven by `crate::ui_pet`'s feed through the
|
||||
engine's pet seam (GetPetActionInfo & co.).
|
||||
|
||||
THE THING TO KNOW ABOUT THIS BAR: the server owns its CONTENTS (all ten slots arrive in
|
||||
one SMSG_PET_SPELLS) but the CLIENT owns its lit state. A press repaints immediately and
|
||||
is never confirmed, because the server does not answer these packets at all — the real
|
||||
client writes its own state before the send (decision 0988). So a button whose ring does
|
||||
not light on click is BROKEN, not patient.
|
||||
|
||||
The ten slots are not a layout. `GetPetActionInfo` reports each slot's own class off its
|
||||
type byte, and the familiar 3 commands / 4 spells / 3 reactions arrangement is just what
|
||||
vmangos's CharmInfo::InitPetActionBar happens to write; a charmed creature arrives with a
|
||||
different mix through the same ten words. Nothing below indexes by position.
|
||||
|
||||
Token slots (the commands and reactions) return the NAMES OF GLOBALS rather than values,
|
||||
which is the reference's own convention (`PetActionBarFrame.lua:98-104`): the name keys are
|
||||
GlobalStrings' (PET_ACTION_ATTACK, PET_MODE_DEFENSIVE, …), and the texture globals are
|
||||
declared below, quoted verbatim from `PetActionBarFrame.lua:6-12`.
|
||||
|
||||
Geometry QUOTED from the ref (PetActionBarFrame.xml): frame 509x43; the two UI-PetBar shelf
|
||||
strips (256x44 at TOPLEFT with TexCoords 0..1 / 0.015625..0.359375, then 184x44 to its
|
||||
right with 0..0.71875 / 0.375..0.71875); buttons 30x30, button1 at frame BOTTOMLEFT +(36,2),
|
||||
chained +8 (with the ref's own +7 between 6 and 7); the 54x54 UI-Quickslot2 ring CENTER
|
||||
(0,-1). The bar rides `BenillaActionBar` BOTTOMLEFT — our MainMenuBar, the same 1024x53
|
||||
frame at screen BOTTOM the ref anchors to — at the managed PETACTIONBAR_YPOS (UIParent.xml
|
||||
declares the row already).
|
||||
|
||||
Ref divergences, each deliberate:
|
||||
- ONE refresh event. The app diffs the whole pushed bar and fires PET_BAR_UPDATE on any
|
||||
change, so the ref's PET_BAR_UPDATE / PET_BAR_UPDATE_COOLDOWN / UNIT_PET / the
|
||||
UNIT_FLAGS+UNIT_AURA pair it filters for arg1=="pet" collapse into one — the same
|
||||
collapse StanceBar.xml makes, for the same reason.
|
||||
- No PET_BAR_SHOWGRID/HIDEGRID. That pair exists for the drag-an-empty-slot state; the
|
||||
bar's own drag is not built yet, so the grid has nothing to reveal.
|
||||
- The autocast sparkle is TEXTURES, not the ref's <Model>. benilla has no Model widget
|
||||
(the Cooldown and the bag ItemAnim took the same posture): model the mechanism, let our
|
||||
own renderer draw it. The numbers below are the shipped model's own — see BENILLA_PET_
|
||||
AUTOCAST_* .
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
BENILLA_NUM_PET_ACTION_SLOTS = 10
|
||||
|
||||
-- The token textures, quoted verbatim from ref PetActionBarFrame.lua:6-12. They are
|
||||
-- GLOBALS on purpose: `GetPetActionInfo` returns the NAME of one for a token slot, and the
|
||||
-- paint below resolves it with getglobal, exactly as the reference does.
|
||||
PET_DEFENSIVE_TEXTURE = "Interface\\Icons\\Ability_Defend"
|
||||
PET_AGGRESSIVE_TEXTURE = "Interface\\Icons\\Ability_Racial_BloodRage"
|
||||
PET_PASSIVE_TEXTURE = "Interface\\Icons\\Ability_Seal"
|
||||
PET_ATTACK_TEXTURE = "Interface\\Icons\\Ability_GhoulFrenzy"
|
||||
PET_FOLLOW_TEXTURE = "Interface\\Icons\\Ability_Tracking"
|
||||
PET_WAIT_TEXTURE = "Interface\\Icons\\Spell_Nature_TimeStop"
|
||||
PET_DISMISS_TEXTURE = "Interface\\Icons\\Spell_Shadow_Teleport"
|
||||
|
||||
-- ── The autocast sparkle ──────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- The reference's "autocast is on" mark is a <Model> playing
|
||||
-- Interface\Buttons\UI-AutoCastButton.mdx, and what that model actually is came out of the
|
||||
-- shipped .m2 with `benilla-extract m2bones`/`m2part`:
|
||||
--
|
||||
-- 4 bones, pivoting at the corners of a 0.02 x 0.02 unit square, each keyed with the
|
||||
-- SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge) —
|
||||
-- the four phase-shifted by one corner each, so they chase one another round it.
|
||||
-- 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture
|
||||
-- Interface\Buttons\GlowStar.blp, size half-extent 0.005 -> 0.002 -> 0.001 over life,
|
||||
-- colour (0.976,0.875,0.192,1) -> (0.996,0.945,0.745,1) -> (1,1,1,0). Its spline has 4
|
||||
-- control points ALL AT THE ORIGIN, so a particle never travels: it is born where the
|
||||
-- bone is and shrinks away there. The effect is therefore four comet trails running
|
||||
-- round the button's rim, not a rotating ring.
|
||||
--
|
||||
-- Model units -> pixels is the same 1200 px/unit calibration BagFrame.xml derived from the
|
||||
-- Cooldown model (its own header carries the derivation), and it CROSS-CHECKS here: the
|
||||
-- lap square is 0.02 units, the ref's Model carries scale="1.2", and 0.02 * 1200 * 1.2 =
|
||||
-- 28.8 px — the rim of a 30 px button, which is where the sparkles visibly run. That
|
||||
-- agreement between two independently-authored UI models is the reason to trust the
|
||||
-- number rather than eyeball a size.
|
||||
--
|
||||
-- OUR ONE DEPARTURE is the particle COUNT: 300 live per emitter is 12 000 quads across a
|
||||
-- full bar, for a trail that reads identically at a fraction of that. We draw
|
||||
-- BENILLA_PET_AUTOCAST_TRAIL per emitter, evenly spaced in AGE, which samples the same
|
||||
-- position/size/colour ramps at the same times — the trail's shape, speed and colour are
|
||||
-- the file's; only its density is ours.
|
||||
BENILLA_PET_AUTOCAST_PX_PER_UNIT = 1200
|
||||
BENILLA_PET_AUTOCAST_SCALE = 1.2 -- the ref Model's
|
||||
BENILLA_PET_AUTOCAST_PERIOD = 2.0 -- seq0 loop, seconds
|
||||
BENILLA_PET_AUTOCAST_LIFE = 1.0 -- particle lifespan
|
||||
BENILLA_PET_AUTOCAST_TRAIL = 8 -- ours (see above)
|
||||
BENILLA_PET_AUTOCAST_EMITTERS = 4
|
||||
-- 0.02 units square, in pixels.
|
||||
BENILLA_PET_AUTOCAST_SIZE = 0.02 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE
|
||||
-- Half-extent -> full width, in pixels: {0.005, 0.002, 0.001} * 2 * K * scale.
|
||||
BENILLA_PET_AUTOCAST_STAR = {
|
||||
0.010 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
|
||||
0.004 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
|
||||
0.002 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
|
||||
}
|
||||
-- The over-life colour ramp's three keys (the file's), as {r, g, b, a}.
|
||||
BENILLA_PET_AUTOCAST_COLOR = {
|
||||
{0.976, 0.875, 0.192, 1.0},
|
||||
{0.996, 0.945, 0.745, 1.0},
|
||||
{1.000, 1.000, 1.000, 0.0},
|
||||
}
|
||||
|
||||
-- Where an emitter sits at lap fraction `f` (0..1), as an offset from the button's CENTER
|
||||
-- in pixels. The bone tracks walk the square anticlockwise from its bottom-left corner,
|
||||
-- one edge per quarter-lap; this is that walk, re-centred (the model's square runs 0..0.02
|
||||
-- in both axes, so its centre is at 0.01).
|
||||
function BenillaPetAutocast_Point(f)
|
||||
local h = BENILLA_PET_AUTOCAST_SIZE / 2
|
||||
f = f - math.floor(f) -- wrap into 0..1
|
||||
local edge = f * 4
|
||||
local leg = math.floor(edge)
|
||||
local t = edge - leg -- 0..1 along this edge
|
||||
if leg == 0 then return -h, -h + t * 2 * h -- bottom-left -> top-left
|
||||
elseif leg == 1 then return -h + t * 2 * h, h -- top-left -> top-right
|
||||
elseif leg == 2 then return h, h - t * 2 * h -- top-right -> bottom-right
|
||||
else return h - t * 2 * h, -h -- bottom-right -> bottom-left
|
||||
end
|
||||
end
|
||||
|
||||
-- Sample a 3-key over-life ramp (the M2's own {k0, k1 at mid, k2} shape, linear between).
|
||||
function BenillaPetAutocast_Ramp(keys, age)
|
||||
local a, b, t
|
||||
if age <= 0.5 then a, b, t = keys[1], keys[2], age * 2
|
||||
else a, b, t = keys[2], keys[3], (age - 0.5) * 2 end
|
||||
if type(a) == "number" then return a + (b - a) * t end
|
||||
local out = {}
|
||||
for i = 1, table.getn(a) do out[i] = a[i] + (b[i] - a[i]) * t end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Build one button's sparkle pool: EMITTERS x TRAIL textures, hidden until autocast is on.
|
||||
function BenillaPetAutocast_Create(button)
|
||||
button.sparks = {}
|
||||
for i = 1, BENILLA_PET_AUTOCAST_EMITTERS * BENILLA_PET_AUTOCAST_TRAIL do
|
||||
local t = button:CreateTexture(nil, "OVERLAY")
|
||||
t:SetTexture("Interface\\Buttons\\GlowStar")
|
||||
t:SetBlendMode("ADD")
|
||||
t:Hide()
|
||||
table.insert(button.sparks, t)
|
||||
end
|
||||
end
|
||||
|
||||
-- Advance one button's sparkles to the bar's shared clock. Shared on purpose: the four
|
||||
-- emitters are one model, and every autocasting button plays the same loop in step, which
|
||||
-- is what the reference's per-button Models do too (each starts at sequence time 0 and
|
||||
-- they never drift, the loop being exact).
|
||||
function BenillaPetAutocast_Update(button, clock)
|
||||
if not button.sparks then return end
|
||||
if not button.autocasting then
|
||||
for i = 1, table.getn(button.sparks) do button.sparks[i]:Hide() end
|
||||
return
|
||||
end
|
||||
local n = 1
|
||||
for e = 0, BENILLA_PET_AUTOCAST_EMITTERS - 1 do
|
||||
-- The four bones are the same lap, a quarter apart.
|
||||
local phase = clock / BENILLA_PET_AUTOCAST_PERIOD + e / BENILLA_PET_AUTOCAST_EMITTERS
|
||||
for k = 0, BENILLA_PET_AUTOCAST_TRAIL - 1 do
|
||||
-- Evenly spaced in AGE: this star was emitted `age` seconds ago, so it sits
|
||||
-- where the bone was then and has aged that far along the ramps.
|
||||
local age = (k / BENILLA_PET_AUTOCAST_TRAIL) * BENILLA_PET_AUTOCAST_LIFE
|
||||
local x, y = BenillaPetAutocast_Point(phase - age / BENILLA_PET_AUTOCAST_PERIOD)
|
||||
local life = age / BENILLA_PET_AUTOCAST_LIFE
|
||||
local size = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_STAR, life)
|
||||
local c = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_COLOR, life)
|
||||
local spark = button.sparks[n]
|
||||
n = n + 1
|
||||
spark:SetWidth(size)
|
||||
spark:SetHeight(size)
|
||||
spark:ClearAllPoints()
|
||||
spark:SetPoint("CENTER", button, "CENTER", x, y)
|
||||
spark:SetVertexColor(c[1], c[2], c[3])
|
||||
spark:SetAlpha(c[4])
|
||||
spark:Show()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── The buttons ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
function BenillaPetActionButton_OnLoad(button, index)
|
||||
-- Ref PetActionButton_OnLoad l.234-235. The right-click registration is load-bearing,
|
||||
-- not boilerplate: it is the whole autocast toggle, and without it a right click is
|
||||
-- simply never delivered.
|
||||
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||
button.index = index
|
||||
button.icon = getglobal(button:GetName() .. "Icon")
|
||||
button.cooldown = getglobal(button:GetName() .. "Cooldown")
|
||||
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
|
||||
BenillaPetAutocast_Create(button)
|
||||
end
|
||||
|
||||
-- Ref PetActionButton_OnClick. The CheckButton widget toggles itself BEFORE OnClick fires,
|
||||
-- and the reference immediately clears that (`this:SetChecked(0)`) — nothing about this bar
|
||||
-- is locally latched, because the server owns every bit of it. Left = run the slot, unless
|
||||
-- the pet is already on the attack, in which case it calls off; right = flip autocast.
|
||||
function BenillaPetActionButton_OnClick(button, mouseButton)
|
||||
button:SetChecked(0)
|
||||
if mouseButton == "RightButton" then
|
||||
TogglePetAutocast(button.index)
|
||||
elseif IsPetAttackActive(button.index) then
|
||||
PetStopAttack()
|
||||
else
|
||||
CastPetAction(button.index)
|
||||
end
|
||||
end
|
||||
|
||||
-- Ref PetActionButton_OnEnter. A token slot has no spell to look up, so its tooltip is
|
||||
-- built from the two strings the info call already returned (the ref's own `isToken` fork);
|
||||
-- a spell slot goes through the engine's tooltip channel.
|
||||
function BenillaPetActionButton_OnEnter(button)
|
||||
if not GameTooltip then return end -- engine tests load file subsets
|
||||
if not button.tooltipName then return end
|
||||
GameTooltip_SetDefaultAnchor(GameTooltip, button)
|
||||
if button.isToken then
|
||||
GameTooltip:SetText(button.tooltipName, 1.0, 1.0, 1.0)
|
||||
if button.tooltipSubtext then
|
||||
GameTooltip:AddLine(button.tooltipSubtext, "", 0.5, 0.5, 0.5)
|
||||
end
|
||||
GameTooltip:Show()
|
||||
else
|
||||
GameTooltip:SetPetAction(button.index)
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaPetActionButton_OnLeave(button)
|
||||
if GameTooltip then GameTooltip:Hide() end
|
||||
end
|
||||
|
||||
-- ── The bar ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Ref PetActionBar_Update + PetActionBar_UpdateCooldowns, fused (one refresh event, see the
|
||||
-- header). Every branch below is the reference's, including the two that are easy to lose:
|
||||
-- an unnamed slot HIDES its button, and a slot with no texture swaps the button's ring from
|
||||
-- the filled UI-Quickslot2 to the empty UI-Quickslot.
|
||||
function BenillaPetActionBar_Update()
|
||||
local usable = GetPetActionsUsable()
|
||||
for i = 1, BENILLA_NUM_PET_ACTION_SLOTS do
|
||||
local button = getglobal("BenillaPetActionButton" .. i)
|
||||
local name, subtext, texture, isToken, isActive, autoCastAllowed, autoCastEnabled = GetPetActionInfo(i)
|
||||
button.isToken = isToken
|
||||
button.tooltipSubtext = subtext
|
||||
if isToken then
|
||||
-- The token convention: what came back are the NAMES of globals.
|
||||
button.icon:SetTexture(getglobal(texture))
|
||||
button.tooltipName = getglobal(name)
|
||||
else
|
||||
button.icon:SetTexture(texture)
|
||||
button.tooltipName = name
|
||||
end
|
||||
|
||||
if isActive then button:SetChecked(1) else button:SetChecked(0) end
|
||||
if autoCastAllowed then button.autocastable:Show() else button.autocastable:Hide() end
|
||||
button.autocasting = autoCastEnabled
|
||||
|
||||
if name then button:Show() else button:Hide() end
|
||||
|
||||
if texture then
|
||||
SetDesaturation(button.icon, not usable and 1 or nil)
|
||||
button.icon:Show()
|
||||
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
|
||||
else
|
||||
button.icon:Hide()
|
||||
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
|
||||
end
|
||||
|
||||
local start, duration, enable = GetPetActionCooldown(i)
|
||||
CooldownFrame_SetTimer(button.cooldown, start, duration, enable)
|
||||
end
|
||||
end
|
||||
|
||||
function BenillaPetActionBar_OnLoad(frame)
|
||||
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
frame:RegisterEvent("PET_BAR_UPDATE")
|
||||
frame.clock = 0
|
||||
BenillaPetActionBar_OnEvent(frame)
|
||||
end
|
||||
|
||||
-- The bar exists exactly while PetHasActionBar() does. Its show/hide re-fires the managed
|
||||
-- bottom-stack pass (UIParent.xml, decision 0272) — the pet term of that stack is what
|
||||
-- lifts the cast bar and the chat frame clear of it, which is the ref's own
|
||||
-- PetActionBarFrame.xml:205-210.
|
||||
function BenillaPetActionBar_OnEvent(frame)
|
||||
if PetHasActionBar() then
|
||||
BenillaPetActionBar_Update()
|
||||
frame:Show()
|
||||
else
|
||||
frame:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- The shared sparkle clock. One OnUpdate for the whole bar rather than ten: the four
|
||||
-- emitters are one looping model, so every autocasting button is in step by construction.
|
||||
function BenillaPetActionBar_OnUpdate(frame, elapsed)
|
||||
frame.clock = (frame.clock or 0) + elapsed
|
||||
if frame.clock > BENILLA_PET_AUTOCAST_PERIOD then
|
||||
frame.clock = frame.clock - BENILLA_PET_AUTOCAST_PERIOD
|
||||
end
|
||||
for i = 1, BENILLA_NUM_PET_ACTION_SLOTS do
|
||||
BenillaPetAutocast_Update(getglobal("BenillaPetActionButton" .. i), frame.clock)
|
||||
end
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
One pet button (ref PetActionButtonTemplate: 30x30, inherits ActionButtonTemplate's art
|
||||
set, plus the AutoCastable ring on OVERLAY at 58x58 CENTER). The sparkle textures are
|
||||
created in OnLoad rather than declared: there are 32 of them per button, and they are the
|
||||
one thing here whose count is ours rather than the reference's.
|
||||
-->
|
||||
<CheckButton name="BenillaPetActionButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="30" y="30"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="$parentIcon"/>
|
||||
</Layer>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
|
||||
<Size><AbsDimension x="58" y="58"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- Ref PetActionButton_OnLoad sizes this 33x33 at CENTER (-2,-1) by hand rather than
|
||||
taking the template's own rect — quoted. -->
|
||||
<Cooldown name="$parentCooldown" hidden="true">
|
||||
<Size><AbsDimension x="33" y="33"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-2" y="-1"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Cooldown>
|
||||
</Frames>
|
||||
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
|
||||
<Size><AbsDimension x="54" y="54"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</NormalTexture>
|
||||
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
|
||||
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
|
||||
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
|
||||
<Scripts>
|
||||
<OnClick>BenillaPetActionButton_OnClick(self, arg1)</OnClick>
|
||||
<OnEnter>BenillaPetActionButton_OnEnter(self)</OnEnter>
|
||||
<OnLeave>BenillaPetActionButton_OnLeave(self)</OnLeave>
|
||||
</Scripts>
|
||||
</CheckButton>
|
||||
|
||||
<Frame name="BenillaPetActionBarFrame" hidden="true">
|
||||
<Size><AbsDimension x="509" y="43"/></Size>
|
||||
<!-- This anchor is the LOAD-TIME value only: UIParent_ManageFramePositions owns the real
|
||||
one and re-seats the frame on every pass (OnShow/OnHide below fire it), because the
|
||||
Y is a managed var — the ref's own PETACTIONBAR_YPOS, 97 here and 97+43 with the
|
||||
bottom-left multibar up. Leaving it seated at the base 97 is what drew the bar across
|
||||
that bar's buttons (decision 0988). The X likewise: 36, or past the last stance
|
||||
button when the two bars share this row.
|
||||
The ref also slides the frame in over 0.09 s; we don't. The slide's start and end
|
||||
anchors are the same two points, and a frame that only ever appears with a pet reads
|
||||
the same arriving seated as it does arriving in 90 ms (the stance bar's own posture,
|
||||
decision 0270). -->
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="BenillaActionBar" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="36" y="97"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="OVERLAY">
|
||||
<Texture name="BenillaSlidingActionBarTexture0" file="Interface\PetActionBar\UI-PetBar">
|
||||
<Size><AbsDimension x="256" y="44"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<TexCoords left="0" right="1.0" top="0.015625" bottom="0.359375"/>
|
||||
</Texture>
|
||||
<Texture name="BenillaSlidingActionBarTexture1" file="Interface\PetActionBar\UI-PetBar">
|
||||
<Size><AbsDimension x="184" y="44"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaSlidingActionBarTexture0" relativePoint="RIGHT"/></Anchors>
|
||||
<TexCoords left="0" right="0.71875" top="0.375" bottom="0.71875"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<CheckButton name="BenillaPetActionButton1" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="36" y="2"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 1)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton2" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton1" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 2)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton3" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton2" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 3)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton4" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton3" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 4)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton5" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton4" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 5)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton6" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton5" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 6)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<!-- The ref's own +7 here, not +8 (PetActionBarFrame.xml l.158-166). -->
|
||||
<CheckButton name="BenillaPetActionButton7" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton6" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 7)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton8" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton7" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 8)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton9" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton8" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 9)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
<CheckButton name="BenillaPetActionButton10" inherits="BenillaPetActionButtonTemplate">
|
||||
<Anchors><Anchor point="LEFT" relativeTo="BenillaPetActionButton9" relativePoint="RIGHT"><Offset><AbsDimension x="8" y="0"/></Offset></Anchor></Anchors>
|
||||
<Scripts><OnLoad>BenillaPetActionButton_OnLoad(self, 10)</OnLoad></Scripts>
|
||||
</CheckButton>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<OnLoad>BenillaPetActionBar_OnLoad(self)</OnLoad>
|
||||
<OnEvent>BenillaPetActionBar_OnEvent(self)</OnEvent>
|
||||
<OnUpdate>BenillaPetActionBar_OnUpdate(self, arg1)</OnUpdate>
|
||||
<OnShow>UIParent_ManageFramePositions()</OnShow>
|
||||
<OnHide>UIParent_ManageFramePositions()</OnHide>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -253,8 +253,27 @@
|
||||
function BenillaSpellButton_OnClick(button, drag)
|
||||
local id = BenillaSpellBook_GetSpellID(button:GetID())
|
||||
button:SetChecked("false")
|
||||
if drag or IsShiftKeyDown() then
|
||||
if drag then
|
||||
PickupSpell(id, BenillaSpellBookFrame.bookType)
|
||||
elseif IsShiftKeyDown() then
|
||||
-- ref SpellButton_OnClick l.271-283 (LIVE since decision 0983): with the macro
|
||||
-- editor open a shift-click APPENDS `/cast <name>[(<rank>)]` to the body instead of
|
||||
-- picking the spell up — the behaviour MACRO_HELP_TEXT_LINE5 advertises ("shift
|
||||
-- click a spell in your spellbook with the macro UI open to add it to your macro").
|
||||
-- The `/cast` alias comes from the shipped strings (SLASH_CAST1), never typed here.
|
||||
local macroFrame = getglobal("BenillaMacroFrame")
|
||||
if macroFrame and macroFrame:IsVisible() then
|
||||
local spellName, subSpellName = GetSpellName(id, BenillaSpellBookFrame.bookType)
|
||||
if spellName and not IsSpellPassive(id, BenillaSpellBookFrame.bookType) then
|
||||
local line = (SLASH_CAST1 or "/cast") .. " " .. spellName
|
||||
if subSpellName and string.len(subSpellName) > 0 then
|
||||
line = line .. "(" .. subSpellName .. ")"
|
||||
end
|
||||
BenillaMacroFrame_AddMacroLine(line)
|
||||
end
|
||||
else
|
||||
PickupSpell(id, BenillaSpellBookFrame.bookType)
|
||||
end
|
||||
else
|
||||
CastSpell(id, BenillaSpellBookFrame.bookType)
|
||||
BenillaSpellButton_UpdateSelection(button) -- ref l.288: the ring re-reads at once
|
||||
|
||||
@@ -36,20 +36,24 @@
|
||||
SHOW_MULTI_ACTIONBAR_1/2; benilla's bottom bars are always-on with no options state
|
||||
(decision 0270), so the flags read the bars' own visibility - identical values, no
|
||||
parallel global to drift.
|
||||
- Frame names: our stance bar is BenillaShapeshiftBarFrame (0270), substituted in the
|
||||
pet-bar visibility check. Rows whose frames don't exist here (GroupLootFrame1,
|
||||
- Frame names: our stance bar is BenillaShapeshiftBarFrame (0270) and our pet bar is
|
||||
BenillaPetActionBarFrame (0982), both substituted in the pet-bar visibility check —
|
||||
either one showing raises the stack by the ref's "pet" delta, which is the ref's own
|
||||
rule (the two bars share that row). Rows whose frames don't exist here (GroupLootFrame1,
|
||||
TutorialFrameParent, FramerateLabel, ChatFrame2, MultiBarBottomLeft - ours is
|
||||
BenillaMultiBarBottomLeft, statically anchored per 0270, like ShapeshiftBarFrame)
|
||||
no-op through the ref's own `if ( frame )` guard and self-activate if the frames
|
||||
ever land under those names.
|
||||
- MultiBarLeft/MultiBarRight (the vertical bars), PetActionBarFrame,
|
||||
- MultiBarLeft/MultiBarRight (the vertical bars),
|
||||
ReputationWatchBar, MainMenuExpBar, and MainMenuBarMaxLevelBar don't exist yet:
|
||||
nil-guarded (the ref reads them bare only because it always creates them).
|
||||
- No IsUserPlaced/IsObjectType/SIMPLE_CHAT legs on the SetPoint: benilla has no
|
||||
user-placed frames and no simple-chat option, so the ref's guarded SetPoint
|
||||
collapses to the plain one.
|
||||
- The ref function's trailing custom positioning (BattlefieldMinimapTab, the
|
||||
shapeshift-bar texture swap) is out of scope - none of those frames exist.
|
||||
- Of the ref function's trailing custom positioning, the pet-bar half IS ours (the
|
||||
sliding shelf art + the PETACTIONBAR_XPOS/YPOS re-seat, at the end of the pass,
|
||||
decision 0988). BattlefieldMinimapTab and the shapeshift-bar art swap stay out of
|
||||
scope - neither frame exists here, our stance bar shipping no end-caps at all (0270).
|
||||
- The isVar globals (CONTAINER_OFFSET_*, PETACTIONBAR_YPOS, BATTLEFIELD_TAB_OFFSET_Y)
|
||||
are pre-declared to their base values below - the ref declares them in
|
||||
ContainerFrame.lua/PetActionBarFrame.lua, which we don't ship - so the ref's
|
||||
@@ -63,6 +67,9 @@
|
||||
CONTAINER_OFFSET_Y = 70
|
||||
BATTLEFIELD_TAB_OFFSET_Y = 210
|
||||
PETACTIONBAR_YPOS = 97
|
||||
-- ref PetActionBarFrame.lua:3. Not a managed row (the manage pass computes only the Y);
|
||||
-- the custom block at the end of the pass recomputes it, for the reason recorded there.
|
||||
PETACTIONBAR_XPOS = 36
|
||||
|
||||
-- ref UIParent.lua:2 — the model panes' held-button spin rate (half a turn per second),
|
||||
-- read by every `Model_OnUpdate` transcription (the character window's + inspect's).
|
||||
@@ -111,7 +118,7 @@
|
||||
table.insert(xOffsetFrames, "rightRight")
|
||||
end
|
||||
|
||||
if ( ( PetActionBarFrame and PetActionBarFrame:IsShown() ) or ( BenillaShapeshiftBarFrame and BenillaShapeshiftBarFrame:IsShown() ) ) then
|
||||
if ( ( BenillaPetActionBarFrame and BenillaPetActionBarFrame:IsShown() ) or ( BenillaShapeshiftBarFrame and BenillaShapeshiftBarFrame:IsShown() ) ) then
|
||||
table.insert(yOffsetFrames, "pet")
|
||||
end
|
||||
if ( ReputationWatchBar and MainMenuExpBar and ReputationWatchBar:IsShown() and MainMenuExpBar:IsShown() ) then
|
||||
@@ -191,6 +198,53 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- Custom positioning the row table cannot express (ref l.1705-1738). Both rules
|
||||
-- belong to the pet/stance ROW — the one row two different bars share — and without
|
||||
-- them the pet bar lands on top of the bottom-left multibar's buttons
|
||||
-- (director-caught: the bar drew across a row of spells, border and all).
|
||||
--
|
||||
-- (a) The shelf art IS the pet bar's border, and it only makes sense when the bar
|
||||
-- sits directly on the main bar. Raised a row it would draw across whatever is
|
||||
-- underneath, so the ref hides both strips (l.1707-1708) — the same move it makes
|
||||
-- for the stance bar's own end-caps, which is why benilla's stance bar has none
|
||||
-- (0270 anchors it statically at the raised position).
|
||||
-- (b) The pet bar is the only managed entry whose row is a VAR: the loop above writes
|
||||
-- the NUMBER PETACTIONBAR_YPOS (97, +43 with the bottom-left bar up) and anchors
|
||||
-- nothing, so the ref re-seats the frame with it here (l.1735-1736).
|
||||
--
|
||||
-- Departure: the ref computes PETACTIONBAR_XPOS in ShowPetActionBar() and reads it
|
||||
-- here, which only works because its bar slides in over several frames. Ours appears
|
||||
-- at once, so the value is computed where it is used. Same rule
|
||||
-- (PetActionBarFrame.lua:170-174): the two bars share this row, so a pet bar shown
|
||||
-- while the stance bar is up starts 20px past the last stance button. That case is
|
||||
-- rare but real — a possessed or charmed unit gives a pet bar to a class that also
|
||||
-- has forms.
|
||||
local bottomLeftUp = BenillaMultiBarBottomLeft and BenillaMultiBarBottomLeft:IsShown()
|
||||
if ( BenillaSlidingActionBarTexture0 and BenillaSlidingActionBarTexture1 ) then
|
||||
if ( bottomLeftUp ) then
|
||||
BenillaSlidingActionBarTexture0:Hide()
|
||||
BenillaSlidingActionBarTexture1:Hide()
|
||||
else
|
||||
BenillaSlidingActionBarTexture0:Show()
|
||||
BenillaSlidingActionBarTexture1:Show()
|
||||
end
|
||||
end
|
||||
if ( BenillaPetActionBarFrame ) then
|
||||
PETACTIONBAR_XPOS = 36
|
||||
local forms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0
|
||||
if ( forms > 0 and BenillaShapeshiftBarFrame and BenillaShapeshiftBarFrame:IsShown() ) then
|
||||
local last = getglobal("BenillaShapeshiftButton"..forms)
|
||||
local right = last and last:GetRight()
|
||||
if ( right ) then
|
||||
PETACTIONBAR_XPOS = right + 20
|
||||
end
|
||||
end
|
||||
-- The ref guards this on IsShown() because a hidden bar of its is mid-slide and
|
||||
-- owns its own anchor; ours has no slide, so re-seating a hidden frame is a
|
||||
-- no-op and the guard would only add an ordering trap (OnShow fires the pass).
|
||||
BenillaPetActionBarFrame:SetPoint("TOPLEFT", "BenillaActionBar", "BOTTOMLEFT", PETACTIONBAR_XPOS, PETACTIONBAR_YPOS)
|
||||
end
|
||||
|
||||
-- The under-minimap right stack (ref l.1740-1770): anchorY walks down capture bars →
|
||||
-- quest timer → durability → quest watch, each shown frame pushing the next one down
|
||||
-- by its own height. benilla ships the durability + quest-watch arms (below); the two
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
-- (`UIPanelWindows["FriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }`,
|
||||
-- UIParent.lua l.29). whileDead: a corpse run is exactly when you want to whisper someone.
|
||||
UIPanelWindows["BenillaFriendsFrame"] = { area = "left", pushable = 0, whileDead = 1 }
|
||||
-- The macro window (decision 0983, MacroFrame.xml) — the ref's own registration line
|
||||
-- verbatim (`UIPanelWindows["MacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }`,
|
||||
-- Blizzard_MacroUI.lua:7). whileDead is the ref's and it earns its keep: writing a macro
|
||||
-- during a corpse run is a real thing people do.
|
||||
UIPanelWindows["BenillaMacroFrame"] = { area = "left", pushable = 5, whileDead = 1 }
|
||||
-- The crafting window (decision 0437 phase 2, TradeSkillFrame.xml): NOT an NPC session (it
|
||||
-- opens off your own cast, TradeSkillFrame.xml's own header comment) — the ref Lua's own
|
||||
-- registration line (Blizzard_TradeSkillUI.lua:13) is `{ area = "left", pushable = 3 }`,
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
//! The app side of the Era atlas seam (decision 0950): read the extractor's manifest once at
|
||||
//! boot and turn it into the [`UiScript`](benilla_ui::script::UiScript) atlas table, whose
|
||||
//! entries carry `era:`-scheme texture paths that [`super::WorldAssets::sprite_texture`]
|
||||
//! resolves from disk. Runtime never touches CASC or DB2 — `scripts/era-extract.py` baked the
|
||||
//! `UiTextureAtlas` indirection offline into `WoW-era/_extracted_ui/{manifest.json,
|
||||
//! textures/<fdid>.blp}`; a missing or stale extraction WARNs and draws blank, never fails the
|
||||
//! load (the misses name themselves through `take_era_atlas_misses`).
|
||||
|
||||
use benilla_ui::script::EraAtlasEntry;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The extracted Era UI assets root — through the `WoW-era` symlink `wt.sh link_wow` provisions
|
||||
/// in every slot (same `CARGO_MANIFEST_DIR` convention as the MPQ `Data` default elsewhere in
|
||||
/// this crate: the path follows the worktree that built the binary).
|
||||
pub(crate) fn era_ui_root() -> std::path::PathBuf {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW-era/_extracted_ui")
|
||||
}
|
||||
|
||||
/// `manifest.json`, exactly as `scripts/era-extract.py` writes it (the fields we consume).
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
build: String,
|
||||
atlases: std::collections::HashMap<String, Member>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Member {
|
||||
/// Relative to the extraction root (`textures/<fdid>.blp`).
|
||||
file: String,
|
||||
/// The sheet's dimensions in pixels.
|
||||
atlas_size: [f32; 2],
|
||||
/// Pixel edges `[left, top, right, bottom)` within the sheet.
|
||||
rect: [f32; 4],
|
||||
/// Nominal draw size in UI units (`useAtlasSize`).
|
||||
size: [f32; 2],
|
||||
}
|
||||
|
||||
/// Load and normalize the manifest into UiScript table entries: pixel rects become the
|
||||
/// `[left, right, top, bottom]` UV form `TexCoords::Rect` stores; files ride the `era:` scheme.
|
||||
/// `None` = no usable manifest (absent install, extraction never run, or a parse error — each
|
||||
/// WARNs with the fix spelled out; the distinction matters when diagnosing a blank window).
|
||||
pub(crate) fn load_era_atlases() -> Option<Vec<(String, EraAtlasEntry)>> {
|
||||
let path = era_ui_root().join("manifest.json");
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
bevy::log::warn!(
|
||||
"era: no UI manifest at {} ({e}) — Era-styled windows will draw blank; \
|
||||
run scripts/era-extract.py",
|
||||
path.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let manifest: Manifest = match serde_json::from_slice(&bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
bevy::log::warn!(
|
||||
"era: manifest.json unparseable ({e}) — re-run scripts/era-extract.py"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
bevy::log::debug!(
|
||||
"era: atlas manifest for build {} — {} members",
|
||||
manifest.build,
|
||||
manifest.atlases.len()
|
||||
);
|
||||
Some(
|
||||
manifest
|
||||
.atlases
|
||||
.into_iter()
|
||||
.map(|(name, m)| {
|
||||
let [aw, ah] = m.atlas_size;
|
||||
let [l, t, r, b] = m.rect;
|
||||
(
|
||||
name,
|
||||
EraAtlasEntry {
|
||||
file: format!("era:{}", m.file),
|
||||
uv: [l / aw, r / aw, t / ah, b / ah],
|
||||
size: [m.size[0], m.size[1]],
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -27,9 +27,6 @@ use benilla_formats::{open_chain, read_texture_mip_chain, read_texture_rgba, Cha
|
||||
mod images;
|
||||
pub(crate) use images::*;
|
||||
|
||||
/// The Era atlas manifest side of decision 0950 (`era:` texture scheme + the UiScript table).
|
||||
pub(crate) mod era;
|
||||
|
||||
/// Shared asset store: the **one** open patch chain plus dedup caches, used by every model/texture
|
||||
/// load (terrain layers, doodads, WMOs, creatures, GameObjects). Replaces the three independent
|
||||
/// chains we used to open. Deduping by path/material means each unique BLP is decoded + uploaded
|
||||
@@ -181,23 +178,6 @@ impl WorldAssets {
|
||||
path: &str,
|
||||
images: &mut Assets<Image>,
|
||||
) -> Option<Handle<Image>> {
|
||||
// `era:<relpath>` (decision 0950): a loose extracted file under
|
||||
// `WoW-era/_extracted_ui/`, never the MPQ chain — the scheme string is its own cache key
|
||||
// (verbatim, case-sensitive: the manifest writes it, authors copy it).
|
||||
if let Some(rel) = path.strip_prefix("era:") {
|
||||
if let Some(cached) = self.sprites.get(path) {
|
||||
return cached.clone();
|
||||
}
|
||||
let loaded = std::fs::read(era::era_ui_root().join(rel))
|
||||
.ok()
|
||||
.and_then(|bytes| benilla_formats::blp_to_rgba(&bytes).ok())
|
||||
.map(|(w, h, rgba)| images.add(sprite_image(w, h, rgba)));
|
||||
if loaded.is_none() {
|
||||
warn!("era: sprite '{path}' unreadable — stale extraction? run scripts/era-extract.py");
|
||||
}
|
||||
self.sprites.insert(path.to_string(), loaded.clone());
|
||||
return loaded;
|
||||
}
|
||||
// FrameXML/DBC texture references are canonically EXTENSIONLESS
|
||||
// (`Interface\Buttons\UI-Quickslot2`) — the real client appends `.blp` at resolve. Paths
|
||||
// that already carry an extension (`textures\moon.blp`) pass through unchanged.
|
||||
|
||||
@@ -958,6 +958,24 @@ pub(super) fn seed_ui_fixture(
|
||||
warn!("capture: ui-options-graphics seed failed: {e}");
|
||||
}
|
||||
}
|
||||
UiFixture::OptionsSearch => {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
// Mid-search (0984), same posture as the page fixtures: real CVar set, then the
|
||||
// live open path and a typed query — "volume" reflows the four volume sliders
|
||||
// under the Audio head (Master matched by the token too, so no pull-in here; the
|
||||
// pull-in has its unit test). Focused (0989): captures pin the caret visible, so
|
||||
// this baseline also pins the caret hugging the text's end — the drawn-space
|
||||
// advance law's visual regression guard.
|
||||
script.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
if let Err(e) = script.run(
|
||||
"ShowUIPanel(OptionsFrame); OptionsFrameSearchBox:SetText(\"volume\"); OptionsFrameSearchBox:SetFocus()",
|
||||
)
|
||||
{
|
||||
warn!("capture: ui-options-search seed failed: {e}");
|
||||
}
|
||||
}
|
||||
UiFixture::SpellBook => {
|
||||
// The director's own report reproduced (decision 0228): a HUMAN WARRIOR (race 1,
|
||||
// class 1) who learned Fireball + Mind Flay via a GM command during testing. The book
|
||||
|
||||
@@ -105,6 +105,12 @@ pub(super) enum UiFixture {
|
||||
/// defaults (hermetic capture = no config file), so the pixels move only with the window,
|
||||
/// the atlas seam, or a registered default.
|
||||
OptionsGraphics,
|
||||
/// The Options window MID-SEARCH (decision 0984) — the results-view look instrument: the
|
||||
/// "volume" query reflows the four live volume sliders under the clickable Audio head
|
||||
/// (GameFontNormalLarge), title "Search Results", Defaults hidden, the clear-X shown in
|
||||
/// the box. Hermetic like the other options fixtures; pixels move only with the window
|
||||
/// or a registered default.
|
||||
OptionsSearch,
|
||||
/// A **floating overhead name with the river surface behind it** — the world-text-vs-liquid
|
||||
/// draw-order instrument. A named unit stands 25 yd out in the Elwynn river (the `water-noon`
|
||||
/// camera), so its name projects onto the water *beyond* it: the exact geometry of the
|
||||
@@ -156,6 +162,7 @@ pub(super) const HOUSE_EYE: [f32; 3] = [-9439.1, 71.2, 68.0];
|
||||
/// `Map.dbc` ids the golden spots stand on.
|
||||
pub(super) const MAP_AZEROTH: u32 = 0;
|
||||
pub(super) const MAP_KALIMDOR: u32 = 1;
|
||||
pub(super) const MAP_DEEPRUN_TRAM: u32 = 369;
|
||||
|
||||
/// THE golden baseline: **three spots FRAMED BY THE DIRECTOR, each at noon and at night — six
|
||||
/// captures, and that is the whole sweep.** A Stormwind canal view, Elwynn water, and a Felwood
|
||||
@@ -327,6 +334,28 @@ pub(super) const SUBJECT_INDOOR: [f32; 3] = [-9469.4, 31.9, 57.9];
|
||||
/// fixtures, the house-compass and street scenes. Capturable by name (`WOW_CAPTURE=<name>`) for
|
||||
/// debugging and look passes, but NOT part of the blessed baseline sweep.
|
||||
pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
// ---- The Deeprun Tram's undersea tube (map 369) ----
|
||||
// The one shipped map with NO `Light.dbc` row at all — not even the falloff-0 global that maps
|
||||
// 0/1 carry — so its whole atmosphere has to come from the building's own MFOG (record 2:
|
||||
// RGB(30,53,100), end 236.1 yd, start scalar 0.05; the camera's group `Subway_002` names it).
|
||||
// A global (WDT `MODF`) WMO too, the placement shape no other scenario exercises.
|
||||
//
|
||||
// ⚠ **This shot currently photographs NOTHING — do not baseline it.** The building registers
|
||||
// ("terrain: map DeeprunTram has no tiles — its world is one WMO") but no group ever reaches
|
||||
// the frame in server-less capture, so the shot is the bare sky dome from any eye, inside the
|
||||
// tube or between the tubes, at `WOW_CAPTURE_STABLE` 30 or 1800 alike (the 1800 run reported
|
||||
// "never settled", i.e. it was still waiting, not still loading). The live client draws this
|
||||
// map fine — the director's screenshots are of it — so the gap is the harness's, and it is
|
||||
// kept here as the reproducer rather than deleted: a WMO-only map is exactly the residency
|
||||
// shape 0688 wired and nothing has ever photographed it.
|
||||
Scenario {
|
||||
name: "tram-undersea",
|
||||
map: MAP_DEEPRUN_TRAM,
|
||||
eye: TRAM_EYE,
|
||||
look: TRAM_LOOK,
|
||||
minute: 720,
|
||||
ui: None,
|
||||
},
|
||||
// ---- Evicted from the blessed sweep by decision 0817, NOT deleted ----
|
||||
// The sweep had grown from 0632's six to twenty-one, i.e. 42 windows on the director's screen per
|
||||
// `selfcheck`, and the director cut it back to three spots x two day times. Everything below this
|
||||
@@ -858,6 +887,16 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::OptionsGraphics),
|
||||
},
|
||||
// The same window MID-SEARCH (0984): the "volume" results view. Run with
|
||||
// `WOW_CAPTURE_UI=1 WOW_CAPTURE=ui-options-search`.
|
||||
Scenario {
|
||||
name: "ui-options-search",
|
||||
map: MAP_AZEROTH,
|
||||
eye: GROUND_EYE,
|
||||
look: GROUND_LOOK,
|
||||
minute: 720,
|
||||
ui: Some(UiFixture::OptionsSearch),
|
||||
},
|
||||
// An overhead NAME with the river surface behind it — the world-text-vs-liquid draw-order
|
||||
// instrument (see [`UiFixture::NameWater`]). Same camera as `water-noon`, plus a named unit
|
||||
// out in the water. Run with `WOW_CAPTURE=name-water`.
|
||||
@@ -870,3 +909,9 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
|
||||
ui: Some(UiFixture::NameWater),
|
||||
},
|
||||
];
|
||||
|
||||
/// The Deeprun Tram undersea tube — see the `tram-undersea` scenario. Raw WoW coords; the Subway
|
||||
/// WMO is the map's global `MODF` at the origin with identity rotation, so these are also its
|
||||
/// model-space coords.
|
||||
pub(super) const TRAM_EYE: [f32; 3] = [-2.44, -1250.0, -120.0];
|
||||
pub(super) const TRAM_LOOK: [f32; 3] = [-2.44, -1400.0, -118.0];
|
||||
|
||||
@@ -473,6 +473,43 @@ impl Cooldowns {
|
||||
);
|
||||
}
|
||||
|
||||
/// One `SMSG_PET_SPELLS` cooldown entry (decision 0982) — [`Self::seed_initial`]'s pet twin,
|
||||
/// separate because the pet block's ids are `u32` where the player's login list packs them
|
||||
/// into `u16`, and because its category duration carries a marker bit the player's does not.
|
||||
///
|
||||
/// Both remainders are what is LEFT (vmangos computes them at send, `WritePetSpellsCooldown`),
|
||||
/// so the record starts now and runs the remainder. The category word's
|
||||
/// [`PET_COOLDOWN_PERMANENT`] bit marks a server-re-armed cooldown; it is stripped, because
|
||||
/// carrying it would read as a ~37-hour sweep on the button.
|
||||
pub(crate) fn seed_pet(
|
||||
&mut self,
|
||||
cd: &benilla_protocol::messages::PetSpellCooldown,
|
||||
spell: Option<&SpellDisplay>,
|
||||
now: Instant,
|
||||
) {
|
||||
use benilla_protocol::messages::PET_COOLDOWN_PERMANENT;
|
||||
let category_ms = cd.category_cd_ms & !PET_COOLDOWN_PERMANENT;
|
||||
self.add(
|
||||
cd.spell_id,
|
||||
0,
|
||||
Timer {
|
||||
start: now,
|
||||
duration: Duration::from_millis(u64::from(cd.spell_cd_ms)),
|
||||
},
|
||||
u32::from(cd.category),
|
||||
// The wildcard row's flag comes off the catalog when we have it — the same resolve
|
||||
// `start_spell` does. Absent catalog ⇒ false, the non-wildcard reading.
|
||||
spell.is_some_and(|s| s.category_wildcard),
|
||||
Timer {
|
||||
start: now,
|
||||
duration: Duration::from_millis(u64::from(category_ms)),
|
||||
},
|
||||
false,
|
||||
0,
|
||||
Timer::none(now),
|
||||
);
|
||||
}
|
||||
|
||||
/// The client's `IsSpellOnCooldown 0x6e1690` — an **"has an on-hold (not-yet-started)
|
||||
/// record"** predicate, NOT a general on-cooldown test (the corrected decode, wow-re
|
||||
/// `gcd-power-gate.md` §3: both legs return 1 only when the matched node's onHold byte is
|
||||
|
||||
@@ -125,8 +125,11 @@ pub(crate) struct Engaged;
|
||||
/// the local cast-send). A REMOTE shooter's drawn idle rides [`RangedHold`] instead. Cleared
|
||||
/// only by [`cancel_auto_repeat_local`]'s callers — the client's cancel `0x6ea080` (its `0x200`
|
||||
/// clear `0x6ea113`), reached from the cast-result fail of the cached spell, the button
|
||||
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and the
|
||||
/// (vmangos-dead) `SMSG_CANCEL_AUTO_REPEAT` (wow-re `nocked-ammo-cancel.md`).
|
||||
/// re-press toggle, melee attack-start, target death, the wand-only new-cast handoff, and
|
||||
/// `SMSG_CANCEL_AUTO_REPEAT` — which vmangos DOES send (corrected 2026-08-05; see
|
||||
/// [`crate::net::apply::spells::cancel_auto_repeat`]), so against a live server that packet, not
|
||||
/// a local death watcher, is what ends a volley whose target dies (wow-re
|
||||
/// `nocked-ammo-cancel.md`).
|
||||
#[derive(Component)]
|
||||
pub(crate) struct AutoRepeatArmed;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ use super::select::{
|
||||
use super::sheath::{advance_sheath_ceremony, start_sheath_ceremony};
|
||||
use super::{
|
||||
find_resolved, move_flags, AnimData, AnimDriver, AutoRepeatArmed, CastHold, DefenseAnim,
|
||||
EmoteAnim, Engaged, MovementState, Overlay, OverlayFade, RangedHold, SheathRequest,
|
||||
EmoteAnim, Engaged, MovementState, NockLatch, Overlay, OverlayFade, RangedHold, SheathRequest,
|
||||
SheathSwapMessage, SwingImpact, SwingMessage, SwingSlowdown, Wielded, WoundAnim,
|
||||
};
|
||||
|
||||
@@ -286,6 +286,10 @@ pub(super) fn drive_animations(
|
||||
Has<SelfPlayer>,
|
||||
Option<&crate::entities::mount::MountBody>,
|
||||
Has<crate::net::CreatureSwimming>,
|
||||
// Is the in-hand arrow currently latched (the `$BWP`..`$BWR` window)? Read only by
|
||||
// the leaving-the-stance un-nock at the tail of this pass, which is edge-triggered
|
||||
// off it rather than issuing a remove every frame.
|
||||
Has<NockLatch>,
|
||||
// The rendered model scale — `OBJECT_FIELD_SCALE_X`, baked onto the entity transform by
|
||||
// `entities::attach`. The locomotion playback rate divides by it (decision 0903), so an
|
||||
// ogre at 2.2× cycles its legs 2.2× slower than a same-speed human. Read-only: nothing
|
||||
@@ -431,7 +435,16 @@ pub(super) fn drive_animations(
|
||||
wielded,
|
||||
cast_hold,
|
||||
facing_step,
|
||||
(engaged, auto_repeat, ranged_hold, is_self, mount_body, creature_swimming, transform),
|
||||
(
|
||||
engaged,
|
||||
auto_repeat,
|
||||
ranged_hold,
|
||||
is_self,
|
||||
mount_body,
|
||||
creature_swimming,
|
||||
nock_latched,
|
||||
transform,
|
||||
),
|
||||
) in &mut units
|
||||
{
|
||||
// A mount child drives from its HOST's movement view (decision 0441) — same inputs the
|
||||
@@ -1260,9 +1273,12 @@ pub(super) fn drive_animations(
|
||||
// Q-I, decision 0412): mid-volley the client plays the FULL fire clip (whose tail
|
||||
// is the visible quick re-nock) and returns to the hold; it NEVER replays the Load
|
||||
// and NEVER rate-scales a bow clip (the `0x5fee80` scaler gate excludes every bow
|
||||
// id). 0411's RENOCK_RATE replay is refuted and removed. `ranged_hold` joins the
|
||||
// gate so a REMOTE shooter's fire clip returns to its hold the same way.
|
||||
if played_oneshot.is_some() && (auto_repeat || ranged_hold) && drv.sheath_cur == Some(2) {
|
||||
// id). 0411's RENOCK_RATE replay is refuted and removed. Same gate as the idle's
|
||||
// own ([`select::ranged_idle_gate`]) so the two cannot drift: a unit that could
|
||||
// not have entered the family has no hold to return to.
|
||||
if played_oneshot.is_some()
|
||||
&& select::ranged_idle_gate(auto_repeat, ranged_hold, drv.ranged_held, drv.sheath_cur)
|
||||
{
|
||||
drv.ranged_held = true;
|
||||
}
|
||||
|
||||
@@ -1357,5 +1373,25 @@ pub(super) fn drive_animations(
|
||||
.entity(entity)
|
||||
.remove::<(super::NockedAmmo, super::NockLatch)>();
|
||||
}
|
||||
// …and MOVING un-nocks too (the arrow leaves the hand, the string relaxes; the cached
|
||||
// ammo display survives for the next pull). The nock latch has exactly two authored
|
||||
// writers — `$BWP` sets, `$BWR` clears — and both live in clips only a STANDING unit
|
||||
// plays: the Load pull and the fire clip. Nothing in a run cycle can ever clear it, so a
|
||||
// latch carried into locomotion is permanent: the director's report and screenshot
|
||||
// (2026-08-05) are a warrior sprinting with the arrow still nocked and the bowstring
|
||||
// still drawn. The hold-pick re-latch (decision 0409's INTERIM, still in the gait arm)
|
||||
// guarantees the leak on every volley — it re-latches each time the drawn hold is picked,
|
||||
// with no writer of its own to undo it.
|
||||
//
|
||||
// **Named approximation, director-grounded.** The reference's un-nock `0x60f530` has six
|
||||
// callers; three (`0x6020ef`, `0x60c951`, `0x60e4ac`) are still uncensused (dispatched to
|
||||
// wow-re 2026-08-05, alongside the asset finding that AttackBow(46) authors NO `$BWP` —
|
||||
// only LoadBow/LoadRifle do — which refutes the Q-I §I2 re-attach inference our INTERIM
|
||||
// stands in for). One of the three may well be exactly this edge. Until that lands, the
|
||||
// rule is what the screen requires and what closes our state machine: every latch we can
|
||||
// set has an owner that tears it down.
|
||||
if moving && nock_latched {
|
||||
commands.entity(entity).remove::<super::NockLatch>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,13 +365,14 @@ pub(super) fn run(
|
||||
// Normal gait: select, cross-fade on change, keep the rate synced each frame.
|
||||
// The engaged standing idle: the weapon-class Ready pick (decision 0073).
|
||||
let ready = (engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.main)));
|
||||
// The ranged standing idle (0099 phase 5): sheath ranged + either armed
|
||||
// bit → the ranged weapon's Load/Hold clip. `auto_repeat` is the local
|
||||
// `0x200` (the resolver `0x5fd460`'s own gate); `ranged_hold` is the
|
||||
// any-caster `0x400` weapon-visual hold — what puts a REMOTE shooter (an
|
||||
// NPC archer, another hunter) in the drawn idle between shots (HoldBow
|
||||
// sustains on `0x200|0x400`, rifle/thrown on `0x400`; the per-weapon
|
||||
// asymmetry is folded into this one gate — no benilla-visible difference).
|
||||
// The ranged standing idle (0099 phase 5): the byte-verified entry/sustain
|
||||
// gate ([`select::ranged_idle_gate`]) → the ranged weapon's Load/Hold clip.
|
||||
// ENTRY is the local `0x200` ([`auto_repeat`]) alone — the resolver
|
||||
// `0x5fd460`'s own and only claim test; the any-caster `0x400`
|
||||
// ([`ranged_hold`]) merely SUSTAINS a hold already on the base track
|
||||
// (`0x5fc3f0`'s self-loop). Folding the two into one entry gate is what
|
||||
// left a shooter permanently aiming after any single ranged spell and a
|
||||
// remote archer permanently drawn (director-reported 2026-08-05).
|
||||
// After one full pass of the Load clip the idle swaps to the drawn HOLD
|
||||
// twin — nock once, then HOLD through every shot: a mid-volley fire clip
|
||||
// returns straight to the hold, never a full re-pull (director-refuted;
|
||||
@@ -382,16 +383,21 @@ pub(super) fn run(
|
||||
// `0x200`-gated resolver) would leave a remote in plain Stand — unobserved
|
||||
// on the ref, and the derivation rests on the LOAD-ANIM-RECOMPUTE-MODE
|
||||
// open item; revisit if the ref's remote hunters visibly differ.
|
||||
let ranged_load =
|
||||
((auto_repeat || ranged_hold) && !moving && drv.sheath_cur == Some(2))
|
||||
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)))
|
||||
.map(|id| {
|
||||
if drv.ranged_held {
|
||||
select::ranged_hold_anim(id)
|
||||
} else {
|
||||
id
|
||||
}
|
||||
});
|
||||
let ranged_load = (!moving
|
||||
&& select::ranged_idle_gate(
|
||||
auto_repeat,
|
||||
ranged_hold,
|
||||
drv.ranged_held,
|
||||
drv.sheath_cur,
|
||||
))
|
||||
.then(|| select::ranged_load_anim(wielded.and_then(|w| w.ranged)))
|
||||
.map(|id| {
|
||||
if drv.ranged_held {
|
||||
select::ranged_hold_anim(id)
|
||||
} else {
|
||||
id
|
||||
}
|
||||
});
|
||||
let cands = gait_candidates(&mv, walk, ready, ranged_load);
|
||||
// The stationary cast/channel hold pins its pose **full-body in the gait slot**
|
||||
// (decision 0107 — the client's `[CGUnit+0xb4]` stationary-cast gate),
|
||||
|
||||
@@ -2628,3 +2628,137 @@ fn the_dismount_cuts_the_saddle_pose_where_the_mount_up_blends_into_it() {
|
||||
"the dismount CUTS: Mount(91) contributes nothing the frame the field clears"
|
||||
);
|
||||
}
|
||||
|
||||
/// A shooter's model: Stand, Run, and the bow Load/Hold pair.
|
||||
fn archer_model() -> ModelAnimations {
|
||||
ModelAnimations {
|
||||
graph: Handle::default(),
|
||||
clips: vec![
|
||||
clip(0, 1, true), // Stand
|
||||
clip(5, 2, true), // Run
|
||||
clip(105, 3, false), // LoadBow — the pull
|
||||
clip(109, 4, true), // HoldBow — the drawn hold
|
||||
],
|
||||
hand_close: [None, None],
|
||||
playable_animation_lookup: Vec::new(),
|
||||
animation_lookup: Vec::new(),
|
||||
global_bones: Vec::new(),
|
||||
first_seq: None,
|
||||
pose: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bow-and-arrow half of the director's 2026-08-05 report — the screenshot is a warrior
|
||||
/// **sprinting** with the arrow still nocked and the bowstring still drawn.
|
||||
///
|
||||
/// The nock latch has exactly two authored writers: `$BWP` sets it, `$BWR` clears it, and both
|
||||
/// tags live only in clips a STANDING unit plays (the Load pull and the fire clip; a real
|
||||
/// character M2's Run authors neither — verified by dumping every playable model's event tracks).
|
||||
/// So a latch carried into locomotion could never be cleared by anything, and the gait arm's
|
||||
/// hold-pick re-latch (0409's INTERIM) re-arms it on every volley — the leak was guaranteed.
|
||||
#[test]
|
||||
fn a_running_shooter_drops_the_nocked_arrow_and_keeps_its_ammo_cache() {
|
||||
let mut app = app();
|
||||
let unit = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
archer_model(),
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimDriver::default(),
|
||||
crate::net::SelfPlayer,
|
||||
Wielded {
|
||||
ranged: Some((2, 0x2)), // bow
|
||||
..Default::default()
|
||||
},
|
||||
crate::creature_anim::NockedAmmo { display_id: 5996 },
|
||||
crate::creature_anim::NockLatch,
|
||||
))
|
||||
.id();
|
||||
// The bow is drawn and the arrow is on the string: the steady state of a standing shooter.
|
||||
app.world_mut().write_message(SheathRequest {
|
||||
entity: unit,
|
||||
state: 2,
|
||||
ceremony: false,
|
||||
});
|
||||
app.update();
|
||||
let latched = |app: &App| {
|
||||
app.world()
|
||||
.entity(unit)
|
||||
.get::<crate::creature_anim::NockLatch>()
|
||||
.is_some()
|
||||
};
|
||||
assert!(latched(&app), "standing drawn: the arrow stays nocked");
|
||||
|
||||
// They run.
|
||||
app.world_mut().entity_mut(unit).insert(MovementState {
|
||||
speed: 7.0,
|
||||
flags: move_flags::FORWARD,
|
||||
..Default::default()
|
||||
});
|
||||
app.update();
|
||||
assert!(
|
||||
!latched(&app),
|
||||
"moving un-nocks: the arrow leaves the hand and the string relaxes"
|
||||
);
|
||||
assert!(
|
||||
app.world()
|
||||
.entity(unit)
|
||||
.get::<crate::creature_anim::NockedAmmo>()
|
||||
.is_some(),
|
||||
"…but the ammo DISPLAY cache survives — the next pull re-nocks the same arrow without \
|
||||
waiting on a fresh SMSG_SPELL_START"
|
||||
);
|
||||
}
|
||||
|
||||
/// The aiming half of the same report ("they keep aiming like they are going to shoot at
|
||||
/// something"): the drawn Load/Hold idle is entered by the LOCAL auto-repeat bit `0x200` alone
|
||||
/// (`0x5fd460`'s only claim test). The any-caster weapon-visual hold `0x400` — which every
|
||||
/// ranged-slot spell's visual sets and nothing ever clears on volley end — must not admit it.
|
||||
#[test]
|
||||
fn the_weapon_visual_hold_alone_never_puts_a_shooter_in_the_drawn_idle() {
|
||||
let spawn = |app: &mut App, auto_repeat: bool| {
|
||||
let mut e = app.world_mut().spawn((
|
||||
archer_model(),
|
||||
AnimationPlayer::default(),
|
||||
AnimationTransitions::new(),
|
||||
AnimDriver::default(),
|
||||
crate::net::SelfPlayer,
|
||||
Wielded {
|
||||
ranged: Some((2, 0x2)),
|
||||
..Default::default()
|
||||
},
|
||||
// Set by ANY ranged spell's visual play — one Multi-Shot is enough, and it is still
|
||||
// set an hour later.
|
||||
crate::creature_anim::RangedHold,
|
||||
));
|
||||
if auto_repeat {
|
||||
e.insert(crate::creature_anim::AutoRepeatArmed);
|
||||
}
|
||||
e.id()
|
||||
};
|
||||
let mut app = app();
|
||||
let shot_once = spawn(&mut app, false);
|
||||
let shooting = spawn(&mut app, true);
|
||||
for unit in [shot_once, shooting] {
|
||||
app.world_mut().write_message(SheathRequest {
|
||||
entity: unit,
|
||||
state: 2,
|
||||
ceremony: false,
|
||||
});
|
||||
}
|
||||
app.update();
|
||||
app.update();
|
||||
let gait = |app: &App, e: Entity| app.world().entity(e).get::<AnimDriver>().unwrap().gait;
|
||||
assert_eq!(
|
||||
gait(&app, shot_once),
|
||||
Some(0),
|
||||
"a hunter who fired one Multi-Shot and stopped stands normally — the bow stays drawn \
|
||||
(nothing stows on combat end), but they are not aiming it"
|
||||
);
|
||||
assert_eq!(
|
||||
gait(&app, shooting),
|
||||
Some(105),
|
||||
"…while an actively auto-repeating shooter pulls the bow: the `0x200` entry"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -576,6 +576,39 @@ pub(super) fn is_ranged_load(id: u16) -> bool {
|
||||
matches!(id, 105 | 106 | 112)
|
||||
}
|
||||
|
||||
/// Does the drawn ranged Load/Hold family own this unit's standing idle? The **entry/sustain
|
||||
/// asymmetry**, byte-verified (wow-re `ranged-shot-anim.md` §b + `nocked-ammo-cancel.md` §Q-I-I0):
|
||||
///
|
||||
/// - **ENTRY is `[+0xd58] & 0x200` alone** — the resolver `0x5fd460` (tier 9 of `ComputeAnimation
|
||||
/// 0x5fd8b0`) claims on `cmp [+0xd40],2` (sheath ranged) `&& test ah,0x2` and nothing else. That
|
||||
/// bit's ONLY writer image-wide is the LOCAL cast-send `0x6e593b`, gated `AttributesEx2 & 0x20`
|
||||
/// — so entry means *this client's player is actively auto-repeating*. Nothing else can start
|
||||
/// the family: 109/110/111 are reachable only through their Load clip's completion
|
||||
/// (`0x5fc3f0` slot 11 → `PlayAnimation(109)`), and the Load comes only from `0x5fd460`.
|
||||
/// - **`0x400` ([`super::RangedHold`], the any-caster weapon-visual hold) only SUSTAINS** — it
|
||||
/// appears in exactly one gait gate, HoldBow's self-loop at `0x5fc3f0` slot 13
|
||||
/// (`[+0xd24] != 0 && (channel || [+0xd58] & 0x600)`), which re-plays a hold **already on the
|
||||
/// base track**. It can never admit the family.
|
||||
///
|
||||
/// Folding `0x400` into the *entry* gate (which this codebase did, with the asymmetry knowingly
|
||||
/// "unmodeled") is what made a single Multi-Shot / Arcane Shot — any ranged-slot spell whose
|
||||
/// visual sets the hold bit — leave the shooter aiming a drawn bow forever, and left a REMOTE
|
||||
/// shooter permanently drawn where the reference stands it up (§c's verified negative: a remote
|
||||
/// unit never runs the local cast-send, so its standing pose is Stand). `held` is our
|
||||
/// "the Load pass finished, the hold is what's playing" latch — the base-track state the
|
||||
/// self-loop gate reads.
|
||||
///
|
||||
/// The caller applies the tier order: locomotion (tier 4) outranks tier 9, so a moving unit is
|
||||
/// never in the idle whatever these bits say.
|
||||
pub(super) fn ranged_idle_gate(
|
||||
auto_repeat: bool,
|
||||
ranged_hold: bool,
|
||||
held: bool,
|
||||
sheath_cur: Option<u8>,
|
||||
) -> bool {
|
||||
sheath_cur == Some(2) && (auto_repeat || (held && ranged_hold))
|
||||
}
|
||||
|
||||
/// The victim **defense-reaction** id (decision 0279 — the `$CPP` dispatch, byte-verified
|
||||
/// `0x624a01` → `0x60ec00`): DODGE/DEFLECTS → Dodge(30) · BLOCKS → ShieldBlock(24) · PARRY keys
|
||||
/// the victim's own **mainhand** through the `0x60ec98` LUT (read off `WoW.exe` `.text`, its
|
||||
|
||||
@@ -464,6 +464,36 @@ fn ranged_load_idle_selects_by_weapon_and_ranks_below_ready() {
|
||||
)));
|
||||
}
|
||||
|
||||
/// The drawn ranged idle's **entry/sustain asymmetry** ([`ranged_idle_gate`], byte-verified):
|
||||
/// `0x5fd460` claims on the local auto-repeat bit `0x200` ALONE, while the any-caster
|
||||
/// weapon-visual hold `0x400` appears only in `0x5fc3f0`'s HoldBow self-loop — it sustains a hold
|
||||
/// already playing and can never start one. Folding the two into one entry gate is the defect the
|
||||
/// director reported on 2026-08-05 ("they keep aiming like they are going to shoot at something"):
|
||||
/// a single Multi-Shot sets `0x400`, and nothing in the client ever clears it on volley end.
|
||||
#[test]
|
||||
fn the_ranged_idle_is_entered_by_auto_repeat_alone_and_only_sustained_by_the_hold_bit() {
|
||||
// ENTRY: the local `0x200`, with the ranged sheath. This is the whole claim of `0x5fd460`.
|
||||
assert!(ranged_idle_gate(true, false, false, Some(2)));
|
||||
// …and it needs CUR == 2: a melee-drawn or stowed unit is never in the family.
|
||||
assert!(!ranged_idle_gate(true, false, false, Some(1)));
|
||||
assert!(!ranged_idle_gate(true, false, false, Some(0)));
|
||||
assert!(!ranged_idle_gate(true, false, false, None));
|
||||
|
||||
// `0x400` ALONE never enters — the regression under the report. This is the state of a
|
||||
// hunter who fired one Multi-Shot and stopped, and of every REMOTE shooter (which never
|
||||
// runs the local cast-send, so `0x200` cannot exist on it — §c's verified negative).
|
||||
assert!(!ranged_idle_gate(false, true, false, Some(2)));
|
||||
// Nor does "no bits at all" with the stance still drawn.
|
||||
assert!(!ranged_idle_gate(false, false, false, Some(2)));
|
||||
|
||||
// SUSTAIN: mid-volley the local shooter has both bits; the hold survives a frame where the
|
||||
// auto-repeat bit is the only one to have dropped, exactly like 109's `& 0x600` self-loop.
|
||||
assert!(ranged_idle_gate(true, true, true, Some(2)));
|
||||
assert!(ranged_idle_gate(false, true, true, Some(2)));
|
||||
// …and once BOTH bits are gone (the cancel `0x6ea080` clears them together), the hold ends.
|
||||
assert!(!ranged_idle_gate(false, false, true, Some(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_emote_idle_only_fills_the_bare_stand_slot() {
|
||||
// Standing, nothing else going on: the one slot the state-emote idle may fill.
|
||||
|
||||
@@ -285,25 +285,33 @@ impl WeaponVisualSrc<'_, '_> {
|
||||
/// A spell's effective `SpellVisual` row (spell → `Spell.dbc` column 115 → `SpellVisual.dbc`).
|
||||
/// `None` = a silent cast (no visual chain at all).
|
||||
///
|
||||
/// The one place the client's **ranged weapon-visual fallback** applies (`0x60d450`, bytes
|
||||
/// `60d465`–`60d4aa`; wow-re `throw-ranged-attack-anim.md`): a RANGED-attribute spell
|
||||
/// (`Attributes & 0x2`) whose own visual resolves to nothing borrows `weapon_visual` — the
|
||||
/// caster's equipped ranged weapon's substitute visual. Every basic shot rides this (they all
|
||||
/// have `SpellVisual1 = 0`): Throw → ReadyThrown/AttackThrown, Auto Shot → LoadBow/AttackBow,
|
||||
/// wand Shoot → the gun kits. A spell with its own visual never takes the fallback (the client's
|
||||
/// `60d4b4` test), and a non-ranged spell never pays the lookup (`FnOnce`).
|
||||
fn resolve_stages<'a>(
|
||||
/// The one place the client's **ranged weapon-visual merge** applies (`0x60d450`; the law is
|
||||
/// [`VisualStages::merged_over_weapon`], byte-read there): a RANGED-attribute spell
|
||||
/// (`Attributes & 0x2`) takes the caster's equipped ranged weapon's substitute visual for every
|
||||
/// slot its **own** row leaves at zero — precast/cast/impact kits, the missile block, the strike
|
||||
/// sound; never state/channel. A basic shot (`SpellVisual1 = 0`) is only the degenerate case of
|
||||
/// that fill — the whole zeroed row comes across (Throw → ReadyThrown/AttackThrown, Auto Shot →
|
||||
/// LoadBow/AttackBow, wand Shoot → the gun kits), which is what decision 0370 read as the *whole*
|
||||
/// mechanism; every hunter shot spell has its own impact + missile row and empty body-anim slots,
|
||||
/// so it needs the per-field arm (decision 0986, bug B153). A non-ranged spell never pays the
|
||||
/// lookup (`FnOnce`).
|
||||
fn resolve_stages(
|
||||
spells: &crate::ui_action::Spells,
|
||||
visuals: &'a SpellVisualCatalog,
|
||||
visuals: &SpellVisualCatalog,
|
||||
spell_id: u32,
|
||||
weapon_visual: impl FnOnce() -> Option<u32>,
|
||||
) -> Option<&'a VisualStages> {
|
||||
) -> Option<VisualStages> {
|
||||
let def = spells.catalog.get(spell_id)?;
|
||||
match visuals.stages(def.visual) {
|
||||
own @ Some(_) => own,
|
||||
None if def.ranged_slot() => visuals.stages(weapon_visual()?),
|
||||
None => None,
|
||||
let own = visuals.stages(def.visual).copied();
|
||||
if !def.ranged_slot() {
|
||||
return own; // `ebx` stayed 0 at `60d468` — the fill block is a no-op
|
||||
}
|
||||
// `60d46e`–`60d4aa`: the caster's ranged display → `ItemDisplayInfo` col 10 → its row.
|
||||
let Some(weapon) = weapon_visual().and_then(|v| visuals.stages(v)) else {
|
||||
return own;
|
||||
};
|
||||
// No own row = the client's `rep stosd` zeroed `outKit` before the same fill (`60d4bc`).
|
||||
Some(own.unwrap_or_default().merged_over_weapon(weapon))
|
||||
}
|
||||
|
||||
/// Resolve one lifecycle stage of `spell_id`'s visual chain to its kit (spell → `Spell.dbc`
|
||||
@@ -317,7 +325,7 @@ fn resolve_kit(
|
||||
stage: impl Fn(&VisualStages) -> u32,
|
||||
weapon_visual: impl FnOnce() -> Option<u32>,
|
||||
) -> Option<VisualKit> {
|
||||
let kit_id = stage(resolve_stages(spells, visuals, spell_id, weapon_visual)?);
|
||||
let kit_id = stage(&resolve_stages(spells, visuals, spell_id, weapon_visual)?);
|
||||
(kit_id != 0)
|
||||
.then(|| visuals.kit(kit_id).copied())
|
||||
.flatten()
|
||||
@@ -793,6 +801,12 @@ pub(super) fn route_cast_visuals(
|
||||
let Some(display) = spells.catalog.get(go.spell_id) else {
|
||||
continue;
|
||||
};
|
||||
// The caster's ranged weapon visual, resolved once per GO (the client's `0x6e802e` call
|
||||
// into `0x60d450`) — its merged row `edi` is what EVERY consumer below reads: the dest
|
||||
// one-shot (`[edi+0x18]`/`[edi+0x30]`), the cast kit (`[edi+0x8]`), the inline impacts
|
||||
// (`6e8169 mov edx,edi`) and the missile spawn (`6e8199 mov edx,edi`).
|
||||
let wv = weapon_src.caster(go.caster);
|
||||
let stages = resolve_stages(spells, &visuals.0, go.spell_id, || wv);
|
||||
// The GO's dest one-shot (decision 0797, byte-pinned `0x6e8088`–`0x6e8143`): a
|
||||
// dest-carrying GO plays the SpellVisual field-12 model ONCE at the packet's point —
|
||||
// gate `field 6 == 0` (no missile owns the arrival) ∧ `field 12 ≠ 0`. NOT gated on the
|
||||
@@ -800,7 +814,7 @@ pub(super) fn route_cast_visuals(
|
||||
// captured Flamestrike shape); fired here at the GO, never waiting on the dynobj
|
||||
// create (wow-re trap #6: the burst precedes the object).
|
||||
if let Some(dest) = go.dest {
|
||||
if let Some(stages) = visuals.0.stages(display.visual) {
|
||||
if let Some(stages) = stages {
|
||||
if stages.missile_gate == 0 && stages.area_effect != 0 {
|
||||
if let Some(path) = visuals.0.effect_path(stages.area_effect) {
|
||||
bursts.write(crate::entities::dest_fx::GroundBurst {
|
||||
@@ -811,9 +825,6 @@ pub(super) fn route_cast_visuals(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The caster's ranged fallback visual, resolved once per GO (the client's `0x6e802e`
|
||||
// call into `0x60d450`) — feeds the inline impacts, the missile chain, and the flight.
|
||||
let wv = weapon_src.caster(go.caster);
|
||||
if display.speed <= 0.0 {
|
||||
for &target in &go.hits {
|
||||
play_impact(
|
||||
@@ -832,7 +843,6 @@ pub(super) fn route_cast_visuals(
|
||||
// flies). The missile-model chain: field 7 ≥ 1 → its effect model (the client's
|
||||
// literal ErrorCube when unresolvable); < 1 / no visual row → `None`, and the
|
||||
// spawner falls to the GO's wire ammo model (`0x479f40`, phase 5).
|
||||
let stages = resolve_stages(spells, &visuals.0, go.spell_id, || wv);
|
||||
let path = stages.and_then(|s| {
|
||||
(s.missile_model >= 1).then(|| {
|
||||
visuals
|
||||
@@ -859,9 +869,10 @@ pub(super) fn route_cast_visuals(
|
||||
// The release gate (the client's `0x6e7a70` flush condition, inverted): a cast
|
||||
// kit that plays a body animation defers the launch to its release keyframe;
|
||||
// no kit / no anim (`kit+8 < 1` ⇔ our `anim_id: None`) launches at GO.
|
||||
let awaits_release =
|
||||
resolve_kit(spells, &visuals.0, go.spell_id, |s| s.cast, || wv)
|
||||
.is_some_and(|k| k.anim_id.is_some());
|
||||
let awaits_release = stages
|
||||
.filter(|s| s.cast != 0)
|
||||
.and_then(|s| visuals.0.kit(s.cast))
|
||||
.is_some_and(|k| k.anim_id.is_some());
|
||||
missiles.write(MissileSpawn {
|
||||
caster: go.caster,
|
||||
spell_id: go.spell_id,
|
||||
|
||||
@@ -338,16 +338,19 @@ fn same_frame_start_and_fail_leave_no_hold() {
|
||||
assert_eq!(hold(&app, unit), None, "the failed cast's hold is released");
|
||||
}
|
||||
|
||||
/// The ranged weapon-visual fallback (`0x60d450`, wow-re `throw-ranged-attack-anim.md`, decision
|
||||
/// 0370), on [`super::resolve_stages`] directly: a RANGED-attribute spell (`Attributes & 0x2`)
|
||||
/// whose own visual is null resolves through the caster's weapon visual; a non-ranged spell
|
||||
/// never does; a spell with its own visual never takes the fallback.
|
||||
/// The ranged weapon-visual MERGE (`0x60d450`, decision 0986 correcting 0370's row-level
|
||||
/// reading), on [`super::resolve_stages`] directly: a RANGED-attribute spell (`Attributes & 0x2`)
|
||||
/// fills every ZERO slot of its own row from the caster's weapon visual — so a basic shot with no
|
||||
/// row at all takes the lot, a hunter shot keeps its impact/missile and gains the body kits, and a
|
||||
/// non-ranged spell never looks.
|
||||
#[test]
|
||||
fn ranged_spells_fall_back_to_the_weapon_visual() {
|
||||
fn ranged_spells_merge_the_weapon_visual_into_their_empty_slots() {
|
||||
const THROW: u32 = 2764; // Attributes 0x410012, SpellVisual1 0 — the real Throw shape
|
||||
const FIREBALL: u32 = 133; // its own visual; the fallback must stay unused
|
||||
const MULTI_SHOT: u32 = 2643; // RANGED, own visual 567: impact + missile, no body kits
|
||||
const NO_VIS_MELEE: u32 = 772; // no visual, no RANGED attribute — stays silent
|
||||
const WEAPON_VISUAL: u32 = 98; // the real thrown ItemDisplayInfo col-10 substitute
|
||||
const MULTI_SHOT_VISUAL: u32 = 567;
|
||||
|
||||
let visuals = SpellVisualCatalog::from_tables(
|
||||
HashMap::from([
|
||||
@@ -366,6 +369,18 @@ fn ranged_spells_fall_back_to_the_weapon_visual() {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
// The real 5875 row: impact kit 658, missile model 528, gate 1, attach 1 — and
|
||||
// both body-kit slots empty, which is the whole of B153.
|
||||
MULTI_SHOT_VISUAL,
|
||||
VisualStages {
|
||||
impact: 658,
|
||||
missile_gate: 1,
|
||||
missile_model: 528,
|
||||
missile_attach: 1,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
]),
|
||||
HashMap::new(),
|
||||
);
|
||||
@@ -387,6 +402,14 @@ fn ranged_spells_fall_back_to_the_weapon_visual() {
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
MULTI_SHOT,
|
||||
SpellDisplay {
|
||||
visual: MULTI_SHOT_VISUAL,
|
||||
attributes: 0x10002, // the real word: RANGED set, own visual present
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
NO_VIS_MELEE,
|
||||
SpellDisplay {
|
||||
@@ -413,7 +436,24 @@ fn ranged_spells_fall_back_to_the_weapon_visual() {
|
||||
super::resolve_stages(&spells, &visuals, FIREBALL, || Some(WEAPON_VISUAL))
|
||||
.map(|s| s.precast),
|
||||
Some(PRECAST_KIT),
|
||||
"an own visual is never displaced by the fallback"
|
||||
"a populated slot is never displaced by the weapon's"
|
||||
);
|
||||
let multi = super::resolve_stages(&spells, &visuals, MULTI_SHOT, || Some(WEAPON_VISUAL))
|
||||
.expect("Multi-Shot has its own row");
|
||||
assert_eq!(
|
||||
(multi.precast, multi.cast),
|
||||
(171, 172),
|
||||
"the empty body-kit slots fill from the weapon — the missing draw/release (B153)"
|
||||
);
|
||||
assert_eq!(
|
||||
(multi.impact, multi.missile_model, multi.missile_attach),
|
||||
(658, 528, 1),
|
||||
"its own impact + missile block survives the merge untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
super::resolve_stages(&spells, &visuals, MULTI_SHOT, || None).map(|s| (s.cast, s.impact)),
|
||||
Some((0, 658)),
|
||||
"no ranged weapon equipped → the own row stands alone, unfilled"
|
||||
);
|
||||
assert!(
|
||||
super::resolve_stages(&spells, &visuals, NO_VIS_MELEE, || Some(WEAPON_VISUAL)).is_none(),
|
||||
|
||||
@@ -40,6 +40,7 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
|
||||
CursorPayload::Item(i) => i.texture,
|
||||
CursorPayload::Spell(s) => s.texture,
|
||||
CursorPayload::Action(a) => a.texture,
|
||||
CursorPayload::Macro(m) => m.texture,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -773,6 +773,9 @@ pub(super) fn attach_entity_visuals(
|
||||
// (appear ramp, stream-out ramp, the self-avatar feather) multiplies
|
||||
// its clouds — decision 0827.
|
||||
alpha: Some(entity),
|
||||
// Scene-graph-carried: this model's world motion arrives on the reference's
|
||||
// device stack, so its cloud RIDES (0986's baseline).
|
||||
world_composed: false,
|
||||
},
|
||||
// The emitters' rate/enabled read this instance's PLAYING sequence — a
|
||||
// unit's or GameObject's `AnimationPlayer` on the root. A quest object's
|
||||
|
||||
@@ -611,6 +611,9 @@ fn spawn_slot(
|
||||
// (`0x714000`) — so the sparkle on a pauldron fades in with the body wearing it
|
||||
// and vanishes with the avatar in first person (0827/0833).
|
||||
alpha: Some(root),
|
||||
// Scene-graph-carried: this model's world motion arrives on the reference's
|
||||
// device stack, so its cloud RIDES (0986's baseline).
|
||||
world_composed: false,
|
||||
},
|
||||
// A held item spawns no rig; its emitters run the item model's own slot-0
|
||||
// loop on the spawn clock (the torch burns always — the doodad law).
|
||||
|
||||
@@ -600,6 +600,12 @@ pub(super) fn attach_effect_visuals(
|
||||
// This instance IS the model these particles belong to; its chain (set above)
|
||||
// carries the host's fade down to them — decision 0833.
|
||||
alpha: Some(root),
|
||||
// The ride-vs-trail baseline (0986): an UNATTACHED instance is a free world model
|
||||
// whose own transform IS its world placement, so its motion reaches the particles
|
||||
// through the emitter matrix and each one hangs where it was born — the hunter
|
||||
// arrow's bead trail. An attached one is carried by the model it hangs on (the
|
||||
// reference's device-stack `S`), and its cloud rides.
|
||||
world_composed: !host.attached,
|
||||
},
|
||||
// The emitters' rate/enabled windows ride the played sequence: a `CEffect` ADVANCES
|
||||
// (`Stand` → `Hold` → `Decay`), so it reads the live one off its own player like a
|
||||
|
||||
@@ -340,6 +340,9 @@ pub(super) fn spawn_wmo_gameobject_props(
|
||||
// A placed prop carries no fade component of its own; its emitters
|
||||
// take the doodad distance fade in the sim instead (0827).
|
||||
alpha: None,
|
||||
// Scene-graph-carried: this model's world motion arrives on the reference's
|
||||
// device stack, so its cloud RIDES (0986's baseline).
|
||||
world_composed: false,
|
||||
},
|
||||
// A placed prop: the doodad law — which is NOT one arm for life. It re-rolls
|
||||
// its variation every play-window (decision 0768), so the emitter resolves
|
||||
|
||||
@@ -50,6 +50,36 @@ pub(crate) fn config_path() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("config.toml"))
|
||||
}
|
||||
|
||||
/// `benilla/macros/account.txt` — the account-wide macro tab (indices 1..=18; decision 0983). One
|
||||
/// file per scope rather than one file with two sections, because the two have different lifetimes:
|
||||
/// the account tab follows the install, a character tab dies with its character.
|
||||
pub(crate) fn macros_account_path() -> Option<PathBuf> {
|
||||
home().map(|h| h.join("macros/account.txt"))
|
||||
}
|
||||
|
||||
/// `benilla/macros/<realm>-<character>.txt` — the per-character macro tab (indices 19..=36). The
|
||||
/// reference nests these (`WTF/Account/<ACC>/<REALM>/<CHAR>/macros-cache.txt`); benilla flattens to
|
||||
/// one `macros/` folder because [`home`] is already account-scoped by the install it sits beside.
|
||||
pub(crate) fn macros_character_path(realm: &str, character: &str) -> Option<PathBuf> {
|
||||
let key = format!("{}-{}", file_token(realm), file_token(character));
|
||||
home().map(|h| h.join("macros").join(format!("{key}.txt")))
|
||||
}
|
||||
|
||||
/// Make an arbitrary realm/character name safe as one path component: anything outside
|
||||
/// `[A-Za-z0-9_]` becomes `_`, so a realm called `Hydraxian Waterlords` or one with a slash cannot
|
||||
/// escape the folder or collide with the path separator.
|
||||
fn file_token(s: &str) -> String {
|
||||
let t: String = s
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
if t.is_empty() {
|
||||
"unknown".into()
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a state file atomically: parent dirs created, contents to `<path>.tmp`, then rename —
|
||||
/// a crash mid-write leaves the old file intact, never a truncated one. The one write path for
|
||||
/// every resident of the folder.
|
||||
@@ -114,6 +144,21 @@ mod tests {
|
||||
let _d = EnvGuard::set("WOW_DATA", tmp.join("Data").to_str().unwrap());
|
||||
assert_eq!(home(), Some(tmp.join("benilla")));
|
||||
assert_eq!(config_path(), Some(tmp.join("benilla/config.toml")));
|
||||
// The macro residents (decision 0983): one account file, one per character, and every
|
||||
// realm/character name reduced to a safe single path component.
|
||||
assert_eq!(
|
||||
macros_account_path(),
|
||||
Some(tmp.join("benilla/macros/account.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
macros_character_path("Hydraxian Waterlords", "Probeone"),
|
||||
Some(tmp.join("benilla/macros/Hydraxian_Waterlords-Probeone.txt"))
|
||||
);
|
||||
assert_eq!(
|
||||
macros_character_path("../evil", "a/b"),
|
||||
Some(tmp.join("benilla/macros/___evil-a_b.txt")),
|
||||
"no name can escape the folder"
|
||||
);
|
||||
|
||||
// 1 · the explicit override wins over the install root.
|
||||
let _h2 = EnvGuard::set("BENILLA_HOME", tmp.join("elsewhere").to_str().unwrap());
|
||||
|
||||
@@ -120,12 +120,14 @@ mod ui_items;
|
||||
mod ui_logout;
|
||||
mod ui_loot;
|
||||
mod ui_loot_roll;
|
||||
mod ui_macro;
|
||||
mod ui_mail;
|
||||
mod ui_merchant;
|
||||
mod ui_mirror;
|
||||
mod ui_net;
|
||||
mod ui_party;
|
||||
mod ui_pass;
|
||||
mod ui_pet;
|
||||
mod ui_quest;
|
||||
mod ui_quest_log;
|
||||
mod ui_script;
|
||||
@@ -209,6 +211,7 @@ use ui_mirror::UiMirrorPlugin;
|
||||
use ui_net::UiNetPlugin;
|
||||
use ui_party::UiPartyPlugin;
|
||||
use ui_pass::PlayerUiPlugin;
|
||||
use ui_pet::UiPetPlugin;
|
||||
use ui_quest::UiQuestPlugin;
|
||||
use ui_quest_log::UiQuestLogPlugin;
|
||||
use ui_script::UiScriptPlugin;
|
||||
@@ -660,6 +663,11 @@ fn main() -> AppExit {
|
||||
// arc (bags/doll/bars/book). After UiActionPlugin (shares its `Spells` resource + the cast
|
||||
// tail `send_spell_cast`).
|
||||
.add_plugins(UiSpellbookPlugin)
|
||||
// The macro system (decision 0983): the icon chooser's catalog, the `benilla/macros/`
|
||||
// files, `UPDATE_MACROS`, and the macro→bound-spell table the action bar's MACRO slots
|
||||
// resolve their cooldown/usability through. After UiSpellbookPlugin — the bound spell is
|
||||
// resolved against the book that feed pushes, by the same law `CastSpellByName` uses.
|
||||
.add_plugins(ui_macro::UiMacroPlugin)
|
||||
// The talent window feed (decision 0304): builds the class pages from Talent.dbc × the
|
||||
// known-spell set + PLAYER_CHARACTER_POINTS, drives TalentFrame.xml through the engine's
|
||||
// talent seam, and drains learn clicks into CMSG_LEARN_TALENT. After UiActionPlugin
|
||||
@@ -670,6 +678,10 @@ fn main() -> AppExit {
|
||||
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
|
||||
// UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail).
|
||||
.add_plugins(UiShapeshiftPlugin)
|
||||
// The pet action bar (decision 0982) — the stance bar's mirror image: server-authoritative,
|
||||
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
|
||||
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
|
||||
.add_plugins(UiPetPlugin)
|
||||
// The connection-telemetry feed: the averaged ping RTT behind `GetNetStats()`, which the main
|
||||
// bar's performance meter polls (decision 0658).
|
||||
.add_plugins(UiNetPlugin)
|
||||
|
||||
@@ -669,6 +669,33 @@ pub(crate) enum ClientCommand {
|
||||
/// engine's own `kind<<24 | action` word. Sent by the action drain on every queued
|
||||
/// `PickupAction`/`PlaceAction` mutation — client-authoritative, no answer packet.
|
||||
SetActionButton { button: u8, packed: u32 },
|
||||
/// Press one pet bar slot (`CMSG_PET_ACTION`, decisions 0982/0988). `packed` is the slot's OWN
|
||||
/// word as the server last sent it — command, reaction and spell all ride this one command,
|
||||
/// because the server dispatches on the type byte inside the word. `target_guid` is the
|
||||
/// player's current selection, which is what the client always sends (wow-re §10.1).
|
||||
///
|
||||
/// **Nothing answers it.** Unlike [`Self::SetActionButton`], whose silence is because the
|
||||
/// state is ours, this one is silent because the *server* simply does not reply — so the
|
||||
/// caller has already applied the visible change locally before queueing it.
|
||||
PetAction {
|
||||
pet_guid: u64,
|
||||
packed: u32,
|
||||
target_guid: u64,
|
||||
},
|
||||
/// Write pet bar slots (`CMSG_PET_SET_ACTION`, decision 0988) — `(0-based position, the whole
|
||||
/// new word)` pairs, one or two.
|
||||
///
|
||||
/// **This is how the autocast toggle travels**, with one entry: the client flips bit 30 in the
|
||||
/// slot's word and posts the result, and the server reads the direction back out of the type
|
||||
/// byte it arrives in. `CMSG_PET_SPELL_AUTOCAST` (0x2F3) is a *different* binding's opcode —
|
||||
/// the pet spellbook's `ToggleSpellAutocast`, which indexes the spellbook rather than the bar
|
||||
/// and which we do not ship. The same send carries the drag when that lands.
|
||||
PetSetAction {
|
||||
pet_guid: u64,
|
||||
entries: Vec<(u32, u32)>,
|
||||
},
|
||||
/// Call the pet off its target (`CMSG_PET_STOP_ATTACK`) — the Attack button's second press.
|
||||
PetStopAttack { pet_guid: u64 },
|
||||
/// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`); echoed as `SMSG_ATTACKSTART`.
|
||||
AttackSwing { guid: u64 },
|
||||
/// Stop melee auto-attack (`CMSG_ATTACKSTOP`); echoed as `SMSG_ATTACKSTOP`, whose receive path
|
||||
|
||||
@@ -27,6 +27,7 @@ mod mount;
|
||||
mod names;
|
||||
mod npc;
|
||||
mod objects;
|
||||
mod pet;
|
||||
mod quests;
|
||||
mod session;
|
||||
mod spells;
|
||||
@@ -52,6 +53,33 @@ use spells::{
|
||||
superceded_spell,
|
||||
};
|
||||
|
||||
/// Which unit's cooldown store a wire cooldown packet addresses (decision 0982).
|
||||
///
|
||||
/// All four of them (`SMSG_SPELL_COOLDOWN`, `_COOLDOWN_EVENT`, `_CLEAR_COOLDOWN`,
|
||||
/// `_COOLDOWN_CHEAT`) carry a caster guid, and until the pet bar existed all four answered it the
|
||||
/// same way: "is it us? then apply, else drop" — four copies of a self-only assumption, each
|
||||
/// inside its own arm. Since the server sends a pet's cooldowns on the pet's guid, that
|
||||
/// assumption silently discarded every one of them. Resolving the guid ONCE, here, is what let the
|
||||
/// pet bar sweep for real without a second copy of any arm; it also matches the reference, whose
|
||||
/// `SMSG_COOLDOWN_CHEAT` handler wipes "the self/pet cooldown list" off exactly this test.
|
||||
///
|
||||
/// `None` = a guid we hold no store for (another player's pet, a stale packet): dropped, as the
|
||||
/// client drops an unknown guid.
|
||||
fn addressed_store<'a>(
|
||||
caster: u64,
|
||||
self_guid: &SelfGuid,
|
||||
player: &'a mut crate::cooldowns::Cooldowns,
|
||||
pet: &'a mut crate::ui_pet::PetBar,
|
||||
) -> Option<&'a mut crate::cooldowns::Cooldowns> {
|
||||
if self_guid.0 == Some(caster) {
|
||||
Some(player)
|
||||
} else if pet.has_bar() && pet.spells.pet_guid == caster {
|
||||
Some(&mut pet.cooldowns)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ── The per-frame bridge systems ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Drain the inbound event channel and mutate real ECS entities: spawn on create, move existing,
|
||||
@@ -149,6 +177,12 @@ pub(super) fn apply_net_updates(
|
||||
// The mirror-timer queue (decision 0874): the breath/fatigue START/PAUSE/STOP edges,
|
||||
// drained into the FrameXML bars by `crate::ui_mirror`.
|
||||
ResMut<crate::ui_mirror::MirrorTimerFeed>,
|
||||
// The pet action bar's server-authoritative state + its own cooldown store
|
||||
// (decision 0982), replaced wholesale on every `SMSG_PET_SPELLS`; and the by-key red
|
||||
// error queue its refused-order feedback rides (the `DisplayError` route, resolved
|
||||
// through the VM's own GlobalStrings by `ui_action::feed_actions`).
|
||||
ResMut<crate::ui_pet::PetBar>,
|
||||
ResMut<crate::ui_action::UiErrorKeys>,
|
||||
),
|
||||
),
|
||||
// One tuple param (the 16-SystemParam ceiling again): the action-bar- + merchant-facing errors
|
||||
@@ -290,6 +324,8 @@ pub(super) fn apply_net_updates(
|
||||
area_table,
|
||||
exploration_sounds,
|
||||
mut mirror_timers,
|
||||
mut pet_bar,
|
||||
mut ui_error_keys,
|
||||
),
|
||||
) = caches;
|
||||
let (
|
||||
@@ -1037,13 +1073,13 @@ pub(super) fn apply_net_updates(
|
||||
&mut commands,
|
||||
&net_commands,
|
||||
),
|
||||
SessionEvent::SpellCooldowns { caster, cooldowns } => spell_cooldowns(
|
||||
caster,
|
||||
cooldowns,
|
||||
&self_guid,
|
||||
ui_actions.11.as_deref(),
|
||||
&mut ui_actions.10,
|
||||
),
|
||||
SessionEvent::SpellCooldowns { caster, cooldowns } => {
|
||||
if let Some(store) =
|
||||
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
|
||||
{
|
||||
spell_cooldowns(caster, cooldowns, ui_actions.11.as_deref(), store);
|
||||
}
|
||||
}
|
||||
SessionEvent::ItemCooldown {
|
||||
item_guid,
|
||||
spell_id,
|
||||
@@ -1056,13 +1092,37 @@ pub(super) fn apply_net_updates(
|
||||
seconds,
|
||||
} => items.set_enchant_deadline(item_guid, slot, seconds),
|
||||
SessionEvent::CooldownEvent { spell_id, caster } => {
|
||||
cooldown_event(spell_id, caster, &self_guid, &mut ui_actions.10)
|
||||
if let Some(store) =
|
||||
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
|
||||
{
|
||||
cooldown_event(spell_id, caster, store);
|
||||
}
|
||||
}
|
||||
SessionEvent::ClearCooldown { spell_id, caster } => {
|
||||
clear_cooldown(spell_id, caster, &self_guid, &mut ui_actions.10)
|
||||
if let Some(store) =
|
||||
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
|
||||
{
|
||||
clear_cooldown(spell_id, caster, store);
|
||||
}
|
||||
}
|
||||
SessionEvent::CooldownCheat { caster } => {
|
||||
cooldown_cheat(caster, &self_guid, &mut ui_actions.10)
|
||||
if let Some(store) =
|
||||
addressed_store(caster, &self_guid, &mut ui_actions.10, &mut pet_bar)
|
||||
{
|
||||
cooldown_cheat(caster, store);
|
||||
}
|
||||
}
|
||||
// The pet action bar (decision 0982) — server-authoritative, so PET_SPELLS is a
|
||||
// wholesale replace and its zero-guid form is the teardown.
|
||||
SessionEvent::PetSpells(spells) => {
|
||||
pet::pet_spells(*spells, ui_actions.11.as_deref(), &mut pet_bar)
|
||||
}
|
||||
SessionEvent::PetMode(mode) => pet::pet_mode(mode, &mut pet_bar),
|
||||
SessionEvent::PetActionFeedback { reason } => {
|
||||
pet::pet_action_feedback(reason, &mut ui_error_keys)
|
||||
}
|
||||
SessionEvent::PetCastFailed { spell_id, reason } => {
|
||||
pet::pet_cast_failed(spell_id, reason, &mut ui_actions.1 .0)
|
||||
}
|
||||
SessionEvent::ChannelStart {
|
||||
spell_id,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//! The pet bar's wire arms (decisions 0982, 0988) — `SMSG_PET_SPELLS`, `SMSG_PET_MODE`,
|
||||
//! `SMSG_PET_ACTION_FEEDBACK`, `SMSG_PET_CAST_FAILED` folded into [`crate::ui_pet::PetBar`].
|
||||
//!
|
||||
//! The whole file is short because `SMSG_PET_SPELLS` is not a delta — it **is** the bar, so
|
||||
//! applying it is a replace. These four arms own the bar's CONTENTS; its lit state is
|
||||
//! [`crate::ui_pet`]'s, latched on the press because the server never answers one (that file's
|
||||
//! module doc has the why). The only state this file writes is what actually arrives on the wire.
|
||||
//!
|
||||
//! The two refusal arms deliberately reuse the *player's* red-line queues rather than growing pet
|
||||
//! copies: both resolve their text through the VM's own `GlobalStrings.lua` the same way, and the
|
||||
//! cast-fail resolver keys its power family on the failing **spell's** record ([`cast_fail`]'s
|
||||
//! `power_keys(spell.power_type)`), so a pet's focus ability already reads "Not enough focus"
|
||||
//! with no pet-awareness anywhere in the display layer.
|
||||
//!
|
||||
//! [`cast_fail`]: crate::ui_action
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::{PetMode, PetSpells};
|
||||
|
||||
use crate::ui_action::{CastErrors, Spells, UiError, UiErrorKeys};
|
||||
use crate::ui_pet::PetBar;
|
||||
|
||||
/// `SMSG_PET_SPELLS` — replace the whole bar, and reseed the pet's own cooldown store from the
|
||||
/// packet's tail.
|
||||
///
|
||||
/// A **zero guid** is the teardown (`Player::RemovePetActionBar`): the bar goes away and the
|
||||
/// cooldown store goes with it, because the next pet is a different unit with different timers.
|
||||
/// Everything else is a wholesale replace — including a re-send from the same pet, which is how a
|
||||
/// learned spell, a mode change or an autocast toggle actually reaches the bar.
|
||||
pub(super) fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut PetBar) {
|
||||
if spells.pet_guid == 0 {
|
||||
if bar.spells.pet_guid != 0 {
|
||||
debug!("net: pet bar torn down");
|
||||
}
|
||||
*bar = PetBar::default();
|
||||
return;
|
||||
}
|
||||
// A pet-GUID CHANGE clears the attack latch (`0x4bc8ce`, the client's own single writer of the
|
||||
// pet guid does it unconditionally) — a freshly summoned pet is not attacking, whatever the
|
||||
// last one was doing. A re-send from the SAME pet leaves it alone: a learned spell must not
|
||||
// silently call the pet off.
|
||||
if bar.spells.pet_guid != spells.pet_guid {
|
||||
bar.attacking = false;
|
||||
}
|
||||
debug!(
|
||||
"net: pet bar for {:#x} — react {} command {} {}, {} known spell(s), {} cooldown(s)",
|
||||
spells.pet_guid,
|
||||
spells.react_state(),
|
||||
spells.command_state(),
|
||||
if spells.bar_disabled() {
|
||||
"DISABLED"
|
||||
} else {
|
||||
"usable"
|
||||
},
|
||||
spells.spells.len(),
|
||||
spells.cooldowns.len(),
|
||||
);
|
||||
// The ten words, spelled out. Worth its own line rather than a raw hex dump: every question
|
||||
// this packet raises ("why is that slot empty", "why is nothing lit", "is that spell in the
|
||||
// catalog") is answered by seeing the type/action pair beside the name we resolved for it, and
|
||||
// reading that off `0xc1000bc2` by hand is exactly the step nobody does.
|
||||
debug!(
|
||||
"net: pet bar slots — {}",
|
||||
spells
|
||||
.bar
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| {
|
||||
let what = match e.kind() {
|
||||
benilla_protocol::messages::PET_ACT_COMMAND => format!("cmd {}", e.action()),
|
||||
benilla_protocol::messages::PET_ACT_REACTION => format!("react {}", e.action()),
|
||||
_ if e.is_empty() => "empty".to_string(),
|
||||
_ => match catalog.and_then(|c| c.catalog.get(e.action())) {
|
||||
Some(d) => format!("{} ({})", d.name, e.action()),
|
||||
None => format!("spell {} NOT IN CATALOG", e.action()),
|
||||
},
|
||||
};
|
||||
format!("{}:{what}/{:#04x}", i + 1, e.kind())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
);
|
||||
let now = Instant::now();
|
||||
bar.cooldowns = crate::cooldowns::Cooldowns::default();
|
||||
for cd in &spells.cooldowns {
|
||||
let display = catalog.and_then(|c| c.catalog.get(cd.spell_id));
|
||||
bar.cooldowns.seed_pet(cd, display, now);
|
||||
}
|
||||
bar.spells = spells;
|
||||
}
|
||||
|
||||
/// `SMSG_PET_MODE` — the react/command state alone. Applied only when it names the pet whose bar
|
||||
/// we actually hold: a mode packet for a unit we have no bar for has nothing to write into, and
|
||||
/// taking its state anyway would light a reaction button on the wrong pet's bar.
|
||||
pub(super) fn pet_mode(mode: PetMode, bar: &mut PetBar) {
|
||||
if bar.spells.pet_guid == 0 || bar.spells.pet_guid != mode.pet_guid {
|
||||
return;
|
||||
}
|
||||
debug!("net: pet mode — state {:#010x}", mode.state);
|
||||
// The whole dword, verbatim — the client stores this packet and `SMSG_PET_SPELLS`' state field
|
||||
// through the same writer (`0x4bc930`), and bit 27 can only ever arrive this way.
|
||||
bar.spells.state = mode.state;
|
||||
}
|
||||
|
||||
/// `SMSG_PET_ACTION_FEEDBACK` — one reason byte for a refused order, queued onto the red line by
|
||||
/// GlobalStrings key ([`UiErrorKeys`], the `DisplayError` route). An unrecognised code queues
|
||||
/// nothing, exactly as an absent key shows nothing.
|
||||
pub(super) fn pet_action_feedback(reason: u8, errors: &mut UiErrorKeys) {
|
||||
debug!("net: pet action feedback {reason}");
|
||||
if let Some(key) = pet_feedback_key(reason) {
|
||||
errors.0.push(UiError::key(key));
|
||||
}
|
||||
}
|
||||
|
||||
/// The `SMSG_PET_ACTION_FEEDBACK` reason → its `GlobalStrings.lua` key.
|
||||
///
|
||||
/// vmangos sends exactly two (`Unit::SendPetActionFeedback`'s call sites): `1` when the pet cannot
|
||||
/// path to the ordered spot, `2` when the ordered target is out of its reach. Both keys ship in
|
||||
/// 1.12's `GlobalStrings.lua` (`PET_SPELL_NOPATH` at l.3054, `SPELL_FAILED_OUT_OF_RANGE`), so the
|
||||
/// text — and its localization — comes from the VM like every other error line.
|
||||
fn pet_feedback_key(reason: u8) -> Option<&'static str> {
|
||||
Some(match reason {
|
||||
1 => "PET_SPELL_NOPATH",
|
||||
2 => "SPELL_FAILED_OUT_OF_RANGE",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `SMSG_PET_CAST_FAILED` — the pet's cast refusal, queued onto the SAME red line as our own
|
||||
/// through [`CastErrors`], because the resolver is spell-keyed and needs no pet-awareness.
|
||||
///
|
||||
/// What it deliberately does NOT do is touch our cast state: the caster is the pet, so there is no
|
||||
/// pending cast of ours to revert, no GCD of ours to clear and no button of ours to unflash — the
|
||||
/// three things `cast_result`'s own failure path does. Reusing that path would have made a pet's
|
||||
/// refused Growl cancel the player's cast bar.
|
||||
pub(super) fn pet_cast_failed(spell_id: u32, reason: Option<u8>, errors: &mut CastErrors) {
|
||||
debug!("net: pet cast failed — spell {spell_id} reason {reason:?}");
|
||||
if let Some(reason) = reason {
|
||||
errors.0.push((spell_id, reason));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::{PetActionEntry, PET_ACT_COMMAND, PET_COMMAND_ATTACK};
|
||||
|
||||
fn a_bar(guid: u64) -> PetSpells {
|
||||
let mut s = PetSpells {
|
||||
pet_guid: guid,
|
||||
state: 0x0101, // react Defensive, command Follow
|
||||
..Default::default()
|
||||
};
|
||||
s.bar[0] = PetActionEntry::from(PET_COMMAND_ATTACK | (u32::from(PET_ACT_COMMAND) << 24));
|
||||
s
|
||||
}
|
||||
|
||||
/// The teardown clears the bar AND its cooldown store — the next pet is a different unit, so
|
||||
/// carrying its predecessor's timers would sweep the new pet's buttons for no reason.
|
||||
#[test]
|
||||
fn a_zero_guid_tears_the_whole_bar_down() {
|
||||
let mut bar = PetBar::default();
|
||||
pet_spells(a_bar(0x2A), None, &mut bar);
|
||||
assert_eq!(bar.spells.pet_guid, 0x2A);
|
||||
|
||||
pet_spells(PetSpells::default(), None, &mut bar);
|
||||
assert_eq!(bar.spells, PetSpells::default());
|
||||
assert!(!bar.has_bar());
|
||||
}
|
||||
|
||||
/// A mode packet for a DIFFERENT unit is dropped — it would otherwise light a reaction button
|
||||
/// on the bar we are holding for somebody else's pet.
|
||||
#[test]
|
||||
fn pet_mode_only_writes_its_own_bar() {
|
||||
let mut bar = PetBar::default();
|
||||
pet_spells(a_bar(0x2A), None, &mut bar);
|
||||
|
||||
pet_mode(
|
||||
PetMode {
|
||||
pet_guid: 0x99,
|
||||
state: 0x0202,
|
||||
},
|
||||
&mut bar,
|
||||
);
|
||||
assert_eq!(bar.spells.react_state(), 1, "a stranger's mode is ignored");
|
||||
|
||||
pet_mode(
|
||||
PetMode {
|
||||
pet_guid: 0x2A,
|
||||
state: 0x0202,
|
||||
},
|
||||
&mut bar,
|
||||
);
|
||||
assert_eq!(bar.spells.react_state(), 2);
|
||||
assert_eq!(bar.spells.command_state(), 2);
|
||||
// The bar itself is untouched — PET_MODE carries no slots.
|
||||
assert_eq!(bar.spells.bar[0].action(), PET_COMMAND_ATTACK);
|
||||
}
|
||||
|
||||
/// The attack latch is cleared by a pet CHANGE and survives a re-send from the same pet — the
|
||||
/// client's own single pet-guid writer does exactly this (`0x4bc8ce`). A learned spell must
|
||||
/// not silently call the pet off; a new pet must not inherit the last one's attack.
|
||||
#[test]
|
||||
fn the_attack_latch_survives_a_resend_and_dies_on_a_new_pet() {
|
||||
let mut bar = PetBar::default();
|
||||
pet_spells(a_bar(0x2A), None, &mut bar);
|
||||
bar.attacking = true;
|
||||
|
||||
pet_spells(a_bar(0x2A), None, &mut bar);
|
||||
assert!(bar.attacking, "the same pet re-sending keeps its attack");
|
||||
|
||||
pet_spells(a_bar(0x99), None, &mut bar);
|
||||
assert!(!bar.attacking, "a different pet is not attacking");
|
||||
}
|
||||
|
||||
/// Only the two codes vmangos actually sends resolve; anything else draws nothing rather than
|
||||
/// putting a bare number on the red line.
|
||||
#[test]
|
||||
fn only_the_shipped_feedback_codes_resolve() {
|
||||
let mut errors = UiErrorKeys::default();
|
||||
for reason in [0u8, 1, 2, 3, 200] {
|
||||
pet_action_feedback(reason, &mut errors);
|
||||
}
|
||||
assert_eq!(
|
||||
errors.0.iter().map(|e| e.key).collect::<Vec<_>>(),
|
||||
["PET_SPELL_NOPATH", "SPELL_FAILED_OUT_OF_RANGE"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -103,10 +103,14 @@ pub(super) fn cast_result(
|
||||
// `nocked-ammo-cancel.md`): iff the failing spellId == the cached auto-repeat spell AND
|
||||
// reason ≠ 0x17, the handler jumps into `0x6ea080` — the SAME routine the
|
||||
// SMSG_CANCEL_AUTO_REPEAT handler runs — clearing the key, the shooting-idle bits, and
|
||||
// the nocked ammo. Against vmangos this path is the ONLY live disarm: the server never
|
||||
// sends SMSG_CANCEL_AUTO_REPEAT (dead packet class, zero send sites); a
|
||||
// deselect/interrupt surfaces solely as this CAST_RESULT failure
|
||||
// the nocked ammo. A deselect/interrupt surfaces as this CAST_RESULT failure
|
||||
// (`HandleSetSelectionOpcode` → `Spell::cancel` → `SendCastResult(INTERRUPTED)`).
|
||||
// **Correction (2026-08-05):** this is NOT the only live disarm — the long-standing note
|
||||
// here that "vmangos never sends SMSG_CANCEL_AUTO_REPEAT (dead packet class, zero send
|
||||
// sites)" is FALSE. `SpellCaster::InterruptSpell` (vmangos `SpellCaster.cpp:1826`) calls
|
||||
// `Player::SendAutoRepeatCancel()` for every player autorepeat interrupt — target death
|
||||
// included (`Unit::_UpdateAutoRepeatSpell` → `CheckCast` fails → `InterruptSpell`) — so
|
||||
// [`cancel_auto_repeat`]'s handler is live, not dormant.
|
||||
let now = Instant::now();
|
||||
// Reason 0x17 DONT_REPORT exits the ref's handler at a bare epilogue BEFORE the GCD
|
||||
// clear and the display (`6e1ce1`/`6e1cf7 → 0x6e224f` — 0948's C4): a silent server
|
||||
@@ -575,6 +579,14 @@ pub(super) fn spell_delayed(
|
||||
/// `[+0xd58] & 0x200` idle bit (0131 recorded "no clearing writer") or the hold-pose layer
|
||||
/// merely makes the ref look still is the dispatched question; the verdict corrects this
|
||||
/// if the mechanism differs.
|
||||
///
|
||||
/// **Live against vmangos, not dormant (corrected 2026-08-05).** The prior note here — "vmangos
|
||||
/// never sends this" — was wrong: `SpellCaster::InterruptSpell` (`SpellCaster.cpp:1826`) sends it
|
||||
/// on every player autorepeat interrupt, which is how **target death** stops a volley
|
||||
/// (`Unit::_UpdateAutoRepeatSpell` → `CheckCast(true)` returns dead/bad-targets →
|
||||
/// `InterruptSpell(CURRENT_AUTOREPEAT_SPELL)` → `SendAutoRepeatCancel`). Movement does NOT
|
||||
/// (`_UpdateAutoRepeatSpell` interrupts only Category 351, the wand, when moving) — Auto Shot
|
||||
/// stays armed across a run, exactly like vanilla.
|
||||
pub(super) fn cancel_auto_repeat(
|
||||
auto_repeat: &mut AutoRepeatActive,
|
||||
self_guid: &SelfGuid,
|
||||
@@ -584,19 +596,19 @@ pub(super) fn cancel_auto_repeat(
|
||||
) {
|
||||
debug!("net: cancel auto-repeat");
|
||||
// The packet thunk `0x6e99d0` funnels into the same cancel `0x6ea080` as every local
|
||||
// trigger — including its CMSG ack (unconditional inside the routine). vmangos never sends
|
||||
// this SMSG, so against it the arm is dormant; kept for wire fidelity.
|
||||
// trigger — including its CMSG ack (unconditional inside the routine). Live against vmangos,
|
||||
// not dormant: the doc block above has the send site and the target-death path to it.
|
||||
let self_e = self_guid.0.and_then(|g| index.0.get(&g)).copied();
|
||||
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, commands, net);
|
||||
}
|
||||
|
||||
/// `SMSG_SPELL_COOLDOWN` (`0x6e9460`) — server-pushed cooldowns for the player (school lockouts;
|
||||
/// the pet list has no benilla consumer yet, so a pet-guid packet for a pet we don't model is
|
||||
/// dropped like the client drops unknown guids).
|
||||
/// `SMSG_SPELL_COOLDOWN` (`0x6e9460`) — server-pushed cooldowns (school lockouts, and the pet's
|
||||
/// own list). **Which unit's store** it lands in is the caller's decision
|
||||
/// ([`super::addressed_store`]): the four cooldown packets all carry a caster guid, and resolving
|
||||
/// it in one place is what let the pet bar have real cooldowns without a second copy of this arm.
|
||||
pub(super) fn spell_cooldowns(
|
||||
caster: u64,
|
||||
pairs: Vec<(u32, u32)>,
|
||||
self_guid: &SelfGuid,
|
||||
spells: Option<&Spells>,
|
||||
cooldowns: &mut Cooldowns,
|
||||
) {
|
||||
@@ -604,9 +616,6 @@ pub(super) fn spell_cooldowns(
|
||||
"net: spell cooldowns for {caster:#x} — {} pair(s)",
|
||||
pairs.len()
|
||||
);
|
||||
if self_guid.0 != Some(caster) {
|
||||
return;
|
||||
}
|
||||
let now = Instant::now();
|
||||
for (spell_id, cooldown_ms) in pairs {
|
||||
let display = spells.and_then(|s| s.catalog.get(spell_id));
|
||||
@@ -629,38 +638,24 @@ pub(super) fn item_cooldown(
|
||||
}
|
||||
|
||||
/// `SMSG_COOLDOWN_EVENT` (`0x6e9670` → `0x6e3050(force=0)`) — start an on-hold cooldown's parked
|
||||
/// timers now (Stealth ends, Feign Death drops).
|
||||
pub(super) fn cooldown_event(
|
||||
spell_id: u32,
|
||||
caster: u64,
|
||||
self_guid: &SelfGuid,
|
||||
cooldowns: &mut Cooldowns,
|
||||
) {
|
||||
/// timers now (Stealth ends, Feign Death drops). Store chosen by [`super::addressed_store`].
|
||||
pub(super) fn cooldown_event(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) {
|
||||
debug!("net: cooldown event — spell {spell_id} on {caster:#x}");
|
||||
if self_guid.0 == Some(caster) {
|
||||
cooldowns.cooldown_event(spell_id, Instant::now());
|
||||
}
|
||||
cooldowns.cooldown_event(spell_id, Instant::now());
|
||||
}
|
||||
|
||||
/// `SMSG_CLEAR_COOLDOWN` (`0x6e9670` → `0x6e3050(force=1)`) — remove the spell's record outright.
|
||||
pub(super) fn clear_cooldown(
|
||||
spell_id: u32,
|
||||
caster: u64,
|
||||
self_guid: &SelfGuid,
|
||||
cooldowns: &mut Cooldowns,
|
||||
) {
|
||||
pub(super) fn clear_cooldown(spell_id: u32, caster: u64, cooldowns: &mut Cooldowns) {
|
||||
debug!("net: clear cooldown — spell {spell_id} on {caster:#x}");
|
||||
if self_guid.0 == Some(caster) {
|
||||
cooldowns.clear_spell(spell_id);
|
||||
}
|
||||
cooldowns.clear_spell(spell_id);
|
||||
}
|
||||
|
||||
/// `SMSG_COOLDOWN_CHEAT` (`0x6e9730` → `0x6e9700`) — the GM reset wipes the whole list.
|
||||
pub(super) fn cooldown_cheat(caster: u64, self_guid: &SelfGuid, cooldowns: &mut Cooldowns) {
|
||||
/// `SMSG_COOLDOWN_CHEAT` (`0x6e9730` → `0x6e9700`) — the GM reset wipes the whole list. The
|
||||
/// reference's own handler wipes "the self/pet cooldown list" on a guid match, which is exactly
|
||||
/// what routing through [`super::addressed_store`] now reproduces.
|
||||
pub(super) fn cooldown_cheat(caster: u64, cooldowns: &mut Cooldowns) {
|
||||
debug!("net: cooldown cheat (wipe) for {caster:#x}");
|
||||
if self_guid.0 == Some(caster) {
|
||||
cooldowns.wipe();
|
||||
}
|
||||
cooldowns.wipe();
|
||||
}
|
||||
|
||||
/// `MSG_CHANNEL_START` — self-only on the wire (no guid), so it goes straight to the cast bar; the
|
||||
@@ -1034,10 +1029,11 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The director's stuck-shooting-idle report: click off the target during Auto Shot and the
|
||||
/// Load/Hold stance never drops. vmangos never sends `SMSG_CANCEL_AUTO_REPEAT` (dead packet
|
||||
/// class) — a deselect surfaces ONLY as a `SMSG_CAST_RESULT` failure for the cached
|
||||
/// auto-repeat spell, and the client's `6e1cd9` jump into `0x6ea080` makes that the full
|
||||
/// local cancel: the key AND the shooting idle both drop.
|
||||
/// Load/Hold stance never drops. A deselect surfaces as a `SMSG_CAST_RESULT` failure for the
|
||||
/// cached auto-repeat spell (vmangos `HandleSetSelectionOpcode` → `Spell::cancel` →
|
||||
/// `SendCastResult(INTERRUPTED)`), and the client's `6e1cd9` jump into `0x6ea080` makes that
|
||||
/// the full local cancel: the key AND the shooting idle both drop. (It is not the only live
|
||||
/// disarm — see [`super::cancel_auto_repeat`]: vmangos does send `SMSG_CANCEL_AUTO_REPEAT`.)
|
||||
#[test]
|
||||
fn a_cast_result_fail_of_the_cached_auto_repeat_spell_disarms_the_shooting_idle() {
|
||||
use crate::creature_anim::AutoRepeatArmed;
|
||||
|
||||
@@ -697,6 +697,15 @@ fn writer_loop(
|
||||
ClientCommand::SetActionButton { button, packed } => {
|
||||
w.set_action_button(button, packed)
|
||||
}
|
||||
ClientCommand::PetAction {
|
||||
pet_guid,
|
||||
packed,
|
||||
target_guid,
|
||||
} => w.pet_action(pet_guid, packed, target_guid),
|
||||
ClientCommand::PetSetAction { pet_guid, entries } => {
|
||||
w.pet_set_action(pet_guid, &entries)
|
||||
}
|
||||
ClientCommand::PetStopAttack { pet_guid } => w.pet_stop_attack(pet_guid),
|
||||
ClientCommand::AttackSwing { guid } => w.attack_swing(guid),
|
||||
ClientCommand::AttackStop => w.attack_stop(),
|
||||
ClientCommand::SetSheathed { state } => w.set_sheathed(state),
|
||||
|
||||
@@ -167,11 +167,26 @@ pub struct ParticleEmitter {
|
||||
/// NOT the emitter's bone joint. The joint composes each particle's birth (position and
|
||||
/// rotation baked, the reference's birth transforms) and then must never move the cloud
|
||||
/// again: an emitter riding an ANIMATED bone — the food sparkle orbits on a global-sequence
|
||||
/// spin — would otherwise drag every risen star in a circle (the director's swirl). File
|
||||
/// flag 0x4000 is the client's only live-particle-follows-emitter plumbing and placed/spell
|
||||
/// content doesn't author it. `None` = anchor at the spawn placement (a placed doodad whose
|
||||
/// emitter rides a joint anchors at the doodad, not the bone).
|
||||
/// spin — would otherwise drag every risen star in a circle (the director's swirl).
|
||||
/// `None` = anchor at the spawn placement (a placed doodad whose emitter rides a joint
|
||||
/// anchors at the doodad, not the bone). [`Self::world_composed`] is what decides whether the
|
||||
/// anchor's own motion carries the pool at all.
|
||||
anchor: Option<Entity>,
|
||||
/// Whether this emitter's world MOTION reaches the particles through its own **emitter
|
||||
/// matrix** rather than through the model's device-stack transform — the reference's real
|
||||
/// ride-vs-trail discriminator (wow-re `part-emitter-motion.md` §2b: "`rt+0x1fc` local, Δ≈0 —
|
||||
/// creature-attached doodads" ⇒ ride, vs "folded into `rt+0x1fc` … a translating missile
|
||||
/// whose own model IS the emitter" ⇒ the birth bakes world-absolute and the particle is
|
||||
/// world-FROZEN at draw). Bit 0x100 is NOT that switch and neither is the follow flag: the
|
||||
/// kobold's candle (file `0x01`) rides while Multi-Shot's FLARE emitters (file `0x0309`,
|
||||
/// equally unflagged) hang in the air behind the arrow.
|
||||
///
|
||||
/// `true` for a FREE world model — a missile, a planted ground burst — whose own transform is
|
||||
/// its world placement; `false` for everything hung off a model that the scene graph moves (a
|
||||
/// creature's own emitters, a kit effect on a unit, a held item's glow). It sets the
|
||||
/// **baseline** the follow-delta term is measured against: 0 (world-frozen) here, 1 (rigid
|
||||
/// ride) otherwise — see the follow block in [`sim`](crate::particles::sim). Decision 0986.
|
||||
world_composed: bool,
|
||||
/// The anchor's last-known world translation (kept when the entity vanishes so the pool
|
||||
/// drains in place; init = the spawn placement's translation).
|
||||
anchor_pos: Vec3,
|
||||
@@ -313,6 +328,10 @@ pub struct EmitterFrames {
|
||||
pub alpha: Option<Entity>,
|
||||
/// What losing `owner` means ([`OwnerLoss`]).
|
||||
pub on_owner_loss: OwnerLoss,
|
||||
/// Whether the model's world motion reaches the particles through the emitter matrix — a FREE
|
||||
/// world model like a missile ([`ParticleEmitter::world_composed`]). Defaults `false`: every
|
||||
/// scene-graph-carried lane (creatures, doodads, kit effects, held items) rides.
|
||||
pub world_composed: bool,
|
||||
}
|
||||
|
||||
/// Spawn an emitter entity for one [`ModelEmitter`] at `placement`. `None` if the emitter has no
|
||||
@@ -594,6 +613,7 @@ pub fn spawn_emitter(
|
||||
alpha_src: frames.alpha,
|
||||
alpha: 1.0,
|
||||
anchor: frames.anchor,
|
||||
world_composed: frames.world_composed,
|
||||
anchor_pos: placement.translation,
|
||||
particles: Vec::new(),
|
||||
accumulator: 0.0,
|
||||
|
||||
@@ -80,6 +80,39 @@ fn integrate_particle(p: &mut Particle, env: &StepEnv) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// **How much of the emitter's per-frame world motion a live particle keeps** — the ride-vs-trail
|
||||
/// law, in one place (decision 0986; `speed` is `|Δ| / dt`, the yd/s the authored follow response
|
||||
/// is keyed on). Two inputs, and reading the first one off the *flag* is what left every hunter
|
||||
/// shot a compact blob where the reference draws a 20-yd bead trail (bug B153):
|
||||
///
|
||||
/// 1. **The baseline is the host class, not a flag** (wow-re `part-emitter-motion.md` §2b's own
|
||||
/// discriminator — 0513 read it as follow-vs-no-follow): a cloud whose model the scene graph
|
||||
/// carries keeps **100%** (the running kobold's candle rides, file flags `0x01`); a FREE world
|
||||
/// model that composes its own motion into the emitter matrix — §2b's "a translating missile
|
||||
/// whose own model IS the emitter" — keeps **0%**, its births baking world-absolute so each
|
||||
/// particle hangs where it was born. Multi-Shot's FLARE emitters are as unflagged as the
|
||||
/// kobold's candle (`0x0309`) and trail the arrow the length of its flight.
|
||||
/// 2. **The follow-delta term** (file `0x4000`, §5-resolved decision 0513) then *overrides* the
|
||||
/// baseline with the authored two-point response
|
||||
/// ([`benilla_formats::ParticleEmitterDef::follow_line`]). Which is exactly why the corpus
|
||||
/// authors it on missiles and nothing else: it exists to claw a fast projectile's head glow
|
||||
/// back onto the tip (ArcaneShot: 0.1 @ 2.5 yd/s → 0.9 @ 16.7) while the unflagged emitters on
|
||||
/// the same model stay behind. A degenerate response — equal authored speeds, which the
|
||||
/// reference answers by zeroing both — is no follow term at all, so the baseline stands.
|
||||
fn world_motion_kept(
|
||||
def: &benilla_formats::ParticleEmitterDef,
|
||||
world_composed: bool,
|
||||
speed: f32,
|
||||
) -> f32 {
|
||||
let baseline = if world_composed { 0.0 } else { 1.0 };
|
||||
if !def.follow_emitter() {
|
||||
return baseline;
|
||||
}
|
||||
def.follow_line().map_or(baseline, |(slope, intercept)| {
|
||||
(slope * speed + intercept).clamp(0.0, 1.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// The velocity-inherit trigger (wow-re `part-emitter-motion.md` §1, `0x7b5230` bytes
|
||||
/// 0x7b53ce–0x7b54ca): accumulate dt; once past 1/30 s (`_DAT_0081d82c`), hold
|
||||
/// `oneFrameΔ · ((1/30)/accum) · scale` — zeroed while nothing is live — and reset. Between
|
||||
@@ -538,6 +571,7 @@ pub(super) fn simulate_particles(
|
||||
alpha_src,
|
||||
alpha,
|
||||
anchor,
|
||||
world_composed,
|
||||
anchor_pos,
|
||||
particles,
|
||||
accumulator,
|
||||
@@ -684,23 +718,18 @@ pub(super) fn simulate_particles(
|
||||
))
|
||||
}
|
||||
};
|
||||
// FOLLOW-DELTA (file 0x4000 — wow-re `part-emitter-motion.md` §2 + §2b, §5-resolved):
|
||||
// a flagged emitter's particles keep exactly `fraction = clamp(slope·|Δ|/dt +
|
||||
// intercept, 0, 1)` of the emitter's per-frame WORLD motion (the authored two-point
|
||||
// response, [`benilla_formats::ParticleEmitterDef::follow_line`]) — the reference's
|
||||
// baseline for this content class is world-frozen (its emitter motion folds into the
|
||||
// emitter matrix, so births bake world-absolute) and the `+fraction·Δ` integrator add
|
||||
// recovers the ride. Over OUR anchor-riding storage the same observable is
|
||||
// `(fraction − 1)·Δ`: zero at saturation (rigid ride, the fast missile), the full
|
||||
// `−Δ` at fraction 0 (a world-frozen trail). Applied to every live particle after its
|
||||
// first integrate (the fresh-bit skip).
|
||||
let follow = if def.follow_emitter() && emitter_delta != Vec3::ZERO {
|
||||
def.follow_line().map_or(Vec3::ZERO, |(slope, intercept)| {
|
||||
let fraction = (slope * emitter_delta.length() / dt + intercept).clamp(0.0, 1.0);
|
||||
to_stored((fraction - 1.0) * emitter_delta, attach_inv, placement)
|
||||
})
|
||||
} else {
|
||||
// The per-frame world-motion carry ([`world_motion_kept`]), folded into the stored frame:
|
||||
// keeping `fraction` of Δ over our anchor-RIDING storage is a `(fraction − 1)·Δ` move on
|
||||
// every live particle after its first integrate (the fresh-bit skip).
|
||||
let follow = if emitter_delta == Vec3::ZERO {
|
||||
Vec3::ZERO
|
||||
} else {
|
||||
let fraction = world_motion_kept(def, *world_composed, emitter_delta.length() / dt);
|
||||
if fraction >= 1.0 {
|
||||
Vec3::ZERO // the rigid ride — the overwhelming majority, kept free
|
||||
} else {
|
||||
to_stored((fraction - 1.0) * emitter_delta, attach_inv, placement)
|
||||
}
|
||||
};
|
||||
// VELOCITY INHERIT (file 0x40): the ~30 Hz trigger holds the inherit velocity births
|
||||
// read; the live gate (rt+0x64) zeroes it while nothing is live.
|
||||
@@ -1121,7 +1150,8 @@ pub(super) fn simulate_particles(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
inherit_trigger, integrate_particle, is_above, ChildEmitter, Particle, StepEnv, Vec3,
|
||||
inherit_trigger, integrate_particle, is_above, world_motion_kept, ChildEmitter, Particle,
|
||||
StepEnv, Vec3,
|
||||
};
|
||||
use bevy::prelude::{Quat, Transform};
|
||||
|
||||
@@ -1196,6 +1226,51 @@ mod tests {
|
||||
assert!(!integrate_particle(&mut out, &kill));
|
||||
}
|
||||
|
||||
/// [`world_motion_kept`] — the ride-vs-trail law (0986). The host class sets the baseline;
|
||||
/// the follow flag overrides it with the authored response; a degenerate response falls back
|
||||
/// to the baseline.
|
||||
#[test]
|
||||
fn world_motion_kept_reads_the_host_class_then_the_follow_response() {
|
||||
let plain = crate::particles::tests::plain_def(); // no flags
|
||||
assert_eq!(
|
||||
world_motion_kept(&plain, false, 30.0),
|
||||
1.0,
|
||||
"scene-graph-carried, unflagged: the kobold's candle rides at any speed"
|
||||
);
|
||||
assert_eq!(
|
||||
world_motion_kept(&plain, true, 30.0),
|
||||
0.0,
|
||||
"a free world model, unflagged: Multi-Shot's flares hang where they were born"
|
||||
);
|
||||
// ArcaneShot's authored pair: 0.1 @ 2.5 yd/s, 0.9 @ 16.667 — the head glow catching up.
|
||||
let following = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4000,
|
||||
follow_speed1: 2.5,
|
||||
follow_scale1: 0.1,
|
||||
follow_speed2: 16.667,
|
||||
follow_scale2: 0.9,
|
||||
..crate::particles::tests::plain_def()
|
||||
};
|
||||
assert!(
|
||||
(world_motion_kept(&following, true, 2.5) - 0.1).abs() < 1e-3,
|
||||
"the flag overrides the world-frozen baseline with the authored response"
|
||||
);
|
||||
assert_eq!(
|
||||
world_motion_kept(&following, true, 40.0),
|
||||
1.0,
|
||||
"clamped at a rigid ride on a fast missile — it never leads"
|
||||
);
|
||||
// Equal authored speeds: the reference zeroes both, so nothing overrides the baseline.
|
||||
let degenerate = benilla_formats::ParticleEmitterDef {
|
||||
flags: 0x4000,
|
||||
follow_speed1: 4.0,
|
||||
follow_speed2: 4.0,
|
||||
..crate::particles::tests::plain_def()
|
||||
};
|
||||
assert_eq!(world_motion_kept(°enerate, true, 30.0), 0.0);
|
||||
assert_eq!(world_motion_kept(°enerate, false, 30.0), 1.0);
|
||||
}
|
||||
|
||||
/// FOLLOW-DELTA (`0x7b2680` @0x7b2744, rt 0x40000): the shared per-frame vector moves every
|
||||
/// live particle — except each particle's FIRST integrate, which consumes its fresh bit
|
||||
/// instead (the reference's particle+0xd first-frame skip).
|
||||
|
||||
@@ -177,6 +177,9 @@ pub(super) fn spawn_booth_effects(
|
||||
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
|
||||
// riders are always opaque (0827).
|
||||
alpha: None,
|
||||
// Scene-graph-carried: this model's world motion arrives on the reference's
|
||||
// device stack, so its cloud RIDES (0986's baseline).
|
||||
world_composed: false,
|
||||
},
|
||||
crate::particles::EmitClock::Pinned, // an item's effects loop forever
|
||||
) else {
|
||||
|
||||
@@ -700,6 +700,9 @@ pub(super) fn sync_glue_scene(
|
||||
// A booth bake has no appear/despawn ramp and no self-avatar feather — its
|
||||
// riders are always opaque (0827).
|
||||
alpha: None,
|
||||
// Scene-graph-carried: this model's world motion arrives on the reference's
|
||||
// device stack, so its cloud RIDES (0986's baseline).
|
||||
world_composed: false,
|
||||
},
|
||||
// A glue scene loops its one authored clip forever — the doodad law.
|
||||
crate::particles::EmitClock::Pinned,
|
||||
|
||||
@@ -205,7 +205,10 @@ fn play_item_gesture_sounds(
|
||||
&config,
|
||||
);
|
||||
}
|
||||
CursorPayload::Spell(_) | CursorPayload::Action(_) => {
|
||||
// Everything that is not a live item shares the generic grab/drop kit — the reference's
|
||||
// own `0x494f60`/`0x494f80` grab path for a macro (mode 8) reaches the same
|
||||
// `INTERFACESOUND_CURSOR*` pair the spell and bar-action modes do.
|
||||
CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_) => {
|
||||
let kit_id = match gesture {
|
||||
CursorGesture::Gain => INTERFACESOUND_CURSORGRABOBJECT,
|
||||
CursorGesture::Loss => INTERFACESOUND_CURSORDROPOBJECT,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
//! (6603) sends `CMSG_ATTACKSWING` at the selection, or acquires the nearest enemy when there is
|
||||
//! none; an ITEM action names an *entry*, not a position, so it must first find a copy and then
|
||||
//! decide equip-vs-use — [`item_action_route`], the byte-verified two-stage law of decision 0666.
|
||||
//! Macro actions are a stated gap (no macro window yet).
|
||||
//! A MACRO action runs its body's lines through the chat-input door (`crate::ui_macro::run`,
|
||||
//! decision 0983) — the `0x4f1460` fork of the reference's own `UseAction`.
|
||||
//! - **Set** ([`drain_action_sets`]): a queued `PickupAction`/`PlaceAction` mutation becomes one
|
||||
//! `CMSG_SET_ACTION_BUTTON` per entry (0218 §4: the bar is client-authoritative, there is no
|
||||
//! answer packet to lock against, and a drag-swap is two independent sends — never atomic).
|
||||
@@ -15,7 +16,9 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::{ActionButton, ACTION_KIND_ITEM, ACTION_KIND_SPELL};
|
||||
use benilla_protocol::messages::{
|
||||
ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL,
|
||||
};
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
@@ -252,9 +255,22 @@ pub(super) fn drain_action_uses(
|
||||
);
|
||||
}
|
||||
}
|
||||
// The MACRO arm (`0x4e5ee0`'s `and ecx,0xbfffffff; call 0x4f1460` fork, wow-re
|
||||
// `action-item-slot.md` §8): run the macro's body. Every line goes onto the chat-input
|
||||
// queue — the door a typed line comes through — so `/cast`, `/target`, `/script`, the
|
||||
// chat types and the 225 emotes all work in a macro by construction
|
||||
// (`crate::ui_macro::run`'s module doc).
|
||||
Some(b) if b.kind == ACTION_KIND_MACRO => {
|
||||
if !crate::ui_macro::run_macro(&mut script, b.action) {
|
||||
debug!(
|
||||
"ui_action: macro action {action} (macro {}) is empty",
|
||||
b.action
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(b) => {
|
||||
debug!(
|
||||
"ui_action: action {action} kind {:#04x} not castable yet (macro)",
|
||||
"ui_action: action {action} kind {:#04x} has no use path",
|
||||
b.kind
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
|
||||
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
|
||||
use benilla_ui::script::{ActionSlot, ScriptValue, UiScript};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
@@ -54,6 +54,10 @@ pub(super) struct FeedMemory {
|
||||
/// The [`Items::template_epoch`] the last identity resolve ran at — the feed's half of the
|
||||
/// landed-template redisplay (decision 0660). An advance re-resolves, exactly like a bar edit.
|
||||
template_epoch: u64,
|
||||
/// The macro-table generation the last identity resolve ran at (decision 0983) — the THIRD
|
||||
/// input, exactly like `template_epoch` above: editing a macro changes its bar icon while
|
||||
/// touching neither the action table nor any item template.
|
||||
macro_generation: u64,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
|
||||
@@ -229,9 +233,16 @@ pub(super) fn feed_actions(
|
||||
// ask and the one and only feed landed 0.5 ms apart, in that order, and nothing re-fed).
|
||||
// The epoch is the second input, so a landed answer redisplays like the ref's DBCACHECALLBACK.
|
||||
let template_epoch = items.template_epoch();
|
||||
if actions.dirty || template_epoch != memory.template_epoch {
|
||||
let macro_generation = script.macros_generation();
|
||||
if actions.dirty
|
||||
|| template_epoch != memory.template_epoch
|
||||
|| macro_generation != memory.macro_generation
|
||||
{
|
||||
actions.dirty = false;
|
||||
memory.template_epoch = template_epoch;
|
||||
memory.macro_generation = macro_generation;
|
||||
// Cloned once per re-resolve, never per frame: the gate above is a `u64` compare.
|
||||
let macros = script.macros();
|
||||
|
||||
// Resolve every occupied wire slot to its display, diff against what the VM holds, push +
|
||||
// fire ACTIONBAR_SLOT_CHANGED (arg1 = the Lua action id) per transition. Item icons/counts
|
||||
@@ -275,8 +286,18 @@ pub(super) fn feed_actions(
|
||||
.unwrap_or(0);
|
||||
(Some(texture), count)
|
||||
}
|
||||
// Macro actions: no macro catalog yet (no macro window ships) — the engine-side
|
||||
// fallback icon shows, matching the pre-slice-4 gap.
|
||||
// A MACRO slot serves **the macro's own icon, never its bound spell's** — the one
|
||||
// asymmetry in the icon resolver, byte-verified: `0x4e6a50`'s macro arm
|
||||
// (`0x4e6bf9`) validates the slot and calls `0x4f0fd0(idx, buf, 0x104)`, the macro
|
||||
// record's own icon-path builder, without ever touching `[rec+0x564]` (wow-re
|
||||
// `action-spell-icon-apis.md` §3.7). Its dynamic state DOES go through the bound
|
||||
// spell — that split is the whole design (`state`'s macro arm, decision 0983).
|
||||
ACTION_KIND_MACRO => (
|
||||
macros
|
||||
.get(button.action as usize)
|
||||
.and_then(|m| m.texture.clone()),
|
||||
0,
|
||||
),
|
||||
_ => (None, 0),
|
||||
};
|
||||
fresh.insert(
|
||||
|
||||
@@ -167,3 +167,82 @@ fn an_unknown_entry_answers_once_and_settles() {
|
||||
"the negative is cached — the feed must not re-ask on the next resolve"
|
||||
);
|
||||
}
|
||||
|
||||
/// A MACRO slot serves **the macro's own icon**, and follows an EDIT of that macro without any
|
||||
/// bar-table change at all (decision 0983).
|
||||
///
|
||||
/// Two things are pinned. The icon rule is byte-verified: `GetActionTexture`'s macro arm
|
||||
/// (`0x4e6bf9`) builds the macro record's own icon path and never touches the bound spell
|
||||
/// (`action-spell-icon-apis.md` §3.7). And the *trigger* is the macro-table generation — the third
|
||||
/// input beside `dirty` and the item-template epoch — because renaming or re-iconing a macro moves
|
||||
/// neither of those, and gating on them alone leaves a stale icon on the bar until some unrelated
|
||||
/// edit happens to re-dirty the feed (exactly decision 0660's bug, one seam over).
|
||||
#[test]
|
||||
fn a_macro_slot_shows_the_macros_own_icon_and_follows_an_edit() {
|
||||
use benilla_protocol::messages::ACTION_KIND_MACRO;
|
||||
use benilla_ui::script::{MacroState, MacroView};
|
||||
|
||||
const SLOT: u8 = 0;
|
||||
const ACTION: u32 = 1;
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
let mut actions = PlayerActions::default();
|
||||
actions.buttons.insert(
|
||||
SLOT,
|
||||
ActionButton {
|
||||
slot: SLOT,
|
||||
action: 1, // macro index 1
|
||||
kind: ACTION_KIND_MACRO,
|
||||
},
|
||||
);
|
||||
actions.dirty = true;
|
||||
app.insert_resource(actions)
|
||||
.init_resource::<Items>()
|
||||
.init_resource::<CastErrors>()
|
||||
.init_resource::<MountErrors>()
|
||||
.init_resource::<UiErrorKeys>()
|
||||
.insert_resource(NetCommands(tx));
|
||||
let mut script = UiScript::new().unwrap();
|
||||
script.set_macros(MacroState {
|
||||
account: vec![MacroView {
|
||||
name: "Ambush".into(),
|
||||
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
|
||||
// The bound spell is deliberately NOT what the icon shows.
|
||||
body: "/cast Ambush".into(),
|
||||
local_only: false,
|
||||
}],
|
||||
character: Vec::new(),
|
||||
});
|
||||
app.insert_non_send_resource(script);
|
||||
app.add_systems(Update, feed_actions);
|
||||
|
||||
app.update();
|
||||
let icon = |app: &mut App| {
|
||||
app.world_mut()
|
||||
.non_send_resource::<UiScript>()
|
||||
.eval::<Option<String>>(&format!("return GetActionTexture({ACTION})"))
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
icon(&mut app).as_deref(),
|
||||
Some("Interface\\Icons\\Ability_Ambush"),
|
||||
"the MACRO's own icon"
|
||||
);
|
||||
|
||||
// Re-icon the macro. Nothing touches `PlayerActions` — only the macro table moves.
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run(r#"EditMacro(1, nil, "Interface\\Icons\\Spell_Fire_FlameBolt")"#)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!app.world().resource::<PlayerActions>().dirty,
|
||||
"the precondition: the bar table is UNtouched by a macro edit"
|
||||
);
|
||||
|
||||
app.update();
|
||||
assert_eq!(
|
||||
icon(&mut app).as_deref(),
|
||||
Some("Interface\\Icons\\Spell_Fire_FlameBolt"),
|
||||
"the generation gate re-resolved the slot"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ use std::time::Instant;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_formats::{SpellDisplay, SpellRange};
|
||||
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_SPELL};
|
||||
use benilla_protocol::messages::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
|
||||
use benilla_ui::script::{ActionState, UiScript};
|
||||
|
||||
use crate::cooldowns::Cooldowns;
|
||||
@@ -193,6 +193,30 @@ fn resolve_range(
|
||||
Some((min, row.max + pad))
|
||||
}
|
||||
|
||||
/// Resolve a slot's `(kind, id)` **through** a macro before any state is computed — the
|
||||
/// reference's own shape, and the reason a macro button on the bar wears its spell's cooldown
|
||||
/// swirl, usability tint, range colour and checked ring while showing its own icon.
|
||||
///
|
||||
/// Every `Is*Action`/`GetActionCooldown` binding routes through the one slot→spell resolver
|
||||
/// `0x4e5a50`, whose MACRO arm resolves the macro record and returns `[rec+0x564]` as the slot's
|
||||
/// spell id (wow-re `action-spell-icon-apis.md` §2, VERIFIED). So from here down, a macro that
|
||||
/// casts Fireball simply *is* the Fireball slot. `GetActionTexture` is the deliberate exception —
|
||||
/// its macro arm keeps the macro's own icon (`super::feed`).
|
||||
///
|
||||
/// `None` = nothing to report (a macro bound to no spell; the reference's `[rec+0x564] == 0`).
|
||||
/// Only the SPELL indirection is modelled: 1.12 has no `/use <item>` slash command, so no 1.12
|
||||
/// macro body can name an item and the resolver's item leg is unreachable from one.
|
||||
fn resolve_through_macro(
|
||||
kind: u8,
|
||||
action: u32,
|
||||
bound: &crate::ui_macro::MacroBoundSpells,
|
||||
) -> Option<(u8, u32)> {
|
||||
match kind {
|
||||
ACTION_KIND_MACRO => bound.0.get(&action).map(|&s| (ACTION_KIND_SPELL, s)),
|
||||
other => Some((other, action)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute + diff-push every occupied slot's dynamic state, and fire the reference event edges.
|
||||
#[allow(clippy::too_many_arguments, clippy::type_complexity)] // a Bevy system's full input set
|
||||
pub(super) fn feed_action_state(
|
||||
@@ -204,12 +228,14 @@ pub(super) fn feed_action_state(
|
||||
auto_repeat: Res<AutoRepeatActive>,
|
||||
// One tuple param (Bevy's 16-SystemParam ceiling): our own cast tracking — the in-flight
|
||||
// guard, the queued on-next-swing strike, the running channel, and the awaiting-click
|
||||
// ground targeting.
|
||||
// ground targeting — plus the macro→spell binding the MACRO arm resolves through
|
||||
// (decision 0983), which rides here for the same ceiling reason.
|
||||
cast_state: (
|
||||
Res<crate::ui_cast::PendingCast>,
|
||||
Res<crate::ui_cast::QueuedMeleeSpell>,
|
||||
Res<crate::ui_cast::ActiveChannel>,
|
||||
Res<super::SpellTargeting>,
|
||||
Res<crate::ui_macro::MacroBoundSpells>,
|
||||
),
|
||||
self_q: Query<(&ObjectStore, &Transform, Has<Engaged>, Option<&Casting>), With<SelfPlayer>>,
|
||||
selection: Res<Selection>,
|
||||
@@ -241,7 +267,7 @@ pub(super) fn feed_action_state(
|
||||
memory.last_cd_trace = Some(now);
|
||||
}
|
||||
|
||||
let (pending, queued_melee, channel, targeting) = &cast_state;
|
||||
let (pending, queued_melee, channel, targeting, bound) = &cast_state;
|
||||
let me = self_q.iter().next();
|
||||
let engaged = me.is_some_and(|(_, _, e, _)| e);
|
||||
let form_byte = me
|
||||
@@ -266,6 +292,16 @@ pub(super) fn feed_action_state(
|
||||
for (&slot, button) in &actions.buttons {
|
||||
let action = u32::from(slot) + 1;
|
||||
let mut st = ActionState::default();
|
||||
let Some((kind, id)) = resolve_through_macro(button.kind, button.action, bound) else {
|
||||
// A macro that casts nothing reports nothing — the reference's `[rec+0x564] == 0`.
|
||||
fresh.insert(action, st);
|
||||
continue;
|
||||
};
|
||||
let button = &benilla_protocol::messages::ActionButton {
|
||||
slot,
|
||||
action: id,
|
||||
kind,
|
||||
};
|
||||
match button.kind {
|
||||
ACTION_KIND_SPELL => {
|
||||
let d = spells.as_ref().and_then(|s| s.catalog.get(button.action));
|
||||
@@ -367,7 +403,7 @@ pub(super) fn feed_action_state(
|
||||
st.cooldown = info.ui_triple(anchor, ui_now);
|
||||
}
|
||||
}
|
||||
_ => {} // macros: everything cold until a macro window exists
|
||||
_ => {}
|
||||
}
|
||||
// No between-generation carry: the triple holds the ABSOLUTE start, so one running
|
||||
// cooldown re-derives the same value every frame (no diff churn) and a re-arm derives a
|
||||
@@ -631,6 +667,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A MACRO slot resolves through its bound spell for EVERY dynamic read (decision 0983) —
|
||||
/// the `0x4e5a50` law — while an unbound macro reports nothing at all.
|
||||
#[test]
|
||||
fn a_macro_slot_resolves_through_its_bound_spell() {
|
||||
use benilla_protocol::messages::ACTION_KIND_MACRO;
|
||||
|
||||
let mut bound = crate::ui_macro::MacroBoundSpells::default();
|
||||
bound.0.insert(3, 133); // macro 3 casts Fireball
|
||||
|
||||
assert_eq!(
|
||||
resolve_through_macro(ACTION_KIND_MACRO, 3, &bound),
|
||||
Some((ACTION_KIND_SPELL, 133)),
|
||||
"from here down the macro IS the Fireball slot"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_through_macro(ACTION_KIND_MACRO, 4, &bound),
|
||||
None,
|
||||
"a macro that casts nothing has no cooldown, no range, no usability"
|
||||
);
|
||||
// Spell and item slots pass through untouched.
|
||||
assert_eq!(
|
||||
resolve_through_macro(ACTION_KIND_SPELL, 133, &bound),
|
||||
Some((ACTION_KIND_SPELL, 133))
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_through_macro(ACTION_KIND_ITEM, 117, &bound),
|
||||
Some((ACTION_KIND_ITEM, 117))
|
||||
);
|
||||
}
|
||||
|
||||
/// The mounted refusal (`0x609c6c`): a live mount blocks unless Attributes carries the
|
||||
/// bit-24 exemption (`0x609c6f`); unmounted always passes; a missing record can claim no
|
||||
/// exemption, so the gate holds.
|
||||
|
||||
@@ -12,7 +12,7 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
mod commands;
|
||||
pub(crate) mod commands;
|
||||
mod edit;
|
||||
mod event;
|
||||
mod feed;
|
||||
|
||||
@@ -42,7 +42,7 @@ use bevy::prelude::*;
|
||||
/// `SLASH_<INDEX><n>` alias-string prefix ([`SlashIndex::key`]). Adding a command is adding a
|
||||
/// variant + its handler arm; its aliases arrive with the shipped strings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum SlashIndex {
|
||||
pub(crate) enum SlashIndex {
|
||||
Reply,
|
||||
Join,
|
||||
Leave,
|
||||
@@ -74,6 +74,14 @@ pub(super) enum SlashIndex {
|
||||
Target,
|
||||
Assist,
|
||||
Follow,
|
||||
/// `/cast <name>` — the reference's `SlashCmdList["CAST"]` is one line, `CastSpellByName(msg)`,
|
||||
/// and that binding is the engine seam benilla implements over the spell book (decision 0983).
|
||||
/// The command the whole macro system exists to run.
|
||||
Cast,
|
||||
/// `/macro` `/m` — opens the macro window (the ref's `ShowMacroFrame()`).
|
||||
MacroUi,
|
||||
/// `/macrohelp` — the reference's own five-line help text.
|
||||
MacroHelp,
|
||||
}
|
||||
|
||||
impl SlashIndex {
|
||||
@@ -111,13 +119,16 @@ impl SlashIndex {
|
||||
Self::Target => "TARGET",
|
||||
Self::Assist => "ASSIST",
|
||||
Self::Follow => "FOLLOW",
|
||||
Self::Cast => "CAST",
|
||||
Self::MacroUi => "MACRO",
|
||||
Self::MacroHelp => "MACROHELP",
|
||||
}
|
||||
}
|
||||
|
||||
/// Every registered index — the registry proper. A reference command NOT in this list resolves
|
||||
/// nowhere and answers `HELP_TEXT_SIMPLE`, exactly as an unknown command does in the reference
|
||||
/// (better than a registered handler that silently does nothing).
|
||||
const ALL: [Self; 31] = [
|
||||
const ALL: [Self; 34] = [
|
||||
Self::Reply,
|
||||
Self::Join,
|
||||
Self::Leave,
|
||||
@@ -149,6 +160,9 @@ impl SlashIndex {
|
||||
Self::Target,
|
||||
Self::Assist,
|
||||
Self::Follow,
|
||||
Self::Cast,
|
||||
Self::MacroUi,
|
||||
Self::MacroHelp,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -156,7 +170,7 @@ impl SlashIndex {
|
||||
/// (decisions 0099 `/castvis`, 0288 `/chattest`, 0434 `/partytest`, 0600 `/shot`, 0634 `/liquid`,
|
||||
/// 0637 `/reaction`). Their aliases are literals because there is nothing shipped to read.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum DevCmd {
|
||||
pub(crate) enum DevCmd {
|
||||
CastVis,
|
||||
ChatTest,
|
||||
PartyTest,
|
||||
@@ -189,7 +203,7 @@ impl DevCmd {
|
||||
|
||||
/// What a typed `/command` resolved to.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Command {
|
||||
pub(crate) enum Command {
|
||||
/// A reference `SlashCmdList` command benilla implements.
|
||||
Slash(SlashIndex),
|
||||
/// An emote alias — carrying the `EmotesText` id its token resolved to (`/lol` → LAUGH → 45).
|
||||
@@ -210,14 +224,14 @@ pub(crate) struct SlashCommands {
|
||||
|
||||
impl SlashCommands {
|
||||
/// Resolve a typed command (no leading `/`, any case).
|
||||
pub(super) fn lookup(&self, cmd: &str) -> Option<Command> {
|
||||
pub(crate) fn lookup(&self, cmd: &str) -> Option<Command> {
|
||||
self.by_alias.get(&cmd.to_ascii_lowercase()).copied()
|
||||
}
|
||||
|
||||
/// Build from the reference's alias strings. `get` reads a global string (the UI VM's globals in
|
||||
/// production, a stub map in tests); `text_id` resolves an `EmotesText` NAME to its id (the
|
||||
/// emote catalog). Pure over those two, so the whole table is testable without a VM.
|
||||
pub(super) fn build(
|
||||
pub(crate) fn build(
|
||||
get: impl Fn(&str) -> Option<String>,
|
||||
text_id: impl Fn(&str) -> Option<u32>,
|
||||
) -> Self {
|
||||
|
||||
@@ -562,6 +562,8 @@ pub(super) fn drain_chat_input(
|
||||
"Duel: /duel [name — bare uses your target], /forfeit (/concede /yield)",
|
||||
"Social: /who [filter], /friends, /ignore, /trade, /inspect",
|
||||
"Emotes: /sit /stand /sleep /kneel and every /wave-style emote",
|
||||
"Spells: /cast <name> [(Rank N)]",
|
||||
"Macros: /macro (/m) opens the window, /macrohelp explains them",
|
||||
"Misc: /afk, /dnd, /random [min] [max], /played, /logout, /quit",
|
||||
"Instruments: /shot, /liquid, /reaction, /castvis, /chattest, /partytest",
|
||||
] {
|
||||
@@ -571,6 +573,23 @@ pub(super) fn drain_chat_input(
|
||||
));
|
||||
}
|
||||
}
|
||||
// `ChatFrame_DisplayMacroHelpText` (ChatFrame.lua): the five shipped lines, read off
|
||||
// the VM's own GlobalStrings so they are the install's text, never a transcription
|
||||
// (decision 0983; the 0881 posture — the strings are data, the handler is ours).
|
||||
ParsedChat::MacroHelp => {
|
||||
for i in 1..=5 {
|
||||
let key = format!("MACRO_HELP_TEXT_LINE{i}");
|
||||
let Ok(line) = script.lua().globals().get::<String>(key.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
if !line.is_empty() {
|
||||
chat_log.push_event(super::event::ChatEvent::text_only(
|
||||
super::event::ChatEventKind::System,
|
||||
line,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// `DoEmote` (`0x5ef560`) end to end — wow-re `object-layer/scratch/emote-posture-
|
||||
// gate.md` §1. The gates in the client's own order, then the two things it DOES: set a
|
||||
// posture (the `/sit` family) and send the packet.
|
||||
|
||||
@@ -172,6 +172,10 @@ pub(in crate::ui_chat) enum ParsedChat {
|
||||
/// only living assistable ones (the ref's filter mode 2); bare follows the current selection,
|
||||
/// creature or player. Client-side movement — nothing goes on the wire.
|
||||
Follow { name: Option<String> },
|
||||
/// `/macrohelp` — the reference's `ChatFrame_DisplayMacroHelpText`: `MACRO_HELP_TEXT_LINE1..5`
|
||||
/// straight out of the shipped `GlobalStrings.lua` (decision 0983). Resolved in the drain,
|
||||
/// which holds the VM, so the text can never go stale against the install.
|
||||
MacroHelp,
|
||||
/// A slash line matching neither a chat command nor an `EmotesText` name — dropped.
|
||||
Unknown,
|
||||
}
|
||||
@@ -358,6 +362,26 @@ fn slash_command(index: SlashIndex, args: &str) -> ParsedChat {
|
||||
},
|
||||
None => ParsedChat::Unknown,
|
||||
},
|
||||
// `/cast <name>` = the ref's `SlashCmdList["CAST"]` verbatim: `if msg ~= "" then
|
||||
// CastSpellByName(msg) end` (ChatFrame.lua:1120). The binding does the book lookup
|
||||
// (`benilla_ui::script::spellbook::resolve_spell_by_name`) and queues onto the ONE cast
|
||||
// path, so a typed `/cast` and a macro's `/cast` line are the same code — decision 0983.
|
||||
S::Cast => {
|
||||
if args.is_empty() {
|
||||
ParsedChat::Unknown
|
||||
} else {
|
||||
ParsedChat::Lua {
|
||||
body: format!("CastSpellByName(\"{}\")", escape_lua_string(args)),
|
||||
}
|
||||
}
|
||||
}
|
||||
// `/macro` `/m` = the ref's own body: `ShowMacroFrame()` (the `RunMacro(msg)` branch beside
|
||||
// it is commented out in the shipped 1.12 ChatFrame.lua — there is no `RunMacro` binding in
|
||||
// the client at all, confirmed against the registered-binding scan).
|
||||
S::MacroUi => ParsedChat::Lua {
|
||||
body: "ShowMacroFrame()".into(),
|
||||
},
|
||||
S::MacroHelp => ParsedChat::MacroHelp,
|
||||
// `/script` = the ref's `RunScript(msg)`: the typed text IS the chunk, un-escaped.
|
||||
S::Script => {
|
||||
if args.is_empty() {
|
||||
|
||||
@@ -650,12 +650,43 @@ fn real_alias_table_resolves_the_shipped_commands() {
|
||||
}
|
||||
);
|
||||
assert_eq!(parse_line("/assist"), ParsedChat::Assist { name: None });
|
||||
// The macro family (decision 0983): `/cast` (with its `/spell` alias — SLASH_CAST1-4 spell two
|
||||
// distinct strings across four slots) runs the ref's own one-line body `CastSpellByName(msg)`;
|
||||
// `/macro`/`/m` open the window; `/macrohelp` prints the shipped five lines.
|
||||
assert_eq!(
|
||||
parse_line("/cast Fireball(Rank 1)"),
|
||||
ParsedChat::Lua {
|
||||
body: "CastSpellByName(\"Fireball(Rank 1)\")".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_line("/spell Frostbolt"),
|
||||
ParsedChat::Lua {
|
||||
body: "CastSpellByName(\"Frostbolt\")".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_line("/cast"),
|
||||
ParsedChat::Unknown,
|
||||
"a bare /cast is the ref's own no-op (`if msg ~= \"\"`)"
|
||||
);
|
||||
for line in ["/macro", "/m"] {
|
||||
assert_eq!(
|
||||
parse_line(line),
|
||||
ParsedChat::Lua {
|
||||
body: "ShowMacroFrame()".into()
|
||||
},
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
assert_eq!(parse_line("/macrohelp"), ParsedChat::MacroHelp);
|
||||
// The whole shipped surface, so a table that half-loaded fails loudly: **225 distinct emote
|
||||
// commands** over the 169 `EmotesText` names (the strings repeat — `EMOTE87_CMD1` and `_CMD2`
|
||||
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **62 distinct
|
||||
// aliases** across the 31 registered `SlashCmdList` indices (0886 added TARGET's `/target`
|
||||
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`).
|
||||
assert_eq!(table.counts(), (62, 225), "(slash, emote) aliases");
|
||||
// are both "/sit" — and EMOTE27 "UNUSED" has no row, so it contributes none), and **67 distinct
|
||||
// aliases** across the 34 registered `SlashCmdList` indices (0886 added TARGET's `/target`
|
||||
// `/tar` and ASSIST's `/assist` `/a` to 0881's 55; 0890 added FOLLOW's `/f` `/follow` `/fol`;
|
||||
// 0983 added CAST's `/cast` `/spell`, MACRO's `/macro` `/m`, and MACROHELP's `/macrohelp`).
|
||||
assert_eq!(table.counts(), (67, 225), "(slash, emote) aliases");
|
||||
}
|
||||
|
||||
// ── The send-side posture-eligibility gate (`emote_send_eligible`) — the director-verified rows
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
//! The **macros** feed (decision 0983) — the app half of `benilla_ui::script::macros`: the icon
|
||||
//! chooser's catalog, persistence under `benilla/macros/`, and `UPDATE_MACROS`.
|
||||
//!
|
||||
//! Unlike every other window feed in this tree there is **no wire traffic here at all**. 1.12
|
||||
//! macros are pure client state — no opcode carries them (vmangos has none), and the reference
|
||||
//! client persists them to `WTF/…/macros-cache.txt` itself. So the engine owns the live table
|
||||
//! (`benilla_ui::script::macros`' module doc says why) and this module owns its two ends:
|
||||
//!
|
||||
//! - **In** — the icon list (`SpellIcon.dbc`, filtered by the client's own `Spell_`/`Ability_`
|
||||
//! prefixes: [`benilla_formats::load_macro_icons`]) at startup, and the saved files at world
|
||||
//! entry.
|
||||
//! - **Out** — a save whenever a script mutated the table ([`save_dirty_macros`]), plus the
|
||||
//! reference's own `UPDATE_MACROS` event on the same edge.
|
||||
//!
|
||||
//! The **runner** and the action-bar's **bound spell** live in [`run`]; the file format is
|
||||
//! [`store`] (the reference's own, so a vanilla `macros-cache.txt` drops straight in).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{MacroState, UiScript};
|
||||
|
||||
use crate::assets::{LockRecover, WorldAssets};
|
||||
use crate::char_select::ClientState;
|
||||
|
||||
pub(crate) mod run;
|
||||
mod store;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Which files this session's macros live in — resolved once the character is known, since the
|
||||
/// per-character tab is keyed by realm + name exactly as the reference's own folder tree is.
|
||||
/// `None` on either path means "session-only": a hermetic capture, or no resolvable install
|
||||
/// (`crate::local_state`'s law). Macros still work in memory; nothing is written.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MacroFiles {
|
||||
account: Option<std::path::PathBuf>,
|
||||
character: Option<std::path::PathBuf>,
|
||||
/// The `(realm, character)` the [`Self::character`] path was built for — the reload trigger
|
||||
/// when a `/logout` brings a different character back into the world.
|
||||
identity: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// Macro index → the spell it casts — benilla's `[rec+0x564]` (wow-re
|
||||
/// `action-spell-icon-apis.md` §2), the field an action-bar MACRO slot's whole dynamic state reads
|
||||
/// through. Stored, not derived at read time, for the reference's own reason: it is a **field on
|
||||
/// the macro record**, recomputed when the macro (or the book) changes, so the three per-frame
|
||||
/// action-bar systems pay a hash lookup instead of a body re-parse each.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct MacroBoundSpells(pub(crate) std::collections::HashMap<u32, u32>);
|
||||
|
||||
pub(crate) struct UiMacroPlugin;
|
||||
|
||||
impl Plugin for UiMacroPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<MacroFiles>()
|
||||
.init_resource::<MacroBoundSpells>()
|
||||
// `PostStartup`, not `Startup.after(AssetSet::Open)` — the same reason
|
||||
// `crate::ui_chat::commands::build_slash_commands` sits there: this needs BOTH the
|
||||
// patch chain and the VM, and the VM is inserted by another `Startup` system whose
|
||||
// `insert_non_send_resource` only lands at the schedule boundary. Ordered after
|
||||
// `AssetSet::Open` alone, this ran before the VM existed and silently pushed no icon
|
||||
// list at all (caught on a clean capture run: the boot line never appeared).
|
||||
.add_systems(PostStartup, load_icon_catalog)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
// Before the action feeds read it (they run in `UnitFeed`), so a macro edited
|
||||
// this frame reports its new spell's cooldown the same frame.
|
||||
rebind_macro_spells.before(crate::ui_unit::UnitFeed),
|
||||
// Load runs in-world only: the per-character file needs the character, and the
|
||||
// roster only names it once a login is live. It self-gates on the identity, so
|
||||
// a re-entry with a different character reloads and a re-entry with the same
|
||||
// one is a no-op.
|
||||
load_macros.run_if(in_state(ClientState::InWorld)),
|
||||
// The save edge is checked every frame, in or out of world: the macro window is
|
||||
// `whileDead = 1` and reachable from the game menu, and a `/logout` must not
|
||||
// strand an unsaved edit.
|
||||
save_dirty_macros.after(load_macros),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the icon chooser's list once at startup — `SpellIcon.dbc` filtered by the client's own
|
||||
/// two prefixes ([`benilla_formats::load_macro_icons`], where the byte citations live). A failed
|
||||
/// load leaves the list empty: the popup then shows no icons and `MacroPopupOkayButton_Update`
|
||||
/// keeps OKAY disabled, which is a visible, diagnosable failure rather than a silent one.
|
||||
fn load_icon_catalog(script: Option<NonSendMut<UiScript>>, assets: Option<Res<WorldAssets>>) {
|
||||
let (Some(mut script), Some(assets)) = (script, assets) else {
|
||||
return;
|
||||
};
|
||||
let loaded = {
|
||||
let mut chain = assets.chain.lock_recover();
|
||||
benilla_formats::load_macro_icons(&mut chain)
|
||||
};
|
||||
match loaded {
|
||||
Ok(icons) => {
|
||||
info!("ui_macro: {} macro icons in the chooser", icons.len());
|
||||
script.set_macro_icons(icons);
|
||||
}
|
||||
Err(e) => error!("ui_macro: SpellIcon.dbc failed — the icon chooser is empty: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Who we are, for the per-character file: `(realm, character)` off the roster's own login pick.
|
||||
/// `None` until the roster and the pick agree — the load simply waits a frame.
|
||||
fn identity(roster: &crate::char_select::Roster) -> Option<(String, String)> {
|
||||
let guid = roster.pending_pick?;
|
||||
let name = roster.chars.iter().find(|c| c.guid == guid)?.name.clone();
|
||||
let realm = roster
|
||||
.realm
|
||||
.as_ref()
|
||||
.map(|r| r.name.clone())
|
||||
.unwrap_or_else(|| "Realm".into());
|
||||
Some((realm, name))
|
||||
}
|
||||
|
||||
/// Seed the engine's macro table from disk, once per character per world entry.
|
||||
fn load_macros(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
roster: Res<crate::char_select::Roster>,
|
||||
mut files: ResMut<MacroFiles>,
|
||||
) {
|
||||
let Some(mut script) = script else { return };
|
||||
let Some(id) = identity(&roster) else { return };
|
||||
if files.identity.as_ref() == Some(&id) {
|
||||
return; // already loaded for this character
|
||||
}
|
||||
let (realm, character) = (&id.0, &id.1);
|
||||
files.account = crate::local_state::macros_account_path();
|
||||
files.character = crate::local_state::macros_character_path(realm, character);
|
||||
files.identity = Some(id.clone());
|
||||
|
||||
let read = |path: &Option<std::path::PathBuf>| -> Vec<benilla_ui::script::MacroView> {
|
||||
let Some(path) = path else { return Vec::new() };
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => store::parse(&text),
|
||||
// Absent is the normal first-run case, not a failure; anything else is worth a line.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => {
|
||||
warn!("ui_macro: reading {}: {e}", path.display());
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
let state = MacroState {
|
||||
account: read(&files.account),
|
||||
character: read(&files.character),
|
||||
};
|
||||
info!(
|
||||
"ui_macro: {} account + {} character macros for {character} on {realm}",
|
||||
state.account.len(),
|
||||
state.character.len()
|
||||
);
|
||||
script.set_macros(state);
|
||||
// The reference's own event for "the macro table changed" (`UPDATE_MACROS`, byte-verified
|
||||
// string at `0x452460`) — the frame redraws off it exactly as it does after an edit.
|
||||
script.fire_event("UPDATE_MACROS", vec![]);
|
||||
}
|
||||
|
||||
/// Persist on the engine's dirty edge, and fire `UPDATE_MACROS`.
|
||||
///
|
||||
/// Deliberately **not** coalesced behind a timer the way `config.toml`'s save is (decision 0954
|
||||
/// §3): a macro edit is a discrete, deliberate act (an OKAY click, a tab switch, a window close),
|
||||
/// not a slider drag, and the file is a few hundred bytes. The dirty flag is already the
|
||||
/// coalescer — a whole `MacroFrame_SaveMacro` + `MacroFrame_Update` round raises it once.
|
||||
fn save_dirty_macros(script: Option<NonSendMut<UiScript>>, files: Res<MacroFiles>) {
|
||||
let Some(mut script) = script else { return };
|
||||
if !script.take_macros_dirty() {
|
||||
return;
|
||||
}
|
||||
let state = script.macros();
|
||||
// Fire first: the event is the UI's redraw trigger and must not depend on the write landing
|
||||
// (a read-only `benilla/` folder still gets a live macro list for the session).
|
||||
script.fire_event("UPDATE_MACROS", vec![]);
|
||||
for (path, macros) in [
|
||||
(&files.account, &state.account),
|
||||
(&files.character, &state.character),
|
||||
] {
|
||||
let Some(path) = path else { continue };
|
||||
if let Err(e) = crate::local_state::write_atomic(path, &store::write(macros)) {
|
||||
warn!("ui_macro: saving {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute every macro's bound spell when the macro table or the spell book moves ([`run`]'s
|
||||
/// module doc for what "bound" means and where it is byte-verified). Change-gated on the engine's
|
||||
/// macro generation plus Bevy's own change detection over the action store (the book derives from
|
||||
/// its known-spell set), so a steady frame does nothing at all.
|
||||
fn rebind_macro_spells(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
actions: Res<crate::ui_action::PlayerActions>,
|
||||
table: Option<Res<crate::ui_chat::commands::SlashCommands>>,
|
||||
mut bound: ResMut<MacroBoundSpells>,
|
||||
mut last_generation: Local<Option<u64>>,
|
||||
) {
|
||||
let (Some(script), Some(table)) = (script, table) else {
|
||||
return;
|
||||
};
|
||||
let generation = script.macros_generation();
|
||||
if *last_generation == Some(generation) && !actions.is_changed() {
|
||||
return;
|
||||
}
|
||||
*last_generation = Some(generation);
|
||||
|
||||
let (macros, book) = (script.macros(), script.spellbook());
|
||||
let mut fresh = std::collections::HashMap::new();
|
||||
for index in 1..=(benilla_ui::script::MAX_MACROS as u32 * 2) {
|
||||
let Some(m) = macros.get(index as usize) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(spell) = run::bound_spell(&table, &m.body, &book) {
|
||||
fresh.insert(index, spell);
|
||||
}
|
||||
}
|
||||
if fresh != bound.0 {
|
||||
debug!("ui_macro: {} macro(s) bound to a spell", fresh.len());
|
||||
bound.0 = fresh;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a macro by its 1-based index — the action bar's MACRO arm (`crate::ui_action::drain`) and
|
||||
/// nothing else today. Each body line is pushed onto the chat-input queue in order, which is where
|
||||
/// a typed line arrives; [`run`]'s module doc is why. Returns whether anything ran.
|
||||
pub(crate) fn run_macro(script: &mut UiScript, index: u32) -> bool {
|
||||
let Some(body) = script.macros().get(index as usize).map(|m| m.body.clone()) else {
|
||||
return false;
|
||||
};
|
||||
let lines: Vec<String> = run::macro_lines(&body).map(str::to_string).collect();
|
||||
if lines.is_empty() {
|
||||
return false;
|
||||
}
|
||||
debug!("ui_macro: running macro {index} ({} line(s))", lines.len());
|
||||
for line in lines {
|
||||
script.push_chat_input(line);
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! **Running** a macro, and the one derivation the action bar needs from a macro body: its
|
||||
//! **bound spell** (decision 0983).
|
||||
//!
|
||||
//! ## Running: a macro line is a typed chat line
|
||||
//!
|
||||
//! The reference reaches Lua's `SlashCmdList` for `/target`, `/script`, the chat types and the 225
|
||||
//! emotes — every one of those handlers *is* FrameXML, so a macro that drives them must arrive
|
||||
//! where a typed line arrives. benilla has exactly one such door already, and it is not a chat
|
||||
//! window: [`UiScript::push_chat_input`], the seam `WOW_PROBE_CHAT` drives, whose drain
|
||||
//! (`crate::ui_chat::input`) is the client's whole slash grammar in one place. So a macro run is
|
||||
//! **its lines pushed onto that queue, in order** — every command a player can type, a macro can
|
||||
//! run, by construction, and a command added later works in macros the day it lands.
|
||||
//!
|
||||
//! That is the *mechanism* implemented once rather than the engine's plumbing aped: the reference
|
||||
//! does this inside `UIMacros.cpp` (`0x4f1460`'s callees), and how those callees hand a line to the
|
||||
//! Lua VM is **not recorded in wow-re** — see decision 0983's open question. What is settled is the
|
||||
//! observable behaviour this reproduces: a `/`-line runs its command, a plain line is sent as chat
|
||||
//! (the director's own 1.12 `macros-cache.txt` drives `.cheat fly on` that way), and every line in
|
||||
//! the body runs.
|
||||
//!
|
||||
//! ## The bound spell (`[rec+0x564]`, VERIFIED consumer)
|
||||
//!
|
||||
//! A macro action-bar slot shows **the macro's own icon** but reports the **cooldown, usability,
|
||||
//! range and checked state of the spell it casts** — byte-verified: `0x4e5a50`'s macro arm
|
||||
//! resolves the macro record through `0x4f0f40` and returns `[rec+0x564]` as the slot's spell id
|
||||
//! (wow-re `action-spell-icon-apis.md` §2), and every `Is*Action`/`GetActionCooldown` binding reads
|
||||
//! through that resolver. [`bound_spell`] is how that field is filled: the body's first `/cast`
|
||||
//! line, or a `CastSpellByName("…")` call in a `/script` line.
|
||||
//!
|
||||
//! Both forms come from the reference's own `UIMacros.cpp` string block, which holds exactly two
|
||||
//! things it could match a body against: **`SLASH_CAST%d`** (`0x44cac4` — the global-string prefix
|
||||
//! whose aliases are `/cast`) and the literal **`CastSpellByName(`** (`0x44cab0`, no format
|
||||
//! specifier, so it is a *compare*, not a build). Nothing else in that TU parses a body. The
|
||||
//! derivation is therefore INFERRED from a verified string set plus a verified consumer — named as
|
||||
//! such in decision 0983.
|
||||
|
||||
use benilla_ui::script::{resolve_spell_by_name, SpellBookState};
|
||||
|
||||
use crate::ui_chat::commands::{Command, SlashCommands, SlashIndex};
|
||||
|
||||
/// The literal the reference matches a `/script`-style body line against (`0x44cab0`).
|
||||
const CAST_BY_NAME_CALL: &str = "CastSpellByName(";
|
||||
|
||||
/// A macro body's runnable lines: split on newlines, trimmed, blanks dropped. Carriage returns go
|
||||
/// too — a `macros-cache.txt` hand-copied off a Windows install is a real input.
|
||||
pub(crate) fn macro_lines(body: &str) -> impl Iterator<Item = &str> {
|
||||
body.lines().map(str::trim).filter(|l| !l.is_empty())
|
||||
}
|
||||
|
||||
/// The spell name a macro body casts, if any — [`bound_spell`]'s parse half, split out because it
|
||||
/// is the whole INFERRED part and deserves its own test.
|
||||
///
|
||||
/// Walks the body in order and returns the FIRST match of either form:
|
||||
/// - a line whose command resolves to [`SlashIndex::Cast`] through the boot-built alias table
|
||||
/// (never a hardcoded `"/cast"` — decision 0881's law), argument taken whole; or
|
||||
/// - a line containing `CastSpellByName(` with a quoted first argument.
|
||||
pub(crate) fn cast_name(table: &SlashCommands, body: &str) -> Option<String> {
|
||||
for line in macro_lines(body) {
|
||||
if let Some(rest) = line.strip_prefix('/') {
|
||||
let (cmd, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
if table.lookup(cmd) == Some(Command::Slash(SlashIndex::Cast)) {
|
||||
let args = args.trim();
|
||||
if !args.is_empty() {
|
||||
return Some(args.to_string());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(name) = quoted_call_argument(line) {
|
||||
return Some(name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `… CastSpellByName("Fireball" …) …` → `Fireball`. Only a double-quoted first argument is read:
|
||||
/// a computed one (`CastSpellByName(spell)`) has no name to bind at parse time, and the reference
|
||||
/// — matching a bare literal with no format specifier — cannot read one either.
|
||||
fn quoted_call_argument(line: &str) -> Option<String> {
|
||||
let after = line.find(CAST_BY_NAME_CALL)? + CAST_BY_NAME_CALL.len();
|
||||
let rest = line.get(after..)?.trim_start();
|
||||
let rest = rest.strip_prefix('"')?;
|
||||
let close = rest.find('"')?;
|
||||
let name = &rest[..close];
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// The macro's bound spell id — [`cast_name`] resolved against the player's book by the same law
|
||||
/// `CastSpellByName` itself uses ([`resolve_spell_by_name`]), so the icon's cooldown swirl and the
|
||||
/// press always agree about which rank is meant. `None` for a macro that casts nothing, or names a
|
||||
/// spell this character does not know: the slot then reports no cooldown and no range, which is
|
||||
/// what the reference's `0x4e5a50` produces for a `[rec+0x564]` of 0.
|
||||
pub(crate) fn bound_spell(table: &SlashCommands, body: &str, book: &SpellBookState) -> Option<u32> {
|
||||
let name = cast_name(table, body)?;
|
||||
resolve_spell_by_name(book, &name).map(|s| s.spell_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ui_chat::commands::SlashCommands;
|
||||
|
||||
/// A table with just the aliases these tests need, built the way boot builds it (through the
|
||||
/// global-string reader), so the parse is exercised against a real lookup and never a literal.
|
||||
fn table() -> SlashCommands {
|
||||
SlashCommands::build(
|
||||
|name| match name {
|
||||
"SLASH_CAST1" => Some("/cast".into()),
|
||||
"SLASH_CAST2" => Some("/spell".into()),
|
||||
"SLASH_SCRIPT1" => Some("/script".into()),
|
||||
"SLASH_TARGET1" => Some("/target".into()),
|
||||
_ => None,
|
||||
},
|
||||
|_| None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macro_lines_trims_blanks_and_windows_line_endings() {
|
||||
let body = "/cast Fireball\r\n\r\n /say pew \n";
|
||||
let lines: Vec<&str> = macro_lines(body).collect();
|
||||
assert_eq!(lines, ["/cast Fireball", "/say pew"]);
|
||||
}
|
||||
|
||||
/// The `/cast` form, through the ALIAS table — `/spell` is `SLASH_CAST2` in the shipped
|
||||
/// strings, so it must bind exactly as `/cast` does.
|
||||
#[test]
|
||||
fn cast_name_reads_the_first_cast_line_through_the_alias_table() {
|
||||
let t = table();
|
||||
assert_eq!(
|
||||
cast_name(&t, "/target Bob\n/cast Fireball\n/say pew"),
|
||||
Some("Fireball".into())
|
||||
);
|
||||
assert_eq!(cast_name(&t, "/spell Frostbolt"), Some("Frostbolt".into()));
|
||||
// The whole argument, subtext included — `resolve_spell_by_name` owns that grammar.
|
||||
assert_eq!(
|
||||
cast_name(&t, "/cast Fireball(Rank 1)"),
|
||||
Some("Fireball(Rank 1)".into())
|
||||
);
|
||||
// First match wins.
|
||||
assert_eq!(
|
||||
cast_name(&t, "/cast Fireball\n/cast Frostbolt"),
|
||||
Some("Fireball".into())
|
||||
);
|
||||
// A bare `/cast` binds nothing and does not stop the walk.
|
||||
assert_eq!(
|
||||
cast_name(&t, "/cast\n/cast Frostbolt"),
|
||||
Some("Frostbolt".into())
|
||||
);
|
||||
assert_eq!(cast_name(&t, "/say hello\n/target Bob"), None);
|
||||
}
|
||||
|
||||
/// The `CastSpellByName(` form — the other half of the reference's own two-literal parse.
|
||||
#[test]
|
||||
fn cast_name_reads_a_quoted_cast_spell_by_name_call() {
|
||||
let t = table();
|
||||
assert_eq!(
|
||||
cast_name(&t, r#"/script CastSpellByName("Shadow Bolt")"#),
|
||||
Some("Shadow Bolt".into())
|
||||
);
|
||||
// Spacing and a second argument don't matter; the first quoted argument is the name.
|
||||
assert_eq!(
|
||||
cast_name(&t, r#"/script CastSpellByName( "Healing Touch", 1 )"#),
|
||||
Some("Healing Touch".into())
|
||||
);
|
||||
// A computed argument has no name to bind — neither here nor in the reference.
|
||||
assert_eq!(cast_name(&t, "/script CastSpellByName(spell)"), None);
|
||||
assert_eq!(cast_name(&t, r#"/script CastSpellByName("")"#), None);
|
||||
}
|
||||
|
||||
/// The bound spell is resolved by the SAME law the press uses, so the swirl and the cast can
|
||||
/// never disagree about the rank.
|
||||
#[test]
|
||||
fn bound_spell_resolves_through_the_book() {
|
||||
use benilla_ui::script::SpellSlotView;
|
||||
|
||||
let book = SpellBookState {
|
||||
tabs: Vec::new(),
|
||||
slots: vec![
|
||||
SpellSlotView {
|
||||
spell_id: 133,
|
||||
name: "Fireball".into(),
|
||||
rank: Some("Rank 1".into()),
|
||||
..Default::default()
|
||||
},
|
||||
SpellSlotView {
|
||||
spell_id: 145,
|
||||
name: "Fireball".into(),
|
||||
rank: Some("Rank 2".into()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
};
|
||||
let t = table();
|
||||
// No subtext -> the highest known rank.
|
||||
assert_eq!(bound_spell(&t, "/cast Fireball", &book), Some(145));
|
||||
// A pinned subtext -> that rank, both spacings.
|
||||
assert_eq!(bound_spell(&t, "/cast Fireball(Rank 1)", &book), Some(133));
|
||||
assert_eq!(bound_spell(&t, "/cast Fireball (Rank 1)", &book), Some(133));
|
||||
// Unknown spell / no cast line -> nothing bound (the slot reports no cooldown).
|
||||
assert_eq!(bound_spell(&t, "/cast Pyroblast", &book), None);
|
||||
assert_eq!(bound_spell(&t, "/say hi", &book), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! The macro **file format** — the reference client's own, byte-for-byte (decision 0983).
|
||||
//!
|
||||
//! The 1.12 client writes each macro with the format string **`MACRO %d "%s" %s`** (byte-read at
|
||||
//! `0x44cb60`, inside the `UIMacros.cpp` string block) followed by the body's lines and a bare
|
||||
//! `END`, into `macros-cache.txt`/`macros-local.txt` (`0x45de74`/`0x45de88`). A real file off the
|
||||
//! director's own install:
|
||||
//!
|
||||
//! ```text
|
||||
//! MACRO 1 "f" Ability_Ambush
|
||||
//! .cheat fly on
|
||||
//! .modify aspeed 10
|
||||
//! END
|
||||
//! ```
|
||||
//!
|
||||
//! benilla writes exactly that. Not nostalgia: it means **a player can drop their vanilla
|
||||
//! `macros-cache.txt` into `benilla/macros/` and their macros are there** — and it keeps the file
|
||||
//! hand-editable, which a serialized blob would not be. The icon column is the texture's *basename*
|
||||
//! (`Ability_Ambush`); the client prepends `Interface\Icons\` (`0x44ca64`), which is why the stored
|
||||
//! token survives an icon list changing under it where a chooser index would silently re-point.
|
||||
//!
|
||||
//! Two liberties, both taken because the reference's own writer can produce files its own reader
|
||||
//! must survive: an index is trusted only as an *ordering key* (the lists are dense — see
|
||||
//! `benilla_ui::script::macros`), and a name containing a `"` round-trips (the reference's `"%s"`
|
||||
//! is unescaped, so we take the span between the FIRST and LAST quote on the line, which is what
|
||||
//! its own writer would have produced).
|
||||
|
||||
use benilla_ui::script::{MacroView, MAX_MACROS};
|
||||
|
||||
/// The path prefix the client prepends to a stored icon token (`0x44ca64`).
|
||||
pub(super) const ICON_PREFIX: &str = "Interface\\Icons\\";
|
||||
|
||||
/// Parse one scope's file into a dense macro list, longest-effort: a malformed record is skipped
|
||||
/// with the rest kept, because losing every macro to one bad line is the worse failure. Records
|
||||
/// beyond [`MAX_MACROS`] are dropped (the tab cannot show them).
|
||||
pub(super) fn parse(text: &str) -> Vec<MacroView> {
|
||||
let mut out: Vec<(u32, MacroView)> = Vec::new();
|
||||
let mut lines = text.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let Some(rest) = line.trim_start().strip_prefix("MACRO ") else {
|
||||
continue; // stray text between records (or a leading blank) — skipped
|
||||
};
|
||||
let Some((index, name, icon)) = parse_header(rest) else {
|
||||
continue;
|
||||
};
|
||||
// Body: every line up to the bare END (or the next MACRO / EOF, so a file truncated
|
||||
// mid-record still yields the macro it was writing).
|
||||
let mut body: Vec<&str> = Vec::new();
|
||||
loop {
|
||||
match lines.peek() {
|
||||
None => break,
|
||||
Some(l) if l.trim() == "END" => {
|
||||
lines.next();
|
||||
break;
|
||||
}
|
||||
Some(l) if l.trim_start().starts_with("MACRO ") => break,
|
||||
Some(_) => body.push(lines.next().unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
out.push((
|
||||
index,
|
||||
MacroView {
|
||||
name,
|
||||
texture: icon.map(icon_path),
|
||||
body: body.join("\n"),
|
||||
local_only: false,
|
||||
},
|
||||
));
|
||||
}
|
||||
out.sort_by_key(|(i, _)| *i);
|
||||
out.into_iter().map(|(_, m)| m).take(MAX_MACROS).collect()
|
||||
}
|
||||
|
||||
/// `<index> "<name>" <icon>` — the tail of a `MACRO ` header line.
|
||||
fn parse_header(rest: &str) -> Option<(u32, String, Option<String>)> {
|
||||
let (index, rest) = rest.split_once('"')?;
|
||||
let index: u32 = index.trim().parse().ok()?;
|
||||
// The name runs to the LAST quote on the line: the reference's `"%s"` is unescaped, so a name
|
||||
// holding a quote is only recoverable from the outside in.
|
||||
let close = rest.rfind('"')?;
|
||||
let name = rest[..close].to_string();
|
||||
let icon = rest[close + 1..].trim();
|
||||
Some((index, name, (!icon.is_empty()).then(|| icon.to_string())))
|
||||
}
|
||||
|
||||
/// Serialize a dense macro list back into the reference's format.
|
||||
pub(super) fn write(macros: &[MacroView]) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, m) in macros.iter().enumerate() {
|
||||
let icon = m.texture.as_deref().map(icon_token).unwrap_or_default();
|
||||
out.push_str(&format!("MACRO {} \"{}\" {}\n", i + 1, m.name, icon));
|
||||
if !m.body.is_empty() {
|
||||
out.push_str(&m.body);
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str("END\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A stored icon token → the full texture path. A token that already carries a path separator is
|
||||
/// taken whole (a hand-edited file naming art outside `Interface\Icons`).
|
||||
fn icon_path(token: String) -> String {
|
||||
if token.contains('\\') || token.contains('/') {
|
||||
token
|
||||
} else {
|
||||
format!("{ICON_PREFIX}{token}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse: a full texture path → the stored token (the basename, when it sits in the icons
|
||||
/// folder; the whole path otherwise, so a hand-edited entry round-trips).
|
||||
fn icon_token(path: &str) -> &str {
|
||||
path.strip_prefix(ICON_PREFIX).unwrap_or(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The **real file** off the director's 1.12 install (`WTF/Account/WINUSER/macros-cache.txt`),
|
||||
/// verbatim — the one input that proves this reader against the reference's own writer. Note
|
||||
/// the out-of-order indices: the file is a dump, the index column is the ordering key.
|
||||
const REAL_112_FILE: &str = "MACRO 1 \"f\" Ability_Ambush\n\
|
||||
.cheat fly on\n\
|
||||
.modify aspeed 10\n\
|
||||
END\n\
|
||||
MACRO 3 \"ns\" Ability_BackStab\n\
|
||||
.cheat fly off\n\
|
||||
.modify aspeed 1\n\
|
||||
END\n\
|
||||
MACRO 2 \"w\" Ability_Creature_Cursed_05\n\
|
||||
.wchange 0 0\n\
|
||||
END\n";
|
||||
|
||||
#[test]
|
||||
fn a_real_1_12_macros_cache_file_parses_in_index_order() {
|
||||
let macros = parse(REAL_112_FILE);
|
||||
assert_eq!(macros.len(), 3);
|
||||
// Sorted by the index COLUMN, not by file position (the real file is 1, 3, 2).
|
||||
let names: Vec<&str> = macros.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, ["f", "w", "ns"]);
|
||||
assert_eq!(
|
||||
macros[0].texture.as_deref(),
|
||||
Some("Interface\\Icons\\Ability_Ambush"),
|
||||
"the stored token is a basename; the client's own prefix completes it"
|
||||
);
|
||||
assert_eq!(macros[0].body, ".cheat fly on\n.modify aspeed 10");
|
||||
assert_eq!(macros[1].body, ".wchange 0 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_then_parse_round_trips_and_matches_the_reference_shape() {
|
||||
let macros = parse(REAL_112_FILE);
|
||||
let text = write(¯os);
|
||||
// The reference's own line shape, with the list re-densified 1..n.
|
||||
assert!(text.starts_with("MACRO 1 \"f\" Ability_Ambush\n"));
|
||||
assert!(text.contains("\nMACRO 2 \"w\" Ability_Creature_Cursed_05\n"));
|
||||
assert!(text.ends_with("END\n"));
|
||||
assert_eq!(parse(&text), macros, "round trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_body_and_a_missing_icon_survive_the_round_trip() {
|
||||
let m = vec![MacroView {
|
||||
name: "bare".into(),
|
||||
texture: None,
|
||||
body: String::new(),
|
||||
local_only: false,
|
||||
}];
|
||||
let text = write(&m);
|
||||
assert_eq!(text, "MACRO 1 \"bare\" \nEND\n");
|
||||
assert_eq!(parse(&text), m);
|
||||
}
|
||||
|
||||
/// Longest-effort parsing: a malformed record loses only itself, and a file truncated
|
||||
/// mid-record (a crash between the body and its `END`) still yields what was written.
|
||||
#[test]
|
||||
fn malformed_records_lose_only_themselves() {
|
||||
let text = "garbage line\n\
|
||||
MACRO notanumber \"x\" Icon\n\
|
||||
END\n\
|
||||
MACRO 2 \"good\" Ability_Ambush\n\
|
||||
/say hi\n\
|
||||
END\n\
|
||||
MACRO 3 \"truncated\" Ability_Ambush\n\
|
||||
/say bye\n";
|
||||
let macros = parse(text);
|
||||
assert_eq!(macros.len(), 2);
|
||||
assert_eq!(macros[0].name, "good");
|
||||
assert_eq!(macros[1].name, "truncated");
|
||||
assert_eq!(macros[1].body, "/say bye", "no END is still a macro");
|
||||
}
|
||||
|
||||
/// A name holding a quote round-trips, because the reference's unescaped `"%s"` means the
|
||||
/// only recoverable reading is first-quote-to-last-quote.
|
||||
#[test]
|
||||
fn a_quoted_name_round_trips_the_way_the_reference_would_write_it() {
|
||||
let m = vec![MacroView {
|
||||
name: "say \"hi\"".into(),
|
||||
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
|
||||
body: "/say hi".into(),
|
||||
local_only: false,
|
||||
}];
|
||||
assert_eq!(parse(&write(&m)), m);
|
||||
}
|
||||
|
||||
/// A hand-edited entry naming art outside `Interface\Icons` keeps its whole path.
|
||||
#[test]
|
||||
fn a_full_path_icon_token_is_taken_whole() {
|
||||
let text = "MACRO 1 \"custom\" Interface\\Buttons\\UI-Panel-Button\nEND\n";
|
||||
let macros = parse(text);
|
||||
assert_eq!(
|
||||
macros[0].texture.as_deref(),
|
||||
Some("Interface\\Buttons\\UI-Panel-Button")
|
||||
);
|
||||
assert_eq!(write(¯os), text, "and writes back unchanged");
|
||||
}
|
||||
|
||||
/// A file with more records than a tab can hold is truncated, never wrapped into the other
|
||||
/// tab (the two scopes are separate files).
|
||||
#[test]
|
||||
fn a_file_longer_than_the_tab_is_truncated() {
|
||||
let text: String = (1..=25)
|
||||
.map(|i| format!("MACRO {i} \"m{i}\" Ability_Ambush\nEND\n"))
|
||||
.collect();
|
||||
assert_eq!(parse(&text).len(), MAX_MACROS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The app half of the macro system (decision 0983): persistence under `benilla/macros/`, the
|
||||
//! runner's route into the chat drain, and the seed/dirty contract the plugin's systems rely on.
|
||||
//!
|
||||
//! The file FORMAT has its own tests in [`super::store`] (including the director's real 1.12
|
||||
//! `macros-cache.txt`); the API's own round trip is `benilla_ui::script::macros`'; the window's is
|
||||
//! `crate::ui_script::macro_tests`. What is only testable here is the wiring.
|
||||
|
||||
use benilla_ui::script::{MacroState, MacroView, UiScript};
|
||||
|
||||
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
|
||||
|
||||
fn macro_view(name: &str, body: &str) -> MacroView {
|
||||
MacroView {
|
||||
name: name.into(),
|
||||
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
|
||||
body: body.into(),
|
||||
local_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A save writes the reference's own format under `benilla/macros/`, and a load brings the same
|
||||
/// macros back — the whole persistence loop over the real `local_state` law.
|
||||
#[test]
|
||||
fn a_saved_macro_table_round_trips_through_benilla_macros() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let tmp = std::env::temp_dir().join(format!("benilla-macros-{}", std::process::id()));
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
let _c = EnvGuard::unset("WOW_CAPTURE");
|
||||
let _h = EnvGuard::set("BENILLA_HOME", tmp.to_str().unwrap());
|
||||
|
||||
let account = crate::local_state::macros_account_path().unwrap();
|
||||
let character = crate::local_state::macros_character_path("Test Realm", "Probeone").unwrap();
|
||||
|
||||
let state = MacroState {
|
||||
account: vec![macro_view("Ambush", "/cast Ambush\n/say pew")],
|
||||
character: vec![macro_view("Charge", "/cast Charge")],
|
||||
};
|
||||
crate::local_state::write_atomic(&account, &super::store::write(&state.account)).unwrap();
|
||||
crate::local_state::write_atomic(&character, &super::store::write(&state.character)).unwrap();
|
||||
|
||||
// The file on disk is the reference's own shape — readable, hand-editable, and the exact
|
||||
// format a vanilla `macros-cache.txt` already has.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&account).unwrap(),
|
||||
"MACRO 1 \"Ambush\" Ability_Ambush\n/cast Ambush\n/say pew\nEND\n"
|
||||
);
|
||||
|
||||
let back = MacroState {
|
||||
account: super::store::parse(&std::fs::read_to_string(&account).unwrap()),
|
||||
character: super::store::parse(&std::fs::read_to_string(&character).unwrap()),
|
||||
};
|
||||
assert_eq!(back, state);
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// A capture run is hermetic (decision 0954): both paths resolve to `None`, so a macro edit during
|
||||
/// a capture is session-only and nothing is written under anyone's install.
|
||||
#[test]
|
||||
fn a_capture_run_persists_nothing() {
|
||||
let _l = ENV_LOCK.lock().unwrap();
|
||||
let _h = EnvGuard::set("BENILLA_HOME", "/tmp/benilla-should-not-exist");
|
||||
let _c = EnvGuard::set("WOW_CAPTURE", "ui-macro");
|
||||
assert_eq!(crate::local_state::macros_account_path(), None);
|
||||
assert_eq!(crate::local_state::macros_character_path("R", "C"), None);
|
||||
}
|
||||
|
||||
/// The runner pushes every body line onto the chat-input queue in order — the door a typed line
|
||||
/// comes through, which is what makes `/cast`, `/target`, `/script`, the chat types and the 225
|
||||
/// emotes all work in a macro without the runner knowing any of them.
|
||||
#[test]
|
||||
fn running_a_macro_queues_its_lines_as_chat_input() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_macros(MacroState {
|
||||
account: vec![macro_view(
|
||||
"Ambush",
|
||||
"/cast Ambush\n\n /say pew \n/target Bob",
|
||||
)],
|
||||
character: Vec::new(),
|
||||
});
|
||||
|
||||
assert!(super::run_macro(&mut s, 1));
|
||||
assert_eq!(
|
||||
s.take_chat_input(),
|
||||
vec!["/cast Ambush", "/say pew", "/target Bob"],
|
||||
"blank lines dropped, each line trimmed, order kept"
|
||||
);
|
||||
|
||||
// An empty macro and an empty slot both run nothing and queue nothing.
|
||||
s.set_macros(MacroState {
|
||||
account: vec![macro_view("Blank", " \n\n")],
|
||||
character: Vec::new(),
|
||||
});
|
||||
assert!(!super::run_macro(&mut s, 1));
|
||||
assert!(!super::run_macro(&mut s, 7));
|
||||
assert!(s.take_chat_input().is_empty());
|
||||
}
|
||||
|
||||
/// A CHARACTER-range macro runs by its own index — the second half of the space is not a special
|
||||
/// case anywhere in the runner.
|
||||
#[test]
|
||||
fn a_character_macro_runs_by_its_own_index() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_macros(MacroState {
|
||||
account: Vec::new(),
|
||||
character: vec![macro_view("Charge", "/cast Charge")],
|
||||
});
|
||||
assert!(super::run_macro(&mut s, 19));
|
||||
assert_eq!(s.take_chat_input(), vec!["/cast Charge"]);
|
||||
}
|
||||
|
||||
/// The seed→dirty→save contract the plugin's two systems rest on: the app's own load must not look
|
||||
/// like a change (or every login would rewrite the file), and every script mutation must.
|
||||
#[test]
|
||||
fn the_dirty_edge_distinguishes_a_load_from_an_edit() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_macros(MacroState {
|
||||
account: vec![macro_view("Ambush", "/cast Ambush")],
|
||||
character: Vec::new(),
|
||||
});
|
||||
assert!(
|
||||
!s.take_macros_dirty(),
|
||||
"loading from disk is not an edit — a save here would be a write-back loop"
|
||||
);
|
||||
|
||||
s.run(r#"EditMacro(1, nil, nil, "/cast Backstab")"#)
|
||||
.unwrap();
|
||||
assert!(s.take_macros_dirty());
|
||||
assert_eq!(s.macros().account[0].body, "/cast Backstab");
|
||||
}
|
||||
|
||||
/// The generation counter is the per-frame consumers' gate (the action bar's identity feed): it
|
||||
/// moves on a seed AND on every mutation, and is never consumed by reading it.
|
||||
#[test]
|
||||
fn the_generation_moves_on_every_write_and_is_not_drained() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let at_start = s.macros_generation();
|
||||
assert_eq!(s.macros_generation(), at_start, "reading never drains it");
|
||||
|
||||
s.set_macros(MacroState {
|
||||
account: vec![macro_view("Ambush", "/cast Ambush")],
|
||||
character: Vec::new(),
|
||||
});
|
||||
let after_seed = s.macros_generation();
|
||||
assert_ne!(after_seed, at_start, "a seed changes the bar's icons too");
|
||||
|
||||
s.run(r#"EditMacro(1, "Renamed", 1)"#).unwrap();
|
||||
assert_ne!(s.macros_generation(), after_seed);
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
//! The pet action bar's feed + drain — the app side of `benilla_ui::script::pet`'s seam
|
||||
//! (decision 0982), and the home of [`PetBar`], the server-authoritative state
|
||||
//! `net/apply/pet.rs` writes.
|
||||
//!
|
||||
//! **The shape of this system is the stance bar's mirror image, and that is the thing to hold on
|
||||
//! to.** `crate::ui_shapeshift` derives its whole bar locally, from the known-spell set × the
|
||||
//! `Spell.dbc` catalog, and the server never has an opinion about it. Here the server owns the
|
||||
//! bar's CONTENTS — which slots exist and what is in them — and hands them over whole in
|
||||
//! `SMSG_PET_SPELLS`. So the feed does no admission and no ordering: it *renders* the ten words
|
||||
//! the last packet delivered.
|
||||
//!
|
||||
//! **But the bar's STATE — the lit command, the lit reaction, the autocast bit — is applied
|
||||
//! LOCALLY, and that is forced rather than chosen.** The server answers none of those three
|
||||
//! presses: `HandlePetAction`'s command and reaction arms send nothing back,
|
||||
//! `HandlePetSpellAutocastOpcode` sets its bits and returns, and `SMSG_PET_MODE` is emitted from
|
||||
//! exactly one place in the whole server (`Pet::SetEnabled` — the enabled flag, nothing else). A
|
||||
//! client that waited for confirmation would show a bar whose reaction buttons never light and
|
||||
//! whose autocast ring never appears. Verified live against vmangos, 2026-08-05: a
|
||||
//! `TogglePetAutocast` and a Follow press drew no reply packet at all. [`drain_pet_actions`]
|
||||
//! carries that half, and the next `SMSG_PET_SPELLS` re-authorities it.
|
||||
//!
|
||||
//! **The slot law** (decode and constants in `benilla_protocol::messages::pet`, VERIFIED against
|
||||
//! the client itself in decision 0988): each slot is one packed word whose type — bits 24–29,
|
||||
//! **masked** — decides its whole reading:
|
||||
//!
|
||||
//! - `PET_ACT_COMMAND 7` — `action` is a `CommandStates`: Stay 0 / Follow 1 / Attack 2 / Dismiss 3.
|
||||
//! A token: `GetPetActionInfo` returns the NAMES of two globals rather than values (the
|
||||
//! reference's own `PetActionBarFrame.lua:98-104` fork), which `PetActionBar.xml` resolves —
|
||||
//! `PET_ACTION_*` out of the shipped `GlobalStrings.lua`, `PET_*_TEXTURE` out of the constants
|
||||
//! that file declares and ours quotes.
|
||||
//! - `PET_ACT_REACTION 6` — `action` is a `ReactStates`: Passive 0 / Defensive 1 / Aggressive 2.
|
||||
//! Also a token.
|
||||
//! - types **1–5** — `action` is a spell id, and the autocast pair rides in bits 31/30 rather than
|
||||
//! in the type. That split is why the server's `ACT_PASSIVE`/`ACT_DISABLED`/`ACT_ENABLED` are
|
||||
//! one branch here and not three.
|
||||
//!
|
||||
//! The two halves of `GetPetActionInfo`'s signature never overlap: `isActive` is a **token**
|
||||
//! concept and `autoCast*` a **spell** one, each nil on the other's branch.
|
||||
//!
|
||||
//! The **3 commands / 4 spells / 3 reactions** arrangement everyone recognises is therefore
|
||||
//! *server data*, not a layout: it is what `CharmInfo::InitPetActionBar` happens to write, and a
|
||||
//! possessed minion or a charmed creature arrives with a different mix through the same ten words.
|
||||
//! Nothing in this file or in `PetActionBar.xml` assumes it.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_protocol::messages::{
|
||||
PetActionEntry, PetSpells, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW,
|
||||
PET_COMMAND_STAY, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
|
||||
};
|
||||
use benilla_ui::script::{PetActionView, UiScript};
|
||||
|
||||
use crate::cooldowns::Cooldowns;
|
||||
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore};
|
||||
use crate::target::Selection;
|
||||
use crate::ui_action::Spells;
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_unit::UnitFeed;
|
||||
|
||||
/// The pet action bar's mirror of the server's state — written only by `net/apply/pet.rs`, read
|
||||
/// only by [`feed_pet_bar`] and [`drain_pet_actions`].
|
||||
///
|
||||
/// `spells.pet_guid == 0` is "there is no pet bar", and it is the single test: the teardown packet
|
||||
/// carries exactly that and nothing else.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct PetBar {
|
||||
/// The last `SMSG_PET_SPELLS` in full, with `SMSG_PET_MODE`'s state edits folded in.
|
||||
pub(crate) spells: PetSpells,
|
||||
/// The **pet's own** cooldown store — a second [`Cooldowns`] instance, not the player's. The
|
||||
/// two must not share: a hunter and their pet both know Growl-shaped spells on independent
|
||||
/// timers, and the reference keeps a `SPELLHISTORY` list per unit for exactly this reason.
|
||||
/// Seeded from the `SMSG_PET_SPELLS` tail and topped up by `SMSG_SPELL_COOLDOWN` addressed to
|
||||
/// the pet's guid.
|
||||
pub(crate) cooldowns: Cooldowns,
|
||||
/// **"The pet is attacking"** — the client's own `[0xb714b0]`, and a purely local latch with
|
||||
/// no field behind it anywhere (wow-re §1).
|
||||
///
|
||||
/// It has exactly three writers, and reproducing them is the whole of `IsPetAttackActive`:
|
||||
/// set by an ATTACK command press (`0x4bd42e`), cleared by `PetStopAttack` (`0x4bd6ae`), and
|
||||
/// cleared on **any** pet-guid change (`0x4bc8ce`) — a new pet is not attacking.
|
||||
///
|
||||
/// benilla read this off the pet's streamed `UNIT_FIELD_TARGET` until the RE landed. That is a
|
||||
/// different question with a different answer: a defensive pet that retaliates on its own has
|
||||
/// a target the player never ordered, and the reference does not light the Attack button for
|
||||
/// it.
|
||||
pub(crate) attacking: bool,
|
||||
}
|
||||
|
||||
impl PetBar {
|
||||
/// `PetHasActionBar()` — is there a bar at all. The client's own gate is exactly this, a
|
||||
/// nonzero cached pet guid: no alive check, no control check (wow-re §3).
|
||||
pub(crate) fn has_bar(&self) -> bool {
|
||||
self.spells.pet_guid != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `GetPetActionsUsable()` — may the bar be used at all (wow-re §4, the predicate `0x4bcf70`).
|
||||
///
|
||||
/// benilla's earlier reading — "the enabled-flags byte's `0x8`" — was REFUTED by the RE as the
|
||||
/// *whole* answer, but it survives as one of the seven steps: the client tests bit 27 of the state
|
||||
/// dword, which is that same byte's `0x8` (see `PET_STATE_BAR_DISABLED`). The step it was missing
|
||||
/// is the pet's own crowd-control state — a **stunned, confused or feared** pet cannot be ordered,
|
||||
/// and its bar greys until it recovers.
|
||||
///
|
||||
/// The remaining four steps are ownership identity (the player resolves, the pet resolves, the pet
|
||||
/// is ours, we are not ourselves charmed). Holding a bar at all already means the server named us
|
||||
/// this pet's controller, so they are structurally true here; the two that can actually change
|
||||
/// while a bar is on screen are the two we test.
|
||||
///
|
||||
/// This is the same predicate that gates whether a press does anything, so a false answer greys
|
||||
/// the icons *and* is the honest reason a click would be ignored.
|
||||
fn actions_usable(bar: &PetBar, pet_flags: Option<u32>) -> bool {
|
||||
!bar.spells.bar_disabled()
|
||||
&& pet_flags.is_none_or(|f| f & benilla_protocol::messages::PET_UNUSABLE_UNIT_FLAGS == 0)
|
||||
}
|
||||
|
||||
/// What the feed last pushed, so `PET_BAR_UPDATE` fires on a real change rather than every frame
|
||||
/// (the [`crate::ui_shapeshift`] memory pattern).
|
||||
#[derive(Default)]
|
||||
struct PetBarMemory {
|
||||
pushed: Option<(bool, bool, Vec<PetActionView>)>,
|
||||
}
|
||||
|
||||
pub(crate) struct UiPetPlugin;
|
||||
|
||||
impl Plugin for UiPetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<PetBar>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// The feed rides with the unit feed (before the VM ticks, like the stance bar's);
|
||||
// the drain runs after the input pass so a click goes out the same frame.
|
||||
feed_pet_bar.in_set(UnitFeed).before(UiInput),
|
||||
drain_pet_actions.after(UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The command tokens' `(GlobalStrings key, texture-global name)` pair.
|
||||
///
|
||||
/// Both are returned as **the names of globals**, not values — the reference's token convention.
|
||||
/// The name keys ship in `GlobalStrings.lua` (`PET_ACTION_ATTACK` = "Attack" at l.3029-3032); the
|
||||
/// texture globals are declared by `PetActionBar.xml`, quoting the reference's
|
||||
/// `PetActionBarFrame.lua:6-12` verbatim.
|
||||
fn command_token(action: u32) -> Option<(&'static str, &'static str)> {
|
||||
Some(match action {
|
||||
PET_COMMAND_STAY => ("PET_ACTION_WAIT", "PET_WAIT_TEXTURE"),
|
||||
PET_COMMAND_FOLLOW => ("PET_ACTION_FOLLOW", "PET_FOLLOW_TEXTURE"),
|
||||
PET_COMMAND_ATTACK => ("PET_ACTION_ATTACK", "PET_ATTACK_TEXTURE"),
|
||||
PET_COMMAND_DISMISS => ("PET_ACTION_DISMISS", "PET_DISMISS_TEXTURE"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The reaction tokens' pair, same convention. `PET_MODE_*` ship in `GlobalStrings.lua`
|
||||
/// (l.3045-3047) — deliberately the `PET_MODE_*` keys, which name the state the pet is IN, not the
|
||||
/// `PET_AGGRESSIVE`/`PET_DEFENSIVE`/`PET_PASSIVE` keys, which are the right-click menu's
|
||||
/// imperatives. Both read identically in enUS ("Aggressive"), so only a localized client can tell
|
||||
/// them apart — which is exactly why the key matters rather than the string.
|
||||
fn reaction_token(action: u32) -> Option<(&'static str, &'static str)> {
|
||||
Some(match action {
|
||||
PET_REACT_PASSIVE => ("PET_MODE_PASSIVE", "PET_PASSIVE_TEXTURE"),
|
||||
PET_REACT_DEFENSIVE => ("PET_MODE_DEFENSIVE", "PET_DEFENSIVE_TEXTURE"),
|
||||
PET_REACT_AGGRESSIVE => ("PET_MODE_AGGRESSIVE", "PET_AGGRESSIVE_TEXTURE"),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve one packed slot word into what the bar draws.
|
||||
///
|
||||
/// `cooldown` is passed in rather than looked up here so the whole function stays a pure
|
||||
/// (state, word) → view mapping, which is what the tests below exercise.
|
||||
fn slot_view(
|
||||
entry: PetActionEntry,
|
||||
bar: &PetSpells,
|
||||
spell: Option<&benilla_formats::SpellDisplay>,
|
||||
cooldown: Option<(i64, u32, bool)>,
|
||||
pet_attacking: bool,
|
||||
) -> PetActionView {
|
||||
let kind = entry.kind();
|
||||
let action = entry.action();
|
||||
|
||||
if let Some((name, texture)) = (kind == benilla_protocol::messages::PET_ACT_COMMAND)
|
||||
.then(|| command_token(action))
|
||||
.flatten()
|
||||
{
|
||||
// A command token lights on `(state >> 8) == action` **or** on the attack latch
|
||||
// (wow-re §2.3, `0x4bdf03`-`0x4bdf22`). Two things live in that `or`:
|
||||
//
|
||||
// - the compare is against the UNMASKED `state >> 8` (`PetSpells::command_state`'s own
|
||||
// note), so a disabled bar puts every command button out;
|
||||
// - ATTACK gets the extra clause, which is why the pet can be *told* to Stay and still
|
||||
// show Attack lit while it is on something.
|
||||
//
|
||||
// `attack_active` — `IsPetAttackActive`, the click fork — is the same latch narrowed to
|
||||
// this slot (wow-re §5: type 7, action 2, and the flag). Same input, so the button that
|
||||
// lights is exactly the button whose next press calls the pet off.
|
||||
let attacking = pet_attacking && action == PET_COMMAND_ATTACK;
|
||||
return PetActionView {
|
||||
name: Some(name.to_string()),
|
||||
texture: Some(texture.to_string()),
|
||||
is_token: true,
|
||||
active: bar.command_state() == action || attacking,
|
||||
attack_active: attacking,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
if let Some((name, texture)) = (kind == benilla_protocol::messages::PET_ACT_REACTION)
|
||||
.then(|| reaction_token(action))
|
||||
.flatten()
|
||||
{
|
||||
// The reaction compare's left side is forced to Passive when the bar is disabled
|
||||
// (wow-re §2.2, `0x4bde3c`): a pet that cannot be ordered reads as Passive rather than
|
||||
// keeping the mode light it had, which is the honest thing for it to say.
|
||||
let showing = if bar.bar_disabled() {
|
||||
benilla_protocol::messages::PET_REACT_PASSIVE
|
||||
} else {
|
||||
bar.react_state()
|
||||
};
|
||||
return PetActionView {
|
||||
name: Some(name.to_string()),
|
||||
texture: Some(texture.to_string()),
|
||||
is_token: true,
|
||||
active: showing == action,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
// A spell slot. `is_empty` is the zero WORD (the client tests the dword); vmangos's own unused
|
||||
// middle slots are not zero and arrive here instead, where their spell id 0 misses the catalog
|
||||
// and takes the same exit — the client's own route to the same empty button.
|
||||
if !entry.is_spell() || entry.is_empty() {
|
||||
return PetActionView::default();
|
||||
}
|
||||
let Some(spell) = spell else {
|
||||
// The catalog failed to load, or the server named a spell 5875's DBC does not have. Draw
|
||||
// the slot as occupied but nameless rather than inventing a name: the button then hides,
|
||||
// which is honest, and the alternative (a "?" with no tooltip) has fooled nobody.
|
||||
return PetActionView::default();
|
||||
};
|
||||
PetActionView {
|
||||
name: Some(spell.name.clone()),
|
||||
subtext: spell.rank.clone(),
|
||||
texture: spell.icon.clone(),
|
||||
is_token: false,
|
||||
spell_id: Some(action),
|
||||
// A spell slot NEVER reports isActive — VERIFIED nil on every path (wow-re §2.1, pushed
|
||||
// at `0x4bdd5e`), which retires 0982's INTERIM. `isActive` is exclusively a token
|
||||
// concept and `autoCast*` exclusively a spell one; the two halves of the signature never
|
||||
// overlap (§2.5 quirk 3).
|
||||
//
|
||||
// The client does express "the pet is running this spell", but as an ICON swap, not a
|
||||
// ring: `GetPetActionInfo` substitutes the record's `ActiveIconID` for its `SpellIconID`
|
||||
// when the spell is a live aura on the pet with its aura-flag bit set (`0x4bcea0`). That
|
||||
// needs an aura-indexed icon we do not carry yet — named in decision 0988, not faked here.
|
||||
active: false,
|
||||
// Autocast is bits 31/30 of the word, not the type byte (wow-re §2.1) — and both are
|
||||
// additionally gated on the spell resolving in `Spell.dbc`, which the early return above
|
||||
// has already enforced by the time we get here.
|
||||
autocast_allowed: entry.autocast_allowed(),
|
||||
autocast_enabled: entry.autocast_on(),
|
||||
attack_active: false,
|
||||
cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the ten slot views each frame and diff-push them, firing `PET_BAR_UPDATE` on a change.
|
||||
///
|
||||
/// One event where the reference has four (`PET_BAR_UPDATE`, `PET_BAR_UPDATE_COOLDOWN`,
|
||||
/// `UNIT_PET`, and the `UNIT_FLAGS`/`UNIT_AURA` pair its bar filters for `arg1 == "pet"`) — the
|
||||
/// deliberate collapse `crate::ui_shapeshift` already makes for the stance bar, and for the same
|
||||
/// reason: we diff the whole pushed state, so one event carries every change there can be.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn feed_pet_bar(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
bar: Res<PetBar>,
|
||||
spells: Option<Res<Spells>>,
|
||||
clock: Res<crate::ui_script::UiClock>,
|
||||
index: Res<GuidIndex>,
|
||||
stores: Query<&ObjectStore>,
|
||||
mut memory: Local<PetBarMemory>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let now = Instant::now();
|
||||
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
|
||||
let has_bar = bar.has_bar();
|
||||
// The pet's own `UNIT_FIELD_FLAGS`, for the crowd-control half of the usability predicate.
|
||||
// `None` = we hold a bar for a unit whose descriptor has not arrived (or has left); the
|
||||
// predicate then rests on bit 27 alone rather than greying a bar on missing data.
|
||||
let pet_flags = index
|
||||
.0
|
||||
.get(&bar.spells.pet_guid)
|
||||
.and_then(|&e| stores.get(e).ok())
|
||||
.map(|s| s.0.unit_flags());
|
||||
let usable = actions_usable(&bar, pet_flags);
|
||||
let pet_attacking = bar.attacking;
|
||||
|
||||
let fresh: Vec<PetActionView> = if has_bar {
|
||||
bar.spells
|
||||
.bar
|
||||
.iter()
|
||||
.map(|&entry| {
|
||||
let display = entry
|
||||
.is_spell()
|
||||
.then(|| spells.as_ref().and_then(|s| s.catalog.get(entry.action())))
|
||||
.flatten();
|
||||
let cooldown = display.and_then(|d| {
|
||||
bar.cooldowns
|
||||
.info(entry.action(), 0, Some(d), now)
|
||||
.ui_triple(anchor, ui_now)
|
||||
});
|
||||
slot_view(entry, &bar.spells, display, cooldown, pet_attacking)
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if memory.pushed.as_ref() != Some(&(has_bar, usable, fresh.clone())) {
|
||||
debug!(
|
||||
"ui_pet: bar {} ({} occupied slot(s), {})",
|
||||
if has_bar { "shown" } else { "hidden" },
|
||||
fresh.iter().filter(|s| s.name.is_some()).count(),
|
||||
if usable { "usable" } else { "disabled" },
|
||||
);
|
||||
memory.pushed = Some((has_bar, usable, fresh.clone()));
|
||||
script.set_pet_actions(has_bar, usable, fresh);
|
||||
script.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain the bar's three intents onto the wire.
|
||||
///
|
||||
/// Every one carries the pet's guid from [`PetBar`] — never a guid the VM supplied — so an intent
|
||||
/// queued in the frame a pet was dismissed dies here rather than naming a unit that is gone.
|
||||
///
|
||||
/// `CMSG_PET_ACTION` echoes the slot's **own packed word**: the server re-splits it and dispatches
|
||||
/// on the type byte, so command, reaction and cast all leave through this one send. The target is
|
||||
/// our current selection, which is what makes "select a mob, press Attack" work; a slot that wants
|
||||
/// no target simply has the server ignore it (`HandlePetAction`'s `explicitlySelectedTarget` fork
|
||||
/// drops a target the spell does not want).
|
||||
///
|
||||
/// **This drain is also where the bar's STATE moves, and that is not an optimisation — it is
|
||||
/// forced.** The server answers none of these three: `HandlePetAction`'s command and reaction arms
|
||||
/// send nothing back, and `HandlePetSpellAutocastOpcode` sets its bits and returns
|
||||
/// (`PetHandler.cpp:451-478`). `SMSG_PET_MODE` is emitted from exactly ONE place in the whole
|
||||
/// server — `Pet::SetEnabled`, i.e. only when the bar is enabled or disabled
|
||||
/// (`Pet.cpp:2362-2377`). So a client that waited for confirmation would show a pet bar whose
|
||||
/// reaction buttons never light and whose autocast ring never appears; the reference plainly does
|
||||
/// not, so it must apply all three locally, and so do we. Verified live, 2026-08-05: a
|
||||
/// `TogglePetAutocast` + a Follow press against vmangos drew no packet whatsoever in reply.
|
||||
///
|
||||
/// The optimism is bounded by the same packet that owns everything else — the next
|
||||
/// `SMSG_PET_SPELLS` (a re-summon, a learn, a stable swap) replaces state and contents together.
|
||||
fn drain_pet_actions(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut bar: ResMut<PetBar>,
|
||||
selection: Res<Selection>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let pressed = script.take_pet_actions();
|
||||
let toggles = script.take_pet_autocast_toggles();
|
||||
let stops = script.take_pet_stop_attacks();
|
||||
if pressed.is_empty() && toggles.is_empty() && stops == 0 {
|
||||
return;
|
||||
}
|
||||
let pet_guid = bar.spells.pet_guid;
|
||||
if pet_guid == 0 {
|
||||
debug!("ui_pet: dropping queued pet intents — the bar is gone");
|
||||
return;
|
||||
}
|
||||
let target_guid = selection.guid.unwrap_or(0);
|
||||
|
||||
for slot in pressed {
|
||||
let Some(entry) = slot_entry(&bar, slot) else {
|
||||
continue;
|
||||
};
|
||||
debug!(
|
||||
"ui_pet: press slot {slot} (action {} kind {:#04x}) at {target_guid:#x}",
|
||||
entry.action(),
|
||||
entry.kind()
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::PetAction {
|
||||
pet_guid,
|
||||
packed: entry.packed,
|
||||
target_guid,
|
||||
});
|
||||
latch_press(&mut bar, entry);
|
||||
}
|
||||
for slot in toggles {
|
||||
let Some(entry) = slot_entry(&bar, slot).filter(|e| e.autocast_allowed()) else {
|
||||
continue;
|
||||
};
|
||||
// The client flips bit 30 in the slot word IN PLACE and sends the whole new word — it is
|
||||
// not a "set autocast to X for spell Y" verb (wow-re §10.2, `0x4bcbff`/`0x4bcc17`). The
|
||||
// server reads the direction back out of the type byte it arrives in.
|
||||
let flipped = entry.with_autocast(!entry.autocast_on());
|
||||
debug!(
|
||||
"ui_pet: autocast {} for spell {} (slot {slot})",
|
||||
flipped.autocast_on(),
|
||||
entry.action()
|
||||
);
|
||||
let _ = commands.0.send(ClientCommand::PetSetAction {
|
||||
pet_guid,
|
||||
// The wire's slot positions are 0-based (vmangos bounds them `< 10`); the Lua's are
|
||||
// 1-based. This subtraction is the whole of the conversion and cannot underflow —
|
||||
// `slot_entry` already rejected 0.
|
||||
entries: vec![(slot - 1, flipped.packed)],
|
||||
});
|
||||
if let Some(e) = slot_entry_mut(&mut bar, slot) {
|
||||
*e = flipped;
|
||||
}
|
||||
}
|
||||
for _ in 0..stops {
|
||||
// `PetStopAttack` no-ops entirely when the pet is not attacking (`0x4bd65e`) — no packet,
|
||||
// no repaint. That gate lives here rather than in the VM because the latch does.
|
||||
if !bar.attacking {
|
||||
continue;
|
||||
}
|
||||
debug!("ui_pet: stop attack");
|
||||
let _ = commands.0.send(ClientCommand::PetStopAttack { pet_guid });
|
||||
bar.attacking = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Latch a pressed slot's state locally — the half the server never confirms (the module doc's
|
||||
/// "forced rather than chosen"), now with the client's own masks rather than byte assignments.
|
||||
///
|
||||
/// Both writes are exact transcriptions, and the masks are the point:
|
||||
///
|
||||
/// - **reaction** (`0x4bc94c`): `state = state & 0xFFFFFF00 | action` — byte 0 only.
|
||||
/// - **command** (`0x4bc96f`): `state = state & 0x080000FF | action << 8` — bits 8–15, keeping
|
||||
/// byte 0 and **bit 27**. The client deliberately preserves the disabled bit while rewriting the
|
||||
/// command, which is also the proof that bit 27 is server-owned: no client path writes it.
|
||||
///
|
||||
/// DISMISS is deliberately not latched: it ends the pet rather than becoming its standing command,
|
||||
/// and the teardown packet clears the whole bar a moment later. ATTACK additionally raises the
|
||||
/// attack latch (`0x4bd42e`), which is what lights the button and arms the call-off fork.
|
||||
fn latch_press(bar: &mut PetBar, entry: PetActionEntry) {
|
||||
match entry.kind() {
|
||||
benilla_protocol::messages::PET_ACT_COMMAND if entry.action() != PET_COMMAND_DISMISS => {
|
||||
bar.spells.state = (bar.spells.state & 0x0800_00FF) | (entry.action() << 8);
|
||||
if entry.action() == PET_COMMAND_ATTACK {
|
||||
bar.attacking = true;
|
||||
}
|
||||
}
|
||||
benilla_protocol::messages::PET_ACT_REACTION => {
|
||||
bar.spells.state = (bar.spells.state & 0xFFFF_FF00) | entry.action();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based Lua slot index → the packed word we still hold for it.
|
||||
fn slot_entry(bar: &PetBar, slot: u32) -> Option<PetActionEntry> {
|
||||
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
|
||||
bar.spells.bar.get(index).copied()
|
||||
}
|
||||
|
||||
/// [`slot_entry`]'s mutable twin, for the autocast flip's write-back.
|
||||
fn slot_entry_mut(bar: &mut PetBar, slot: u32) -> Option<&mut PetActionEntry> {
|
||||
let index = usize::try_from(slot.checked_sub(1)?).ok()?;
|
||||
bar.spells.bar.get_mut(index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::{
|
||||
PET_ACT_COMMAND, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION,
|
||||
PET_STATE_BAR_DISABLED,
|
||||
};
|
||||
|
||||
fn packed(action: u32, kind: u8) -> PetActionEntry {
|
||||
PetActionEntry::from(action | (u32::from(kind) << 24))
|
||||
}
|
||||
|
||||
fn spell(name: &str, rank: Option<&str>) -> benilla_formats::SpellDisplay {
|
||||
benilla_formats::SpellDisplay {
|
||||
name: name.to_string(),
|
||||
rank: rank.map(str::to_string),
|
||||
icon: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The state dword as the server packs it: react in byte 0, command in byte 1.
|
||||
fn state(command: u32, react: u32) -> PetSpells {
|
||||
PetSpells {
|
||||
pet_guid: 0x2A,
|
||||
state: react | (command << 8),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command token returns GLOBAL NAMES for both name and texture, and is lit exactly when it
|
||||
/// IS the pet's current command — the whole reason three command buttons can share one state.
|
||||
#[test]
|
||||
fn command_tokens_light_on_the_current_command() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
|
||||
let follow = slot_view(
|
||||
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(follow.name.as_deref(), Some("PET_ACTION_FOLLOW"));
|
||||
assert_eq!(follow.texture.as_deref(), Some("PET_FOLLOW_TEXTURE"));
|
||||
assert!(follow.is_token && follow.active);
|
||||
assert!(!follow.attack_active, "Follow is not the attack fork");
|
||||
|
||||
let stay = slot_view(
|
||||
packed(PET_COMMAND_STAY, PET_ACT_COMMAND),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert!(!stay.active, "only the CURRENT command is lit");
|
||||
}
|
||||
|
||||
/// The Attack button's second clause: the attack latch lights it and arms the call-off fork
|
||||
/// **independently of the command state**, which is how a pet told to Follow can still show
|
||||
/// Attack lit while it is on something.
|
||||
#[test]
|
||||
fn the_attack_latch_lights_attack_whatever_the_command_state_says() {
|
||||
let following = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
let attack = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
|
||||
|
||||
let idle = slot_view(attack, &following, None, None, false);
|
||||
assert!(!idle.active && !idle.attack_active);
|
||||
|
||||
let on = slot_view(attack, &following, None, None, true);
|
||||
assert!(on.active, "the latch lights it even on a FOLLOW command");
|
||||
assert!(on.attack_active, "and the next press calls the pet off");
|
||||
|
||||
// The latch never reaches another command's button, however busy the pet is.
|
||||
let follow = slot_view(
|
||||
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
|
||||
&following,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
);
|
||||
assert!(!follow.attack_active);
|
||||
assert!(follow.active, "…but Follow is still the standing command");
|
||||
}
|
||||
|
||||
/// Reaction tokens use the `PET_MODE_*` keys — the state words — not the right-click menu's
|
||||
/// `PET_AGGRESSIVE` imperatives. Identical in enUS, distinguishable only by key.
|
||||
#[test]
|
||||
fn reaction_tokens_use_the_mode_keys_and_light_on_the_current_react() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
let def = slot_view(
|
||||
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(def.name.as_deref(), Some("PET_MODE_DEFENSIVE"));
|
||||
assert_eq!(def.texture.as_deref(), Some("PET_DEFENSIVE_TEXTURE"));
|
||||
assert!(def.is_token && def.active);
|
||||
assert!(
|
||||
!slot_view(
|
||||
packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
)
|
||||
.active
|
||||
);
|
||||
}
|
||||
|
||||
/// A DISABLED bar (bit 27) changes what both token classes report, and neither is a special
|
||||
/// case we wrote: the reaction compare's left side is forced to Passive, and the command
|
||||
/// compare — unmasked `state >> 8` — is put out of range of every command at once.
|
||||
#[test]
|
||||
fn a_disabled_bar_reads_passive_and_lights_no_command() {
|
||||
let mut bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
bar.state |= PET_STATE_BAR_DISABLED;
|
||||
|
||||
let passive = slot_view(
|
||||
packed(PET_REACT_PASSIVE, PET_ACT_REACTION),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert!(passive.active, "a bar that cannot be ordered reads Passive");
|
||||
assert!(
|
||||
!slot_view(
|
||||
packed(PET_REACT_DEFENSIVE, PET_ACT_REACTION),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
)
|
||||
.active
|
||||
);
|
||||
|
||||
assert!(
|
||||
!slot_view(
|
||||
packed(PET_COMMAND_FOLLOW, PET_ACT_COMMAND),
|
||||
&bar,
|
||||
None,
|
||||
None,
|
||||
false
|
||||
)
|
||||
.active,
|
||||
"the command it IS on goes dark too"
|
||||
);
|
||||
}
|
||||
|
||||
/// `GetPetActionsUsable` — bit 27 OR the pet's own crowd-control flags. The second half is the
|
||||
/// leg benilla was missing: a feared/stunned/confused pet's bar greys.
|
||||
#[test]
|
||||
fn usability_is_the_disabled_bit_and_the_pets_crowd_control() {
|
||||
let mut bar = PetBar {
|
||||
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(actions_usable(&bar, Some(0)));
|
||||
assert!(
|
||||
actions_usable(&bar, None),
|
||||
"a missing descriptor is not a no"
|
||||
);
|
||||
|
||||
for flag in [0x0004_0000, 0x0040_0000, 0x0080_0000] {
|
||||
assert!(!actions_usable(&bar, Some(flag)), "flag {flag:#x} disables");
|
||||
}
|
||||
// POSSESSED is deliberately not one of them — that IS the pet-bar case.
|
||||
assert!(actions_usable(&bar, Some(0x0100_0000)));
|
||||
|
||||
bar.spells.state |= PET_STATE_BAR_DISABLED;
|
||||
assert!(!actions_usable(&bar, Some(0)));
|
||||
}
|
||||
|
||||
/// A spell slot: real name, rank subtext, icon PATH — and the autocast pair read off **bits
|
||||
/// 31/30**, not the type byte. `isActive` is nil on every spell path.
|
||||
#[test]
|
||||
fn spell_slots_read_their_autocast_off_bits_31_and_30() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
let claw = spell("Claw", Some("Rank 3"));
|
||||
|
||||
let on = slot_view(
|
||||
packed(3010, PET_ACT_ENABLED),
|
||||
&bar,
|
||||
Some(&claw),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert_eq!(on.name.as_deref(), Some("Claw"));
|
||||
assert_eq!(on.subtext.as_deref(), Some("Rank 3"));
|
||||
assert_eq!(on.spell_id, Some(3010));
|
||||
assert!(!on.is_token);
|
||||
assert!(on.autocast_allowed && on.autocast_enabled);
|
||||
assert!(!on.active, "a SPELL slot never reports isActive");
|
||||
|
||||
let off = slot_view(
|
||||
packed(3010, PET_ACT_DISABLED),
|
||||
&bar,
|
||||
Some(&claw),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert!(off.autocast_allowed && !off.autocast_enabled);
|
||||
|
||||
// A passive pet spell shows, but can never autocast — no ring, no sparkle.
|
||||
let passive = slot_view(
|
||||
packed(3010, PET_ACT_PASSIVE),
|
||||
&bar,
|
||||
Some(&claw),
|
||||
None,
|
||||
false,
|
||||
);
|
||||
assert!(!passive.autocast_allowed && !passive.autocast_enabled);
|
||||
}
|
||||
|
||||
/// The two routes to an empty-looking button: the client's zero WORD, and vmangos's own
|
||||
/// `(0, ACT_DISABLED)` filler, which takes the spell branch and misses the catalog. Both must
|
||||
/// draw nothing — reading either one wrong puts four "?" buttons mid-bar.
|
||||
#[test]
|
||||
fn both_kinds_of_empty_slot_draw_nothing() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
|
||||
let zero = PetActionEntry::default();
|
||||
assert!(zero.is_empty());
|
||||
assert_eq!(
|
||||
slot_view(zero, &bar, None, None, false),
|
||||
PetActionView::default()
|
||||
);
|
||||
|
||||
let filler = packed(0, PET_ACT_DISABLED);
|
||||
assert!(!filler.is_empty(), "the WORD is not zero");
|
||||
assert_eq!(
|
||||
slot_view(filler, &bar, None, None, false),
|
||||
PetActionView::default(),
|
||||
"…but spell id 0 resolves to nothing, so the button is still empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// A spell the catalog cannot name draws nothing rather than a nameless icon — and with it
|
||||
/// goes the autocast pair, which the client also gates on the record resolving.
|
||||
#[test]
|
||||
fn an_unresolvable_spell_draws_nothing() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
let v = slot_view(packed(999, PET_ACT_ENABLED), &bar, None, None, false);
|
||||
assert_eq!(v, PetActionView::default());
|
||||
assert!(!v.autocast_allowed && !v.autocast_enabled);
|
||||
}
|
||||
|
||||
/// A type outside 1–7 is inert. The client's own default arm under-pushes here (wow-re §2.5);
|
||||
/// we answer the empty slot instead, and in particular never reach the spell catalog with an
|
||||
/// arbitrary number.
|
||||
#[test]
|
||||
fn an_unknown_type_byte_is_inert() {
|
||||
let bar = state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE);
|
||||
let claw = spell("Claw", None);
|
||||
assert_eq!(
|
||||
slot_view(packed(3010, 0x33), &bar, Some(&claw), None, false),
|
||||
PetActionView::default()
|
||||
);
|
||||
}
|
||||
|
||||
/// The slot index map is 1-based and bounded — a stale index from the VM cannot read past the
|
||||
/// ten words or wrap into slot 10.
|
||||
#[test]
|
||||
fn slot_lookup_is_one_based_and_bounded() {
|
||||
let mut bar = PetBar {
|
||||
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
|
||||
..Default::default()
|
||||
};
|
||||
bar.spells.bar[0] = packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND);
|
||||
bar.spells.bar[9] = packed(PET_REACT_PASSIVE, PET_ACT_REACTION);
|
||||
|
||||
assert_eq!(slot_entry(&bar, 1).unwrap().action(), PET_COMMAND_ATTACK);
|
||||
assert_eq!(slot_entry(&bar, 10).unwrap().action(), PET_REACT_PASSIVE);
|
||||
assert!(slot_entry(&bar, 0).is_none());
|
||||
assert!(slot_entry(&bar, 11).is_none());
|
||||
assert!(slot_entry_mut(&mut bar, 0).is_none());
|
||||
assert!(slot_entry_mut(&mut bar, 11).is_none());
|
||||
}
|
||||
|
||||
/// The local latch, which exists because the server confirms none of it (module doc), written
|
||||
/// with the client's own masks: a command press moves bits 8–15 and keeps byte 0 **and bit
|
||||
/// 27**, a reaction press moves byte 0 only, a spell press moves neither — and DISMISS moves
|
||||
/// nothing, because it ends the pet rather than becoming its standing order.
|
||||
#[test]
|
||||
fn a_press_latches_the_state_the_server_never_echoes() {
|
||||
let mut bar = PetBar {
|
||||
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
|
||||
..Default::default()
|
||||
};
|
||||
bar.spells.state |= PET_STATE_BAR_DISABLED;
|
||||
|
||||
latch_press(&mut bar, packed(PET_COMMAND_STAY, PET_ACT_COMMAND));
|
||||
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
|
||||
assert_eq!(bar.spells.react_state(), PET_REACT_DEFENSIVE, "untouched");
|
||||
assert!(
|
||||
bar.spells.bar_disabled(),
|
||||
"the disabled bit is the SERVER's — a command press must preserve it"
|
||||
);
|
||||
|
||||
latch_press(&mut bar, packed(PET_REACT_AGGRESSIVE, PET_ACT_REACTION));
|
||||
assert_eq!(bar.spells.react_state(), PET_REACT_AGGRESSIVE);
|
||||
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
|
||||
assert!(bar.spells.bar_disabled());
|
||||
|
||||
latch_press(&mut bar, packed(3010, PET_ACT_DISABLED));
|
||||
assert_eq!(bar.spells.command_state() & 0xFF, PET_COMMAND_STAY);
|
||||
assert_eq!(bar.spells.react_state(), PET_REACT_AGGRESSIVE);
|
||||
|
||||
latch_press(&mut bar, packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND));
|
||||
assert_eq!(
|
||||
bar.spells.command_state() & 0xFF,
|
||||
PET_COMMAND_STAY,
|
||||
"Dismiss ends the pet; it never becomes its standing command"
|
||||
);
|
||||
}
|
||||
|
||||
/// ATTACK is the one command press that also raises the attack latch — the flag that lights
|
||||
/// the button and turns its next press into a call-off.
|
||||
#[test]
|
||||
fn an_attack_press_raises_the_latch_and_dismiss_does_not() {
|
||||
let mut bar = PetBar {
|
||||
spells: state(PET_COMMAND_FOLLOW, PET_REACT_DEFENSIVE),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!bar.attacking);
|
||||
|
||||
latch_press(&mut bar, packed(PET_COMMAND_ATTACK, PET_ACT_COMMAND));
|
||||
assert!(bar.attacking);
|
||||
|
||||
bar.attacking = false;
|
||||
latch_press(&mut bar, packed(PET_COMMAND_DISMISS, PET_ACT_COMMAND));
|
||||
latch_press(&mut bar, packed(PET_REACT_PASSIVE, PET_ACT_REACTION));
|
||||
assert!(!bar.attacking, "only ATTACK raises it");
|
||||
}
|
||||
}
|
||||
@@ -594,6 +594,7 @@ pub(super) fn drive_script(
|
||||
CursorPayload::Item(i) => i.texture,
|
||||
CursorPayload::Spell(s) => s.texture,
|
||||
CursorPayload::Action(a) => a.texture,
|
||||
CursorPayload::Macro(m) => m.texture,
|
||||
});
|
||||
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
|
||||
if let Some(handle) = assets
|
||||
|
||||
@@ -101,9 +101,10 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The four entries with nothing behind them — AddOns, Edit Mode, Support, Macros — are DISABLED
|
||||
/// (the pending idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry).
|
||||
/// Everything else in the ladder is live.
|
||||
/// The three entries with nothing behind them — AddOns, Edit Mode, Support — are DISABLED (the
|
||||
/// pending idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry).
|
||||
/// Everything else in the ladder is live. **Macros left this list in decision 0983**, when the
|
||||
/// macro window landed behind it.
|
||||
#[test]
|
||||
fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
|
||||
let s = harness();
|
||||
@@ -113,7 +114,6 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
|
||||
"GameMenuButtonAddOns",
|
||||
"GameMenuButtonEditMode",
|
||||
"GameMenuButtonSupport",
|
||||
"GameMenuButtonMacros",
|
||||
] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!("return {name}:IsEnabled()"))
|
||||
@@ -123,6 +123,7 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
|
||||
}
|
||||
for name in [
|
||||
"GameMenuButtonOptions",
|
||||
"GameMenuButtonMacros",
|
||||
"GameMenuButtonLogout",
|
||||
"GameMenuButtonQuit",
|
||||
"GameMenuButtonContinue",
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
//! The shipped `assets/ui/MacroFrame.xml` — the macro editor + its name/icon popup (decision
|
||||
//! 0983), driven against the engine's own macro table.
|
||||
//!
|
||||
//! What these guard, end to end through the real file: the window loads clean in its real
|
||||
//! neighbourhood; NEW → pick an icon → OKAY actually creates a macro on the right tab; typing in
|
||||
//! the body and closing the window commits it (the `MacroFrame_SaveMacro` line that makes the
|
||||
//! whole editor work); the two tabs address the two index ranges; DELETE removes and re-selects;
|
||||
//! and a macro button is a drag source that loads the cursor with the macro payload.
|
||||
//!
|
||||
//! The **saving** half is deliberately exercised through the window rather than the bindings: the
|
||||
//! bindings' own round trip is `benilla_ui::script::macros`' unit tests, and what can only break
|
||||
//! here is the wiring — a tab that forgets to save, an OKAY that creates on the wrong tab.
|
||||
|
||||
use benilla_ui::script::{CursorPayload, UiScript};
|
||||
|
||||
/// The window's real neighbourhood, in the manifest's own order.
|
||||
fn harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
// The global strings the file reads by name (the app runs the real `GlobalStrings.lua`; here
|
||||
// only the handful this window formats need to exist).
|
||||
s.run(
|
||||
r#"
|
||||
CREATE_MACROS = "Create Macros"
|
||||
GENERAL_MACROS = "General Macros"
|
||||
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
|
||||
ENTER_MACRO_LABEL = "Enter Macro Commands:"
|
||||
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
|
||||
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
|
||||
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
|
||||
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
|
||||
DELETE = "Delete"
|
||||
NEW = "New"
|
||||
EXIT = "Exit"
|
||||
CANCEL = "Cancel"
|
||||
OKAY = "Okay"
|
||||
MACROS = "Macros"
|
||||
-- The tooltip plate colours the body's backdrop reads (the app gets these from
|
||||
-- UIParent.lua's own globals; the window only needs them to exist).
|
||||
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
|
||||
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
for file in [
|
||||
"Fonts.xml",
|
||||
"UiPanels.xml",
|
||||
"ScrollTemplates.xml",
|
||||
"MicroMenu.xml",
|
||||
"MacroFrame.xml",
|
||||
] {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"{file}: loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
// The icon chooser's list is the app's push; three entries is enough to index into.
|
||||
s.set_macro_icons(vec![
|
||||
"Interface\\Icons\\Ability_Ambush".into(),
|
||||
"Interface\\Icons\\Ability_BackStab".into(),
|
||||
"Interface\\Icons\\Spell_Fire_FlameBolt".into(),
|
||||
]);
|
||||
s
|
||||
}
|
||||
|
||||
/// Assert no script error has been collected, naming the step that produced it.
|
||||
fn no_errors(s: &UiScript, step: &str) {
|
||||
assert!(s.errors().is_empty(), "{step}: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The whole creation flow through the window's own buttons: NEW opens the popup, an icon click
|
||||
/// selects, OKAY enables and creates. This is the path a player takes, and every step of it is
|
||||
/// wiring this file owns.
|
||||
#[test]
|
||||
fn new_then_pick_an_icon_then_okay_creates_the_macro() {
|
||||
let s = harness();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
no_errors(&s, "show");
|
||||
|
||||
// OKAY starts disabled: no name, no icon.
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled()")
|
||||
.unwrap(),
|
||||
"a nameless, iconless macro cannot be created"
|
||||
);
|
||||
|
||||
s.run(r#"BenillaMacroPopupEditBox:SetText("Ambush")"#)
|
||||
.unwrap();
|
||||
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton1)")
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroPopupOkayButton:IsEnabled()")
|
||||
.unwrap(),
|
||||
"a name and an icon enable OKAY"
|
||||
);
|
||||
|
||||
s.run("BenillaMacroPopupOkayButton_OnClick()").unwrap();
|
||||
no_errors(&s, "okay");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(1, 0),
|
||||
"created on the ACCOUNT tab (macroBase 0)"
|
||||
);
|
||||
let (name, tex) = s
|
||||
.eval::<(String, String)>("local n, t = GetMacroInfo(1) return n, t")
|
||||
.unwrap();
|
||||
assert_eq!(name, "Ambush");
|
||||
assert_eq!(tex, "Interface\\Icons\\Ability_Ambush");
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroPopupFrame:IsVisible()")
|
||||
.unwrap(),
|
||||
"OKAY closes the popup"
|
||||
);
|
||||
// …and the new macro is selected and shown in the detail pane.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
|
||||
.unwrap(),
|
||||
"Ambush"
|
||||
);
|
||||
}
|
||||
|
||||
/// The body editor's commit path — the single line that makes the window an editor at all
|
||||
/// (`MacroFrame_SaveMacro`, called from the tab switch, the list click, and the window's OnHide).
|
||||
/// A regression here silently discards everything the player typed.
|
||||
#[test]
|
||||
fn typing_a_body_and_closing_the_window_commits_it() {
|
||||
let s = harness();
|
||||
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run(r#"BenillaMacroFrameText:SetText("/cast Ambush\n/say pew")"#)
|
||||
.unwrap();
|
||||
no_errors(&s, "type");
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
|
||||
.unwrap(),
|
||||
"OnTextChanged marks the window dirty"
|
||||
);
|
||||
// The character counter is the ref's own MACROFRAME_CHAR_LIMIT fill.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameCharLimitText:GetText()")
|
||||
.unwrap(),
|
||||
"21/255 Characters Used"
|
||||
);
|
||||
|
||||
s.run(r#"HideUIPanel(BenillaMacroFrame)"#).unwrap();
|
||||
no_errors(&s, "hide");
|
||||
assert_eq!(
|
||||
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
|
||||
.unwrap(),
|
||||
"/cast Ambush\n/say pew",
|
||||
"closing the window commits the body"
|
||||
);
|
||||
}
|
||||
|
||||
/// The two tabs address the two index ranges, and switching tabs saves first. The 19 is the whole
|
||||
/// point: `MacroFrame.macroBase` is 0 or MAX_MACROS, and every binding takes `macroBase + i`.
|
||||
#[test]
|
||||
fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
|
||||
let s = harness();
|
||||
s.run(r#"CreateMacro("Acct", 1, "")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
|
||||
// Type into the account macro, then switch tabs WITHOUT any explicit save.
|
||||
s.run(r#"BenillaMacroFrameText:SetText("/say account")"#)
|
||||
.unwrap();
|
||||
s.run("BenillaMacroFrameTab2:Click()").unwrap();
|
||||
no_errors(&s, "tab 2");
|
||||
assert_eq!(
|
||||
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
|
||||
.unwrap(),
|
||||
"/say account",
|
||||
"the tab switch saved the body first"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BenillaMacroFrame.macroBase").unwrap(),
|
||||
18
|
||||
);
|
||||
|
||||
// Creating on this tab lands at 19 (the character range's base + 1).
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
s.run(r#"BenillaMacroPopupEditBox:SetText("Char")"#)
|
||||
.unwrap();
|
||||
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton2)")
|
||||
.unwrap();
|
||||
s.run("BenillaMacroPopupOkayButton_OnClick()").unwrap();
|
||||
no_errors(&s, "create on tab 2");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(1, 1)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMacroInfo(19)").unwrap(),
|
||||
"Char",
|
||||
"the character tab's first slot is index 19"
|
||||
);
|
||||
|
||||
// …and back to tab 1 shows the account macro again.
|
||||
s.run("BenillaMacroFrameTab1:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BenillaMacroFrame.macroBase").unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
|
||||
.unwrap(),
|
||||
"Acct"
|
||||
);
|
||||
}
|
||||
|
||||
/// DELETE removes the macro and leaves the window on a sane selection — the ref re-runs its own
|
||||
/// OnLoad, which re-selects the first macro (or clears the detail pane when none is left).
|
||||
#[test]
|
||||
fn delete_removes_the_macro_and_re_selects() {
|
||||
let s = harness();
|
||||
s.run(r#"CreateMacro("One", 1, "/say one")"#).unwrap();
|
||||
s.run(r#"CreateMacro("Two", 2, "/say two")"#).unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
|
||||
s.run("BenillaMacroDeleteButton:Click()").unwrap();
|
||||
no_errors(&s, "delete");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(1, 0)
|
||||
);
|
||||
// The list closed its gap, so slot 1 is now the survivor and it is what's selected.
|
||||
assert_eq!(s.eval::<String>("return GetMacroInfo(1)").unwrap(), "Two");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return BenillaMacroFrameSelectedMacroName:GetText()")
|
||||
.unwrap(),
|
||||
"Two"
|
||||
);
|
||||
|
||||
// Deleting the last one clears the detail pane rather than leaving a stale selection.
|
||||
s.run("BenillaMacroDeleteButton:Click()").unwrap();
|
||||
no_errors(&s, "delete last");
|
||||
assert_eq!(
|
||||
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
|
||||
.unwrap(),
|
||||
(0, 0)
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroFrameSelectedMacroButton:IsVisible()")
|
||||
.unwrap(),
|
||||
"no selection, no detail pane"
|
||||
);
|
||||
}
|
||||
|
||||
/// A macro button is a DRAG SOURCE: `OnDragStart` loads the cursor with the macro payload, which
|
||||
/// `PlaceAction` then packs onto a bar slot under the MACRO tag. This is the only route a macro
|
||||
/// reaches the action bar.
|
||||
#[test]
|
||||
fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
|
||||
let s = harness();
|
||||
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
|
||||
.unwrap();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
|
||||
s.run("BenillaMacroButton1:GetScript(\"OnDragStart\")(BenillaMacroButton1)")
|
||||
.unwrap();
|
||||
no_errors(&s, "drag");
|
||||
let payload = s.cursor_payload();
|
||||
assert!(
|
||||
matches!(&payload, Some(CursorPayload::Macro(m)) if m.index == 1),
|
||||
"the macro payload, carrying its index: {payload:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
|
||||
.unwrap(),
|
||||
("macro".to_string(), 1)
|
||||
);
|
||||
|
||||
// Placing it on a bar slot packs the MACRO tag (0x40 << 24) with the macro index.
|
||||
let mut s = s;
|
||||
s.run("PlaceAction(1)").unwrap();
|
||||
assert_eq!(s.take_action_sets(), vec![(1, 0x4000_0000 | 1)]);
|
||||
}
|
||||
|
||||
/// The icon chooser's grid: 20 buttons over the app-pushed list, the tail hidden rather than
|
||||
/// blank, and a click marking the selection.
|
||||
#[test]
|
||||
fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
|
||||
let s = harness();
|
||||
s.run("ShowMacroFrame()").unwrap();
|
||||
s.run("BenillaMacroNewButton_OnClick()").unwrap();
|
||||
no_errors(&s, "popup");
|
||||
|
||||
assert_eq!(s.eval::<i64>("return GetNumMacroIcons()").unwrap(), 3);
|
||||
for i in 1..=3 {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return BenillaMacroPopupButton{i}:IsVisible()"))
|
||||
.unwrap(),
|
||||
"button {i} shows an icon"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!s.eval::<bool>("return BenillaMacroPopupButton4:IsVisible()")
|
||||
.unwrap(),
|
||||
"past the end of the list the button hides — not a blank square"
|
||||
);
|
||||
|
||||
s.run("BenillaMacroPopupButton_OnClick(BenillaMacroPopupButton3)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return BenillaMacroPopupFrame.selectedIcon")
|
||||
.unwrap(),
|
||||
3
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return BenillaMacroPopupButton3:GetChecked()")
|
||||
.unwrap(),
|
||||
"the picked icon is checked"
|
||||
);
|
||||
}
|
||||
@@ -202,9 +202,7 @@ impl Plugin for UiScriptPlugin {
|
||||
arbitrate_pointer_over_ui
|
||||
.after(UiInput)
|
||||
.before(WorldStage::Input),
|
||||
)
|
||||
// Runtime era-atlas misses surface as WARNs the frame they happen (decision 0950).
|
||||
.add_systems(Update, warn_era_atlas_misses.after(UiInput));
|
||||
);
|
||||
|
||||
// `WOW_CAPTURE_UI=1` on a capture: override the "player" token with a synthetic snapshot so
|
||||
// the unit frames are populated in a server-less capture. Ordered after the real feed (so it
|
||||
@@ -240,7 +238,7 @@ fn arbitrate_pointer_over_ui(
|
||||
}
|
||||
|
||||
fn setup_script(world: &mut World) {
|
||||
let mut script = match UiScript::new() {
|
||||
let script = match UiScript::new() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("ui_script: VM init failed: {e}");
|
||||
@@ -249,12 +247,6 @@ fn setup_script(world: &mut World) {
|
||||
};
|
||||
load_global_strings(world, &script);
|
||||
load_emote_tokens(world, &script);
|
||||
// The Era atlas table (decision 0950) — pushed BEFORE the XML loads so `atlas=` attributes
|
||||
// resolve during materialization. A missing manifest already WARNed inside the loader; the
|
||||
// windows then draw blank and every unresolved name is drained as a miss below.
|
||||
if let Some(entries) = crate::assets::era::load_era_atlases() {
|
||||
script.set_era_atlases(entries);
|
||||
}
|
||||
// The unit frames are the default player UI — always loaded in a normal run. Captures stay
|
||||
// pristine (world-render baselines) unless WOW_CAPTURE_UI=1 opts the UI in.
|
||||
let capturing = world.contains_resource::<crate::capture::CaptureMode>();
|
||||
@@ -263,23 +255,9 @@ fn setup_script(world: &mut World) {
|
||||
// assertion (`shipped_xml_tests`), not a second reporting channel.
|
||||
let _ = load_default_ui(&script);
|
||||
}
|
||||
// Boot-time atlas misses (an `atlas=` or OnLoad `SetAtlas` the table couldn't serve) name
|
||||
// themselves once, here; runtime misses drain per frame in `warn_era_atlas_misses`.
|
||||
for name in script.take_era_atlas_misses() {
|
||||
warn!("ui_script: unresolved era atlas '{name}' — stale extraction? run scripts/era-extract.py");
|
||||
}
|
||||
world.insert_non_send_resource(script);
|
||||
}
|
||||
|
||||
/// Per-frame drain of runtime `SetAtlas` misses (hover/selection swaps naming an atlas the boot
|
||||
/// table lacks) — warn-once per name, the boot drain's live twin. Free when nothing missed.
|
||||
fn warn_era_atlas_misses(script: Option<NonSendMut<UiScript>>) {
|
||||
let Some(mut script) = script else { return };
|
||||
for name in script.take_era_atlas_misses() {
|
||||
warn!("ui_script: unresolved era atlas '{name}' — stale extraction? run scripts/era-extract.py");
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
|
||||
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
|
||||
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
|
||||
@@ -474,6 +452,13 @@ fn load_default_ui(script: &UiScript) -> Vec<String> {
|
||||
// (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
|
||||
// anchor target).
|
||||
"StanceBar.xml",
|
||||
// The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
|
||||
// i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
|
||||
// never has one never sees it. Right after the stance bar, whose row it shares (either
|
||||
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
|
||||
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
|
||||
// GameTooltip.xml (its hover).
|
||||
"PetActionBar.xml",
|
||||
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
|
||||
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
|
||||
// into BenillaActionBarArtFrame by name and seats itself with that file's
|
||||
@@ -621,8 +606,18 @@ fn load_default_ui(script: &UiScript) -> Vec<String> {
|
||||
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
|
||||
"QuestLogFrame.xml",
|
||||
"ChatFrame.xml",
|
||||
// The options window (decision 0950): the era-skinned OptionsFrame the game menu's
|
||||
// Options button opens. Needs only Fonts' objects and UiPanels' panel manager (both far
|
||||
// The macro window (decision 0983): the editor + its name/icon popup, driven over the
|
||||
// engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
|
||||
// side, so this is the one window whose model is not an app feed). Needs UiPanels (the
|
||||
// panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
|
||||
// the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
|
||||
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
|
||||
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
|
||||
"MacroFrame.xml",
|
||||
// The options window (the 0985 provenance split: 0950's era structure, 0978's
|
||||
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
|
||||
// cuts — bare fully-live sliders, no corner X): the era-shaped
|
||||
// OptionsFrame the game menu's Options button opens. Needs only Fonts' objects and UiPanels' panel manager (both far
|
||||
// above); sits right before GameMenuFrame so the menu — which reaches it by name at
|
||||
// click time — stays the last, outermost layer.
|
||||
"OptionsFrame.xml",
|
||||
@@ -883,6 +878,9 @@ mod action_bar_tests;
|
||||
#[cfg(test)]
|
||||
mod multibar_stance_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod pet_bar_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod micro_menu_tests;
|
||||
|
||||
@@ -930,6 +928,9 @@ mod escape_tests;
|
||||
#[cfg(test)]
|
||||
mod game_menu_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod macro_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod options_tests;
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! The shipped `assets/ui/OptionsFrame.xml` — the era-skinned options window (decision 0950:
|
||||
//! the shell; 0957: the Audio page; 0959: the Graphics page).
|
||||
//! The shipped `assets/ui/OptionsFrame.xml` — the era-shaped, 1.12-skinned options window
|
||||
//! (0950: the shell; 0957: the Audio page; 0959: the Graphics page; 0978: the 1.12-native
|
||||
//! skin — no era extraction, every texture from the MPQ chain; 0981: the 1.14 System-window
|
||||
//! dialog chrome — translucent dark ground, outline boxes, hairline dividers; 0984: the 1.14
|
||||
//! select/hover wash mechanism, the working era search; 0985: the provenance split those
|
||||
//! cite; 0989: the directed cuts — steppers and the corner X gone, the whole bar live via
|
||||
//! the engine's track-press law, the search box at the era's verbatim seat).
|
||||
//!
|
||||
//! What these guard: the file loads clean inside the real neighbourhood (Fonts + UiPanels +
|
||||
//! GameMenuFrame); the menu's Options button is the door in (menu down, options up, on the ref's
|
||||
//! own kit); Controls is the default category and the page title follows the selection; both
|
||||
//! close spellings put the window away; and — against a hand-pushed atlas table, the
|
||||
//! era_atlas_tests idiom — the selected row actually wears `options_list_active` and drops it
|
||||
//! when the selection moves. The harness pushes NO atlas table by default: `SetAtlas` is a
|
||||
//! warn-once no-op there by design (a missing extraction must not kill the UI), which is exactly
|
||||
//! why the one atlas-backed test pushes its own two members.
|
||||
//! close spellings put the window away; the selected row wears the LOCKED GOLD additive wash
|
||||
//! and hover runs the steel-blue one (the 1.14 pair); and the search reflows the live rows
|
||||
//! under category heads and restores the authored page exactly.
|
||||
//!
|
||||
//! The page tests (0957 Audio, 0959 Graphics) run against the REAL registered CVar set
|
||||
//! (`crate::cvars`): rows read the table on select, writes land on the change queue the host
|
||||
@@ -16,7 +19,7 @@
|
||||
//! farclip min-anchored 60), the 1.12 master→ambience dependency greys, and Defaults walks the
|
||||
//! visible page back to the registered defaults.
|
||||
|
||||
use benilla_ui::script::{EraAtlasEntry, SoundRequest, UiScript};
|
||||
use benilla_ui::script::{QuadContent, SoundRequest, UiScript};
|
||||
|
||||
/// The window's real neighbourhood, in the manifest's own order (options before the menu — the
|
||||
/// game_menu_tests::harness_with idiom, minus the extras this file never needs).
|
||||
@@ -24,7 +27,7 @@ fn harness() -> UiScript {
|
||||
harness_on(UiScript::new().unwrap())
|
||||
}
|
||||
|
||||
/// Load the four files onto a prepared script — split out so the atlas test can push its table
|
||||
/// Load the four files onto a prepared script — split out so page tests can seed CVars
|
||||
/// BEFORE the XML loads, the way the app does (ui_script::setup_script).
|
||||
fn harness_on(mut s: UiScript) -> UiScript {
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
@@ -147,11 +150,11 @@ fn clicking_a_row_moves_the_selection_and_the_page_title() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Both close spellings — the red Close and the corner X — hide the window on the shared
|
||||
/// igMainMenuClose kit; a page WITHOUT rows keeps Defaults disabled (Controls has rows since
|
||||
/// 0961, so the rowless check moved to Interface).
|
||||
/// The red Close hides the window on the igMainMenuClose kit, the corner X does not EXIST
|
||||
/// (0989's directed cut), and a page WITHOUT rows keeps Defaults disabled (Controls has rows
|
||||
/// since 0961, so the rowless check moved to Interface).
|
||||
#[test]
|
||||
fn both_close_buttons_hide_the_window_and_defaults_is_disabled() {
|
||||
fn the_close_button_hides_the_window_and_defaults_is_disabled() {
|
||||
let mut s = harness();
|
||||
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
@@ -169,70 +172,357 @@ fn both_close_buttons_hide_the_window_and_defaults_is_disabled() {
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuClose".into())));
|
||||
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
let _ = s.take_sounds();
|
||||
s.run("OptionsFrameClosePanelButton:Click()").unwrap();
|
||||
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
|
||||
// No corner X (0989's directed cut — the era HAS one; the red button and ESC are the
|
||||
// window's exits).
|
||||
assert!(s
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuClose".into())));
|
||||
.eval::<bool>("return OptionsFrameClosePanelButton == nil")
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The row art, against a hand-pushed two-member atlas table (real manifest numbers, the
|
||||
/// era_atlas_tests idiom): the selected row's bg wears `options_list_active` at atlas size, and
|
||||
/// a moved selection strips the old row bare (`SetTexture(nil)` → GetAtlas nil). The window's
|
||||
/// OTHER atlas members (nine-slice, divider, inner panel) stay unserved here on purpose — they
|
||||
/// drain as warn-once misses, never errors.
|
||||
/// The row wash (0984: the 1.14 OptionsListButtonTemplate mechanism, verbatim — ONE
|
||||
/// UI-QuestLogTitleHighlight quad in ADD blend, two tints): the selected row's wash draws
|
||||
/// additive in the LOCKED GOLD (1,1,0) at the era plate seat (187x21 — sized once in
|
||||
/// OptionsCategoryRow_OnLoad); a moved selection reseats the single gold quad lower; and
|
||||
/// hovering an UNSELECTED row lights the same texture in the steel-blue hover tint
|
||||
/// (.196,.388,.8) while the locked gold stands — 1.14's LockHighlight guard.
|
||||
#[test]
|
||||
fn the_selected_row_wears_the_active_atlas_and_yields_it_on_reselect() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_era_atlases([
|
||||
(
|
||||
"options_list_active".to_string(),
|
||||
EraAtlasEntry {
|
||||
file: "era:textures/1318750.blp".to_string(),
|
||||
uv: [604.0 / 1024.0, 791.0 / 1024.0, 1.0 / 1024.0, 22.0 / 1024.0],
|
||||
size: [187.0, 21.0],
|
||||
},
|
||||
),
|
||||
(
|
||||
"options_list_hover".to_string(),
|
||||
EraAtlasEntry {
|
||||
file: "era:textures/1318750.blp".to_string(),
|
||||
uv: [793.0 / 1024.0, 980.0 / 1024.0, 1.0 / 1024.0, 22.0 / 1024.0],
|
||||
size: [187.0, 21.0],
|
||||
},
|
||||
),
|
||||
]);
|
||||
let s = harness_on(s);
|
||||
|
||||
fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
|
||||
let mut s = harness();
|
||||
// Pin the window at scale 1 so row rects and the pointer share coordinates (the fit
|
||||
// clamp stays out of the way at 1024x768: both ratios sit above 1).
|
||||
s.run("ERA_WINDOW_SCALE = 1").unwrap();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameCategoryListRowControlsBg:GetAtlas()")
|
||||
.unwrap(),
|
||||
"options_list_active",
|
||||
"the default selection wears the active art"
|
||||
);
|
||||
// useAtlasSize rode along: the bg is the member's nominal 187×21, wider than the 175 row —
|
||||
// the era's own look.
|
||||
|
||||
// The plate seat: the era's 187x21, authored in the row OnLoad now that no
|
||||
// SetAtlas(name, true) sizes it.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return OptionsFrameCategoryListRowControlsBg:GetWidth()")
|
||||
.unwrap(),
|
||||
187.0
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return OptionsFrameCategoryListRowControlsBg:GetHeight()")
|
||||
.unwrap(),
|
||||
21.0
|
||||
);
|
||||
|
||||
// Every visible category wash: rect + tint + blend.
|
||||
let washes = |s: &mut UiScript| -> Vec<(benilla_ui::layout::Rect, [f32; 4], bool)> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color,
|
||||
additive,
|
||||
..
|
||||
} if p.contains("UI-QuestLogTitleHighlight") => {
|
||||
q.rect.map(|r| (r, color.unwrap_or([1.0; 4]), *additive))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let gold =
|
||||
|c: &[f32; 4]| (c[0] - 1.0).abs() < 1e-3 && (c[1] - 1.0).abs() < 1e-3 && c[2].abs() < 1e-3;
|
||||
let blue = |c: &[f32; 4]| {
|
||||
(c[0] - 0.196).abs() < 1e-3 && (c[1] - 0.388).abs() < 1e-3 && (c[2] - 0.8).abs() < 1e-3
|
||||
};
|
||||
|
||||
let before = washes(&mut s);
|
||||
assert_eq!(
|
||||
before.len(),
|
||||
1,
|
||||
"exactly one wash shows: the locked selection"
|
||||
);
|
||||
assert!(before[0].2, "the wash draws ADD — 1.14's alphaMode");
|
||||
assert!(gold(&before[0].1), "…in the locked gold: {:?}", before[0].1);
|
||||
|
||||
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
|
||||
let after = washes(&mut s);
|
||||
assert_eq!(after.len(), 1, "the wash moved, not multiplied");
|
||||
assert!(gold(&after[0].1), "still the gold tint: {:?}", after[0].1);
|
||||
assert!(
|
||||
s.eval::<bool>("return OptionsFrameCategoryListRowControlsBg:GetAtlas() == nil")
|
||||
after[0].0.top < before[0].0.top,
|
||||
"Audio sits below Controls, so the wash reseats lower (top {} -> {})",
|
||||
before[0].0.top,
|
||||
after[0].0.top
|
||||
);
|
||||
|
||||
// Hover the now-unselected Controls row: its wash lights BLUE beside Audio's gold.
|
||||
let (cx, cy) = {
|
||||
let l: f32 = s
|
||||
.eval("return OptionsFrameCategoryListRowControls:GetLeft()")
|
||||
.unwrap();
|
||||
let r: f32 = s
|
||||
.eval("return OptionsFrameCategoryListRowControls:GetRight()")
|
||||
.unwrap();
|
||||
let t: f32 = s
|
||||
.eval("return OptionsFrameCategoryListRowControls:GetTop()")
|
||||
.unwrap();
|
||||
let b: f32 = s
|
||||
.eval("return OptionsFrameCategoryListRowControls:GetBottom()")
|
||||
.unwrap();
|
||||
((l + r) * 0.5, (t + b) * 0.5)
|
||||
};
|
||||
s.mouse_move(cx, cy);
|
||||
let hovered = washes(&mut s);
|
||||
assert_eq!(
|
||||
hovered.len(),
|
||||
2,
|
||||
"hover adds its wash beside the locked gold"
|
||||
);
|
||||
assert_eq!(hovered.iter().filter(|w| gold(&w.1)).count(), 1);
|
||||
assert_eq!(
|
||||
hovered.iter().filter(|w| blue(&w.1)).count(),
|
||||
1,
|
||||
"the hover tint is 1.14's steel-blue: {:?}",
|
||||
hovered.iter().map(|w| w.1).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Leaving puts the hover wash away; the lock stays.
|
||||
s.mouse_move(5.0, 5.0);
|
||||
let left = washes(&mut s);
|
||||
assert_eq!(left.len(), 1);
|
||||
assert!(gold(&left[0].1));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Search (0984: the era SettingsPanel mechanism transcribed): typing reflows the LIVE
|
||||
/// matching rows under a clickable category head — a matched CHILD pulls its parent volume
|
||||
/// row in above it (the era parentInitializer rule) — the title reads "Search Results" and
|
||||
/// Defaults hides; clearing the box lands every row back on its authored XML chain exactly
|
||||
/// (the one-layout-law pin) with the page view restored.
|
||||
#[test]
|
||||
fn search_reflows_live_rows_and_restores_the_page() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
|
||||
s.resolve();
|
||||
let authored_top: f32 = s
|
||||
.eval("return OptionsFrameContainerBodyAudioRowMaster:GetTop()")
|
||||
.unwrap();
|
||||
|
||||
s.run("OptionsFrameSearchBox:SetText(\"music\")").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
|
||||
.unwrap(),
|
||||
"the old row stripped bare when the selection moved"
|
||||
"Search Results"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return OptionsFrameContainerDefaults:IsVisible()")
|
||||
.unwrap(),
|
||||
"Defaults hides while the search holds the page (the era SetShown(not hasText))"
|
||||
);
|
||||
// One head — Audio's; the matches plus their pulled-in parent; nothing else.
|
||||
for (frame, shown) in [
|
||||
("OptionsFrameContainerBodySearchHeadAudio", true),
|
||||
("OptionsFrameContainerBodySearchHeadControls", false),
|
||||
("OptionsFrameContainerBodySearchHeadGraphics", false),
|
||||
("OptionsFrameContainerBodyAudioRowMusic", true),
|
||||
("OptionsFrameContainerBodyAudioRowEnableMusic", true),
|
||||
("OptionsFrameContainerBodyAudioRowMaster", true), // the parent, pulled in unmatched
|
||||
("OptionsFrameContainerBodyAudioRowSound", false),
|
||||
("OptionsFrameContainerBodyAudioRowEnableAll", false),
|
||||
("OptionsFrameContainerBodyControlsRowAutoLoot", false),
|
||||
("OptionsFrameContainerBodyGraphicsRowUiScale", false),
|
||||
("OptionsFrameContainerBodyNoResults", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
|
||||
.unwrap(),
|
||||
shown,
|
||||
"{frame} shown={shown}"
|
||||
);
|
||||
}
|
||||
// The chain reads head → parent → the two matches, downward.
|
||||
s.resolve();
|
||||
let tops: Vec<f32> = [
|
||||
"OptionsFrameContainerBodySearchHeadAudio",
|
||||
"OptionsFrameContainerBodyAudioRowMaster",
|
||||
"OptionsFrameContainerBodyAudioRowMusic",
|
||||
"OptionsFrameContainerBodyAudioRowEnableMusic",
|
||||
]
|
||||
.iter()
|
||||
.map(|f| s.eval::<f32>(&format!("return {f}:GetTop()")).unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
tops[0] > tops[1] && tops[1] > tops[2] && tops[2] > tops[3],
|
||||
"head, parent, then the matches chain downward: {tops:?}"
|
||||
);
|
||||
// The reflowed row is LIVE: moving the music slider writes its CVar mid-search.
|
||||
let _ = s.take_cvar_changes();
|
||||
s.run("OptionsFrameContainerBodyAudioRowMusicControlSlider:SetValue(0.25)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("MusicVolume".to_string(), "0.25".to_string())]
|
||||
);
|
||||
|
||||
// Clearing restores the page view and the authored chain EXACTLY.
|
||||
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
|
||||
.unwrap(),
|
||||
"Audio"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsVisible()")
|
||||
.unwrap());
|
||||
s.resolve();
|
||||
let restored_top: f32 = s
|
||||
.eval("return OptionsFrameContainerBodyAudioRowMaster:GetTop()")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(restored_top - authored_top).abs() < 0.01,
|
||||
"the restore law equals the authored XML chain ({restored_top} vs {authored_top})"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The era's word scoring (Blizzard_Settings.lua MatchesSearchTags + the words list): the
|
||||
/// WHOLE query is the first word, so a phrase match outscores its own tokens and chains
|
||||
/// first within the group — "master volume" seats Master above the token-matched children.
|
||||
#[test]
|
||||
fn the_phrase_match_outranks_its_words() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameSearchBox:SetText(\"master volume\")")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let tops: Vec<f32> = [
|
||||
"OptionsFrameContainerBodyAudioRowMaster", // phrase hit, score 12
|
||||
"OptionsFrameContainerBodyAudioRowSound", // "VOLUME" hits, score 5, page order
|
||||
"OptionsFrameContainerBodyAudioRowMusic",
|
||||
"OptionsFrameContainerBodyAudioRowAmbience",
|
||||
]
|
||||
.iter()
|
||||
.map(|f| s.eval::<f32>(&format!("return {f}:GetTop()")).unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
tops[0] > tops[1] && tops[1] > tops[2] && tops[2] > tops[3],
|
||||
"phrase first, then the word matches in page order: {tops:?}"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The era's search exits: clicking a results head (the era's jump — SelectCategory clears
|
||||
/// the box) and a no-match query (the section-header "no results" line, nothing chained).
|
||||
#[test]
|
||||
fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
|
||||
let s = harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
|
||||
s.run("OptionsFrameSearchBox:SetText(\"volume\")").unwrap();
|
||||
s.run("OptionsFrameContainerBodySearchHeadAudio:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameSearchBox:GetText()")
|
||||
.unwrap(),
|
||||
"",
|
||||
"the head click cleared the search (the era SelectCategory law)"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameCategoryListRowAudioBg:GetAtlas()")
|
||||
s.eval::<String>("return OptionsFrame.selectedCategory")
|
||||
.unwrap(),
|
||||
"options_list_active"
|
||||
"Audio"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OptionsFrameContainerTitle:GetText()")
|
||||
.unwrap(),
|
||||
"Audio"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyAudio:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
s.run("OptionsFrameSearchBox:SetText(\"flibbertigibbet\")")
|
||||
.unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyNoResults:IsVisible()")
|
||||
.unwrap());
|
||||
for head in ["Controls", "Audio", "Graphics"] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodySearchHead{head}:IsVisible()"
|
||||
))
|
||||
.unwrap(),
|
||||
"no head on a miss"
|
||||
);
|
||||
}
|
||||
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
|
||||
assert!(!s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyNoResults:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The bar IS the control (0989: steppers cut by the director's call; the engine's
|
||||
/// track-press law): a LeftButton press on the slider's track — off the thumb — seats the
|
||||
/// thumb under the cursor (the value jumps, the CVar write queues), the SAME press keeps
|
||||
/// dragging (0250 §5's capture began), and the stepper buttons no longer exist.
|
||||
#[test]
|
||||
fn a_track_press_seats_the_thumb_and_keeps_dragging() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.set_cvar_host("MasterVolume", "0.1");
|
||||
s.run("ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowAudio:Click()").unwrap();
|
||||
let _ = s.take_cvar_changes();
|
||||
|
||||
// The steppers are gone.
|
||||
for btn in ["Back", "Forward"] {
|
||||
assert!(s
|
||||
.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyAudioRowMasterControl{btn} == nil"
|
||||
))
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
let slider = "OptionsFrameContainerBodyAudioRowMasterControlSlider";
|
||||
s.resolve(); // seat the rects before reading them (the wash test's idiom)
|
||||
let (l, r, t, b) = (
|
||||
s.eval::<f32>(&format!("return {slider}:GetLeft()"))
|
||||
.unwrap(),
|
||||
s.eval::<f32>(&format!("return {slider}:GetRight()"))
|
||||
.unwrap(),
|
||||
s.eval::<f32>(&format!("return {slider}:GetTop()")).unwrap(),
|
||||
s.eval::<f32>(&format!("return {slider}:GetBottom()"))
|
||||
.unwrap(),
|
||||
);
|
||||
let cy = (t + b) * 0.5;
|
||||
// Press 1 unit inside the RIGHT end of the track — far from the 0.1-seated thumb. The
|
||||
// center-grab seat clamps the fraction to 1.0 there (the cursor sits inside the thumb's
|
||||
// half-width end zone), so the value lands exactly at the max.
|
||||
s.mouse_button(r - 1.0, cy, "LeftButton", true);
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return math.abs({slider}:GetValue() - 1.0) < 0.0001"
|
||||
))
|
||||
.unwrap(),
|
||||
"the press itself seats the thumb"
|
||||
);
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("MasterVolume".to_string(), "1".to_string())]
|
||||
);
|
||||
// Still held: the same gesture drags on. The slider's midpoint is exactly fraction 0.5
|
||||
// ((mid − thumb/2 − left) / (width − thumb)), on the 0.05 grid, so no snap correction.
|
||||
s.mouse_move((l + r) * 0.5, cy);
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return math.abs({slider}:GetValue() - 0.5) < 0.0001"
|
||||
))
|
||||
.unwrap(),
|
||||
"the capture began at the press — the drag follows without re-grabbing"
|
||||
);
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("MasterVolume".to_string(), "0.5".to_string())]
|
||||
);
|
||||
s.mouse_button((l + r) * 0.5, cy, "LeftButton", false);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -324,17 +614,6 @@ fn a_slider_move_snaps_and_writes_the_cvar() {
|
||||
vec![("MasterVolume".to_string(), "0.45".to_string())]
|
||||
);
|
||||
|
||||
// The steppers move one step on the era's own kit sound.
|
||||
let _ = s.take_sounds();
|
||||
s.run("OptionsFrameContainerBodyAudioRowMasterControlForward:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("MasterVolume".to_string(), "0.5".to_string())]
|
||||
);
|
||||
assert!(s
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -504,14 +783,11 @@ fn the_ui_scale_slider_defers_to_the_apply_button() {
|
||||
.eval::<bool>("return OptionsFrameApplyButton:IsEnabled()")
|
||||
.unwrap());
|
||||
|
||||
// The stepper still moves the slider (and plays its kit) — still nothing queues.
|
||||
let _ = s.take_sounds();
|
||||
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlForward:Click()")
|
||||
// A second move re-stages the pending value — still nothing queues (0989: the steppers
|
||||
// are gone; a drag's SetValue is the remaining move).
|
||||
s.run("OptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.8)")
|
||||
.unwrap();
|
||||
assert!(s.take_cvar_changes().is_empty());
|
||||
assert!(s
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
|
||||
|
||||
// Apply commits the LAST pending value, once, and the button goes away.
|
||||
s.run("OptionsFrameApplyButton:Click()").unwrap();
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! The pet action bar (`PetActionBar.xml`) 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};
|
||||
|
||||
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
|
||||
fn load_xml(s: &UiScript, file: &str) {
|
||||
let text = std::fs::read_to_string(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("assets/ui")
|
||||
.join(file),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||||
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"loader errors in {file}: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 `BenillaActionBar` anchor target) — then the bar.
|
||||
fn load_pet_bar(s: &UiScript) {
|
||||
for file in [
|
||||
"UiPanels.xml",
|
||||
"UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"ActionBar.xml",
|
||||
"PetActionBar.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.)
|
||||
fn declare_token_strings(s: &UiScript) {
|
||||
s.run(
|
||||
"PET_ACTION_ATTACK = 'Attack' \
|
||||
PET_MODE_DEFENSIVE = 'Defensive'",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// 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).
|
||||
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,
|
||||
..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,
|
||||
..Default::default()
|
||||
};
|
||||
slots[8] = PetActionView {
|
||||
name: Some("PET_MODE_DEFENSIVE".into()),
|
||||
texture: Some("PET_DEFENSIVE_TEXTURE".into()),
|
||||
is_token: true,
|
||||
active: true,
|
||||
..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. The tick is what runs the bar's OnUpdate, i.e. the sparkle trail — it is an
|
||||
// animation, so it exists from the first frame after the repaint, not from the repaint.
|
||||
s.set_pet_actions(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 sparkle trail running.
|
||||
assert_eq!(
|
||||
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
|
||||
1,
|
||||
"only Claw can autocast"
|
||||
);
|
||||
assert_eq!(
|
||||
textures(&quads, "Interface\\Buttons\\GlowStar"),
|
||||
32,
|
||||
"4 emitters x 8 trail stars, on the one autocasting slot"
|
||||
);
|
||||
|
||||
// Geometry, quoted from the ref: the bar's TOPLEFT is BenillaActionBar's BOTTOMLEFT +(36,97),
|
||||
// and BenillaActionBar 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 hides again, sparkles and all.
|
||||
s.set_pet_actions(false, true, Vec::new());
|
||||
s.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
s.tick(0.05);
|
||||
s.resolve();
|
||||
let gone = s.extract();
|
||||
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
|
||||
assert_eq!(textures(&gone, "Interface\\Buttons\\GlowStar"), 0);
|
||||
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, 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, 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 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, 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"
|
||||
);
|
||||
// `SetDesaturation` has no shader path in this engine, so the grey is the reference's own
|
||||
// no-shader fallback: vertex colour 0.5 (UiPanels.xml's transcription).
|
||||
let tint = quads
|
||||
.iter()
|
||||
.find_map(|q| match &q.content {
|
||||
QuadContent::Texture {
|
||||
path: Some(p),
|
||||
color,
|
||||
..
|
||||
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*color),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Claw's icon");
|
||||
assert_eq!(
|
||||
tint.map(|c| [c[0], c[1], c[2]]),
|
||||
Some([0.5, 0.5, 0.5]),
|
||||
"a disabled bar greys every icon on it"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The pet bar as it lands with the bottom-left multibar loaded and shown — which is how benilla
|
||||
/// actually runs (0270: the bottom bars are always on). `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);
|
||||
for file in [
|
||||
"UiPanels.xml",
|
||||
"UIParent.xml",
|
||||
"Cooldown.xml",
|
||||
"ActionBar.xml",
|
||||
] {
|
||||
load_xml(&s, file);
|
||||
}
|
||||
if with_multibar {
|
||||
load_xml(&s, "MultiBars.xml");
|
||||
}
|
||||
load_xml(&s, "PetActionBar.xml");
|
||||
declare_token_strings(&s);
|
||||
|
||||
s.set_pet_actions(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})"
|
||||
);
|
||||
}
|
||||
@@ -199,9 +199,33 @@ pub(crate) fn line_advances(atlas: &mut UiFontAtlas, text: &str, font: FontSpec)
|
||||
cum[i] = cum[i - 1];
|
||||
}
|
||||
}
|
||||
// Into DRAWN space ([`drawn_k`]): the caret/click/selection geometry these feed must land on
|
||||
// the glyphs the 0581 rescale actually put on screen, not on the pre-rescale shaping.
|
||||
let k = drawn_k(font.height, font_size);
|
||||
if k != 1.0 {
|
||||
for v in &mut cum {
|
||||
*v *= k;
|
||||
}
|
||||
}
|
||||
cum
|
||||
}
|
||||
|
||||
/// The exact-height rescale factor the DRAW applies (decision 0581, [`layout_text_quads_inner`]'s
|
||||
/// tail): a requested height between baked sizes shapes at the snapped `font_size` and scales the
|
||||
/// finished quads by `req / font_size` about the justify anchor. Every measure that hands geometry
|
||||
/// back onto the drawn glyphs — the editbox advance table, the caret cell, the row pitch — must
|
||||
/// return values in that DRAWN space, so they apply the same factor. Identity for a baked size and
|
||||
/// for `height: None` (the draw's rescale gate is `font.height`-Some too). This stopped being a
|
||||
/// theoretical case when the era-shaped windows started riding `SetScale` (0950): their frame
|
||||
/// scale multiplies into every drawn height, landing between bakes — the options search box's
|
||||
/// caret sat 28% past its own text (director, 2026-08-05; decision 0989).
|
||||
fn drawn_k(font_height: Option<f32>, font_size: f32) -> f32 {
|
||||
match font_height {
|
||||
Some(req) if ((req / font_size) - 1.0).abs() > 1e-3 => req / font_size,
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline-EditBox row answer (the 2-D half of
|
||||
/// [`benilla_ui::script::UiScript::set_editbox_advances`]'s payload): the byte offset where each
|
||||
/// wrapped display row of `text` begins at `wrap_width` — the exact [`wrap_line`] pass the render
|
||||
@@ -256,7 +280,10 @@ pub(crate) fn line_rows(
|
||||
if rows.is_empty() {
|
||||
rows.push(0);
|
||||
}
|
||||
(rows, font_size)
|
||||
// Row PITCH in drawn space ([`drawn_k`] — vertical distances rescale with the quads). The row
|
||||
// STARTS stay: both this wrap and the draw's break at unscaled steps against the same width,
|
||||
// so the byte boundaries already agree.
|
||||
(rows, font_size * drawn_k(font.height, font_size))
|
||||
}
|
||||
|
||||
/// [`line_rows`]'s reconstruction walk for one `\n` segment: push the byte start of each wrapped
|
||||
@@ -588,11 +615,14 @@ pub(crate) fn line_origin(
|
||||
let font_size = atlas.snap_for(&family, font.height.unwrap_or(DEFAULT_FONT_SIZE));
|
||||
let scale = atlas.scale;
|
||||
let step_extra = step_extra_of(font.outline);
|
||||
// Everything below returns DRAWN-space geometry ([`drawn_k`]): the string's drawn width, the
|
||||
// caret cell's height, and the cell top all carry the 0581 rescale the glyph quads take.
|
||||
let k = drawn_k(font.height, font_size);
|
||||
// The line origin under justifyH (LEFT needs no width measure — the chat case).
|
||||
let x0 = match justify.h {
|
||||
JustifyH::Left => rect.min.x,
|
||||
JustifyH::Center | JustifyH::Right => {
|
||||
let w = measure_line_width(
|
||||
let w = k * measure_line_width(
|
||||
&mut atlas.font_system,
|
||||
&atlas.glyphs,
|
||||
&attrs,
|
||||
@@ -610,18 +640,25 @@ pub(crate) fn line_origin(
|
||||
};
|
||||
// The single-line block's v_offset — [`layout_text_quads_inner`]'s law with block_h = 1 line,
|
||||
// through the same ONE vertical snap ([`snap_block_top`]) so the caret/selection cell sits
|
||||
// exactly on the drawn glyphs.
|
||||
// exactly on the drawn glyphs — then the 0581 rescale about the same v anchor the draw
|
||||
// scales its quads about (snap first, scale second: the draw's own order).
|
||||
let top = if rect.height() > f32::EPSILON {
|
||||
let v_offset = match justify.v {
|
||||
JustifyV::Top => 0.0,
|
||||
JustifyV::Middle => (rect.height() - font_size) * 0.5,
|
||||
JustifyV::Bottom => rect.height() - font_size,
|
||||
};
|
||||
snap_block_top(rect.min.y + v_offset)
|
||||
let snapped = snap_block_top(rect.min.y + v_offset);
|
||||
let anchor_y = match justify.v {
|
||||
JustifyV::Top => rect.min.y,
|
||||
JustifyV::Middle => (rect.min.y + rect.max.y) * 0.5,
|
||||
JustifyV::Bottom => rect.max.y,
|
||||
};
|
||||
anchor_y + (snapped - anchor_y) * k
|
||||
} else {
|
||||
rect.min.y
|
||||
};
|
||||
(x0, top, font_size)
|
||||
(x0, top, font_size * k)
|
||||
}
|
||||
|
||||
/// The client's SINGLE vertical anchor snap (`anchor_justify_snap 0x5cdf70` @`0x5ce051`,
|
||||
@@ -1128,6 +1165,18 @@ mod seat_tests {
|
||||
assert_eq!(snap_block_top(10.5), snap_block_top_law(10.5) + 1.0);
|
||||
assert_eq!(snap_block_top(0.0), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_measures_ride_the_drawn_space_factor() {
|
||||
// [`drawn_k`] mirrors the 0581 rescale gate exactly: identity at a baked size and for
|
||||
// height-None; the requested/snapped ratio between bakes (0989 — the era-scaled options
|
||||
// window put every request between bakes, and the caret drifted 28% past its text).
|
||||
assert_eq!(drawn_k(None, 8.0), 1.0);
|
||||
assert_eq!(drawn_k(Some(8.0), 8.0), 1.0);
|
||||
assert_eq!(drawn_k(Some(8.004), 8.0), 1.0); // inside the draw's 1e-3 gate
|
||||
let k = drawn_k(Some(6.398), 8.203);
|
||||
assert!((k - 6.398 / 8.203).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -884,3 +884,52 @@ fn wmo_camera_void_audit() {
|
||||
"an eye above the terrain lid must remain OUTSIDE"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The Deeprun Tram's undersea tube — the map with no `Light.dbc` row.**
|
||||
///
|
||||
/// Map 369 carries no `Light.dbc` sphere at all (not even a falloff-0 global, unlike maps 0/1), so
|
||||
/// every scrap of its atmosphere has to come from the building: the root's MFOG. `Subway.wmo`
|
||||
/// authors four records, and the undersea stretch is record **2** — pos `(25, -1256, -117)`,
|
||||
/// radii 154/247, colour RGB(30,53,100), end 236.1 yd, start scalar 0.05. The camera's group
|
||||
/// there (`Subway_002`, flags `0x2805` ⇒ INTERIOR) names it: `fogIds = (2,0,0,0)`.
|
||||
///
|
||||
/// This pins the two links that have to hold for that fog to reach the frame — the down-ray seed
|
||||
/// claiming the tunnel group, and the selector resolving record 2 over the record-0 seed — on a
|
||||
/// **global (WDT `MODF`) WMO**, the placement shape the rest of this module's sites never exercise.
|
||||
#[test]
|
||||
#[ignore = "needs the local game data (WoW/Data); run with --ignored"]
|
||||
fn deeprun_tram_undersea_claims_its_own_mfog() {
|
||||
let subject = load_subject(r"World\wmo\Dungeon\AZ_Subway\Subway.wmo", None);
|
||||
let model = &subject.model;
|
||||
// The undersea tunnel group, by its authored fog list rather than a hardcoded index.
|
||||
let gi = 2usize;
|
||||
let nav = &model.group_nav[gi];
|
||||
assert_eq!(
|
||||
nav.fog_indices,
|
||||
[2, 0, 0, 0],
|
||||
"g02 should name MFOG record 2"
|
||||
);
|
||||
assert_eq!(nav.flags & EXTERIOR, 0, "g02 should be an INTERIOR group");
|
||||
assert!(model.fogs.len() >= 2, "4 MFOG records; no count==1 bail");
|
||||
|
||||
let eye = interior_standing_eye(model, gi).expect("a standing eye inside g02");
|
||||
println!("eye_local = {eye:?}");
|
||||
let seeds = subject.seeds(eye);
|
||||
println!("down-ray seeds = {seeds:?}");
|
||||
assert_eq!(
|
||||
seeds.in_group,
|
||||
Some(gi),
|
||||
"the down-ray must claim the tunnel group — otherwise no MFOG ever engages"
|
||||
);
|
||||
|
||||
let target = crate::wmo_portal::fog::select_wmo_fog(&model.fogs, nav.fog_indices, eye)
|
||||
.expect("record 2 covers this eye, so a target must resolve");
|
||||
println!("resolved MFOG target = {target:?}");
|
||||
let rgb = target.color.map(|c| (c * 255.0).round() as i32);
|
||||
assert_eq!(rgb, [30, 53, 100], "the undersea record's authored colour");
|
||||
assert!(
|
||||
(target.end - 236.1).abs() < 0.5,
|
||||
"end {} should be the record's 236.1 yd",
|
||||
target.end
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user