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:
Sam
2026-08-05 19:03:13 +07:00
co-authored by Claude Fable 5
parent 994be22f5c
commit 77dc8f8ead
98 changed files with 8922 additions and 1089 deletions
Generated
-1
View File
@@ -595,7 +595,6 @@ dependencies = [
"rand 0.8.6",
"raw-window-handle",
"serde",
"serde_json",
"smithay-clipboard",
"toml",
]
+38
View File
@@ -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`]
+1
View File
@@ -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};
+51 -3
View File
@@ -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"
);
}
+16 -4
View File
@@ -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`.
+28 -7
View File
@@ -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 -3
View File
@@ -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 }
+668
View File
@@ -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),
)
}
}
+1 -19
View File
@@ -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).
-81
View File
@@ -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
);
}
}
+8 -29
View File
@@ -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(
&region,
"SetAtlas",
(atlas.to_string(), t.attr_bool("useAtlasSize")),
dbg,
);
}
if let Some(mode) = t.attr("alphaMode") {
this.call_region(&region, "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(
&region,
"SetAtlas",
(atlas.to_string(), tt.attr_bool("useAtlasSize")),
dbg,
);
}
if let Some(mode) = tt.attr("alphaMode") {
self.call_region(&region, "SetBlendMode", mode.to_string(), dbg);
}
+2 -1
View File
@@ -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,
+3 -1
View File
@@ -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
}
+24 -4
View File
@@ -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)),
}
})?,
+6 -1
View File
@@ -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,
+3 -1
View File
@@ -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());
}
+742
View File
@@ -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());
}
}
+12 -27
View File
@@ -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 /
+38 -12
View File
@@ -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(),
+432
View File
@@ -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());
}
}
+26 -8
View File
@@ -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()]) {
-52
View File
@@ -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).
+38 -18
View File
@@ -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
+104
View File
@@ -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)| {
+33 -14
View File
@@ -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
-16
View File
@@ -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
+2 -4
View File
@@ -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,
+20 -1
View File
@@ -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
+933
View File
@@ -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
+435
View File
@@ -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>
+20 -1
View File
@@ -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
+60 -6
View File
@@ -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
+5
View File
@@ -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 }`,
-87
View File
@@ -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(),
)
}
-20
View File
@@ -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.
+18
View File
@@ -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
+45
View File
@@ -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];
+37
View File
@@ -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
+5 -2
View File
@@ -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;
+41 -5
View File
@@ -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>();
}
}
}
+23 -17
View File
@@ -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(),
+1
View File
@@ -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
+3
View File
@@ -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
+45
View File
@@ -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());
+12
View File
@@ -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)
+27
View File
@@ -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
+70 -10
View File
@@ -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,
+232
View File
@@ -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"]
);
}
}
+36 -40
View File
@@ -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;
+9
View File
@@ -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),
+24 -4
View File
@@ -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,
+92 -17
View File
@@ -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(&degenerate, true, 30.0), 0.0);
assert_eq!(world_motion_kept(&degenerate, 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).
+3
View File
@@ -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,
+4 -1
View File
@@ -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,
+19 -3
View File
@@ -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
);
}
+25 -4
View File
@@ -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"
);
}
+70 -4
View File
@@ -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.
+1 -1
View File
@@ -12,7 +12,7 @@ use bevy::prelude::*;
use crate::ui_script::UiInput;
mod commands;
pub(crate) mod commands;
mod edit;
mod event;
mod feed;
+20 -6
View File
@@ -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 {
+19
View File
@@ -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.
+24
View File
@@ -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() {
+35 -4
View File
@@ -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
+239
View File
@@ -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
}
+204
View File
@@ -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);
}
}
+228
View File
@@ -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(&macros);
// 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(&macros), 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);
}
}
+148
View File
@@ -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);
}
+812
View File
@@ -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",
+330
View File
@@ -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"
);
}
+27 -26
View File
@@ -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;
+351 -75
View File
@@ -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})"
);
}
+54 -5
View File
@@ -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
);
}